/api/realtime
diff --git a/Makefile b/Makefile
index 6dc9b9a8d..8dfe90412 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ lint:
golangci-lint run -c ./golangci.yml ./...
test:
- go test -v --cover ./...
+ go test ./... -v --cover
test-report:
- go test -v --cover -coverprofile=coverage.out ./...
+ go test ./... -v --cover -coverprofile=coverage.out
go tool cover -html=coverage.out
diff --git a/README.md b/README.md
index ffbcc5e94..f263fc7c3 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ func main() {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
diff --git a/apis/admin.go b/apis/admin.go
index 1273a1478..3209911ba 100644
--- a/apis/admin.go
+++ b/apis/admin.go
@@ -9,20 +9,19 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/rest"
"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) {
+// 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-via-email", api.emailAuth, RequireGuestOnly())
+ subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
- subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
+ subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth())
@@ -37,7 +36,7 @@ type adminApi struct {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
- return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
+ return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
@@ -54,24 +53,24 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
})
}
-func (api *adminApi) refresh(c echo.Context) error {
+func (api *adminApi) authRefresh(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
- return rest.NewNotFoundError("Missing auth admin context.", nil)
+ return NewNotFoundError("Missing auth admin context.", nil)
}
return api.authResponse(c, admin)
}
-func (api *adminApi) emailAuth(c echo.Context) error {
+func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
+ return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, admin)
@@ -80,11 +79,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
+ return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
@@ -101,12 +100,12 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
+ return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
- return rest.NewBadRequestError("Failed to set new password.", submitErr)
+ return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, admin)
@@ -124,7 +123,7 @@ func (api *adminApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &admins)
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
@@ -141,12 +140,12 @@ func (api *adminApi) list(c echo.Context) error {
func (api *adminApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
@@ -166,7 +165,7 @@ func (api *adminApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
@@ -179,7 +178,7 @@ func (api *adminApi) create(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create admin.", err)
+ return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -197,19 +196,19 @@ func (api *adminApi) create(c echo.Context) error {
func (api *adminApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
form := forms.NewAdminUpsert(api.app, admin)
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
@@ -222,7 +221,7 @@ func (api *adminApi) update(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update admin.", err)
+ return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -240,12 +239,12 @@ func (api *adminApi) update(c echo.Context) error {
func (api *adminApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
@@ -255,7 +254,7 @@ func (api *adminApi) delete(c echo.Context) error {
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
- return rest.NewBadRequestError("Failed to delete admin.", err)
+ return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
diff --git a/apis/admin_test.go b/apis/admin_test.go
index a95583db8..39f695892 100644
--- a/apis/admin_test.go
+++ b/apis/admin_test.go
@@ -14,39 +14,47 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
-func TestAdminAuth(t *testing.T) {
+func TestAdminAuthWithEmail(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
+ Url: "/api/admins/auth-with-password",
Body: strings.NewReader(``),
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
+ 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-via-email",
+ Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "wrong email/password",
+ 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-via-email",
- Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
+ 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-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
@@ -54,11 +62,11 @@ func TestAdminAuth(t *testing.T) {
{
Name: "valid email/password (guest)",
Method: http.MethodPost,
- Url: "/api/admins/auth-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
+ Url: "/api/admins/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -158,21 +166,41 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`),
+ 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",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`),
+ 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: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -193,30 +221,40 @@ func TestAdminRefresh(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/admins/refresh",
+ Url: "/api/admins/auth-refresh",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPost,
- Url: "/api/admins/refresh",
+ Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin",
+ 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/refresh",
+ Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -244,7 +282,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -254,16 +292,17 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":2`,
+ `"totalItems":3`,
`"items":[{`,
- `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sywbhecnh46rhm0"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ `"id":"9q2trqumvlyr3bd"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -274,15 +313,19 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?page=2&perPage=1&sort=-created",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":1`,
- `"totalItems":2`,
+ `"totalItems":3`,
`"items":[{`,
- `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -293,7 +336,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -301,9 +344,9 @@ func TestAdminsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
- Url: "/api/admins?filter=email~'test2'",
+ Url: "/api/admins?filter=email~'test3'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -311,7 +354,11 @@ func TestAdminsList(t *testing.T) {
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"9q2trqumvlyr3bd"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -329,36 +376,26 @@ func TestAdminView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
- {
- Name: "authorized as admin + invalid admin id",
- Method: http.MethodGet,
- Url: "/api/admins/invalid",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodGet,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
+ Url: "/api/admins/nonexisting",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -366,13 +403,17 @@ func TestAdminView(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodGet,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
+ },
+ NotExpectedContent: []string{
+ `"tokenKey"`,
+ `"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminViewRequest": 1,
@@ -390,36 +431,26 @@ func TestAdminDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + invalid admin id",
+ Name: "authorized as admin + missing admin id",
Method: http.MethodDelete,
- Url: "/api/admins/invalid",
+ Url: "/api/admins/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting admin id",
- Method: http.MethodDelete,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -427,9 +458,9 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodDelete,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -442,15 +473,15 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin - try to delete the only remaining admin",
Method: http.MethodDelete,
- Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ Url: "/api/admins/sywbhecnh46rhm0",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ "id": "sywbhecnh46rhm0",
})).Execute()
if err != nil {
t.Fatal(err)
@@ -508,7 +539,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -519,7 +550,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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."}}`},
@@ -530,7 +561,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -539,20 +570,36 @@ func TestAdminCreate(t *testing.T) {
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}`),
+ Body: strings.NewReader(`{
+ "email":"test@example.com",
+ "password":"1234",
+ "passwordConfirm":"4321",
+ "avatar":99
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
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}`),
+ Body: strings.NewReader(`{
+ "email":"testnew@example.com",
+ "password":"1234567890",
+ "passwordConfirm":"1234567890",
+ "avatar":3
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -560,6 +607,12 @@ func TestAdminCreate(t *testing.T) {
`"email":"testnew@example.com"`,
`"avatar":3`,
},
+ NotExpectedContent: []string{
+ `"password"`,
+ `"passwordConfirm"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
@@ -579,38 +632,27 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + invalid admin id",
- Method: http.MethodPatch,
- Url: "/api/admins/invalid",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting admin id",
+ Name: "authorized as admin + missing admin",
Method: http.MethodPatch,
- Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
+ Url: "/api/admins/missing",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -618,14 +660,14 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + empty data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
`"email":"test2@example.com"`,
`"avatar":2`,
},
@@ -639,10 +681,10 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid formatted data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
+ Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -650,27 +692,49 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid data",
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
+ Url: "/api/admins/sbmbsdb40jyxf7h",
+ Body: strings.NewReader(`{
+ "email":"test@example.com",
+ "password":"1234",
+ "passwordConfirm":"4321",
+ "avatar":99
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
Method: http.MethodPatch,
- Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
- Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
+ Url: "/api/admins/sbmbsdb40jyxf7h",
+ Body: strings.NewReader(`{
+ "email":"testnew@example.com",
+ "password":"1234567891",
+ "passwordConfirm":"1234567891",
+ "avatar":5
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
+ `"id":"sbmbsdb40jyxf7h"`,
`"email":"testnew@example.com"`,
`"avatar":5`,
},
+ NotExpectedContent: []string{
+ `"password"`,
+ `"passwordConfirm"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ },
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
diff --git a/tools/rest/api_error.go b/apis/api_error.go
similarity index 94%
rename from tools/rest/api_error.go
rename to apis/api_error.go
index bef430dee..f5f823913 100644
--- a/tools/rest/api_error.go
+++ b/apis/api_error.go
@@ -1,4 +1,4 @@
-package rest
+package apis
import (
"net/http"
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/tools/inflector"
)
-// ApiError defines the properties for a basic api error response.
+// ApiError defines the struct for a basic api error response.
type ApiError struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -23,6 +23,7 @@ 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
}
diff --git a/tools/rest/api_error_test.go b/apis/api_error_test.go
similarity index 92%
rename from tools/rest/api_error_test.go
rename to apis/api_error_test.go
index 89d527971..c9744f4b9 100644
--- a/tools/rest/api_error_test.go
+++ b/apis/api_error_test.go
@@ -1,4 +1,4 @@
-package rest_test
+package apis_test
import (
"encoding/json"
@@ -6,11 +6,11 @@ import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/apis"
)
func TestNewApiErrorWithRawData(t *testing.T) {
- e := rest.NewApiError(
+ e := apis.NewApiError(
300,
"message_test",
"rawData_test",
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
}
func TestNewApiErrorWithValidationData(t *testing.T) {
- e := rest.NewApiError(
+ e := apis.NewApiError(
300,
"message_test",
validation.Errors{
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewNotFoundError(scenario.message, scenario.data)
+ e := apis.NewNotFoundError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewBadRequestError(scenario.message, scenario.data)
+ e := apis.NewBadRequestError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewForbiddenError(scenario.message, scenario.data)
+ e := apis.NewForbiddenError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
}
for i, scenario := range scenarios {
- e := rest.NewUnauthorizedError(scenario.message, scenario.data)
+ e := apis.NewUnauthorizedError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
diff --git a/apis/base.go b/apis/base.go
index a9657b10f..934594ff1 100644
--- a/apis/base.go
+++ b/apis/base.go
@@ -2,6 +2,7 @@
package apis
import (
+ "errors"
"fmt"
"io/fs"
"log"
@@ -13,7 +14,6 @@ import (
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
@@ -43,7 +43,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
return
}
- var apiErr *rest.ApiError
+ var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
@@ -51,8 +51,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
- apiErr = rest.NewApiError(v.Code, msg, v)
- case *rest.ApiError:
+ apiErr = NewApiError(v.Code, msg, v)
+ case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
@@ -61,7 +61,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
if err != nil && app.IsDebug() {
log.Println(err)
}
- apiErr = rest.NewBadRequestError("", err)
+ apiErr = NewBadRequestError("", err)
}
// Send response
@@ -84,14 +84,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default routes
api := e.Group("/api")
- BindSettingsApi(app, api)
- BindAdminApi(app, api)
- BindUserApi(app, api)
- BindCollectionApi(app, api)
- BindRecordApi(app, api)
- BindFileApi(app, api)
- BindRealtimeApi(app, api)
- BindLogsApi(app, 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
@@ -114,22 +114,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
// 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, disablePathUnescaping bool) echo.HandlerFunc {
+func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.PathParam("*")
- if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
- tmpPath, err := url.PathUnescape(p)
- if err != nil {
- return fmt.Errorf("failed to unescape path variable: %w", err)
- }
- p = tmpPath
+
+ // 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, "/")))
- return c.FileFS(name, fileSystem)
+ fileErr := c.FileFS(name, fileSystem)
+
+ if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
+ return c.FileFS("index.html", fileSystem)
+ }
+
+ return fileErr
}
}
diff --git a/apis/base_test.go b/apis/base_test.go
index c96969392..b676b6594 100644
--- a/apis/base_test.go
+++ b/apis/base_test.go
@@ -6,8 +6,8 @@ import (
"testing"
"github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/rest"
)
func Test404(t *testing.T) {
@@ -91,7 +91,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Method: http.MethodGet,
Path: "/api-error",
Handler: func(c echo.Context) error {
- return rest.NewApiError(500, "test message", errors.New("internal_test"))
+ return apis.NewApiError(500, "test message", errors.New("internal_test"))
},
})
},
diff --git a/apis/collection.go b/apis/collection.go
index e06ec81a6..861de01b0 100644
--- a/apis/collection.go
+++ b/apis/collection.go
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
-// BindCollectionApi registers the collection api endpoints and the corresponding handlers.
-func BindCollectionApi(app core.App, rg *echo.Group) {
+// 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())
@@ -30,7 +29,7 @@ type collectionApi struct {
func (api *collectionApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
- "id", "created", "updated", "name", "system",
+ "id", "created", "updated", "name", "system", "type",
)
collections := []*models.Collection{}
@@ -40,7 +39,7 @@ func (api *collectionApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &collections)
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
@@ -57,7 +56,7 @@ func (api *collectionApi) list(c echo.Context) error {
func (api *collectionApi) view(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
@@ -77,7 +76,7 @@ func (api *collectionApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
@@ -90,7 +89,7 @@ func (api *collectionApi) create(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create the collection.", err)
+ return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -108,14 +107,14 @@ func (api *collectionApi) create(c echo.Context) error {
func (api *collectionApi) update(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
form := forms.NewCollectionUpsert(api.app, collection)
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
@@ -128,7 +127,7 @@ func (api *collectionApi) update(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update the collection.", err)
+ return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -146,7 +145,7 @@ func (api *collectionApi) update(c echo.Context) error {
func (api *collectionApi) delete(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
@@ -156,7 +155,7 @@ func (api *collectionApi) delete(c echo.Context) error {
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
- return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
+ return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -174,7 +173,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
// load request data
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
@@ -189,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to import the submitted collections.", err)
+ return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
diff --git a/apis/collection_test.go b/apis/collection_test.go
index 2966c71e0..13792f6b4 100644
--- a/apis/collection_test.go
+++ b/apis/collection_test.go
@@ -2,6 +2,8 @@ package apis_test
import (
"net/http"
+ "os"
+ "path/filepath"
"strings"
"testing"
@@ -24,7 +26,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -34,19 +36,23 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":5`,
+ `"totalItems":7`,
`"items":[{`,
- `"id":"abe78266-fd4d-4aea-962d-8c0138ac522b"`,
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"id":"3cd6fe92-70dc-4819-8542-4d036faabd89"`,
- `"id":"f12f3eb6-b980-4bf6-b1e4-36de0450c8be"`,
+ `"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,
@@ -57,16 +63,16 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?page=2&perPage=2&sort=-created",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
- `"totalItems":5`,
+ `"totalItems":7`,
`"items":[{`,
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
+ `"id":"4d1blo5cuycfaca"`,
+ `"id":"wzlqyes4orhoygb"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -77,7 +83,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?filter=invalidfield~'demo2'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -85,17 +91,20 @@ func TestCollectionsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
- Url: "/api/collections?filter=name~'demo2'",
+ Url: "/api/collections?filter=name~'demo'",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":1`,
+ `"totalItems":4`,
`"items":[{`,
- `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"id":"sz5l5z67tg7gku0"`,
+ `"id":"wzlqyes4orhoygb"`,
+ `"id":"4d1blo5cuycfaca"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -113,16 +122,16 @@ func TestCollectionView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -132,7 +141,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -140,13 +149,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodGet,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -155,13 +165,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection id",
Method: http.MethodGet,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
+ Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -175,20 +186,29 @@ func TestCollectionView(t *testing.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/demo3",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
- Url: "/api/collections/demo3",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -196,9 +216,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + nonexisting collection identifier",
Method: http.MethodDelete,
- Url: "/api/collections/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
+ Url: "/api/collections/missing",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -206,9 +226,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
- Url: "/api/collections/demo3",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -217,13 +237,16 @@ func TestCollectionDelete(t *testing.T) {
"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/3cd6fe92-70dc-4819-8542-4d036faabd89",
+ Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -232,13 +255,16 @@ func TestCollectionDelete(t *testing.T) {
"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/profiles",
+ Url: "/api/collections/nologin",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -249,9 +275,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + trying to delete a referenced collection",
Method: http.MethodDelete,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo2",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -280,7 +306,7 @@ func TestCollectionCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/collections",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -291,7 +317,7 @@ func TestCollectionCreate(t *testing.T) {
Url: "/api/collections",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -304,9 +330,9 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPost,
Url: "/api/collections",
- Body: strings.NewReader(`{"name":"demo","schema":[{"type":"text","name":""}]}`),
+ Body: strings.NewReader(`{"name":"demo1","type":"base","schema":[{"type":"text","name":""}]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -319,16 +345,117 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/collections",
- Body: strings.NewReader(`{"name":"new","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
+ 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": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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,
@@ -337,6 +464,55 @@ func TestCollectionCreate(t *testing.T) {
"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 {
@@ -349,64 +525,80 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
+ Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as admin + empty data",
+ Name: "authorized as admin + missing collection",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
- Body: strings.NewReader(``),
+ Url: "/api/collections/missing",
+ Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":"wsmn24bux7wo113"`,
+ `"name":"demo1"`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
+ "OnCollectionBeforeUpdateRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
},
},
{
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPatch,
- Url: "/api/collections/demo",
- Body: strings.NewReader(`{"name":"demo2"}`),
+ Url: "/api/collections/demo1",
+ Body: strings.NewReader(`{
+ "name":"demo2",
+ "type":"auth"
+ }`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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/demo",
+ Url: "/api/collections/demo1",
Body: strings.NewReader(`{"name":"new"}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
+ `"id":`,
`"name":"new"`,
},
ExpectedEvents: map[string]int{
@@ -415,19 +607,87 @@ func TestCollectionUpdate(t *testing.T) {
"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: "authorized as admin + valid data and id as identifier",
+ Name: "trying to update auth collection with reserved auth fields",
Method: http.MethodPatch,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
- Body: strings.NewReader(`{"name":"new"}`),
+ 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": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "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{
- `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"name":"new"`,
+ `"name":"demo1"`,
+ `"type":"base"`,
+ `"schema":[{`,
+ `"email"`,
+ `"username"`,
+ `"verified"`,
+ `"emailVisibility"`,
+ `"lastResetSentAt"`,
+ `"lastVerificationSentAt"`,
+ `"tokenKey"`,
+ `"passwordHash"`,
+ `"password"`,
+ `"passwordConfirm"`,
+ `"oldPassword"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -436,6 +696,52 @@ func TestCollectionUpdate(t *testing.T) {
"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 {
@@ -457,7 +763,7 @@ func TestCollectionImport(t *testing.T) {
Method: http.MethodPut,
Url: "/api/collections/import",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -468,7 +774,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -480,8 +786,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -491,7 +798,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -500,14 +807,16 @@ func TestCollectionImport(t *testing.T) {
},
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)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -531,7 +840,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -547,8 +856,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 5 {
- t.Fatalf("Expected %d collections, got %d", 5, len(collections))
+ expected := 7
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -581,7 +891,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -595,8 +905,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 7 {
- t.Fatalf("Expected %d collections, got %d", 7, len(collections))
+ expected := 9
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -608,45 +919,54 @@ func TestCollectionImport(t *testing.T) {
"deleteMissing": true,
"collections":[
{
- "id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "name":"profiles",
- "system":true,
- "listRule":"userId = @request.user.id",
- "viewRule":"created > 'test_change'",
- "createRule":"userId = @request.user.id",
- "updateRule":"userId = @request.user.id",
- "deleteRule":"userId = @request.user.id",
- "schema":[
- {
- "id":"koih1lqx",
- "name":"userId",
- "type":"user",
- "system":true,
- "required":true,
- "unique":true,
- "options":{
- "maxSelect":1,
- "cascadeDelete":true
- }
- },
+ "name": "new_import",
+ "schema": [
{
- "id":"69ycbg3q",
- "name":"rel",
- "type":"relation",
- "system":false,
- "required":false,
- "unique":false,
- "options":{
- "maxSelect":2,
- "collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
- "cascadeDelete":false
- }
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
}
]
},
{
- "id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
- "name":"demo",
+ "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",
@@ -662,28 +982,18 @@ func TestCollectionImport(t *testing.T) {
}
}
]
- },
- {
- "name": "new_import",
- "schema": [
- {
- "id": "koih1lqx",
- "name": "test",
- "type": "text"
- }
- ]
}
]
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
- "OnModelBeforeDelete": 3,
- "OnModelAfterDelete": 3,
+ "OnModelBeforeDelete": 5,
+ "OnModelAfterDelete": 5,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
@@ -694,8 +1004,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
- if len(collections) != 3 {
- t.Fatalf("Expected %d collections, got %d", 3, len(collections))
+ expected := 3
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
diff --git a/apis/file.go b/apis/file.go
index 50c358b87..8bcca4439 100644
--- a/apis/file.go
+++ b/apis/file.go
@@ -6,14 +6,13 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/rest"
)
-var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"}
+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) {
+// 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))
@@ -27,30 +26,30 @@ type fileApi struct {
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
recordId := c.PathParam("recordId")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
+ record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
filename := c.PathParam("filename")
fileField := record.FindFileFieldByFile(filename)
if fileField == nil {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
fs, err := api.app.NewFilesystem()
if err != nil {
- return rest.NewBadRequestError("Filesystem initialization failure.", err)
+ return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
// extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath)
if oAttrsErr != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
// check if it is an image
@@ -96,7 +95,7 @@ func (api *fileApi) download(c echo.Context) error {
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
return nil
diff --git a/apis/file_test.go b/apis/file_test.go
index d6d106f83..a2f10735a 100644
--- a/apis/file_test.go
+++ b/apis/file_test.go
@@ -14,14 +14,15 @@ import (
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
- testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt")
- testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
- testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png")
+
+ 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 {
@@ -67,28 +68,28 @@ func TestFileDownload(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodGet,
- Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record",
Method: http.MethodGet,
- Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing file",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "existing image",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -98,7 +99,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -108,7 +109,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
@@ -118,7 +119,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50t",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
@@ -128,7 +129,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50b",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
@@ -138,7 +139,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50f",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
@@ -148,7 +149,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=0x50",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
@@ -158,7 +159,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
- Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x0",
+ Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
@@ -168,7 +169,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
- Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100",
+ Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
diff --git a/apis/logs.go b/apis/logs.go
index 1cec710e1..7452fd656 100644
--- a/apis/logs.go
+++ b/apis/logs.go
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
-// BindLogsApi registers the request logs api endpoints.
-func BindLogsApi(app core.App, rg *echo.Group) {
+// bindLogsApi registers the request logs api endpoints.
+func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth())
@@ -39,7 +38,7 @@ func (api *logsApi) requestsList(c echo.Context) error {
ParseAndExec(c.QueryString(), &[]*models.Request{})
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
return c.JSON(http.StatusOK, result)
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil {
- return rest.NewBadRequestError("Invalid filter format.", err)
+ return NewBadRequestError("Invalid filter format.", err)
}
}
stats, err := api.app.LogsDao().RequestsStats(expr)
if err != nil {
- return rest.NewBadRequestError("Failed to generate requests stats.", err)
+ return NewBadRequestError("Failed to generate requests stats.", err)
}
return c.JSON(http.StatusOK, stats)
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
func (api *logsApi) requestView(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil {
- return rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
}
return c.JSON(http.StatusOK, request)
diff --git a/apis/logs_test.go b/apis/logs_test.go
index 98db6c1ad..648fb0e2e 100644
--- a/apis/logs_test.go
+++ b/apis/logs_test.go
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -32,7 +32,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -54,7 +54,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -101,7 +101,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -116,7 +116,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -159,7 +159,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -168,7 +168,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
+ `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
{
@@ -176,7 +176,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -185,7 +185,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
+ `[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
}
diff --git a/apis/middlewares.go b/apis/middlewares.go
index 06c6cb7b3..542ce751b 100644
--- a/apis/middlewares.go
+++ b/apis/middlewares.go
@@ -11,30 +11,32 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "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 (
- ContextUserKey string = "user"
ContextAdminKey string = "admin"
+ ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
-// Authorization header set.
+// Authorization header.
//
-// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
+// 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 := rest.NewBadRequestError("The request can be accessed only by guests.", nil)
+ err := NewBadRequestError("The request can be accessed only by guests.", nil)
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user != nil {
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record != nil {
return err
}
@@ -48,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
}
}
-// RequireUserAuth middleware requires a request to have
-// a valid user Authorization header set (aka. `Authorization: User ...`).
-func RequireUserAuth() echo.MiddlewareFunc {
+// 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 {
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user == nil {
- return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil)
+ 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)
@@ -64,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
}
// RequireAdminAuth middleware requires a request to have
-// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
+// 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 rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
+ return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
return next(c)
@@ -79,14 +122,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
// RequireAdminAuthOnlyIfAny middleware requires a request to have
-// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
-// ONLY if the application has at least 1 existing Admin model.
+// 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 rest.NewBadRequestError("Failed to fetch admins info.", err)
+ return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -95,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return next(c)
}
- return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
+ return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
}
}
-// RequireAdminOrUserAuth middleware requires a request to have
-// a valid admin or user Authorization header set
-// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
+// 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 RequireAdminOrUserAuth() echo.MiddlewareFunc {
+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)
- user, _ := c.Get(ContextUserKey).(*models.User)
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if admin == nil && user == nil {
- return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
+ 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)
@@ -121,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
}
// RequireAdminOrOwnerAuth middleware requires a request to have
-// a valid admin or user owner Authorization header set
-// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
+// a valid admin or auth record owner Authorization header set.
//
-// This middleware is similar to [apis.RequireAdminOrUserAuth()] but
-// for the user token expects to have the same id as the path parameter
-// `ownerIdParam` (default to "id").
+// 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 {
- if ownerIdParam == "" {
- ownerIdParam = "id"
+ admin, _ := c.Get(ContextAdminKey).(*models.Admin)
+ if admin != nil {
+ return next(c)
}
- ownerId := c.PathParam(ownerIdParam)
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
+ record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if record == nil {
+ return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
+ }
- if admin == nil && loggedUser == nil {
- return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
+ if ownerIdParam == "" {
+ ownerIdParam = "id"
}
+ ownerId := c.PathParam(ownerIdParam)
- if admin == nil && loggedUser.Id != ownerId {
- return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
+ // 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)
@@ -152,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
}
// LoadAuthContext middleware reads the Authorization request header
-// and loads the token related user or admin instance into the
+// and loads the token related record or admin instance into the
// request's context.
//
-// This middleware is expected to be registered by default for all routes.
+// 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)
+ }
- if token != "" {
- if strings.HasPrefix(token, "User ") {
- user, err := app.Dao().FindUserByToken(
- token[5:],
- app.Settings().UserAuthToken.Secret,
- )
- if err == nil && user != nil {
- c.Set(ContextUserKey, user)
- }
- } else if strings.HasPrefix(token, "Admin ") {
- admin, err := app.Dao().FindAdminByToken(
- token[6:],
- app.Settings().AdminAuthToken.Secret,
- )
- if err == nil && admin != nil {
- c.Set(ContextAdminKey, admin)
- }
+ // 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)
}
}
@@ -188,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
// LoadCollectionContext middleware finds the collection with related
// path identifier and loads it into the request context.
-func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
+//
+// 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 rest.NewNotFoundError("", err)
+ return NewNotFoundError("", err)
+ }
+
+ if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
+ return NewBadRequestError("Invalid collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -231,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
- case *rest.ApiError:
+ case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -242,8 +309,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
}
requestAuth := models.RequestAuthGuest
- if c.Get(ContextUserKey) != nil {
- requestAuth = models.RequestAuthUser
+ if c.Get(ContextAuthRecordKey) != nil {
+ requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
diff --git a/apis/middlewares_test.go b/apis/middlewares_test.go
index 451145ce1..6dd81fdd6 100644
--- a/apis/middlewares_test.go
+++ b/apis/middlewares_test.go
@@ -12,11 +12,11 @@ import (
func TestRequireGuestOnly(t *testing.T) {
scenarios := []tests.ApiScenario{
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -38,7 +38,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -60,7 +60,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -103,7 +103,7 @@ func TestRequireGuestOnly(t *testing.T) {
}
}
-func TestRequireUserAuth(t *testing.T) {
+func TestRequireRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -129,7 +129,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -151,7 +151,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ apis.RequireRecordAuth(),
},
})
},
@@ -169,11 +169,11 @@ func TestRequireUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireUserAuth(),
+ 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(),
},
})
},
@@ -223,7 +383,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -241,11 +401,11 @@ func TestRequireAdminAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -267,7 +427,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -342,7 +502,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -360,11 +520,11 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -386,7 +546,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -410,7 +570,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
}
-func TestRequireAdminOrUserAuth(t *testing.T) {
+func TestRequireAdminOrRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -436,7 +596,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -454,11 +614,11 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token",
+ Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ 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"),
},
})
},
@@ -480,7 +684,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrUserAuth(),
+ 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"),
},
})
},
@@ -509,7 +735,7 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "guest",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
@@ -528,9 +754,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -548,12 +774,33 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token (different user)",
+ 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/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -571,11 +818,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "valid user token (owner)",
+ Name: "valid record token (owner)",
Method: http.MethodGet,
- Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -595,9 +842,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "valid admin token",
Method: http.MethodGet,
- Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
+ Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
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
index d10ba9f51..5ba3fdb60 100644
--- a/apis/realtime.go
+++ b/apis/realtime.go
@@ -15,13 +15,12 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/rest"
"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) {
+// bindRealtimeApi registers the realtime api endpoints.
+func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app))
@@ -113,25 +112,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// read request data
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
// validate request data
if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
// find subscription client
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
- return rest.NewNotFoundError("Missing or invalid client id.", err)
+ 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 rest.NewForbiddenError("The current and the previous request authorization don't match.", nil)
+ return NewForbiddenError("The current and the previous request authorization don't match.", nil)
}
event := &core.RealtimeSubscribeEvent{
@@ -143,7 +142,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
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(ContextUserKey, e.HttpContext.Get(ContextUserKey))
+ e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
@@ -161,53 +160,52 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
return handlerErr
}
-func (api *realtimeApi) bindEvents() {
- userTable := (&models.User{}).TableName()
- adminTable := (&models.Admin{}).TableName()
+// 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
+}
- // update user/admin auth state
+// 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 {
- modelTable := e.Model.TableName()
-
- var contextKey string
- switch modelTable {
- case userTable:
- contextKey = ContextUserKey
- case adminTable:
- contextKey = ContextAdminKey
- default:
- return nil
+ if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
+ return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- model, _ := client.Get(contextKey).(models.Model)
- if model != nil && model.GetId() == e.Model.GetId() {
- client.Set(contextKey, e.Model)
- }
+ if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
+ return api.updateClientsAuthModel(ContextAdminKey, admin)
}
return nil
})
- // remove user/admin client(s)
+ // remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
- modelTable := e.Model.TableName()
-
- var contextKey string
- switch modelTable {
- case userTable:
- contextKey = ContextUserKey
- case adminTable:
- contextKey = ContextAdminKey
- default:
- return nil
+ if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
+ return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
}
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- model, _ := client.Get(contextKey).(models.Model)
- if model != nil && model.GetId() == e.Model.GetId() {
- api.app.SubscriptionsBroker().Unregister(client.Id())
- }
+ if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
+ return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
}
return nil
@@ -254,17 +252,17 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
// emulate request data
requestData := map[string]any{
- "method": "get",
+ "method": "GET",
"query": map[string]any{},
"data": map[string]any{},
- "user": nil,
+ "auth": nil,
}
- user, _ := client.Get(ContextUserKey).(*models.User)
- if user != nil {
- requestData["user"], _ = user.AsMap()
+ authRecord, _ := client.Get(ContextAuthRecordKey).(*models.Record)
+ if authRecord != nil {
+ requestData["auth"] = authRecord.PublicExport()
}
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
@@ -275,7 +273,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil
}
- foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
+ foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
@@ -303,6 +301,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
// 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,
@@ -316,7 +316,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
Record: &cleanRecord,
}
- serializedData, err := json.Marshal(data)
+ dataBytes, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
@@ -324,6 +324,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return err
}
+ encodedData := string(dataBytes)
+
for _, client := range clients {
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
@@ -336,7 +338,21 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
msg := subscriptions.Message{
Name: subscription,
- Data: string(serializedData),
+ 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
@@ -351,9 +367,9 @@ type getter interface {
}
func extractAuthIdFromGetter(val getter) string {
- user, _ := val.Get(ContextUserKey).(*models.User)
- if user != nil {
- return user.Id
+ record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
+ if record != nil {
+ return record.Id
}
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
diff --git a/apis/realtime_test.go b/apis/realtime_test.go
index 9de66139b..6810ad228 100644
--- a/apis/realtime_test.go
+++ b/apis/realtime_test.go
@@ -46,7 +46,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
client.Set(apis.ContextAdminKey, nil)
- client.Set(apis.ContextUserKey, nil)
+ client.Set(apis.ContextAuthRecordKey, nil)
}
scenarios := []tests.ApiScenario{
@@ -113,7 +113,7 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -132,12 +132,12 @@ func TestRealtimeSubscribe(t *testing.T) {
},
},
{
- Name: "existing client - authorized user",
+ 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": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -148,9 +148,9 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- user, _ := client.Get(apis.ContextUserKey).(*models.User)
- if user == nil {
- t.Errorf("Expected user auth model, got nil")
+ authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ if authRecord == nil {
+ t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -161,21 +161,21 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- initialAuth := &models.User{}
+ initialAuth := &models.Record{}
initialAuth.RefreshId()
- client.Set(apis.ContextUserKey, initialAuth)
+ client.Set(apis.ContextAuthRecordKey, initialAuth)
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- user, _ := client.Get(apis.ContextUserKey).(*models.User)
- if user == nil {
- t.Errorf("Expected user auth model, got nil")
+ authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ if authRecord == nil {
+ t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -187,55 +187,55 @@ func TestRealtimeSubscribe(t *testing.T) {
}
}
-func TestRealtimeUserDeleteEvent(t *testing.T) {
+func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
- user, err := testApp.Dao().FindUserByEmail("test@example.com")
+ authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextUserKey, user)
+ client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
- testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
+ 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 TestRealtimeUserUpdateEvent(t *testing.T) {
+func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
- user1, err := testApp.Dao().FindUserByEmail("test@example.com")
+ authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextUserKey, user1)
+ client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the user and change its email
- user2, err := testApp.Dao().FindUserByEmail("test@example.com")
+ // refetch the authRecord and change its email
+ authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
- user2.Email = "new@example.com"
+ authRecord2.SetEmail("new@example.com")
- testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
+ testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
- clientUser, _ := client.Get(apis.ContextUserKey).(*models.User)
- if clientUser.Email != user2.Email {
- t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email)
+ 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())
}
}
@@ -276,7 +276,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the user and change its email
+ // refetch the authRecord and change its email
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
@@ -287,6 +287,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
- t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.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
new file mode 100644
index 000000000..76ae9d635
--- /dev/null
+++ b/apis/record_auth.go
@@ -0,0 +1,477 @@
+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 {
+ admin, _ := e.HttpContext.Get(ContextAdminKey).(*models.Admin)
+
+ // 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 {
+ requestData := exportRequestData(e.HttpContext)
+ requestData["auth"] = e.Record.PublicExport()
+ failed := api.app.Dao().ExpandRecord(
+ e.Record,
+ expands,
+ expandFetch(api.app.Dao(), admin != nil, 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 := exportRequestData(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: %v", 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)
+ }
+
+ // run in background because we don't need to show
+ // the result to the user (prevents users enumeration)
+ routine.FireAndForget(func() {
+ if err := form.Submit(); err != nil && api.app.IsDebug() {
+ log.Println(err)
+ }
+ })
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+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)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("Failed to set new password.", submitErr)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+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)
+ }
+
+ // run in background because we don't need to show
+ // the result to the user (prevents users enumeration)
+ routine.FireAndForget(func() {
+ if err := form.Submit(); err != nil && api.app.IsDebug() {
+ log.Println(err)
+ }
+ })
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+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)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("An error occurred while submitting the form.", submitErr)
+ }
+
+ // don't return an auth response if the collection doesn't allow email or username authentication
+ authOptions := collection.AuthOptions()
+ if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
+ return c.NoContent(http.StatusNoContent)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+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)
+ }
+
+ if err := form.Submit(); err != nil {
+ return NewBadRequestError("Failed to request email change.", err)
+ }
+
+ return c.NoContent(http.StatusNoContent)
+}
+
+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)
+ }
+
+ record, submitErr := form.Submit()
+ if submitErr != nil {
+ return NewBadRequestError("Failed to confirm email change.", submitErr)
+ }
+
+ return api.authResponse(c, record, nil)
+}
+
+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.OnRecordListExternalAuths().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
new file mode 100644
index 000000000..97dc98a3b
--- /dev/null
+++ b/apis/record_auth_test.go
@@ -0,0 +1,1115 @@
+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,
+ "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?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
+ "password":"12345678",
+ "passwordConfirm":"12345678"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"test@example.com"`,
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 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,
+ },
+ {
+ 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,
+ "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,
+ 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?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"test@example.com"`,
+ `"verified":true`,
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 1,
+ },
+ },
+ {
+ Name: "valid token (already verified)",
+ Method: http.MethodPost,
+ Url: "/api/collections/users/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"oap640cot4yru2s"`,
+ `"email":"test2@example.com"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ `"expand":`, // no rel id attached
+ `"missing":`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ },
+ },
+ {
+ Name: "valid verification token from a collection without allowed login",
+ Method: http.MethodPost,
+ Url: "/api/collections/nologin/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.coREjeTDS3_Go7DP1nxHtevIX5rujwHU-_mRB6oOm3w"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 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,
+ },
+ },
+ }
+
+ 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: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"email":"change@example.com"`,
+ `"verified":true`,
+ },
+ ExpectedEvents: map[string]int{
+ "OnRecordAuthRequest": 1,
+ "OnModelAfterUpdate": 1,
+ "OnModelBeforeUpdate": 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{"OnRecordListExternalAuths": 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{"OnRecordListExternalAuths": 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{"OnRecordListExternalAuths": 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{"OnRecordListExternalAuths": 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.go b/apis/record_crud.go
similarity index 63%
rename from apis/record.go
rename to apis/record_crud.go
index 8493ac8e6..ea8790f77 100644
--- a/apis/record.go
+++ b/apis/record_crud.go
@@ -13,27 +13,27 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
-// BindRecordApi registers the record api endpoints and the corresponding handlers.
-func BindRecordApi(app core.App, rg *echo.Group) {
+// 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/records",
+ "/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
- subGroup.GET("", api.list)
- subGroup.POST("", api.create)
- subGroup.GET("/:id", api.view)
- subGroup.PATCH("/:id", api.update)
- subGroup.DELETE("/:id", api.delete)
+ 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 {
@@ -43,13 +43,13 @@ type recordApi struct {
func (api *recordApi) list(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid users and guests to query special filter/sort fields
@@ -57,13 +57,18 @@ func (api *recordApi) list(c echo.Context) error {
return err
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
- fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ fieldsResolver := resolvers.NewRecordFieldResolver(
+ api.app.Dao(),
+ collection,
+ requestData,
+ // hidden fields are searchable only by admins
+ admin != nil,
+ )
searchProvider := search.NewProvider(fieldsResolver).
- Query(api.app.Dao().RecordQuery(collection)).
- CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
+ Query(api.app.Dao().RecordQuery(collection))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
@@ -72,7 +77,7 @@ func (api *recordApi) list(c echo.Context) error {
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
if err != nil {
- return rest.NewBadRequestError("Invalid filter parameters.", err)
+ return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
@@ -83,13 +88,22 @@ func (api *recordApi) list(c echo.Context) error {
failed := api.app.Dao().ExpandRecords(
records,
expands,
- api.expandFunc(c, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), records, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
result.Items = records
event := &core.RecordsListEvent{
@@ -107,25 +121,25 @@ func (api *recordApi) list(c echo.Context) error {
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
@@ -136,21 +150,30 @@ func (api *recordApi) view(c echo.Context) error {
return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
- api.expandFunc(c, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
@@ -164,21 +187,27 @@ func (api *recordApi) view(c echo.Context) error {
func (api *recordApi) create(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
+
+ hasFullManageAccess := admin != nil
// temporary save the record and check it against the create rule
- if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" {
- ruleFunc := func(q *dbx.SelectQuery) error {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ if 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
@@ -190,25 +219,32 @@ func (api *recordApi) create(c echo.Context) error {
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
- if err := testForm.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ 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 {
- _, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc)
- return fetchErr
+ foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
+ if err != nil {
+ return err
+ }
+ hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
+ return nil
})
+
if testErr != nil {
- return rest.NewBadRequestError("Failed to create record.", testErr)
+ return NewBadRequestError("Failed to create record.", fmt.Errorf("DrySubmit error: %v", testErr))
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(api.app, record)
+ form.SetFullManageAccess(hasFullManageAccess)
// load request
- if err := form.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if err := form.LoadRequest(c.Request(), ""); err != nil {
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
@@ -221,19 +257,28 @@ func (api *recordApi) create(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create record.", err)
+ return NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
- api.expandFunc(e.HttpContext, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -249,25 +294,25 @@ func (api *recordApi) create(c echo.Context) error {
func (api *recordApi) update(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -279,16 +324,17 @@ func (api *recordApi) update(c echo.Context) error {
}
// fetch record
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
form := forms.NewRecordUpsert(api.app, record)
+ form.SetFullManageAccess(admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
// load request
- if err := form.LoadData(c.Request()); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if err := form.LoadRequest(c.Request(), ""); err != nil {
+ return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
@@ -301,19 +347,28 @@ func (api *recordApi) update(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update record.", err)
+ return NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
- api.expandFunc(e.HttpContext, requestData),
+ expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
+ if collection.IsAuth() {
+ err := autoIgnoreAuthRecordsEmailVisibility(
+ api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
+ )
+ if err != nil && api.app.IsDebug() {
+ log.Println("IgnoreEmailVisibility failure:", err)
+ }
+ }
+
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -329,25 +384,25 @@ func (api *recordApi) update(c echo.Context) error {
func (api *recordApi) delete(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
- return rest.NewNotFoundError("", "Missing collection context.")
+ return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
- return rest.NewForbiddenError("Only admins can perform this action.", nil)
+ return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
- return rest.NewNotFoundError("", nil)
+ return NewNotFoundError("", nil)
}
- requestData := api.exportRequestData(c)
+ requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
+ resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
@@ -358,9 +413,9 @@ func (api *recordApi) delete(c echo.Context) error {
return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
+ record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return rest.NewNotFoundError("", fetchErr)
+ return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
@@ -371,7 +426,7 @@ func (api *recordApi) delete(c echo.Context) error {
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 rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
+ 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)
@@ -384,29 +439,6 @@ func (api *recordApi) delete(c echo.Context) error {
return handlerErr
}
-func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
- result := map[string]any{}
- queryParams := map[string]any{}
- bodyData := map[string]any{}
- method := c.Request().Method
-
- echo.BindQueryParams(c, &queryParams)
-
- rest.BindBody(c, &bodyData)
-
- result["method"] = method
- result["query"] = queryParams
- result["data"] = bodyData
- result["user"] = nil
-
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
- if loggedUser != nil {
- result["user"], _ = loggedUser.AsMap()
- }
-
- return result
-}
-
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
@@ -418,37 +450,9 @@ func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
- return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
+ return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
-
-func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
-
- return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
- return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error {
- if admin != nil {
- return nil // admin 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(api.app.Dao(), relCollection, requestData)
- expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
- if err != nil {
- return err
- }
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
- }
-
- return nil
- })
- }
-}
diff --git a/apis/record_crud_test.go b/apis/record_crud_test.go
new file mode 100644
index 000000000..4e47b7a87
--- /dev/null
+++ b/apis/record_crud_test.go
@@ -0,0 +1,1725 @@
+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 invalid format",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ Name: "submit nil body",
+ Method: http.MethodPost,
+ Url: "/api/collections/demo2/records",
+ Body: nil,
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ },
+ {
+ 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: "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
new file mode 100644
index 000000000..c2b8c98c1
--- /dev/null
+++ b/apis/record_helpers.go
@@ -0,0 +1,186 @@
+package apis
+
+import (
+ "fmt"
+
+ "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"
+ "github.com/spf13/cast"
+)
+
+// exportRequestData exports a map with common request fields.
+//
+// @todo consider changing the map to a typed struct after v0.8 and the
+// IN operator support.
+func exportRequestData(c echo.Context) map[string]any {
+ result := map[string]any{}
+ queryParams := map[string]any{}
+ bodyData := map[string]any{}
+ method := c.Request().Method
+
+ echo.BindQueryParams(c, &queryParams)
+
+ rest.BindBody(c, &bodyData)
+
+ result["method"] = method
+ result["query"] = queryParams
+ result["data"] = bodyData
+ result["auth"] = nil
+
+ auth, _ := c.Get(ContextAuthRecordKey).(*models.Record)
+ if auth != nil {
+ result["auth"] = auth.PublicExport()
+ }
+
+ return result
+}
+
+// expandFetch is the records fetch function that is used to expand related records.
+func expandFetch(
+ dao *daos.Dao,
+ isAdmin bool,
+ requestData map[string]any,
+) 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 isAdmin {
+ 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, isAdmin, 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,
+ isAdmin bool,
+ requestData map[string]any,
+) error {
+ if len(records) == 0 || !records[0].Collection().IsAuth() {
+ return nil // nothing to check
+ }
+
+ if isAdmin {
+ for _, rec := range records {
+ rec.IgnoreEmailVisibility(true)
+ }
+ return nil
+ }
+
+ collection := records[0].Collection()
+
+ mappedRecords := make(map[string]*models.Record, len(records))
+ recordIds := make([]any, 0, len(records))
+ for _, rec := range records {
+ mappedRecords[rec.Id] = rec
+ recordIds = append(recordIds, rec.Id)
+ }
+
+ if auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil {
+ mappedRecords[cast.ToString(auth["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 map[string]any,
+) 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 auth, ok := requestData["auth"].(map[string]any); !ok || cast.ToString(auth["id"]) == "" {
+ 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_test.go b/apis/record_test.go
deleted file mode 100644
index e0ed740d5..000000000
--- a/apis/record_test.go
+++ /dev/null
@@ -1,1052 +0,0 @@
-package apis_test
-
-import (
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestRecordsList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodGet,
- Url: "/api/collections/missing/records",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "public collection but with admin only filter/sort (aka. @collection)",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records?filter=@collection.demo.title='test'",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records?filter=%40collection.demo.title%3D%27test%27",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "public collection",
- Method: http.MethodGet,
- Url: "/api/collections/demo3/records",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":1`,
- `"items":[{`,
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "using the collection id as identifier",
- Method: http.MethodGet,
- Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89/records",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":1`,
- `"items":[{`,
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "valid query params",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records?filter=title%7E%27test%27&sort=-title",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":2`,
- `"items":[{`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "invalid filter",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records?filter=invalid~'test'",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expand relations",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":2`,
- `"totalItems":2`,
- `"items":[{`,
- `"@expand":{`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"manyrels":[{`,
- `"manyrels":[]`,
- `"cascaderel":"`,
- `"onerel":{"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc","@collectionName":"demo",`,
- `"json":[1,2,3]`,
- `"select":["a","b"]`,
- `"select":[]`,
- `"user":""`,
- `"bool":true`,
- `"number":456`,
- `"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "authorized as user that DOESN'T match the collection list rule",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records",
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":0`,
- `"items":[]`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- {
- Name: "authorized as user that matches the collection list rule",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":2`,
- `"items":[{`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordView(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodGet,
- Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (unauthorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid record id (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/invalid",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (unauthorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (authorized)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as admin",
- Method: http.MethodGet,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as admin (using the collection id as identifier)",
- Method: http.MethodGet,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as admin (test rule skipping)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- `"manyrels":[]`,
- `"onerel":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "access record as user (filter mismatch)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as user (filter match)",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- {
- Name: "expand relations",
- Method: http.MethodGet,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=manyrels,onerel",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
- `"@collectionName":"demo2"`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- },
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordDelete(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/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (unauthorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record (authorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (unauthorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "mismatched collection-record pair (authorized)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user trying to access nil rule collection (aka. need admin auth)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "access record as admin",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelAfterUpdate": 1, // nullify related record
- "OnModelBeforeUpdate": 1, // nullify related record
- "OnModelBeforeDelete": 3, // +2 cascade delete related records
- "OnModelAfterDelete": 3, // +2 cascade delete related records
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "access record as admin (using the collection id as identifier)",
- Method: http.MethodDelete,
- Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelAfterUpdate": 1, // nullify related record
- "OnModelBeforeUpdate": 1, // nullify related record
- "OnModelBeforeDelete": 3, // +2 cascade delete related records
- "OnModelAfterDelete": 3, // +2 cascade delete related records
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "deleting record as admin (test rule skipping)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- },
- },
- {
- Name: "deleting record as user (filter mismatch)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "deleting record as user (filter match)",
- Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- {
- Name: "trying to delete record while being part of a non-cascade required relation",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/848a1dea-5ddd-42d6-a00d-030547bffcfe",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- },
- },
- {
- Name: "cascade delete referenced records",
- Method: http.MethodDelete,
- Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeDelete": 3,
- "OnModelAfterDelete": 3,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- recId := "63c2ab80-84ab-4057-a592-4604a731f78f"
- col, _ := app.Dao().FindCollectionByNameOrId("demo2")
- rec, _ := app.Dao().FindRecordById(col, recId, nil)
- if rec != nil {
- t.Errorf("Expected record %s to be cascade deleted", recId)
- }
- ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
- ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordCreate(t *testing.T) {
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "new",
- }, "file")
- 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/demo/records",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user trying to access nil-rule collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo/records",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit invalid format",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit nil body",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: nil,
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest submit in public collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
- Body: strings.NewReader(`{"title":"new"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"title":"new"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule failure check)",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
- Body: strings.NewReader(`{
- "cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels": ["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "text": "test123",
- "bool": "false",
- "number": 1
- }`),
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "text":"test123",
- "bool":true,
- "number":1
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"text":"test123"`,
- `"bool":true`,
- `"number":1`,
- `"@expand":{`,
- `"selfrel":{`,
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- },
- NotExpectedContent: []string{
- // user don't have access to view the below expands
- `"manyrels":[{`,
- `"onerel":{`,
- },
- 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/demo2/records?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
- "manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
- "selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9",
- "text": "test123",
- "bool": false,
- "number": 1
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"text":"test123"`,
- `"bool":false`,
- `"number":1`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"selfrel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- {
- 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": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- 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": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- 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": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"123456789012345"`,
- `"title":"test"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
-
- {
- Name: "submit via multipart form data",
- Method: http.MethodPost,
- Url: "/api/collections/demo/records",
- Body: formData,
- RequestHeaders: map[string]string{
- "Content-Type": mp.FormDataContentType(),
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"`,
- `"title":"new"`,
- `"file":"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordUpdate(t *testing.T) {
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "new",
- }, "file")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodPatch,
- Url: "/api/collections/missing/records/2c542824-9de1-42fe-8924-e57c86267760",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/00000000-9de1-42fe-8924-e57c86267760",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest trying to edit nil-rule collection record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user trying to edit nil-rule collection record",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit invalid format",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit nil body",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: nil,
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest submit in public collection",
- Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760",
- Body: strings.NewReader(`{"title":"new"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"2c542824-9de1-42fe-8924-e57c86267760"`,
- `"title":"new"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule failure check)",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9",
- Body: strings.NewReader(`{"text": "test_new"}`),
- RequestHeaders: map[string]string{
- // test@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "text":"test_new",
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "bool":true
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
- `"bool":true`,
- `"text":"test_new"`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"@expand":{`,
- `"selfrel":{`,
- },
- NotExpectedContent: []string{
- // user don't have access to view the below expands
- `"manyrels":[{`,
- `"onerel":{`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "user submit in restricted collection (rule pass check) + expand relations (no view rule access when bool is false)",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
- Body: strings.NewReader(`{
- "selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
- "bool":false
- }`),
- RequestHeaders: map[string]string{
- // test3@example.com
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"bool":false`,
- `"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- },
- NotExpectedContent: []string{
- `"@expand":{`,
- `"manyrels":[{`, // admin only
- `"onerel":{`, // admin only
- `"selfrel":{`, // bool=true view rule
- },
- 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/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=onerel,manyrels,selfrel,missing",
- Body: strings.NewReader(`{
- "text":"test_new",
- "number":1,
- "selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9"
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
- `"text":"test_new"`,
- `"number":1`,
- `"@expand":{`,
- `"manyrels":[{`,
- `"onerel":{`,
- `"selfrel":{`,
- `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
- `"@collectionName":"demo"`,
- `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
- `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
- `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "submit via multipart form data",
- Method: http.MethodPatch,
- Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209",
- Body: formData,
- RequestHeaders: map[string]string{
- "Content-Type": mp.FormDataContentType(),
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`,
- `"title":"new"`,
- `"file":"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
diff --git a/apis/settings.go b/apis/settings.go
index 415eea23e..56035a1f2 100644
--- a/apis/settings.go
+++ b/apis/settings.go
@@ -7,12 +7,11 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
)
-// BindSettingsApi registers the settings api endpoints.
-func BindSettingsApi(app core.App, rg *echo.Group) {
+// 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())
@@ -29,7 +28,7 @@ type settingsApi struct {
func (api *settingsApi) list(c echo.Context) error {
settings, err := api.app.Settings().RedactClone()
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
@@ -47,7 +46,7 @@ func (api *settingsApi) set(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
@@ -61,12 +60,12 @@ func (api *settingsApi) set(c echo.Context) error {
return func() error {
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
- return rest.NewBadRequestError("An error occurred while submitting the form.", err)
+ return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
- return rest.NewBadRequestError("", err)
+ return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
@@ -83,23 +82,23 @@ func (api *settingsApi) set(c echo.Context) error {
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
- return rest.NewBadRequestError("S3 storage is not enabled.", nil)
+ return NewBadRequestError("S3 storage is not enabled.", nil)
}
fs, err := api.app.NewFilesystem()
if err != nil {
- return rest.NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
}
defer fs.Close()
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt"
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
- return rest.NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
- return rest.NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
+ return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
@@ -110,18 +109,18 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
+ 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 rest.NewBadRequestError("Failed to send the test email.", fErr)
+ return NewBadRequestError("Failed to send the test email.", fErr)
}
// mailer error
- return rest.NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
+ 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
index 55020ad1e..70f21264a 100644
--- a/apis/settings_test.go
+++ b/apis/settings_test.go
@@ -19,11 +19,11 @@ func TestSettingsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -33,7 +33,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -43,15 +43,16 @@ func TestSettingsList(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
+ `"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
@@ -68,7 +69,7 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
- validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
+ validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{
{
@@ -80,12 +81,12 @@ func TestSettingsSet(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -96,7 +97,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -106,10 +107,10 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -119,7 +120,6 @@ func TestSettingsSet(t *testing.T) {
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
- `"minPasswordLength":8`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -132,15 +132,14 @@ func TestSettingsSet(t *testing.T) {
Name: "authorized as admin submitting invalid data",
Method: http.MethodPatch,
Url: "/api/settings",
- Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
+ Body: strings.NewReader(`{"meta":{"appName":""}}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
- `"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
+ `"meta":{"appName":{"code":"validation_required"`,
},
},
{
@@ -149,7 +148,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -159,20 +158,20 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
- `"userAuthToken":{`,
- `"userPasswordResetToken":{`,
- `"userEmailChangeToken":{`,
- `"userVerificationToken":{`,
+ `"recordAuthToken":{`,
+ `"recordPasswordResetToken":{`,
+ `"recordEmailChangeToken":{`,
+ `"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
+ `"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
- `"minPasswordLength":12`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -198,11 +197,11 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -212,12 +211,11 @@ func TestSettingsTestS3(t *testing.T) {
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
- // @todo consider creating a test S3 filesystem
}
for _, scenario := range scenarios {
@@ -239,7 +237,7 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
- Name: "authorized as user",
+ Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
@@ -247,7 +245,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -258,7 +256,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -269,7 +267,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -286,7 +284,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -304,8 +302,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserVerificationSend": 1,
- "OnMailerAfterUserVerificationSend": 1,
+ "OnMailerBeforeRecordVerificationSend": 1,
+ "OnMailerAfterRecordVerificationSend": 1,
},
},
{
@@ -317,7 +315,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -335,8 +333,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserResetPasswordSend": 1,
- "OnMailerAfterUserResetPasswordSend": 1,
+ "OnMailerBeforeRecordResetPasswordSend": 1,
+ "OnMailerAfterRecordResetPasswordSend": 1,
},
},
{
@@ -348,7 +346,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -366,8 +364,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeUserChangeEmailSend": 1,
- "OnMailerAfterUserChangeEmailSend": 1,
+ "OnMailerBeforeRecordChangeEmailSend": 1,
+ "OnMailerAfterRecordChangeEmailSend": 1,
},
},
}
diff --git a/apis/user.go b/apis/user.go
deleted file mode 100644
index c7f00eeaa..000000000
--- a/apis/user.go
+++ /dev/null
@@ -1,519 +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/auth"
- "github.com/pocketbase/pocketbase/tools/rest"
- "github.com/pocketbase/pocketbase/tools/routine"
- "github.com/pocketbase/pocketbase/tools/search"
- "github.com/pocketbase/pocketbase/tools/security"
- "golang.org/x/oauth2"
-)
-
-// BindUserApi registers the user api endpoints and the corresponding handlers.
-func BindUserApi(app core.App, rg *echo.Group) {
- api := userApi{app: app}
-
- subGroup := rg.Group("/users", ActivityLogger(app))
- subGroup.GET("/auth-methods", api.authMethods)
- subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly())
- subGroup.POST("/auth-via-email", api.emailAuth, 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, RequireUserAuth())
- subGroup.POST("/confirm-email-change", api.confirmEmailChange)
- subGroup.POST("/refresh", api.refresh, RequireUserAuth())
- // crud
- subGroup.GET("", api.list, RequireAdminAuth())
- subGroup.POST("", api.create)
- subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
- subGroup.PATCH("/:id", api.update, RequireAdminAuth())
- subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
- subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
- subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
-}
-
-type userApi struct {
- app core.App
-}
-
-func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error {
- token, tokenErr := tokens.NewUserAuthToken(api.app, user)
- if tokenErr != nil {
- return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
- }
-
- event := &core.UserAuthEvent{
- HttpContext: c,
- User: user,
- Token: token,
- Meta: meta,
- }
-
- return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error {
- result := map[string]any{
- "token": e.Token,
- "user": e.User,
- }
-
- if e.Meta != nil {
- result["meta"] = e.Meta
- }
-
- return e.HttpContext.JSON(http.StatusOK, result)
- })
-}
-
-func (api *userApi) refresh(c echo.Context) error {
- user, _ := c.Get(ContextUserKey).(*models.User)
- if user == nil {
- return rest.NewNotFoundError("Missing auth user context.", nil)
- }
-
- return api.authResponse(c, user, 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 *userApi) authMethods(c echo.Context) error {
- result := struct {
- EmailPassword bool `json:"emailPassword"`
- AuthProviders []providerInfo `json:"authProviders"`
- }{
- EmailPassword: true,
- AuthProviders: []providerInfo{},
- }
-
- settings := api.app.Settings()
-
- result.EmailPassword = settings.EmailAuth.Enabled
-
- nameConfigMap := 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)
- }
-
- // skip provider
- continue
- }
-
- if err := config.SetupProvider(provider); err != nil {
- if api.app.IsDebug() {
- log.Println(err)
- }
-
- // skip provider
- continue
- }
-
- 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 *userApi) oauth2Auth(c echo.Context) error {
- form := forms.NewUserOauth2Login(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, authData, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
- }
-
- return api.authResponse(c, user, authData)
-}
-
-func (api *userApi) emailAuth(c echo.Context) error {
- if !api.app.Settings().EmailAuth.Enabled {
- return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
- }
-
- form := forms.NewUserEmailLogin(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to authenticate.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestPasswordReset(c echo.Context) error {
- form := forms.NewUserPasswordResetRequest(api.app)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- // run in background because we don't need to show
- // the result to the user (prevents users enumeration)
- routine.FireAndForget(func() {
- if err := form.Submit(); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmPasswordReset(c echo.Context) error {
- form := forms.NewUserPasswordResetConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to set new password.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestEmailChange(c echo.Context) error {
- loggedUser, _ := c.Get(ContextUserKey).(*models.User)
- if loggedUser == nil {
- return rest.NewUnauthorizedError("The request requires valid authorized user.", nil)
- }
-
- form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Submit(); err != nil {
- return rest.NewBadRequestError("Failed to request email change.", err)
- }
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmEmailChange(c echo.Context) error {
- form := forms.NewUserEmailChangeConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("Failed to confirm email change.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-func (api *userApi) requestVerification(c echo.Context) error {
- form := forms.NewUserVerificationRequest(api.app)
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return rest.NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- // run in background because we don't need to show
- // the result to the user (prevents users enumeration)
- routine.FireAndForget(func() {
- if err := form.Submit(); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *userApi) confirmVerification(c echo.Context) error {
- form := forms.NewUserVerificationConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- user, submitErr := form.Submit()
- if submitErr != nil {
- return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
- }
-
- return api.authResponse(c, user, nil)
-}
-
-// -------------------------------------------------------------------
-// CRUD
-// -------------------------------------------------------------------
-
-func (api *userApi) list(c echo.Context) error {
- fieldResolver := search.NewSimpleFieldResolver(
- "id", "created", "updated", "email", "verified",
- )
-
- users := []*models.User{}
-
- result, searchErr := search.NewProvider(fieldResolver).
- Query(api.app.Dao().UserQuery()).
- ParseAndExec(c.QueryString(), &users)
- if searchErr != nil {
- return rest.NewBadRequestError("", searchErr)
- }
-
- // eager load user profiles (if any)
- if err := api.app.Dao().LoadProfiles(users); err != nil {
- return rest.NewBadRequestError("", err)
- }
-
- event := &core.UsersListEvent{
- HttpContext: c,
- Users: users,
- Result: result,
- }
-
- return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.Result)
- })
-}
-
-func (api *userApi) view(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- event := &core.UserViewEvent{
- HttpContext: c,
- User: user,
- }
-
- return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
-}
-
-func (api *userApi) create(c echo.Context) error {
- if !api.app.Settings().EmailAuth.Enabled {
- return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
- }
-
- user := &models.User{}
- form := forms.NewUserUpsert(api.app, user)
-
- // load request
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := &core.UserCreateEvent{
- HttpContext: c,
- User: user,
- }
-
- // create the user
- submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
- if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to create user.", err)
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
- }
- })
-
- if submitErr == nil {
- api.app.OnUserAfterCreateRequest().Trigger(event)
- }
-
- return submitErr
-}
-
-func (api *userApi) update(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- form := forms.NewUserUpsert(api.app, user)
-
- // load request
- if err := c.Bind(form); err != nil {
- return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := &core.UserUpdateEvent{
- HttpContext: c,
- User: user,
- }
-
- // update the user
- submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
- return func() error {
- return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
- if err := next(); err != nil {
- return rest.NewBadRequestError("Failed to update user.", err)
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.User)
- })
- }
- })
-
- if submitErr == nil {
- api.app.OnUserAfterUpdateRequest().Trigger(event)
- }
-
- return submitErr
-}
-
-func (api *userApi) delete(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- event := &core.UserDeleteEvent{
- HttpContext: c,
- User: user,
- }
-
- handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error {
- // delete the user model
- if err := api.app.Dao().DeleteUser(e.User); err != nil {
- return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err)
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
-
- if handlerErr == nil {
- api.app.OnUserAfterDeleteRequest().Trigger(event)
- }
-
- return handlerErr
-}
-
-func (api *userApi) listExternalAuths(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
- if err != nil {
- return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
- }
-
- event := &core.UserListExternalAuthsEvent{
- HttpContext: c,
- User: user,
- ExternalAuths: externalAuths,
- }
-
- return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
- })
-}
-
-func (api *userApi) unlinkExternalAuth(c echo.Context) error {
- id := c.PathParam("id")
- provider := c.PathParam("provider")
- if id == "" || provider == "" {
- return rest.NewNotFoundError("", nil)
- }
-
- user, err := api.app.Dao().FindUserById(id)
- if err != nil || user == nil {
- return rest.NewNotFoundError("", err)
- }
-
- externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
- if err != nil {
- return rest.NewNotFoundError("Missing external auth provider relation.", err)
- }
-
- event := &core.UserUnlinkExternalAuthEvent{
- HttpContext: c,
- User: user,
- ExternalAuth: externalAuth,
- }
-
- handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
- if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
- return rest.NewBadRequestError("Cannot unlink the external auth provider. Make sure that the user has other linked auth providers OR has an email address.", err)
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
-
- if handlerErr == nil {
- api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
- }
-
- return handlerErr
-}
diff --git a/apis/user_test.go b/apis/user_test.go
deleted file mode 100644
index d1576f777..000000000
--- a/apis/user_test.go
+++ /dev/null
@@ -1,1113 +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 TestUsersAuthMethods(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Method: http.MethodGet,
- Url: "/api/users/auth-methods",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"emailPassword":true`,
- `"authProviders":[{`,
- `"authProviders":[{`,
- `"name":"gitlab"`,
- `"state":`,
- `"codeVerifier":`,
- `"codeChallenge":`,
- `"codeChallengeMethod":`,
- `"authUrl":`,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserEmailAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "authorized as user",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid body format",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"","password":""}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{`,
- `"password":{`,
- },
- },
- {
- Name: "disabled email/pass auth with valid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"test@example.com","password":"123456"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.Settings().EmailAuth.Enabled = false
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid data",
- Method: http.MethodPost,
- Url: "/api/users/auth-via-email",
- Body: strings.NewReader(`{"email":"test2@example.com","password":"123456"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token"`,
- `"user"`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"test2@example.com"`,
- `"verified":false`, // unverified user should be able to authenticate
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/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/users/request-password-reset",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing user",
- Method: http.MethodPost,
- Url: "/api/users/request-password-reset",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing user",
- Method: http.MethodPost,
- Url: "/api/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,
- "OnMailerBeforeUserResetPasswordSend": 1,
- "OnMailerAfterUserResetPasswordSend": 1,
- },
- },
- {
- Name: "existing user (after already sent)",
- Method: http.MethodPost,
- Url: "/api/users/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
- user, err := app.Dao().FindUserByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- user.LastResetSentAt = types.NowDateTime()
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(user); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users/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 format",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"password`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQxMDMxMjAwfQ.t2lVe0ny9XruQsSFQdXqBi0I85i6vIUAQjFXZY5HPxc","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- {
- Name: "valid token and data",
- Method: http.MethodPost,
- Url: "/api/users/confirm-password-reset",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTU2MDAwfQ.V1gEbY4caEIF6IhQAJ8KZD4RvOGvTCFuYg1fTRSvhe0","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/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/users/request-verification",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing already verified user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing unverified user",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnMailerBeforeUserVerificationSend": 1,
- "OnMailerAfterUserVerificationSend": 1,
- },
- },
- {
- Name: "existing unverified user (after already sent)",
- Method: http.MethodPost,
- Url: "/api/users/request-verification",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent verification sent
- user, err := app.Dao().FindUserByEmail("test2@example.com")
- if err != nil {
- t.Fatal(err)
- }
- user.LastVerificationSentAt = types.NowDateTime()
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(user); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"token":{"code":"validation_required"`,
- },
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // expired token
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTY0MTAzMTIwMH0.YCqyREksfqn7cWu-innNNTbWQCr9DgYr7dduM2wxrtQ"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- // valid token
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-verification",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTk1NjAwMH0.OsxRKuZrNTnwyVjvCwB4jY8TbT-NPZ-UFCpRhCvuv2U"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"test2@example.com"`,
- `"verified":true`,
- },
- ExpectedEvents: map[string]int{
- "OnUserAuthRequest": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRequestEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_required"`,
- },
- },
- // valid data (existing email)
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_user_email_exists"`,
- },
- },
- // valid data (new email)
- {
- Method: http.MethodPost,
- Url: "/api/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnMailerBeforeUserChangeEmailSend": 1,
- "OnMailerAfterUserChangeEmailSend": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserConfirmEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // empty data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"token":{"code":"validation_required"`,
- `"password":{"code":"validation_required"`,
- },
- },
- // invalid data
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // expired token and correct password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjAwfQ.DOqNtSDcXbWix8OsK13X-tjfWi6jZNlAzIZiwG_YDOs","password":"123456"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- // valid token and incorrect password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"654321"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"password":{`,
- `"code":"validation_invalid_password"`,
- },
- },
- // valid token and correct password
- {
- Method: http.MethodPost,
- Url: "/api/users/confirm-email-change",
- Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"123456"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"email":"change@example.com"`,
- `"verified":true`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserRefresh(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as user
- {
- Method: http.MethodPost,
- Url: "/api/users/refresh",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"user":`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserAuthRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUsersList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- // unauthorized
- {
- Method: http.MethodGet,
- Url: "/api/users",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as user
- {
- Method: http.MethodGet,
- Url: "/api/users",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin
- {
- Method: http.MethodGet,
- Url: "/api/users",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":4`,
- `"items":[{`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- `"id":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- // authorized as admin + paging and sorting
- {
- Method: http.MethodGet,
- Url: "/api/users?page=2&perPage=2&sort=-created",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":2`,
- `"perPage":2`,
- `"totalItems":4`,
- `"items":[{`,
- `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- // authorized as admin + invalid filter
- {
- Method: http.MethodGet,
- Url: "/api/users?filter=invalidfield~'test2'",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- // authorized as admin + valid filter
- {
- Method: http.MethodGet,
- Url: "/api/users?filter=verified=true",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
- `"id":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUsersListRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserView(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodGet,
- Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
- },
- {
- Name: "authorized as user - trying to view another user",
- Method: http.MethodGet,
- Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- },
- ExpectedEvents: map[string]int{"OnUserViewRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserDelete(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodDelete,
- Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnUserBeforeDeleteRequest": 1,
- "OnUserAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 2, // cascade delete to related Record model
- "OnModelAfterDelete": 2, // cascade delete to related Record model
- },
- },
- {
- Name: "authorized as user - trying to delete another user",
- Method: http.MethodDelete,
- Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner",
- Method: http.MethodDelete,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnUserBeforeDeleteRequest": 1,
- "OnUserAfterDeleteRequest": 1,
- "OnModelBeforeDelete": 2, // cascade delete to related Record model
- "OnModelAfterDelete": 2, // cascade delete to related Record model
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserCreate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_required"`,
- `"password":{"code":"validation_required"`,
- },
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_user_email_exists"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
- },
- },
- {
- Name: "valid data but with disabled email/pass auth",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.Settings().EmailAuth.Enabled = false
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid data",
- Method: http.MethodPost,
- Url: "/api/users",
- Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"email":"newuser@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeCreateRequest": 1,
- "OnUserAfterCreateRequest": 1,
- "OnModelBeforeCreate": 2, // +1 for the created profile record
- "OnModelAfterCreate": 2, // +1 for the created profile record
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserUpdate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user (owner)",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - invalid/missing user id",
- Method: http.MethodPatch,
- Url: "/api/users/invalid",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - empty data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeUpdateRequest": 1,
- "OnUserAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- {
- Name: "authorized as admin - invalid data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"email":{"code":"validation_user_email_exists"`,
- },
- },
- {
- Name: "authorized as admin - valid data",
- Method: http.MethodPatch,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
- Body: strings.NewReader(`{"email":"new@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`,
- `"email":"new@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnUserBeforeUpdateRequest": 1,
- "OnUserAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserListExternalsAuths(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting user id",
- Method: http.MethodGet,
- Url: "/api/users/000000000000000/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing user id and no external auths",
- Method: http.MethodGet,
- Url: "/api/users/97cc3d3d-6ba2-383f-b42a-7bc84d27410c/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `[]`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as admin + existing user id and 2 external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"abcdefghijklmn1"`,
- `"id":"abcdefghijklmn0"`,
- `"userId":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as user - trying to list another user external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner without external auths",
- Method: http.MethodGet,
- Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `[]`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- {
- Name: "authorized as user - owner with 2 external auths",
- Method: http.MethodGet,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"abcdefghijklmn1"`,
- `"id":"abcdefghijklmn0"`,
- `"userId":"cx9u0dh2udo8xol"`,
- },
- ExpectedEvents: map[string]int{"OnUserListExternalAuths": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestUserUnlinkExternalsAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - nonexisting user id",
- Method: http.MethodDelete,
- Url: "/api/users/000000000000000/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - nonexisting provider",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/facebook",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin - existing provider",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnUserAfterUnlinkExternalAuthRequest": 1,
- "OnUserBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "google")
- if auth != nil {
- t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
- }
- },
- },
- {
- Name: "authorized as user - trying to unlink another user external auth",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user - owner with existing external auth",
- Method: http.MethodDelete,
- Url: "/api/users/cx9u0dh2udo8xol/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImN4OXUwZGgydWRvOHhvbCIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.NgFYG2D7PftFW1tcfe5E2oDi_AVakDR9J6WI6VUZQfw",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnUserAfterUnlinkExternalAuthRequest": 1,
- "OnUserBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- auth, _ := app.Dao().FindExternalAuthByUserIdAndProvider("cx9u0dh2udo8xol", "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/cmd/temp_upgrade.go b/cmd/temp_upgrade.go
new file mode 100644
index 000000000..127e16822
--- /dev/null
+++ b/cmd/temp_upgrade.go
@@ -0,0 +1,444 @@
+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.0!
+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
index 8e0ea52c9..75dde9ed2 100644
--- a/core/app.go
+++ b/core/app.go
@@ -126,38 +126,38 @@ type App interface {
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
- // OnMailerBeforeUserResetPasswordSend hook is triggered right before
- // sending a password reset email to a user.
+ // 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.
- OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserResetPasswordSend hook is triggered after
- // a user password reset email was successfully sent.
- OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordResetPasswordSend hook is triggered after
+ // an auth record password reset email was successfully sent.
+ OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerBeforeUserVerificationSend hook is triggered right before
- // sending a verification email to a user.
+ // 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.
- OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserVerificationSend hook is triggered after a user
- // verification email was successfully sent.
- OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordVerificationSend hook is triggered after a
+ // verification email was successfully sent to an auth record.
+ OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerBeforeUserChangeEmailSend hook is triggered right before
- // sending a confirmation new address email to a a user.
+ // 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.
- OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
+ OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
- // OnMailerAfterUserChangeEmailSend hook is triggered after a user
- // change address email was successfully sent.
- OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
+ // OnMailerAfterRecordChangeEmailSend hook is triggered after a
+ // verification email was successfully sent to an auth record.
+ OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
// ---------------------------------------------------------------
// Realtime API event hooks
@@ -264,74 +264,31 @@ type App interface {
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// ---------------------------------------------------------------
- // User API event hooks
+ // Auth Record API event hooks
// ---------------------------------------------------------------
- // OnUsersListRequest hook is triggered on each API Users list request.
+ // OnRecordAuthRequest hook is triggered on each successful API
+ // record authentication request (sign-in, token refresh, etc.).
//
- // Could be used to validate or modify the response before returning it to the client.
- OnUsersListRequest() *hook.Hook[*UsersListEvent]
-
- // OnUserViewRequest hook is triggered on each API User view request.
- //
- // Could be used to validate or modify the response before returning it to the client.
- OnUserViewRequest() *hook.Hook[*UserViewEvent]
-
- // OnUserBeforeCreateRequest hook is triggered before each API User
- // 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]).
- OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
-
- // OnUserAfterCreateRequest hook is triggered after each
- // successful API User create request.
- OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
-
- // OnUserBeforeUpdateRequest hook is triggered before each API User
- // 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]).
- OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
-
- // OnUserAfterUpdateRequest hook is triggered after each
- // successful API User update request.
- OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
-
- // OnUserBeforeDeleteRequest hook is triggered before each API User
- // 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]).
- OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
-
- // OnUserAfterDeleteRequest hook is triggered after each
- // successful API User delete request.
- OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]
-
- // OnUserAuthRequest hook is triggered on each successful API User
- // authentication request (sign-in, token refresh, etc.).
- //
- // Could be used to additionally validate or modify the
- // authenticated user data and token.
- OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
+ // Could be used to additionally validate or modify the authenticated
+ // record data and token.
+ OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
- // OnUserListExternalAuths hook is triggered on each API user's external auths list request.
+ // OnRecordListExternalAuths 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.
- OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]
+ OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
- // OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
+ // 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]).
- OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
+ OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
- // OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
- // successful API user's external auth unlink request.
- OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
+ // OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
+ // successful API record external auth unlink request.
+ OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record API event hooks
diff --git a/core/base.go b/core/base.go
index 708fe9654..5369ef92e 100644
--- a/core/base.go
+++ b/core/base.go
@@ -52,14 +52,14 @@ type BaseApp struct {
onModelAfterDelete *hook.Hook[*ModelEvent]
// mailer event hooks
- onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerBeforeUserResetPasswordSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserResetPasswordSend *hook.Hook[*MailerUserEvent]
- onMailerBeforeUserVerificationSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserVerificationSend *hook.Hook[*MailerUserEvent]
- onMailerBeforeUserChangeEmailSend *hook.Hook[*MailerUserEvent]
- onMailerAfterUserChangeEmailSend *hook.Hook[*MailerUserEvent]
+ 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]
@@ -85,19 +85,11 @@ type BaseApp struct {
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
- // user api event hooks
- onUsersListRequest *hook.Hook[*UsersListEvent]
- onUserViewRequest *hook.Hook[*UserViewEvent]
- onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
- onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
- onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
- onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
- onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
- onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
- onUserAuthRequest *hook.Hook[*UserAuthEvent]
- onUserListExternalAuths *hook.Hook[*UserListExternalAuthsEvent]
- onUserBeforeUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
- onUserAfterUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
+ // user api event hooks
+ onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
+ onRecordListExternalAuths *hook.Hook[*RecordListExternalAuthsEvent]
+ onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
+ onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
// record api event hooks
onRecordsListRequest *hook.Hook[*RecordsListEvent]
@@ -147,14 +139,14 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onModelAfterDelete: &hook.Hook[*ModelEvent]{},
// mailer event hooks
- onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerBeforeUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
- onMailerBeforeUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
- onMailerBeforeUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
- onMailerAfterUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
+ 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]{},
@@ -181,18 +173,10 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
// user API event hooks
- onUsersListRequest: &hook.Hook[*UsersListEvent]{},
- onUserViewRequest: &hook.Hook[*UserViewEvent]{},
- onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
- onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
- onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
- onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
- onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
- onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
- onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
- onUserListExternalAuths: &hook.Hook[*UserListExternalAuthsEvent]{},
- onUserBeforeUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
- onUserAfterUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
+ onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
+ onRecordListExternalAuths: &hook.Hook[*RecordListExternalAuthsEvent]{},
+ onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
+ onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
// record API event hooks
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
@@ -469,28 +453,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
return app.onMailerAfterAdminResetPasswordSend
}
-func (app *BaseApp) OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserResetPasswordSend
+func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordResetPasswordSend
}
-func (app *BaseApp) OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserResetPasswordSend
+func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordResetPasswordSend
}
-func (app *BaseApp) OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserVerificationSend
+func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordVerificationSend
}
-func (app *BaseApp) OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserVerificationSend
+func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordVerificationSend
}
-func (app *BaseApp) OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerBeforeUserChangeEmailSend
+func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerBeforeRecordChangeEmailSend
}
-func (app *BaseApp) OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
- return app.onMailerAfterUserChangeEmailSend
+func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
+ return app.onMailerAfterRecordChangeEmailSend
}
// -------------------------------------------------------------------
@@ -574,55 +558,23 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
}
// -------------------------------------------------------------------
-// User API event hooks
+// Auth Record API event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnUsersListRequest() *hook.Hook[*UsersListEvent] {
- return app.onUsersListRequest
+func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
+ return app.onRecordAuthRequest
}
-func (app *BaseApp) OnUserViewRequest() *hook.Hook[*UserViewEvent] {
- return app.onUserViewRequest
+func (app *BaseApp) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] {
+ return app.onRecordListExternalAuths
}
-func (app *BaseApp) OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] {
- return app.onUserBeforeCreateRequest
+func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
+ return app.onRecordBeforeUnlinkExternalAuthRequest
}
-func (app *BaseApp) OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] {
- return app.onUserAfterCreateRequest
-}
-
-func (app *BaseApp) OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] {
- return app.onUserBeforeUpdateRequest
-}
-
-func (app *BaseApp) OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] {
- return app.onUserAfterUpdateRequest
-}
-
-func (app *BaseApp) OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] {
- return app.onUserBeforeDeleteRequest
-}
-
-func (app *BaseApp) OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] {
- return app.onUserAfterDeleteRequest
-}
-
-func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] {
- return app.onUserAuthRequest
-}
-
-func (app *BaseApp) OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] {
- return app.onUserListExternalAuths
-}
-
-func (app *BaseApp) OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
- return app.onUserBeforeUnlinkExternalAuthRequest
-}
-
-func (app *BaseApp) OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
- return app.onUserAfterUnlinkExternalAuthRequest
+func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
+ return app.onRecordAfterUnlinkExternalAuthRequest
}
// -------------------------------------------------------------------
diff --git a/core/base_test.go b/core/base_test.go
index c09f66389..dfa042614 100644
--- a/core/base_test.go
+++ b/core/base_test.go
@@ -195,28 +195,28 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
}
- if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend)
+ 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.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend)
+ 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.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend)
+ 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.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend)
+ 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.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil {
- t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend)
+ 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.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil {
- t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend)
+ 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 {
@@ -283,52 +283,52 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
}
- if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil {
- t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest)
+ 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.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil {
- t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest)
+ 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.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest)
+ 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.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest)
+ 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.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest)
+ 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.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest)
+ 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.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest)
+ 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.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest)
+ 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.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
+ 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.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
- t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
+ if app.onRecordListExternalAuths != app.OnRecordListExternalAuths() || app.OnRecordListExternalAuths() == nil {
+ t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths)
}
- if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
+ 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.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
- t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
+ 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 {
diff --git a/core/events.go b/core/events.go
index 11f885348..e2c55bec6 100644
--- a/core/events.go
+++ b/core/events.go
@@ -33,9 +33,9 @@ type ModelEvent struct {
// Mailer events data
// -------------------------------------------------------------------
-type MailerUserEvent struct {
+type MailerRecordEvent struct {
MailClient mailer.Mailer
- User *models.User
+ Record *models.Record
Meta map[string]any
}
@@ -143,51 +143,25 @@ type AdminAuthEvent struct {
}
// -------------------------------------------------------------------
-// User API events data
+// Auth Record API events data
// -------------------------------------------------------------------
-type UsersListEvent struct {
+type RecordAuthEvent struct {
HttpContext echo.Context
- Users []*models.User
- Result *search.Result
-}
-
-type UserViewEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserCreateEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserUpdateEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserDeleteEvent struct {
- HttpContext echo.Context
- User *models.User
-}
-
-type UserAuthEvent struct {
- HttpContext echo.Context
- User *models.User
+ Record *models.Record
Token string
Meta any
}
-type UserListExternalAuthsEvent struct {
+type RecordListExternalAuthsEvent struct {
HttpContext echo.Context
- User *models.User
+ Record *models.Record
ExternalAuths []*models.ExternalAuth
}
-type UserUnlinkExternalAuthEvent struct {
+type RecordUnlinkExternalAuthEvent struct {
HttpContext echo.Context
- User *models.User
+ Record *models.Record
ExternalAuth *models.ExternalAuth
}
diff --git a/core/settings.go b/core/settings.go
index 4a592855a..d87518c05 100644
--- a/core/settings.go
+++ b/core/settings.go
@@ -23,14 +23,16 @@ type Settings struct {
Smtp SmtpConfig `form:"smtp" json:"smtp"`
S3 S3Config `form:"s3" json:"s3"`
- AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
- AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
- UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"`
- UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"`
- UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"`
- UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"`
-
- EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
+ AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
+ AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
+ RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
+ RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
+ RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
+ RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
+
+ // Deprecated: Will be removed in v0.9!
+ EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
+
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
@@ -52,9 +54,8 @@ func NewSettings() *Settings {
ResetPasswordTemplate: defaultResetPasswordTemplate,
ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
},
-
Logs: LogsConfig{
- MaxDays: 7,
+ MaxDays: 5,
},
Smtp: SmtpConfig{
Enabled: false,
@@ -72,49 +73,39 @@ func NewSettings() *Settings {
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- UserAuthToken: TokenConfig{
+ RecordAuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1209600, // 14 days,
},
- UserPasswordResetToken: TokenConfig{
+ RecordPasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- UserVerificationToken: TokenConfig{
+ RecordVerificationToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 604800, // 7 days,
},
- UserEmailChangeToken: TokenConfig{
+ RecordEmailChangeToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 1800, // 30 minutes,
},
- EmailAuth: EmailAuthConfig{
- Enabled: true,
- MinPasswordLength: 8,
- },
GoogleAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
FacebookAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
GithubAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
GitlabAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
DiscordAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
TwitterAuth: AuthProviderConfig{
- Enabled: false,
- AllowRegistrations: true,
+ Enabled: false,
},
}
}
@@ -129,13 +120,12 @@ func (s *Settings) Validate() error {
validation.Field(&s.Logs),
validation.Field(&s.AdminAuthToken),
validation.Field(&s.AdminPasswordResetToken),
- validation.Field(&s.UserAuthToken),
- validation.Field(&s.UserPasswordResetToken),
- validation.Field(&s.UserEmailChangeToken),
- validation.Field(&s.UserVerificationToken),
+ validation.Field(&s.RecordAuthToken),
+ validation.Field(&s.RecordPasswordResetToken),
+ validation.Field(&s.RecordEmailChangeToken),
+ validation.Field(&s.RecordVerificationToken),
validation.Field(&s.Smtp),
validation.Field(&s.S3),
- validation.Field(&s.EmailAuth),
validation.Field(&s.GoogleAuth),
validation.Field(&s.FacebookAuth),
validation.Field(&s.GithubAuth),
@@ -182,10 +172,10 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.S3.Secret,
&clone.AdminAuthToken.Secret,
&clone.AdminPasswordResetToken.Secret,
- &clone.UserAuthToken.Secret,
- &clone.UserPasswordResetToken.Secret,
- &clone.UserEmailChangeToken.Secret,
- &clone.UserVerificationToken.Secret,
+ &clone.RecordAuthToken.Secret,
+ &clone.RecordPasswordResetToken.Secret,
+ &clone.RecordEmailChangeToken.Secret,
+ &clone.RecordVerificationToken.Secret,
&clone.GoogleAuth.ClientSecret,
&clone.FacebookAuth.ClientSecret,
&clone.GithubAuth.ClientSecret,
@@ -407,43 +397,13 @@ func (c LogsConfig) Validate() error {
// -------------------------------------------------------------------
-type EmailAuthConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
- OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
- MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
-}
-
-// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface.
-func (c EmailAuthConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(
- &c.ExceptDomains,
- validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &c.OnlyDomains,
- validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &c.MinPasswordLength,
- validation.When(c.Enabled, validation.Required),
- validation.Min(5),
- validation.Max(100),
- ),
- )
-}
-
-// -------------------------------------------------------------------
-
type AuthProviderConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"`
- ClientId string `form:"clientId" json:"clientId,omitempty"`
- ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
- AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
- TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
- UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
+ Enabled bool `form:"enabled" json:"enabled"`
+ ClientId string `form:"clientId" json:"clientId,omitempty"`
+ ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
+ AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
+ TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
+ UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
}
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
@@ -485,3 +445,18 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
return nil
}
+
+// -------------------------------------------------------------------
+
+// Deprecated: Will be removed in v0.9!
+type EmailAuthConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+ ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
+ OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
+ MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
+}
+
+// Deprecated: Will be removed in v0.9!
+func (c EmailAuthConfig) Validate() error {
+ return nil
+}
diff --git a/core/settings_templates.go b/core/settings_templates.go
index 23c0083cf..006e56e07 100644
--- a/core/settings_templates.go
+++ b/core/settings_templates.go
@@ -20,7 +20,7 @@ var defaultVerificationTemplate = EmailTemplate{
Thanks,
` + EmailPlaceholderAppName + ` 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",x=p(),q=s("div"),q.textContent="Query parameters",W=p(),T=s("table"),G=s("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),R=p(),C=s("h6"),C.textContent="API details",N=p(),y=s("div"),F=s("strong"),F.textContent="POST",pe=p(),x=s("div"),D=s("p"),he=v("/api/collections/"),Q=s("strong"),z=v(M),be=v("/auth-with-oauth2"),K=p(),I=s("div"),I.textContent="Body Parameters",Y=p(),q=s("table"),q.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.
=P)d=$+1;else{i=t[h+2],O.advance();continue O}}break}}function C(t,O=Uint16Array){if(typeof t!="string")return t;let e=null;for(let a=0,i=0;a PQtO1G1VOOQ(C[1G1X1G1XO5tQ`O1G2}O#>WQ`O1G2}O#>]Q`O1G2}O#>bQ`O1G2}OOQS1G2}1G2}O#>gQ&kO1G2bO7YQ`O'#JQO7YQ`O'#EaO7YQ`O'#IWO#>xQ(ChO,5?yOOQS1G2f1G2fO!0VQ`O1G2lOIWQ&jO1G2iO#?TQ`O1G2iOOQS1G2j1G2jOIWQ&jO1G2jO#?YQaO1G2jO#?bQ7[O'#GhOOQS1G2l1G2lO!'VQ7[O'#IYO!0[QpO1G2oOOQS1G2o1G2oOOQS,5=Y,5=YO#?jQ&kO,5=[O5tQ`O,5=[O#6SQ`O,5=_O5bQ`O,5=_O!-OQ!bO,5=_O!-WQ&jO,5=_O5yQ&jO,5=_O#?{Q`O'#JaO#@WQ`O,5=`OOQS1G.j1G.jO#@]Q(ChO1G.jO#@hQ`O1G.jO#@mQ`O1G.jO5lQ(ChO1G.jO#@uQtO,5@OO#APQ`O,5@OO#A[QdO,5=gO#AcQ`O,5=gO7YQ`O,5@OOOQS1G3P1G3PO`QdO1G3POOQS1G3V1G3VOOQS1G3X1G3XO:[Q`O1G3ZO#AhQdO1G3]O#EcQdO'#H[OOQS1G3`1G3`O#EpQ`O'#HbO:aQ`O'#HdOOQS1G3f1G3fO#ExQdO1G3fO5lQ(ChO1G3lOOQS1G3n1G3nOOQ(CW'#Fx'#FxO5lQ(ChO1G3pO5lQ(ChO1G3rOOOW1G/^1G/^O#IvQpO,5 =P)u=h+1;else{i=t[$+2],O.advance();continue O}}break}}function C(t,O=Uint16Array){if(typeof t!="string")return t;let e=null;for(let a=0,i=0;a PQtO1G1VOOQ(C[1G1X1G1XO5tQ`O1G2}O#>WQ`O1G2}O#>]Q`O1G2}O#>bQ`O1G2}OOQS1G2}1G2}O#>gQ&kO1G2bO7YQ`O'#JQO7YQ`O'#EaO7YQ`O'#IWO#>xQ(ChO,5?yOOQS1G2f1G2fO!0VQ`O1G2lOIWQ&jO1G2iO#?TQ`O1G2iOOQS1G2j1G2jOIWQ&jO1G2jO#?YQaO1G2jO#?bQ7[O'#GhOOQS1G2l1G2lO!'VQ7[O'#IYO!0[QpO1G2oOOQS1G2o1G2oOOQS,5=Y,5=YO#?jQ&kO,5=[O5tQ`O,5=[O#6SQ`O,5=_O5bQ`O,5=_O!-OQ!bO,5=_O!-WQ&jO,5=_O5yQ&jO,5=_O#?{Q`O'#JaO#@WQ`O,5=`OOQS1G.j1G.jO#@]Q(ChO1G.jO#@hQ`O1G.jO#@mQ`O1G.jO5lQ(ChO1G.jO#@uQtO,5@OO#APQ`O,5@OO#A[QdO,5=gO#AcQ`O,5=gO7YQ`O,5@OOOQS1G3P1G3PO`QdO1G3POOQS1G3V1G3VOOQS1G3X1G3XO:[Q`O1G3ZO#AhQdO1G3]O#EcQdO'#H[OOQS1G3`1G3`O#EpQ`O'#HbO:aQ`O'#HdOOQS1G3f1G3fO#ExQdO1G3fO5lQ(ChO1G3lOOQS1G3n1G3nOOQ(CW'#Fx'#FxO5lQ(ChO1G3pO5lQ(ChO1G3rOOOW1G/^1G/^O#IvQpO,5 Enter the email associated with your account and we\u2019ll send you a recovery link: 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. 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). 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
+ Param
+ Type
+ Description
+
+ String
+ The token from the change email request email. `,ae=p(),U=s("div"),U.textContent="Query parameters",se=p(),O=s("table"),ne=s("thead"),ne.innerHTML=`
+ String
+ The account password to confirm the email change. `,$e=p(),ie=s("tbody"),T=s("tr"),re=s("td"),re.textContent="expand",ye=p(),ce=s("td"),ce.innerHTML='String',we=p(),_=s("td"),Oe=m(`Auto expand record relations. Ex.:
+ `),be(E.$$.fragment),Te=m(`
+ Supports up to 6-levels depth nested relations expansion. `),Pe=s("br"),Re=m(`
+ The expanded relations will be appended to the record under the
+ `),de=s("code"),de.textContent="expand",Ee=m(" property (eg. "),pe=s("code"),pe.textContent='"expand": {"relField1": {...}, ...}',De=m(`).
+ `),Ae=s("br"),Be=m(`
+ Only the relations to which the account has permissions to `),ue=s("strong"),ue.textContent="view",qe=m(" will be expanded."),fe=p(),M=s("div"),M.textContent="Responses",me=p(),P=s("div"),N=s("div");for(let e=0;eParam
+ Type
+ Description
+ Param
+ Type
+ Description
+
+ String
+ The token from the password reset request email.
+
+ String
+ The new password to set. `,oe=p(),M=a("div"),M.textContent="Query parameters",ae=p(),$=a("table"),ne=a("thead"),ne.innerHTML=`
+ String
+ The new password confirmation. `,Re=p(),ie=a("tbody"),y=a("tr"),re=a("td"),re.textContent="expand",ge=p(),de=a("td"),de.innerHTML='String',Ce=p(),_=a("td"),$e=b(`Auto expand record relations. Ex.:
+ `),me(T.$$.fragment),ye=b(`
+ Supports up to 6-levels depth nested relations expansion. `),Oe=a("br"),Ne=b(`
+ The expanded relations will be appended to the record under the
+ `),ce=a("code"),ce.textContent="expand",Te=b(" property (eg. "),pe=a("code"),pe.textContent='"expand": {"relField1": {...}, ...}',We=b(`).
+ `),De=a("br"),Ee=b(`
+ Only the relations to which the account has permissions to `),fe=a("strong"),fe.textContent="view",Ae=b(" will be expanded."),ue=p(),F=a("div"),F.textContent="Responses",be=p(),O=a("div"),q=a("div");for(let e=0;eParam
+ Type
+ Description
+ Param
+ Type
+ Description `,se=f(),N=a("div"),N.textContent="Query parameters",ae=f(),T=a("table"),ne=a("thead"),ne.innerHTML=`
+ String
+ The token from the verification request email. `,ge=f(),ie=a("tbody"),P=a("tr"),re=a("td"),re.textContent="expand",we=f(),ce=a("td"),ce.innerHTML='String',Ce=f(),_=a("td"),Te=b(`Auto expand record relations. Ex.:
+ `),me(B.$$.fragment),Pe=b(`
+ Supports up to 6-levels depth nested relations expansion. `),Oe=a("br"),Ve=b(`
+ The expanded relations will be appended to the record under the
+ `),de=a("code"),de.textContent="expand",Be=b(" property (eg. "),fe=a("code"),fe.textContent='"expand": {"relField1": {...}, ...}',De=b(`).
+ `),Ee=a("br"),qe=b(`
+ Only the relations to which the account has permissions to `),pe=a("strong"),pe.textContent="view",Me=b(" will be expanded."),ue=f(),R=a("div"),R.textContent="Responses",be=f(),O=a("div"),F=a("div");for(let e=0;e<$.length;e+=1)$[e].c();Re=f(),K=a("div");for(let e=0;eParam
+ Type
+ Description Authorization:TOKEN
header",h(e,"class","txt-hint txt-sm txt-right")},m(l,s){o(l,e,s)},d(l){l&&r(e)}}}function kt(d){let e,l,s,m,p,c,f,y,$,w,M,F,D,I,A,J,j,g,S,N,O,C,_;function L(u,T){var ee,z;return(z=(ee=u[0])==null?void 0:ee.options)!=null&&z.requireEmail?Rt:Pt}let x=L(d),P=x(d);return{c(){e=a("tr"),e.innerHTML='Auth fields ',l=b(),s=a("tr"),s.innerHTML=`
+ String
+ The username of the auth record.
+ `,m=b(),p=a("tr"),c=a("td"),f=a("div"),P.c(),y=b(),$=a("span"),$.textContent="email",w=b(),M=a("td"),M.innerHTML='String',F=b(),D=a("td"),D.textContent="Auth record email address.",I=b(),A=a("tr"),A.innerHTML=`
+ If not set, it will be auto generated.
+ Boolean
+ Whether to show/hide the auth record email when fetching the record data. `,J=b(),j=a("tr"),j.innerHTML=`
+ String
+ Auth record password. `,g=b(),S=a("tr"),S.innerHTML=`
+ String
+ Auth record password confirmation. `,N=b(),O=a("tr"),O.innerHTML=`
+ Boolean
+ Indicates whether the auth record is verified or not.
+ `,C=b(),_=a("tr"),_.innerHTML='
+ This field can be set only by admins or auth records with "Manage" access.Schema fields ',h(f,"class","inline-flex")},m(u,T){o(u,e,T),o(u,l,T),o(u,s,T),o(u,m,T),o(u,p,T),n(p,c),n(c,f),P.m(f,null),n(f,y),n(f,$),n(p,w),n(p,M),n(p,F),n(p,D),o(u,I,T),o(u,A,T),o(u,J,T),o(u,j,T),o(u,g,T),o(u,S,T),o(u,N,T),o(u,O,T),o(u,C,T),o(u,_,T)},p(u,T){x!==(x=L(u))&&(P.d(1),P=x(u),P&&(P.c(),P.m(f,y)))},d(u){u&&r(e),u&&r(l),u&&r(s),u&&r(m),u&&r(p),P.d(),u&&r(I),u&&r(A),u&&r(J),u&&r(j),u&&r(g),u&&r(S),u&&r(N),u&&r(O),u&&r(C),u&&r(_)}}}function Pt(d){let e;return{c(){e=a("span"),e.textContent="Optional",h(e,"class","label label-warning")},m(l,s){o(l,e,s)},d(l){l&&r(e)}}}function Rt(d){let e;return{c(){e=a("span"),e.textContent="Required",h(e,"class","label label-success")},m(l,s){o(l,e,s)},d(l){l&&r(e)}}}function gt(d){let e;return{c(){e=a("span"),e.textContent="Optional",h(e,"class","label label-warning")},m(l,s){o(l,e,s)},d(l){l&&r(e)}}}function Bt(d){let e;return{c(){e=a("span"),e.textContent="Required",h(e,"class","label label-success")},m(l,s){o(l,e,s)},d(l){l&&r(e)}}}function Ft(d){var p;let e,l=((p=d[12].options)==null?void 0:p.maxSelect)===1?"id":"ids",s,m;return{c(){e=k("Relation record "),s=k(l),m=k(".")},m(c,f){o(c,e,f),o(c,s,f),o(c,m,f)},p(c,f){var y;f&1&&l!==(l=((y=c[12].options)==null?void 0:y.maxSelect)===1?"id":"ids")&&Z(s,l)},d(c){c&&r(e),c&&r(s),c&&r(m)}}}function jt(d){let e,l,s,m,p;return{c(){e=k("File object."),l=a("br"),s=k(`
+ Set to `),m=a("code"),m.textContent="null",p=k(" to delete already uploaded file(s).")},m(c,f){o(c,e,f),o(c,l,f),o(c,s,f),o(c,m,f),o(c,p,f)},p:le,d(c){c&&r(e),c&&r(l),c&&r(s),c&&r(m),c&&r(p)}}}function Dt(d){let e;return{c(){e=k("URL address.")},m(l,s){o(l,e,s)},p:le,d(l){l&&r(e)}}}function Nt(d){let e;return{c(){e=k("Email address.")},m(l,s){o(l,e,s)},p:le,d(l){l&&r(e)}}}function It(d){let e;return{c(){e=k("JSON array or object.")},m(l,s){o(l,e,s)},p:le,d(l){l&&r(e)}}}function Jt(d){let e;return{c(){e=k("Number value.")},m(l,s){o(l,e,s)},p:le,d(l){l&&r(e)}}}function Et(d){let e;return{c(){e=k("Plain text value.")},m(l,s){o(l,e,s)},p:le,d(l){l&&r(e)}}}function yt(d,e){let l,s,m,p,c,f=e[12].name+"",y,$,w,M,F=Q.getFieldValueType(e[12])+"",D,I,A,J;function j(_,L){return _[12].required?Bt:gt}let g=j(e),S=g(e);function N(_,L){if(_[12].type==="text")return Et;if(_[12].type==="number")return Jt;if(_[12].type==="json")return It;if(_[12].type==="email")return Nt;if(_[12].type==="url")return Dt;if(_[12].type==="file")return jt;if(_[12].type==="relation")return Ft}let O=N(e),C=O&&O(e);return{key:d,first:null,c(){l=a("tr"),s=a("td"),m=a("div"),S.c(),p=b(),c=a("span"),y=k(f),$=b(),w=a("td"),M=a("span"),D=k(F),I=b(),A=a("td"),C&&C.c(),J=b(),h(m,"class","inline-flex"),h(M,"class","label"),this.first=l},m(_,L){o(_,l,L),n(l,s),n(s,m),S.m(m,null),n(m,p),n(m,c),n(c,y),n(l,$),n(l,w),n(w,M),n(M,D),n(l,I),n(l,A),C&&C.m(A,null),n(l,J)},p(_,L){e=_,g!==(g=j(e))&&(S.d(1),S=g(e),S&&(S.c(),S.m(m,p))),L&1&&f!==(f=e[12].name+"")&&Z(y,f),L&1&&F!==(F=Q.getFieldValueType(e[12])+"")&&Z(D,F),O===(O=N(e))&&C?C.p(e,L):(C&&C.d(1),C=O&&O(e),C&&(C.c(),C.m(A,null)))},d(_){_&&r(l),S.d(),C&&C.d()}}}function vt(d,e){let l,s=e[7].code+"",m,p,c,f;function y(){return e[6](e[7])}return{key:d,first:null,c(){l=a("button"),m=k(s),p=b(),h(l,"class","tab-item"),ue(l,"active",e[1]===e[7].code),this.first=l},m($,w){o($,l,w),n(l,m),n(l,p),c||(f=qt(l,"click",y),c=!0)},p($,w){e=$,w&4&&s!==(s=e[7].code+"")&&Z(m,s),w&6&&ue(l,"active",e[1]===e[7].code)},d($){$&&r(l),c=!1,f()}}}function ht(d,e){let l,s,m,p;return s=new wt({props:{content:e[7].body}}),{key:d,first:null,c(){l=a("div"),Pe(s.$$.fragment),m=b(),h(l,"class","tab-item"),ue(l,"active",e[1]===e[7].code),this.first=l},m(c,f){o(c,l,f),Re(s,l,null),n(l,m),p=!0},p(c,f){e=c;const y={};f&4&&(y.content=e[7].body),s.$set(y),(!p||f&6)&&ue(l,"active",e[1]===e[7].code)},i(c){p||(fe(s.$$.fragment,c),p=!0)},o(c){pe(s.$$.fragment,c),p=!1},d(c){c&&r(l),ge(s)}}}function Ut(d){var st,it,at,ot;let e,l,s=d[0].name+"",m,p,c,f,y,$,w,M=d[0].name+"",F,D,I,A,J,j,g,S,N,O,C,_,L,x,P,u,T,ee,z=d[0].name+"",be,Be,Fe,me,ne,_e,K,ke,je,E,ye,De,ve,U=[],Ne=new Map,he,se,we,W,Ce,Ie,Se,Y,Te,Je,$e,Ee,H,Ue,te,Ve,Qe,xe,Oe,ze,Me,Ke,We,Ye,Le,Ge,He,ie,qe,G,ae,V=[],Xe=new Map,Ze,oe,B=[],et=new Map,X;S=new At({props:{js:`
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('${d[4]}');
+
+...
+
+// example create data
+const data = ${JSON.stringify(Object.assign({},d[3],Q.dummyCollectionSchemaData(d[0])),null,4)};
+
+const record = await pb.collection('${(st=d[0])==null?void 0:st.name}').create(data);
+ `,dart:`
+import 'package:pocketbase/pocketbase.dart';
+
+final pb = PocketBase('${d[4]}');
+
+...
+
+// example create body
+final body = multipart/form-data
.`,J=b(),j=a("p"),j.innerHTML=`File upload is supported only via multipart/form-data
.
+
+ For more info and examples you could check the detailed
+ Files upload and handling docs
+ .`,g=b(),Pe(S.$$.fragment),N=b(),O=a("h6"),O.textContent="API details",C=b(),_=a("div"),L=a("strong"),L.textContent="POST",x=b(),P=a("div"),u=a("p"),T=k("/api/collections/"),ee=a("strong"),be=k(z),Be=k("/records"),Fe=b(),R&&R.c(),me=b(),ne=a("div"),ne.textContent="Body Parameters",_e=b(),K=a("table"),ke=a("thead"),ke.innerHTML=` `,je=b(),E=a("tbody"),ye=a("tr"),ye.innerHTML=`Param
+ Type
+ Description
+ String
+ 15 characters string to store as record ID.
+ `,De=b(),q&&q.c(),ve=b();for(let t=0;t
+ If not set, it will be auto generated.Param
+ Type
+ Description `,Ie=b(),Se=a("tbody"),Y=a("tr"),Te=a("td"),Te.textContent="expand",Je=b(),$e=a("td"),$e.innerHTML='String',Ee=b(),H=a("td"),Ue=k(`Auto expand relations when returning the created record. Ex.:
+ `),Pe(te.$$.fragment),Ve=k(`
+ Supports up to 6-levels depth nested relations expansion. `),Qe=a("br"),xe=k(`
+ The expanded relations will be appended to the record under the
+ `),Oe=a("code"),Oe.textContent="expand",ze=k(" property (eg. "),Me=a("code"),Me.textContent='"expand": {"relField1": {...}, ...}',Ke=k(`).
+ `),We=a("br"),Ye=k(`
+ Only the relations to which the account has permissions to `),Le=a("strong"),Le.textContent="view",Ge=k(" will be expanded."),He=b(),ie=a("div"),ie.textContent="Responses",qe=b(),G=a("div"),ae=a("div");for(let t=0;tAuthorization: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"),z(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&&z(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"),z(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)&&z(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 He(o){var ue,pe;let l,s,a=o[0].name+"",h,i,r,u,$,b,g,q=o[0].name+"",F,le,K,C,N,O,Q,y,L,se,H,E,oe,G,U=o[0].name+"",J,ae,V,ne,W,T,X,B,Y,I,Z,R,A,w=[],ie=new Map,re,M,v=[],ce=new Map,P;C=new Le({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;e
+ Param
+ Type
+ Description `,Y=k(),I=c("div"),I.textContent="Responses",Z=k(),R=c("div"),A=c("div");for(let e=0;eid
+ String
+ ID of the record to delete. OPERAND
+ 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"),H=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(),M=l("span"),M.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(M,"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,H),e(H,W),e(H,fe),e(H,T),e(h,pe),e(h,O),e(O,G),e(O,C),e(O,M),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=Mt(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,Ht,{})}}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||(_=Mt(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+"",H,W,fe,T,pe,O,G,C,M,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,He,Ke,_e,Ve,$e,We,ke,Xe,oe,Me,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 result = 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',
+ );
+
+ // alternatively 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 record2 = 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;tParam
+ Type
+ Description `,Z=a(),v=l("tbody"),I=l("tr"),I.innerHTML=`page
+ Number
+ The page (aka. offset) of the paginated list (default to 1). `,ee=a(),me=l("tr"),me.innerHTML=`perPage
+ Number
+ Specify the max returned records per page (default to 30). `,te=a(),N=l("tr"),B=l("td"),B.textContent="sort",le=a(),be=l("td"),be.innerHTML='String',se=a(),x=l("td"),J=k("Specify the records order attribute(s). "),ne=l("br"),Le=k(`
+ Add `),K=l("code"),K.textContent="-",he=k(" / "),V=l("code"),V.textContent="+",F=k(` (default) in front of the attribute for DESC / ASC order.
+ Ex.:
+ `),ge(R.$$.fragment),Qe=a(),ie=l("tr"),Ne=l("td"),Ne.textContent="filter",Je=a(),He=l("td"),He.innerHTML='String',Ke=a(),_e=l("td"),Ve=k(`Filter the returned records. Ex.:
+ `),ge($e.$$.fragment),We=a(),ge(ke.$$.fragment),Xe=a(),oe=l("tr"),Me=l("td"),Me.textContent="expand",Ye=a(),qe=l("td"),qe.innerHTML='String',Ze=a(),y=l("td"),et=k(`Auto expand record relations. Ex.:
+ `),ge(we.$$.fragment),tt=k(`
+ Supports up to 6-levels depth nested relations expansion. `),lt=l("br"),st=k(`
+ The expanded relations will be appended to each individual record under the
+ `),De=l("code"),De.textContent="expand",nt=k(" property (eg. "),Ie=l("code"),Ie.textContent='"expand": {"relField1": {...}, ...}',it=k(`).
+ `),ot=l("br"),at=k(`
+ Only the relations to which the account has permissions to `),Be=l("strong"),Be.textContent="view",rt=k(" will be expanded."),ze=a(),Te=l("div"),Te.textContent="Responses",Ge=a(),ae=l("div"),Pe=l("div");for(let t=0;t
+ Param
+ Type
+ Description `,x=h(),B=n("div"),B.textContent="Responses",ee=h(),P=n("div"),q=n("div");for(let e=0;e<$.length;e+=1)$[e].c();ue=h(),L=n("div");for(let e=0;eid
+ String
+ ID of the auth record. Forgotten admin password
+import{S as M,i as T,s as j,F as z,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as A,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as E,v as I,w as h,x as J,y as P,z as R}from"./index.97f016a1.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`Forgotten admin password
+ Param
+ Type
+ Description `,Y=h(),R=c("div"),R.textContent="Responses",Z=h(),E=c("div"),M=c("div");for(let e=0;e
+ String
+ The new email address to send the change email request.
+ Param
+ Type
+ Description `,X=v(),M=c("div"),M.textContent="Responses",Y=v(),g=c("div"),A=c("div");for(let e=0;e
+ String
+ The auth record email address to send the password reset request (if exists).
+ Param
+ Type
+ Description `,X=v(),V=c("div"),V.textContent="Responses",Y=v(),y=c("div"),M=c("div");for(let e=0;e<$.length;e+=1)$[e].c();ae=v(),A=c("div");for(let e=0;e
+ String
+ The auth record email address to send the verification request (if exists).
+ Param
+ Type
+ Description
+ id
+ String
+ ID of the auth record. `,ee=h(),U=i("div"),U.textContent="Responses",te=h(),C=i("div"),q=i("div");for(let e=0;eprovider
+ String
+ The name of the auth provider to unlink, eg. 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,D,A,F,M,j,B;return{c(){t=r("tr"),t.innerHTML='Auth fields ',l=m(),s=r("tr"),s.innerHTML=`
+ String
+ The username of the auth record. `,b=m(),u=r("tr"),u.innerHTML=`
+ String
+ The auth record email address.
+ `,d=m(),f=r("tr"),f.innerHTML=`
+ This field can be updated only by admins or auth records with "Manage" access.
+
+ Regular accounts can update their email by calling "Request email change".
+ Boolean
+ Whether to show/hide the auth record email when fetching the record data. `,k=m(),C=r("tr"),C.innerHTML=`
+ String
+ Old auth record password.
+ `,v=m(),O=r("tr"),O.innerHTML=`
+ This field is required only when changing the record password. Admins and auth records with
+ "Manage" access can skip this field.
+ String
+ New auth record password. `,D=m(),A=r("tr"),A.innerHTML=`
+ String
+ New auth record password confirmation. `,F=m(),M=r("tr"),M.innerHTML=`
+ Boolean
+ Indicates whether the auth record is verified or not.
+ `,j=m(),B=r("tr"),B.innerHTML='
+ This field can be set only by admins or auth records with "Manage" access.Schema fields '},m(c,_){a(c,t,_),a(c,l,_),a(c,s,_),a(c,b,_),a(c,u,_),a(c,d,_),a(c,f,_),a(c,k,_),a(c,C,_),a(c,v,_),a(c,O,_),a(c,D,_),a(c,A,_),a(c,F,_),a(c,M,_),a(c,j,_),a(c,B,_)},d(c){c&&o(t),c&&o(l),c&&o(s),c&&o(b),c&&o(u),c&&o(d),c&&o(f),c&&o(k),c&&o(C),c&&o(v),c&&o(O),c&&o(D),c&&o(A),c&&o(F),c&&o(M),c&&o(j),c&&o(B)}}}function Pt(p){let t;return{c(){t=r("span"),t.textContent="Optional",T(t,"class","label label-warning")},m(l,s){a(l,t,s)},d(l){l&&o(t)}}}function At(p){let t;return{c(){t=r("span"),t.textContent="Required",T(t,"class","label label-success")},m(l,s){a(l,t,s)},d(l){l&&o(t)}}}function Bt(p){var u;let t,l=((u=p[12].options)==null?void 0:u.maxSelect)>1?"ids":"id",s,b;return{c(){t=y("User "),s=y(l),b=y(".")},m(d,f){a(d,t,f),a(d,s,f),a(d,b,f)},p(d,f){var k;f&1&&l!==(l=((k=d[12].options)==null?void 0:k.maxSelect)>1?"ids":"id")&&U(s,l)},d(d){d&&o(t),d&&o(s),d&&o(b)}}}function Ft(p){var u;let t,l=((u=p[12].options)==null?void 0:u.maxSelect)>1?"ids":"id",s,b;return{c(){t=y("Relation record "),s=y(l),b=y(".")},m(d,f){a(d,t,f),a(d,s,f),a(d,b,f)},p(d,f){var k;f&1&&l!==(l=((k=d[12].options)==null?void 0:k.maxSelect)>1?"ids":"id")&&U(s,l)},d(d){d&&o(t),d&&o(s),d&&o(b)}}}function jt(p){let t,l,s,b,u;return{c(){t=y("File object."),l=r("br"),s=y(`
+ Set to `),b=r("code"),b.textContent="null",u=y(" to delete already uploaded file(s).")},m(d,f){a(d,t,f),a(d,l,f),a(d,s,f),a(d,b,f),a(d,u,f)},p:G,d(d){d&&o(t),d&&o(l),d&&o(s),d&&o(b),d&&o(u)}}}function Nt(p){let t;return{c(){t=y("URL address.")},m(l,s){a(l,t,s)},p:G,d(l){l&&o(t)}}}function Et(p){let t;return{c(){t=y("Email address.")},m(l,s){a(l,t,s)},p:G,d(l){l&&o(t)}}}function It(p){let t;return{c(){t=y("JSON array or object.")},m(l,s){a(l,t,s)},p:G,d(l){l&&o(t)}}}function Ut(p){let t;return{c(){t=y("Number value.")},m(l,s){a(l,t,s)},p:G,d(l){l&&o(t)}}}function gt(p){let t;return{c(){t=y("Plain text value.")},m(l,s){a(l,t,s)},p:G,d(l){l&&o(t)}}}function ht(p,t){let l,s,b,u,d,f=t[12].name+"",k,C,v,O,D=I.getFieldValueType(t[12])+"",A,F,M,j;function B(h,L){return h[12].required?At:Pt}let c=B(t),_=c(t);function K(h,L){if(h[12].type==="text")return gt;if(h[12].type==="number")return Ut;if(h[12].type==="json")return It;if(h[12].type==="email")return Et;if(h[12].type==="url")return Nt;if(h[12].type==="file")return jt;if(h[12].type==="relation")return Ft;if(h[12].type==="user")return Bt}let H=K(t),S=H&&H(t);return{key:p,first:null,c(){l=r("tr"),s=r("td"),b=r("div"),_.c(),u=m(),d=r("span"),k=y(f),C=m(),v=r("td"),O=r("span"),A=y(D),F=m(),M=r("td"),S&&S.c(),j=m(),T(b,"class","inline-flex"),T(O,"class","label"),this.first=l},m(h,L){a(h,l,L),i(l,s),i(s,b),_.m(b,null),i(b,u),i(b,d),i(d,k),i(l,C),i(l,v),i(v,O),i(O,A),i(l,F),i(l,M),S&&S.m(M,null),i(l,j)},p(h,L){t=h,c!==(c=B(t))&&(_.d(1),_=c(t),_&&(_.c(),_.m(b,u))),L&1&&f!==(f=t[12].name+"")&&U(k,f),L&1&&D!==(D=I.getFieldValueType(t[12])+"")&&U(A,D),H===(H=K(t))&&S?S.p(t,L):(S&&S.d(1),S=H&&H(t),S&&(S.c(),S.m(M,null)))},d(h){h&&o(l),_.d(),S&&S.d()}}}function vt(p,t){let l,s=t[7].code+"",b,u,d,f;function k(){return t[6](t[7])}return{key:p,first:null,c(){l=r("button"),b=y(s),u=m(),T(l,"class","tab-item"),ce(l,"active",t[1]===t[7].code),this.first=l},m(C,v){a(C,l,v),i(l,b),i(l,u),d||(f=Ht(l,"click",k),d=!0)},p(C,v){t=C,v&4&&s!==(s=t[7].code+"")&&U(b,s),v&6&&ce(l,"active",t[1]===t[7].code)},d(C){C&&o(l),d=!1,f()}}}function wt(p,t){let l,s,b,u;return s=new Tt({props:{content:t[7].body}}),{key:p,first:null,c(){l=r("div"),Ae(s.$$.fragment),b=m(),T(l,"class","tab-item"),ce(l,"active",t[1]===t[7].code),this.first=l},m(d,f){a(d,l,f),Be(s,l,null),i(l,b),u=!0},p(d,f){t=d;const k={};f&4&&(k.content=t[7].body),s.$set(k),(!u||f&6)&&ce(l,"active",t[1]===t[7].code)},i(d){u||(pe(s.$$.fragment,d),u=!0)},o(d){fe(s.$$.fragment,d),u=!1},d(d){d&&o(l),Fe(s)}}}function Jt(p){var it,at,ot,dt;let t,l,s=p[0].name+"",b,u,d,f,k,C,v,O=p[0].name+"",D,A,F,M,j,B,c,_,K,H,S,h,L,je,ae,W,Ne,ue,oe=p[0].name+"",be,Ee,me,Ie,_e,X,ye,Z,ke,ee,he,g,ve,Ue,J,we,N=[],ge=new Map,Te,te,Ce,V,Se,Je,Oe,x,Me,Ve,$e,xe,$,Qe,Y,ze,Ke,We,Re,Ye,qe,Ge,De,Xe,He,le,Le,Q,se,E=[],Ze=new Map,et,ne,P=[],tt=new Map,z;_=new Lt({props:{js:`
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('${p[4]}');
+
+...
+
+// example update data
+const data = ${JSON.stringify(Object.assign({},p[3],I.dummyCollectionSchemaData(p[0])),null,4)};
+
+const record = await pb.collection('${(it=p[0])==null?void 0:it.name}').update('RECORD_ID', data);
+ `,dart:`
+import 'package:pocketbase/pocketbase.dart';
+
+final pb = PocketBase('${p[4]}');
+
+...
+
+// example update body
+final body = multipart/form-data
.`,j=m(),B=r("p"),B.innerHTML=`File upload is supported only via multipart/form-data
.
+
+ For more info and examples you could check the detailed
+ Files upload and handling docs
+ .`,c=m(),Ae(_.$$.fragment),K=m(),H=r("h6"),H.textContent="API details",S=m(),h=r("div"),L=r("strong"),L.textContent="PATCH",je=m(),ae=r("div"),W=r("p"),Ne=y("/api/collections/"),ue=r("strong"),be=y(oe),Ee=y("/records/"),me=r("strong"),me.textContent=":id",Ie=m(),R&&R.c(),_e=m(),X=r("div"),X.textContent="Path parameters",ye=m(),Z=r("table"),Z.innerHTML=`
+ Param
+ Type
+ Description `,ke=m(),ee=r("div"),ee.textContent="Body Parameters",he=m(),g=r("table"),ve=r("thead"),ve.innerHTML=`id
+ String
+ ID of the record to update. `,Ue=m(),J=r("tbody"),q&&q.c(),we=m();for(let e=0;eParam
+ Type
+ Description Param
+ Type
+ Description `,Je=m(),Oe=r("tbody"),x=r("tr"),Me=r("td"),Me.textContent="expand",Ve=m(),$e=r("td"),$e.innerHTML='String',xe=m(),$=r("td"),Qe=y(`Auto expand relations when returning the updated record. Ex.:
+ `),Ae(Y.$$.fragment),ze=y(`
+ Supports up to 6-levels depth nested relations expansion. `),Ke=r("br"),We=y(`
+ The expanded relations will be appended to the record under the
+ `),Re=r("code"),Re.textContent="expand",Ye=y(" property (eg. "),qe=r("code"),qe.textContent='"expand": {"relField1": {...}, ...}',Ge=y(`). Only
+ the relations that the user has permissions to `),De=r("strong"),De.textContent="view",Xe=y(" will be expanded."),He=m(),le=r("div"),le.textContent="Responses",Le=m(),Q=r("div"),se=r("div");for(let e=0;eAuthorization: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,F){r(h,n,F),l(n,w),l(n,c),p||(u=rt(n,"click",C),p=!0)},p(h,F){s=h,F&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 Ue,je;let s,n,a=i[0].name+"",w,c,p,u,C,h,F,U=i[0].name+"",K,ve,W,g,X,B,Y,$,j,we,N,E,ye,Z,Q=i[0].name+"",ee,$e,te,Ce,le,I,se,x,ne,A,oe,O,ie,Re,ae,D,re,Fe,de,ge,k,Oe,S,De,Pe,Te,ce,Ee,pe,Se,Be,Ie,fe,xe,ue,M,be,P,H,R=[],Ae=new Map,Me,L,y=[],He=new Map,T;g=new dt({props:{js:`
+ import PocketBase from 'pocketbase';
+
+ const pb = new PocketBase('${i[3]}');
+
+ ...
+
+ const record1 = await pb.collection('${(Ue=i[0])==null?void 0:Ue.name}').getOne('RECORD_ID', {
+ expand: 'relField1,relField2.subRelField',
+ });
+ `,dart:`
+ import 'package:pocketbase/pocketbase.dart';
+
+ final pb = PocketBase('${i[3]}');
+
+ ...
+
+ final record1 = await pb.collection('${(je=i[0])==null?void 0:je.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 Le=e=>e[6].code;for(let e=0;e
+ Param
+ Type
+ Description `,ne=f(),A=o("div"),A.textContent="Query parameters",oe=f(),O=o("table"),ie=o("thead"),ie.innerHTML=`id
+ String
+ ID of the record to view. `,Re=f(),ae=o("tbody"),D=o("tr"),re=o("td"),re.textContent="expand",Fe=f(),de=o("td"),de.innerHTML='String',ge=f(),k=o("td"),Oe=m(`Auto expand record relations. Ex.:
+ `),_e(S.$$.fragment),De=m(`
+ Supports up to 6-levels depth nested relations expansion. `),Pe=o("br"),Te=m(`
+ The expanded relations will be appended to the record under the
+ `),ce=o("code"),ce.textContent="expand",Ee=m(" property (eg. "),pe=o("code"),pe.textContent='"expand": {"relField1": {...}, ...}',Se=m(`).
+ `),Be=o("br"),Ie=m(`
+ Only the relations to which the account has permissions to `),fe=o("strong"),fe.textContent="view",xe=m(" will be expanded."),ue=f(),M=o("div"),M.textContent="Responses",be=f(),P=o("div"),H=o("div");for(let e=0;eParam
+ Type
+ Description >(-2*s&6)):0)i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".indexOf(i);return o};var yl=function(){function n(e){e===void 0&&(e={}),this.load(e||{})}return n.prototype.load=function(e){for(var t=0,i=Object.entries(e);tCreate your first admin account in order to continue
",i=O(),j(s.$$.fragment),l=O(),j(o.$$.fragment),r=O(),j(a.$$.fragment),u=O(),f=v("button"),f.innerHTML=`Create and login
+ `,p(t,"class","content txt-center m-b-base"),p(f,"type","submit"),p(f,"class","btn btn-lg btn-block btn-next"),ee(f,"btn-disabled",n[3]),ee(f,"btn-loading",n[3]),p(e,"class","block"),p(e,"autocomplete","off")},m(m,b){$(m,e,b),_(e,t),_(e,i),R(s,e,null),_(e,l),R(o,e,null),_(e,r),R(a,e,null),_(e,u),_(e,f),c=!0,d||(h=U(e,"submit",ut(n[4])),d=!0)},p(m,[b]){const g={};b&1537&&(g.$$scope={dirty:b,ctx:m}),s.$set(g);const y={};b&1538&&(y.$$scope={dirty:b,ctx:m}),o.$set(y);const k={};b&1540&&(k.$$scope={dirty:b,ctx:m}),a.$set(k),(!c||b&8)&&ee(f,"btn-disabled",m[3]),(!c||b&8)&&ee(f,"btn-loading",m[3])},i(m){c||(E(s.$$.fragment,m),E(o.$$.fragment,m),E(a.$$.fragment,m),c=!0)},o(m){I(s.$$.fragment,m),I(o.$$.fragment,m),I(a.$$.fragment,m),c=!1},d(m){m&&S(e),H(s),H(o),H(a),d=!1,h()}}}function F1(n,e,t){const i=It();let s="",l="",o="",r=!1;async function a(){if(!r){t(3,r=!0);try{await me.admins.create({email:s,password:l,passwordConfirm:o}),await me.admins.authWithPassword(s,l),i("submit")}catch(d){me.errorResponseHandler(d)}t(3,r=!1)}}function u(){s=this.value,t(0,s)}function f(){l=this.value,t(1,l)}function c(){o=this.value,t(2,o)}return[s,l,o,r,a,u,f,c]}class R1 extends Me{constructor(e){super(),Ce(this,e,F1,N1,we,{})}}function Fu(n){let e,t;return e=new kg({props:{$$slots:{default:[H1]},$$scope:{ctx:n}}}),{c(){j(e.$$.fragment)},m(i,s){R(e,i,s),t=!0},p(i,s){const l={};s&9&&(l.$$scope={dirty:s,ctx:i}),e.$set(l)},i(i){t||(E(e.$$.fragment,i),t=!0)},o(i){I(e.$$.fragment,i),t=!1},d(i){H(e,i)}}}function H1(n){let e,t;return e=new R1({}),e.$on("submit",n[1]),{c(){j(e.$$.fragment)},m(i,s){R(e,i,s),t=!0},p:x,i(i){t||(E(e.$$.fragment,i),t=!0)},o(i){I(e.$$.fragment,i),t=!1},d(i){H(e,i)}}}function j1(n){let e,t,i=n[0]&&Fu(n);return{c(){i&&i.c(),e=Fe()},m(s,l){i&&i.m(s,l),$(s,e,l),t=!0},p(s,[l]){s[0]?i?(i.p(s,l),l&1&&E(i,1)):(i=Fu(s),i.c(),E(i,1),i.m(e.parentNode,e)):i&&(be(),I(i,1,1,()=>{i=null}),ve())},i(s){t||(E(i),t=!0)},o(s){I(i),t=!1},d(s){i&&i.d(s),s&&S(e)}}}function q1(n,e,t){let i=!1;s();function s(){if(t(0,i=!1),new URLSearchParams(window.location.search).has("installer")){me.logout(!1),t(0,i=!0);return}me.authStore.isValid?ki("/collections"):me.logout()}return[i,async()=>{t(0,i=!1),await $n(),window.location.search=""}]}class V1 extends Me{constructor(e){super(),Ce(this,e,q1,j1,we,{})}}const mt=Cn(""),_o=Cn(""),ks=Cn(!1);function Vo(n){const e=n-1;return e*e*e+1}function bo(n,{delay:e=0,duration:t=400,easing:i=bl}={}){const s=+getComputedStyle(n).opacity;return{delay:e,duration:t,easing:i,css:l=>`opacity: ${l*s}`}}function kn(n,{delay:e=0,duration:t=400,easing:i=Vo,x:s=0,y:l=0,opacity:o=0}={}){const r=getComputedStyle(n),a=+r.opacity,u=r.transform==="none"?"":r.transform,f=a*(1-o);return{delay:e,duration:t,easing:i,css:(c,d)=>`
+ transform: ${u} translate(${(1-c)*s}px, ${(1-c)*l}px);
+ opacity: ${a-f*d}`}}function St(n,{delay:e=0,duration:t=400,easing:i=Vo}={}){const s=getComputedStyle(n),l=+s.opacity,o=parseFloat(s.height),r=parseFloat(s.paddingTop),a=parseFloat(s.paddingBottom),u=parseFloat(s.marginTop),f=parseFloat(s.marginBottom),c=parseFloat(s.borderTopWidth),d=parseFloat(s.borderBottomWidth);return{delay:e,duration:t,easing:i,css:h=>`overflow: hidden;opacity: ${Math.min(h*20,1)*l};height: ${h*o}px;padding-top: ${h*r}px;padding-bottom: ${h*a}px;margin-top: ${h*u}px;margin-bottom: ${h*f}px;border-top-width: ${h*c}px;border-bottom-width: ${h*d}px;`}}function $t(n,{delay:e=0,duration:t=400,easing:i=Vo,start:s=0,opacity:l=0}={}){const o=getComputedStyle(n),r=+o.opacity,a=o.transform==="none"?"":o.transform,u=1-s,f=r*(1-l);return{delay:e,duration:t,easing:i,css:(c,d)=>`
+ transform: ${a} scale(${1-u*d});
+ opacity: ${r-f*d}
+ `}}function z1(n){let e,t,i,s;return{c(){e=v("input"),p(e,"type","text"),p(e,"id",n[8]),p(e,"placeholder",t=n[0]||n[1])},m(l,o){$(l,e,o),n[13](e),he(e,n[7]),i||(s=U(e,"input",n[14]),i=!0)},p(l,o){o&3&&t!==(t=l[0]||l[1])&&p(e,"placeholder",t),o&128&&e.value!==l[7]&&he(e,l[7])},i:x,o:x,d(l){l&&S(e),n[13](null),i=!1,s()}}}function B1(n){let e,t,i,s;function l(a){n[12](a)}var o=n[4];function r(a){let u={id:a[8],singleLine:!0,disableRequestKeys:!0,disableIndirectCollectionsKeys:!0,extraAutocompleteKeys:a[3],baseCollection:a[2],placeholder:a[0]||a[1]};return a[7]!==void 0&&(u.value=a[7]),{props:u}}return o&&(e=Qt(o,r(n)),le.push(()=>ke(e,"value",l)),e.$on("submit",n[10])),{c(){e&&j(e.$$.fragment),i=Fe()},m(a,u){e&&R(e,a,u),$(a,i,u),s=!0},p(a,u){const f={};if(u&8&&(f.extraAutocompleteKeys=a[3]),u&4&&(f.baseCollection=a[2]),u&3&&(f.placeholder=a[0]||a[1]),!t&&u&128&&(t=!0,f.value=a[7],$e(()=>t=!1)),o!==(o=a[4])){if(e){be();const c=e;I(c.$$.fragment,1,0,()=>{H(c,1)}),ve()}o?(e=Qt(o,r(a)),le.push(()=>ke(e,"value",l)),e.$on("submit",a[10]),j(e.$$.fragment),E(e.$$.fragment,1),R(e,i.parentNode,i)):e=null}else o&&e.$set(f)},i(a){s||(e&&E(e.$$.fragment,a),s=!0)},o(a){e&&I(e.$$.fragment,a),s=!1},d(a){a&&S(i),e&&H(e,a)}}}function Ru(n){let e,t,i,s,l,o,r=n[7]!==n[0]&&Hu();return{c(){r&&r.c(),e=O(),t=v("button"),t.innerHTML='Clear',p(t,"type","button"),p(t,"class","btn btn-secondary btn-sm btn-hint p-l-xs p-r-xs m-l-10")},m(a,u){r&&r.m(a,u),$(a,e,u),$(a,t,u),s=!0,l||(o=U(t,"click",n[15]),l=!0)},p(a,u){a[7]!==a[0]?r?u&129&&E(r,1):(r=Hu(),r.c(),E(r,1),r.m(e.parentNode,e)):r&&(be(),I(r,1,1,()=>{r=null}),ve())},i(a){s||(E(r),a&&Qe(()=>{i||(i=je(t,kn,{duration:150,x:5},!0)),i.run(1)}),s=!0)},o(a){I(r),a&&(i||(i=je(t,kn,{duration:150,x:5},!1)),i.run(0)),s=!1},d(a){r&&r.d(a),a&&S(e),a&&S(t),a&&i&&i.end(),l=!1,o()}}}function Hu(n){let e,t,i;return{c(){e=v("button"),e.innerHTML='Search',p(e,"type","submit"),p(e,"class","btn btn-expanded btn-sm btn-warning")},m(s,l){$(s,e,l),i=!0},i(s){i||(s&&Qe(()=>{t||(t=je(e,kn,{duration:150,x:5},!0)),t.run(1)}),i=!0)},o(s){s&&(t||(t=je(e,kn,{duration:150,x:5},!1)),t.run(0)),i=!1},d(s){s&&S(e),s&&t&&t.end()}}}function W1(n){let e,t,i,s,l,o,r,a,u,f,c;const d=[B1,z1],h=[];function m(g,y){return g[4]&&!g[5]?0:1}o=m(n),r=h[o]=d[o](n);let b=(n[0].length||n[7].length)&&Ru(n);return{c(){e=v("div"),t=v("form"),i=v("label"),s=v("i"),l=O(),r.c(),a=O(),b&&b.c(),p(s,"class","ri-search-line"),p(i,"for",n[8]),p(i,"class","m-l-10 txt-xl"),p(t,"class","searchbar"),p(e,"class","searchbar-wrapper")},m(g,y){$(g,e,y),_(e,t),_(t,i),_(i,s),_(t,l),h[o].m(t,null),_(t,a),b&&b.m(t,null),u=!0,f||(c=[U(t,"click",Yn(n[11])),U(t,"submit",ut(n[10]))],f=!0)},p(g,[y]){let k=o;o=m(g),o===k?h[o].p(g,y):(be(),I(h[k],1,1,()=>{h[k]=null}),ve(),r=h[o],r?r.p(g,y):(r=h[o]=d[o](g),r.c()),E(r,1),r.m(t,a)),g[0].length||g[7].length?b?(b.p(g,y),y&129&&E(b,1)):(b=Ru(g),b.c(),E(b,1),b.m(t,null)):b&&(be(),I(b,1,1,()=>{b=null}),ve())},i(g){u||(E(r),E(b),u=!0)},o(g){I(r),I(b),u=!1},d(g){g&&S(e),h[o].d(),b&&b.d(),f=!1,Re(c)}}}function U1(n,e,t){const i=It(),s="search_"+B.randomString(7);let{value:l=""}=e,{placeholder:o='Search filter, ex. created > "2022-01-01"...'}=e,{autocompleteCollection:r=new Pn}=e,{extraAutocompleteKeys:a=[]}=e,u,f=!1,c,d="";function h(M=!0){t(7,d=""),M&&(c==null||c.focus()),i("clear")}function m(){t(0,l=d),i("submit",l)}async function b(){u||f||(t(5,f=!0),t(4,u=(await st(()=>import("./FilterAutocompleteInput.7da1d2a3.js"),["./FilterAutocompleteInput.7da1d2a3.js","./index.9c8b95cd.js"],import.meta.url)).default),t(5,f=!1))}un(()=>{b()});function g(M){Ve.call(this,n,M)}function y(M){d=M,t(7,d),t(0,l)}function k(M){le[M?"unshift":"push"](()=>{c=M,t(6,c)})}function w(){d=this.value,t(7,d),t(0,l)}const C=()=>{h(!1),m()};return n.$$set=M=>{"value"in M&&t(0,l=M.value),"placeholder"in M&&t(1,o=M.placeholder),"autocompleteCollection"in M&&t(2,r=M.autocompleteCollection),"extraAutocompleteKeys"in M&&t(3,a=M.extraAutocompleteKeys)},n.$$.update=()=>{n.$$.dirty&1&&typeof l=="string"&&t(7,d=l)},[l,o,r,a,u,f,c,d,s,h,m,g,y,k,w,C]}class ka extends Me{constructor(e){super(),Ce(this,e,U1,W1,we,{value:0,placeholder:1,autocompleteCollection:2,extraAutocompleteKeys:3})}}let qr,Ii;const Vr="app-tooltip";function ju(n){return typeof n=="string"?{text:n,position:"bottom",hideOnClick:null}:n||{}}function _i(){return Ii=Ii||document.querySelector("."+Vr),Ii||(Ii=document.createElement("div"),Ii.classList.add(Vr),document.body.appendChild(Ii)),Ii}function wg(n,e){let t=_i();if(!t.classList.contains("active")||!(e!=null&&e.text)){zr();return}t.textContent=e.text,t.className=Vr+" active",e.class&&t.classList.add(e.class),e.position&&t.classList.add(e.position),t.style.top="0px",t.style.left="0px";let i=t.offsetHeight,s=t.offsetWidth,l=n.getBoundingClientRect(),o=0,r=0,a=5;e.position=="left"?(o=l.top+l.height/2-i/2,r=l.left-s-a):e.position=="right"?(o=l.top+l.height/2-i/2,r=l.right+a):e.position=="top"?(o=l.top-i-a,r=l.left+l.width/2-s/2):e.position=="top-left"?(o=l.top-i-a,r=l.left):e.position=="top-right"?(o=l.top-i-a,r=l.right-s):e.position=="bottom-left"?(o=l.top+l.height+a,r=l.left):e.position=="bottom-right"?(o=l.top+l.height+a,r=l.right-s):(o=l.top+l.height+a,r=l.left+l.width/2-s/2),r+s>document.documentElement.clientWidth&&(r=document.documentElement.clientWidth-s),r=r>=0?r:0,o+i>document.documentElement.clientHeight&&(o=document.documentElement.clientHeight-i),o=o>=0?o:0,t.style.top=o+"px",t.style.left=r+"px"}function zr(){clearTimeout(qr),_i().classList.remove("active"),_i().activeNode=void 0}function Y1(n,e){_i().activeNode=n,clearTimeout(qr),qr=setTimeout(()=>{_i().classList.add("active"),wg(n,e)},isNaN(e.delay)?0:e.delay)}function Be(n,e){let t=ju(e);function i(){Y1(n,t)}function s(){zr()}return n.addEventListener("mouseenter",i),n.addEventListener("mouseleave",s),n.addEventListener("blur",s),(t.hideOnClick===!0||t.hideOnClick===null&&B.isFocusable(n))&&n.addEventListener("click",s),_i(),{update(l){var o,r;t=ju(l),(r=(o=_i())==null?void 0:o.activeNode)!=null&&r.contains(n)&&wg(n,t)},destroy(){var l,o;(o=(l=_i())==null?void 0:l.activeNode)!=null&&o.contains(n)&&zr(),n.removeEventListener("mouseenter",i),n.removeEventListener("mouseleave",s),n.removeEventListener("blur",s),n.removeEventListener("click",s)}}}function K1(n){let e,t,i,s;return{c(){e=v("button"),e.innerHTML='',p(e,"type","button"),p(e,"class","btn btn-secondary btn-circle svelte-1bvelc2"),ee(e,"refreshing",n[1])},m(l,o){$(l,e,o),i||(s=[Le(t=Be.call(null,e,n[0])),U(e,"click",n[2])],i=!0)},p(l,[o]){t&&Wt(t.update)&&o&1&&t.update.call(null,l[0]),o&2&&ee(e,"refreshing",l[1])},i:x,o:x,d(l){l&&S(e),i=!1,Re(s)}}}function J1(n,e,t){const i=It();let{tooltip:s={text:"Refresh",position:"right"}}=e,l=null;function o(){i("refresh");const r=s;t(0,s=null),clearTimeout(l),t(1,l=setTimeout(()=>{t(1,l=null),t(0,s=r)},150))}return un(()=>()=>clearTimeout(l)),n.$$set=r=>{"tooltip"in r&&t(0,s=r.tooltip)},[s,l,o]}class wa extends Me{constructor(e){super(),Ce(this,e,J1,K1,we,{tooltip:0})}}function Z1(n){let e,t,i,s,l;const o=n[6].default,r=Ot(o,n,n[5],null);return{c(){e=v("th"),r&&r.c(),p(e,"tabindex","0"),p(e,"class",t="col-sort "+n[1]),ee(e,"col-sort-disabled",n[3]),ee(e,"sort-active",n[0]==="-"+n[2]||n[0]==="+"+n[2]),ee(e,"sort-desc",n[0]==="-"+n[2]),ee(e,"sort-asc",n[0]==="+"+n[2])},m(a,u){$(a,e,u),r&&r.m(e,null),i=!0,s||(l=[U(e,"click",n[7]),U(e,"keydown",n[8])],s=!0)},p(a,[u]){r&&r.p&&(!i||u&32)&&Et(r,o,a,a[5],i?Dt(o,a[5],u,null):At(a[5]),null),(!i||u&2&&t!==(t="col-sort "+a[1]))&&p(e,"class",t),(!i||u&10)&&ee(e,"col-sort-disabled",a[3]),(!i||u&7)&&ee(e,"sort-active",a[0]==="-"+a[2]||a[0]==="+"+a[2]),(!i||u&7)&&ee(e,"sort-desc",a[0]==="-"+a[2]),(!i||u&7)&&ee(e,"sort-asc",a[0]==="+"+a[2])},i(a){i||(E(r,a),i=!0)},o(a){I(r,a),i=!1},d(a){a&&S(e),r&&r.d(a),s=!1,Re(l)}}}function G1(n,e,t){let{$$slots:i={},$$scope:s}=e,{class:l=""}=e,{name:o}=e,{sort:r=""}=e,{disable:a=!1}=e;function u(){a||("-"+o===r?t(0,r="+"+o):t(0,r="-"+o))}const f=()=>u(),c=d=>{(d.code==="Enter"||d.code==="Space")&&(d.preventDefault(),u())};return n.$$set=d=>{"class"in d&&t(1,l=d.class),"name"in d&&t(2,o=d.name),"sort"in d&&t(0,r=d.sort),"disable"in d&&t(3,a=d.disable),"$$scope"in d&&t(5,s=d.$$scope)},[r,l,o,a,u,s,i,f,c]}class Ft extends Me{constructor(e){super(),Ce(this,e,G1,Z1,we,{class:1,name:2,sort:0,disable:3})}}function X1(n){let e;return{c(){e=v("span"),e.textContent="N/A",p(e,"class","txt txt-hint")},m(t,i){$(t,e,i)},p:x,d(t){t&&S(e)}}}function Q1(n){let e,t,i,s,l,o,r;return{c(){e=v("div"),t=v("div"),i=z(n[2]),s=O(),l=v("div"),o=z(n[1]),r=z(" UTC"),p(t,"class","date"),p(l,"class","time svelte-zdiknu"),p(e,"class","datetime svelte-zdiknu")},m(a,u){$(a,e,u),_(e,t),_(t,i),_(e,s),_(e,l),_(l,o),_(l,r)},p(a,u){u&4&&ue(i,a[2]),u&2&&ue(o,a[1])},d(a){a&&S(e)}}}function x1(n){let e;function t(l,o){return l[0]?Q1:X1}let i=t(n),s=i(n);return{c(){s.c(),e=Fe()},m(l,o){s.m(l,o),$(l,e,o)},p(l,[o]){i===(i=t(l))&&s?s.p(l,o):(s.d(1),s=i(l),s&&(s.c(),s.m(e.parentNode,e)))},i:x,o:x,d(l){s.d(l),l&&S(e)}}}function ev(n,e,t){let i,s,{date:l=""}=e;return n.$$set=o=>{"date"in o&&t(0,l=o.date)},n.$$.update=()=>{n.$$.dirty&1&&t(2,i=l?l.substring(0,10):null),n.$$.dirty&1&&t(1,s=l?l.substring(10,19):null)},[l,s,i]}class Ki extends Me{constructor(e){super(),Ce(this,e,ev,x1,we,{date:0})}}const tv=n=>({}),qu=n=>({}),nv=n=>({}),Vu=n=>({});function iv(n){let e,t,i,s,l,o,r,a;const u=n[5].before,f=Ot(u,n,n[4],Vu),c=n[5].default,d=Ot(c,n,n[4],null),h=n[5].after,m=Ot(h,n,n[4],qu);return{c(){e=v("div"),f&&f.c(),t=O(),i=v("div"),d&&d.c(),l=O(),m&&m.c(),p(i,"class",s="horizontal-scroller "+n[0]+" "+n[3]+" svelte-wc2j9h"),p(e,"class","horizontal-scroller-wrapper svelte-wc2j9h")},m(b,g){$(b,e,g),f&&f.m(e,null),_(e,t),_(e,i),d&&d.m(i,null),n[6](i),_(e,l),m&&m.m(e,null),o=!0,r||(a=[U(window,"resize",n[1]),U(i,"scroll",n[1])],r=!0)},p(b,[g]){f&&f.p&&(!o||g&16)&&Et(f,u,b,b[4],o?Dt(u,b[4],g,nv):At(b[4]),Vu),d&&d.p&&(!o||g&16)&&Et(d,c,b,b[4],o?Dt(c,b[4],g,null):At(b[4]),null),(!o||g&9&&s!==(s="horizontal-scroller "+b[0]+" "+b[3]+" svelte-wc2j9h"))&&p(i,"class",s),m&&m.p&&(!o||g&16)&&Et(m,h,b,b[4],o?Dt(h,b[4],g,tv):At(b[4]),qu)},i(b){o||(E(f,b),E(d,b),E(m,b),o=!0)},o(b){I(f,b),I(d,b),I(m,b),o=!1},d(b){b&&S(e),f&&f.d(b),d&&d.d(b),n[6](null),m&&m.d(b),r=!1,Re(a)}}}function sv(n,e,t){let{$$slots:i={},$$scope:s}=e,{class:l=""}=e,o=null,r="",a=null,u;function f(){!o||(clearTimeout(a),a=setTimeout(()=>{const d=o.offsetWidth,h=o.scrollWidth;h-d?(t(3,r="scrollable"),o.scrollLeft===0?t(3,r+=" scroll-start"):o.scrollLeft+d==h&&t(3,r+=" scroll-end")):t(3,r="")},100))}un(()=>(f(),u=new MutationObserver(()=>{f()}),u.observe(o,{attributeFilter:["width"],childList:!0,subtree:!0}),()=>{u==null||u.disconnect(),clearTimeout(a)}));function c(d){le[d?"unshift":"push"](()=>{o=d,t(2,o)})}return n.$$set=d=>{"class"in d&&t(0,l=d.class),"$$scope"in d&&t(4,s=d.$$scope)},[l,f,o,r,s,i,c]}class Sa extends Me{constructor(e){super(),Ce(this,e,sv,iv,we,{class:0,refresh:1})}get refresh(){return this.$$.ctx[1]}}function zu(n,e,t){const i=n.slice();return i[23]=e[t],i}function lv(n){let e;return{c(){e=v("div"),e.innerHTML=`
+ method`,p(e,"class","col-header-content")},m(t,i){$(t,e,i)},p:x,d(t){t&&S(e)}}}function ov(n){let e,t,i,s;return{c(){e=v("div"),t=v("i"),i=O(),s=v("span"),s.textContent="url",p(t,"class",B.getFieldTypeIcon("url")),p(s,"class","txt"),p(e,"class","col-header-content")},m(l,o){$(l,e,o),_(e,t),_(e,i),_(e,s)},p:x,d(l){l&&S(e)}}}function rv(n){let e,t,i,s;return{c(){e=v("div"),t=v("i"),i=O(),s=v("span"),s.textContent="referer",p(t,"class",B.getFieldTypeIcon("url")),p(s,"class","txt"),p(e,"class","col-header-content")},m(l,o){$(l,e,o),_(e,t),_(e,i),_(e,s)},p:x,d(l){l&&S(e)}}}function av(n){let e,t,i,s;return{c(){e=v("div"),t=v("i"),i=O(),s=v("span"),s.textContent="User IP",p(t,"class",B.getFieldTypeIcon("number")),p(s,"class","txt"),p(e,"class","col-header-content")},m(l,o){$(l,e,o),_(e,t),_(e,i),_(e,s)},p:x,d(l){l&&S(e)}}}function uv(n){let e,t,i,s;return{c(){e=v("div"),t=v("i"),i=O(),s=v("span"),s.textContent="status",p(t,"class",B.getFieldTypeIcon("number")),p(s,"class","txt"),p(e,"class","col-header-content")},m(l,o){$(l,e,o),_(e,t),_(e,i),_(e,s)},p:x,d(l){l&&S(e)}}}function fv(n){let e,t,i,s;return{c(){e=v("div"),t=v("i"),i=O(),s=v("span"),s.textContent="created",p(t,"class",B.getFieldTypeIcon("date")),p(s,"class","txt"),p(e,"class","col-header-content")},m(l,o){$(l,e,o),_(e,t),_(e,i),_(e,s)},p:x,d(l){l&&S(e)}}}function Bu(n){let e;function t(l,o){return l[6]?dv:cv}let i=t(n),s=i(n);return{c(){s.c(),e=Fe()},m(l,o){s.m(l,o),$(l,e,o)},p(l,o){i===(i=t(l))&&s?s.p(l,o):(s.d(1),s=i(l),s&&(s.c(),s.m(e.parentNode,e)))},d(l){s.d(l),l&&S(e)}}}function cv(n){var r;let e,t,i,s,l,o=((r=n[0])==null?void 0:r.length)&&Wu(n);return{c(){e=v("tr"),t=v("td"),i=v("h6"),i.textContent="No logs found.",s=O(),o&&o.c(),l=O(),p(t,"colspan","99"),p(t,"class","txt-center txt-hint p-xs")},m(a,u){$(a,e,u),_(e,t),_(t,i),_(t,s),o&&o.m(t,null),_(e,l)},p(a,u){var f;(f=a[0])!=null&&f.length?o?o.p(a,u):(o=Wu(a),o.c(),o.m(t,null)):o&&(o.d(1),o=null)},d(a){a&&S(e),o&&o.d()}}}function dv(n){let e;return{c(){e=v("tr"),e.innerHTML=`
+ `},m(t,i){$(t,e,i)},p:x,d(t){t&&S(e)}}}function Wu(n){let e,t,i;return{c(){e=v("button"),e.innerHTML='Clear filters',p(e,"type","button"),p(e,"class","btn btn-hint btn-expanded m-t-sm")},m(s,l){$(s,e,l),t||(i=U(e,"click",n[19]),t=!0)},p:x,d(s){s&&S(e),t=!1,i()}}}function Uu(n){let e;return{c(){e=v("i"),p(e,"class","ri-error-warning-line txt-danger m-l-5 m-r-5"),p(e,"title","Error")},m(t,i){$(t,e,i)},d(t){t&&S(e)}}}function Yu(n,e){var Se,ye,We;let t,i,s,l=((Se=e[23].method)==null?void 0:Se.toUpperCase())+"",o,r,a,u,f,c=e[23].url+"",d,h,m,b,g,y,k=(e[23].referer||"N/A")+"",w,C,M,T,D,A=(e[23].userIp||"N/A")+"",P,L,V,F,W,G=e[23].status+"",K,X,Z,ie,J,fe,Y,re,Oe,ge,ae=(((ye=e[23].meta)==null?void 0:ye.errorMessage)||((We=e[23].meta)==null?void 0:We.errorData))&&Uu();ie=new Ki({props:{date:e[23].created}});function pe(){return e[17](e[23])}function de(...ce){return e[18](e[23],...ce)}return{key:n,first:null,c(){t=v("tr"),i=v("td"),s=v("span"),o=z(l),a=O(),u=v("td"),f=v("span"),d=z(c),m=O(),ae&&ae.c(),b=O(),g=v("td"),y=v("span"),w=z(k),M=O(),T=v("td"),D=v("span"),P=z(A),V=O(),F=v("td"),W=v("span"),K=z(G),X=O(),Z=v("td"),j(ie.$$.fragment),J=O(),fe=v("td"),fe.innerHTML='',Y=O(),p(s,"class",r="label txt-uppercase "+e[9][e[23].method.toLowerCase()]),p(i,"class","col-type-text col-field-method min-width"),p(f,"class","txt txt-ellipsis"),p(f,"title",h=e[23].url),p(u,"class","col-type-text col-field-url"),p(y,"class","txt txt-ellipsis"),p(y,"title",C=e[23].referer),ee(y,"txt-hint",!e[23].referer),p(g,"class","col-type-text col-field-referer"),p(D,"class","txt txt-ellipsis"),p(D,"title",L=e[23].userIp),ee(D,"txt-hint",!e[23].userIp),p(T,"class","col-type-number col-field-userIp"),p(W,"class","label"),ee(W,"label-danger",e[23].status>=400),p(F,"class","col-type-number col-field-status"),p(Z,"class","col-type-date col-field-created"),p(fe,"class","col-type-action min-width"),p(t,"tabindex","0"),p(t,"class","row-handle"),this.first=t},m(ce,se){$(ce,t,se),_(t,i),_(i,s),_(s,o),_(t,a),_(t,u),_(u,f),_(f,d),_(u,m),ae&&ae.m(u,null),_(t,b),_(t,g),_(g,y),_(y,w),_(t,M),_(t,T),_(T,D),_(D,P),_(t,V),_(t,F),_(F,W),_(W,K),_(t,X),_(t,Z),R(ie,Z,null),_(t,J),_(t,fe),_(t,Y),re=!0,Oe||(ge=[U(t,"click",pe),U(t,"keydown",de)],Oe=!0)},p(ce,se){var ne,Ee,it;e=ce,(!re||se&8)&&l!==(l=((ne=e[23].method)==null?void 0:ne.toUpperCase())+"")&&ue(o,l),(!re||se&8&&r!==(r="label txt-uppercase "+e[9][e[23].method.toLowerCase()]))&&p(s,"class",r),(!re||se&8)&&c!==(c=e[23].url+"")&&ue(d,c),(!re||se&8&&h!==(h=e[23].url))&&p(f,"title",h),((Ee=e[23].meta)==null?void 0:Ee.errorMessage)||((it=e[23].meta)==null?void 0:it.errorData)?ae||(ae=Uu(),ae.c(),ae.m(u,null)):ae&&(ae.d(1),ae=null),(!re||se&8)&&k!==(k=(e[23].referer||"N/A")+"")&&ue(w,k),(!re||se&8&&C!==(C=e[23].referer))&&p(y,"title",C),(!re||se&8)&&ee(y,"txt-hint",!e[23].referer),(!re||se&8)&&A!==(A=(e[23].userIp||"N/A")+"")&&ue(P,A),(!re||se&8&&L!==(L=e[23].userIp))&&p(D,"title",L),(!re||se&8)&&ee(D,"txt-hint",!e[23].userIp),(!re||se&8)&&G!==(G=e[23].status+"")&&ue(K,G),(!re||se&8)&&ee(W,"label-danger",e[23].status>=400);const te={};se&8&&(te.date=e[23].created),ie.$set(te)},i(ce){re||(E(ie.$$.fragment,ce),re=!0)},o(ce){I(ie.$$.fragment,ce),re=!1},d(ce){ce&&S(t),ae&&ae.d(),H(ie),Oe=!1,Re(ge)}}}function pv(n){let e,t,i,s,l,o,r,a,u,f,c,d,h,m,b,g,y,k,w,C,M,T,D,A,P=[],L=new Map,V;function F(de){n[11](de)}let W={disable:!0,class:"col-field-method",name:"method",$$slots:{default:[lv]},$$scope:{ctx:n}};n[1]!==void 0&&(W.sort=n[1]),s=new Ft({props:W}),le.push(()=>ke(s,"sort",F));function G(de){n[12](de)}let K={disable:!0,class:"col-type-text col-field-url",name:"url",$$slots:{default:[ov]},$$scope:{ctx:n}};n[1]!==void 0&&(K.sort=n[1]),r=new Ft({props:K}),le.push(()=>ke(r,"sort",G));function X(de){n[13](de)}let Z={disable:!0,class:"col-type-text col-field-referer",name:"referer",$$slots:{default:[rv]},$$scope:{ctx:n}};n[1]!==void 0&&(Z.sort=n[1]),f=new Ft({props:Z}),le.push(()=>ke(f,"sort",X));function ie(de){n[14](de)}let J={disable:!0,class:"col-type-number col-field-userIp",name:"userIp",$$slots:{default:[av]},$$scope:{ctx:n}};n[1]!==void 0&&(J.sort=n[1]),h=new Ft({props:J}),le.push(()=>ke(h,"sort",ie));function fe(de){n[15](de)}let Y={disable:!0,class:"col-type-number col-field-status",name:"status",$$slots:{default:[uv]},$$scope:{ctx:n}};n[1]!==void 0&&(Y.sort=n[1]),g=new Ft({props:Y}),le.push(()=>ke(g,"sort",fe));function re(de){n[16](de)}let Oe={disable:!0,class:"col-type-date col-field-created",name:"created",$$slots:{default:[fv]},$$scope:{ctx:n}};n[1]!==void 0&&(Oe.sort=n[1]),w=new Ft({props:Oe}),le.push(()=>ke(w,"sort",re));let ge=n[3];const ae=de=>de[23].id;for(let de=0;de a&&ui&&n[s-1]>t;)s--;return i>0||st.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,i)=>lr(i.backgroundColor),this.hoverBorderColor=(t,i)=>lr(i.borderColor),this.hoverColor=(t,i)=>lr(i.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e)}set(e,t){return or(this,e,t)}get(e){return tl(this,e)}describe(e,t){return or(Ur,e,t)}override(e,t){return or(Ji,e,t)}route(e,t,i,s){const l=tl(this,e),o=tl(this,i),r="_"+t;Object.defineProperties(l,{[r]:{value:l[t],writable:!0},[t]:{enumerable:!0,get(){const a=this[r],u=o[s];return Ue(a)?Object.assign({},u,a):Ge(a,u)},set(a){this[r]=a}}})}}var Xe=new iy({_scriptable:n=>!n.startsWith("on"),_indexable:n=>n!=="events",hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}});function sy(n){return!n||nt(n.size)||nt(n.family)?null:(n.style?n.style+" ":"")+(n.weight?n.weight+" ":"")+n.size+"px "+n.family}function So(n,e,t,i,s){let l=e[s];return l||(l=e[s]=n.measureText(s).width,t.push(s)),l>i&&(i=l),i}function ly(n,e,t,i){i=i||{};let s=i.data=i.data||{},l=i.garbageCollect=i.garbageCollect||[];i.font!==e&&(s=i.data={},l=i.garbageCollect=[],i.font=e),n.save(),n.font=e;let o=0;const r=t.length;let a,u,f,c,d;for(a=0;a