mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca439e5f7 | ||
|
|
f31a3b133c | ||
|
|
850fe353da | ||
|
|
16a6b32a76 | ||
|
|
a1487c3235 | ||
|
|
5255515e26 | ||
|
|
0b90ce43c3 | ||
|
|
899b8217e0 | ||
|
|
69bf9779d9 | ||
|
|
48d6803d17 | ||
|
|
216efb95a8 | ||
|
|
29a264e132 | ||
|
|
976a9e2f27 | ||
|
|
005e77151d | ||
|
|
f12467f2b3 | ||
|
|
fe03dd0789 | ||
|
|
ff1f99436a | ||
|
|
3ea02c945d | ||
|
|
4c903684d8 | ||
|
|
d0239f25ed | ||
|
|
05adb8c018 | ||
|
|
c901c9ab7d | ||
|
|
c9fba9972a | ||
|
|
2c40811fac | ||
|
|
b81112f82e | ||
|
|
0acf8198a4 | ||
|
|
5ece360808 | ||
|
|
3a5d3d521f | ||
|
|
f024de3cc4 | ||
|
|
a4abe9e2cb | ||
|
|
5678339af0 | ||
|
|
e5a22b8bd8 | ||
|
|
be0ee7f66c | ||
|
|
78a9f7429c | ||
|
|
d8b4daf47f | ||
|
|
7a2360d785 | ||
|
|
67ecebe935 | ||
|
|
5cbdbf6c10 | ||
|
|
0710214701 | ||
|
|
9736a45e80 | ||
|
|
923fc26a31 | ||
|
|
a79f3a7c56 | ||
|
|
8430944650 | ||
|
|
9b54fd3516 | ||
|
|
226352f884 | ||
|
|
2a682a10b2 | ||
|
|
b67549209a | ||
|
|
1126a0e16f | ||
|
|
17472cb40a | ||
|
|
981de64c7f | ||
|
|
1b45e23c81 | ||
|
|
29621303df | ||
|
|
a0ec5707d1 | ||
|
|
695c20a969 | ||
|
|
95bb2eb871 | ||
|
|
971916c20d | ||
|
|
44f5172db7 | ||
|
|
5fd103481c | ||
|
|
82abd7e0b0 | ||
|
|
361cfb56b7 | ||
|
|
4d16d0e16e | ||
|
|
254e691e92 | ||
|
|
e735d9d21b | ||
|
|
31b24ecb86 | ||
|
|
3eb3d0bbdf | ||
|
|
f180af7af7 | ||
|
|
cce9116fa7 | ||
|
|
7cfbe49b32 | ||
|
|
b564be06f4 | ||
|
|
f5493dd608 | ||
|
|
49227f5436 | ||
|
|
94d2f296b2 | ||
|
|
d046811df7 | ||
|
|
4768e07c0b | ||
|
|
0547202382 | ||
|
|
684f91f7e5 | ||
|
|
bce4094134 | ||
|
|
6a60bc1df3 | ||
|
|
19b7a7a74f | ||
|
|
8de0e61cf4 | ||
|
|
5344ec83fa | ||
|
|
af34eb544d | ||
|
|
65aa114103 | ||
|
|
8728161288 | ||
|
|
2420b2804a | ||
|
|
42e288c71a | ||
|
|
2791628d96 | ||
|
|
01f0af7af6 | ||
|
|
e8d61e7b45 | ||
|
|
a5ac83c7b0 | ||
|
|
a67c14c368 | ||
|
|
19faa0d8e7 | ||
|
|
56a77fd5ff | ||
|
|
07727dbde6 | ||
|
|
a3d26a73c3 | ||
|
|
b328827705 | ||
|
|
f5e5fae773 | ||
|
|
41f01bab0d | ||
|
|
dc96a12dc0 | ||
|
|
0bb58eb52c | ||
|
|
cf6d325add | ||
|
|
578e1c9bc1 | ||
|
|
4778fc7a46 | ||
|
|
f1a6a82bd3 | ||
|
|
21b152b58c | ||
|
|
aa4e405f92 | ||
|
|
e529fe7e2a | ||
|
|
6ab2fa9489 | ||
|
|
010a396b0e | ||
|
|
65a148b741 | ||
|
|
503c65a767 | ||
|
|
eec3261d67 | ||
|
|
0db6c783cd | ||
|
|
4fdc8feafc | ||
|
|
41c3cc8a90 | ||
|
|
0afb09b3bd | ||
|
|
1075292321 | ||
|
|
b184ef6c3a | ||
|
|
5fd7f61656 | ||
|
|
851711e231 | ||
|
|
a07f67002f | ||
|
|
0052e2ab2a | ||
|
|
0948bf416d | ||
|
|
9fd6e7fca1 | ||
|
|
fa65a20202 | ||
|
|
668698feb2 | ||
|
|
f475967a4a | ||
|
|
23dfa9c634 | ||
|
|
1b21e86be6 | ||
|
|
7d38971850 | ||
|
|
15102138c3 | ||
|
|
2378bc72c5 | ||
|
|
69b80123de | ||
|
|
250642a8f9 |
@@ -42,6 +42,6 @@ jobs:
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+25
-2
@@ -7,7 +7,20 @@ before:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./examples/base
|
||||
- id: build_cgo
|
||||
main: ./examples/base
|
||||
binary: pocketbase
|
||||
ldflags:
|
||||
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
- id: build_noncgo
|
||||
main: ./examples/base
|
||||
binary: pocketbase
|
||||
ldflags:
|
||||
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
|
||||
@@ -33,7 +46,17 @@ release:
|
||||
draft: true
|
||||
|
||||
archives:
|
||||
- format: zip
|
||||
- id: archive_cgo
|
||||
builds: [build_cgo]
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- CHANGELOG*
|
||||
|
||||
- id: archive_noncgo
|
||||
builds: [build_noncgo]
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- CHANGELOG*
|
||||
|
||||
+165
@@ -1,3 +1,168 @@
|
||||
## v0.14.2
|
||||
|
||||
- Reverted part of the old `COALESCE` handling to continue supporting empty string comparison with missing joined relation fields.
|
||||
|
||||
|
||||
## v0.14.1
|
||||
|
||||
- Fixed realtime events firing before the files upload completion.
|
||||
|
||||
- Updated the underlying S3 lib to use `aws-sdk-go-v2` ([#1346](https://github.com/pocketbase/pocketbase/pull/1346); thanks @yuxiang-gao).
|
||||
|
||||
- Updated TinyMCE to v6.4.1.
|
||||
|
||||
- Updated the godoc of `Dao.Save*` methods.
|
||||
|
||||
|
||||
## v0.14.0
|
||||
|
||||
- Added _experimental_ Apple OAuth2 integration.
|
||||
|
||||
- Added `@request.headers.*` filter rule support.
|
||||
|
||||
- Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544))
|
||||
|
||||
- Simplified the collections fields UI to allow easier and quicker scaffolding of the data schema.
|
||||
|
||||
- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes.
|
||||
The `Unique` field is a no-op and will be removed in future version.
|
||||
|
||||
- Removed the `COALESCE` wrapping from some of the generated filter conditions to make better use of the indexes ([#1939](https://github.com/pocketbase/pocketbase/issues/1939)).
|
||||
|
||||
- Detect `id` aliased view columns as single `relation` fields ([#2029](https://github.com/pocketbase/pocketbase/discussions/2029)).
|
||||
|
||||
- Optimized single relation lookups.
|
||||
|
||||
- Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`).
|
||||
When changing **from single to multiple** all already inserted single values are converted to an array.
|
||||
When changing **from multiple to single** only the last item of the already inserted array items is kept.
|
||||
|
||||
- Changed the cost/round factor of bcrypt hash generation from 13 to 12 since several users complained about the slow authWithPassword responses on lower spec hardware.
|
||||
_The change will affect only new users. Depending on the demand, we might make it configurable from the auth options._
|
||||
|
||||
- Simplified the default mail template styles to allow more control over the template layout ([#1904](https://github.com/pocketbase/pocketbase/issues/1904)).
|
||||
|
||||
- Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)).
|
||||
|
||||
- Added `migrate history-sync` command to clean `_migrations` history table from deleted migration files references.
|
||||
|
||||
- Added new fields to the `core.RecordAuthWithOAuth2Event` struct:
|
||||
```
|
||||
IsNewRecord bool, // boolean field indicating whether the OAuth2 action created a new auth record
|
||||
ProviderName string, // the name of the OAuth2 provider (eg. "google")
|
||||
ProviderClient auth.Provider, // the loaded Provider client instance
|
||||
```
|
||||
|
||||
- Added CGO linux target for the prebuilt executable.
|
||||
|
||||
- **!** Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
|
||||
|
||||
- **!** Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
|
||||
|
||||
- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
|
||||
If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
|
||||
|
||||
- **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
|
||||
|
||||
- **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
|
||||
|
||||
- Other minor UI improvements.
|
||||
|
||||
|
||||
## v0.13.4
|
||||
|
||||
- Removed eager unique collection name check to support lazy validation during bulk import.
|
||||
|
||||
|
||||
## v0.13.3
|
||||
|
||||
- Fixed view collections import ([#2044](https://github.com/pocketbase/pocketbase/issues/2044)).
|
||||
|
||||
- Updated the records picker Admin UI to show properly view collection relations.
|
||||
|
||||
|
||||
## v0.13.2
|
||||
|
||||
- Fixed Admin UI js error when selecting multiple `file` field as `relation` "Display fields" ([#1989](https://github.com/pocketbase/pocketbase/issues/1989)).
|
||||
|
||||
|
||||
## v0.13.1
|
||||
|
||||
- Added `HEAD` request method support for the `/api/files/:collection/:recordId/:filename` route ([#1976](https://github.com/pocketbase/pocketbase/discussions/1976)).
|
||||
|
||||
|
||||
## v0.13.0
|
||||
|
||||
- Added new "View" collection type allowing you to create a read-only collection from a custom SQL `SELECT` statement. It supports:
|
||||
- aggregations (`COUNT()`, `MIN()`, `MAX()`, `GROUP BY`, etc.)
|
||||
- column and table aliases
|
||||
- CTEs and subquery expressions
|
||||
- auto `relation` fields association
|
||||
- `file` fields proxying (up to 5 linked relations, eg. view1->view2->...->base)
|
||||
- `filter`, `sort` and `expand`
|
||||
- List and View API rules
|
||||
|
||||
- Added auto fail/retry (default to 8 attempts) for the `SELECT` queries to gracefully handle the `database is locked` errors ([#1795](https://github.com/pocketbase/pocketbase/discussions/1795#discussioncomment-4882169)).
|
||||
_The default max attempts can be accessed or changed via `Dao.MaxLockRetries`._
|
||||
|
||||
- Added default max query execution timeout (30s).
|
||||
_The default timeout can be accessed or changed via `Dao.ModelQueryTimeout`._
|
||||
_For the prebuilt executables it can be also changed via the `--queryTimeout=10` flag._
|
||||
|
||||
- Added support for `dao.RecordQuery(collection)` to scan directly the `One()` and `All()` results in `*models.Record` or `[]*models.Record` without the need of explicit `NullStringMap`.
|
||||
|
||||
- Added support to overwrite the default file serve headers if an explicit response header is set.
|
||||
|
||||
- Added file thumbs when visualizing `relation` display file fields.
|
||||
|
||||
- Added "Min select" `relation` field option.
|
||||
|
||||
- Enabled `process.env` in JS migrations to allow accessing `os.Environ()`.
|
||||
|
||||
- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
|
||||
|
||||
- **!** Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
|
||||
|
||||
- **!** Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
|
||||
|
||||
- **!** Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
|
||||
|
||||
If you are sending custom emails, you'll have to replace:
|
||||
```go
|
||||
message := &mailer.Message{
|
||||
...
|
||||
|
||||
// (old) To: mail.Address{Address: "to@example.com"}
|
||||
To: []mail.Address{{Address: "to@example.com", Name: "Some optional name"}},
|
||||
|
||||
// (old) Cc: []string{"cc@example.com"}
|
||||
Cc: []mail.Address{{Address: "cc@example.com", Name: "Some optional name"}},
|
||||
|
||||
// (old) Bcc: []string{"bcc@example.com"}
|
||||
Bcc: []mail.Address{{Address: "bcc@example.com", Name: "Some optional name"}},
|
||||
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **!** Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
|
||||
_If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
|
||||
_To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
|
||||
|
||||
- **!** Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
|
||||
|
||||
- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
|
||||
|
||||
- Other minor Admin UI improvements.
|
||||
|
||||
|
||||
## v0.12.3
|
||||
|
||||
- Fixed "Toggle column" reactivity when navigating between collections ([#1836](https://github.com/pocketbase/pocketbase/pull/1836)).
|
||||
|
||||
- Logged the current datetime on server start ([#1822](https://github.com/pocketbase/pocketbase/issues/1822)).
|
||||
|
||||
|
||||
## v0.12.2
|
||||
|
||||
- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
|
||||
|
||||
+9
-6
@@ -36,8 +36,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
// default middlewares
|
||||
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
// ignore Admin UI route(s)
|
||||
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
|
||||
// enable by default only for the API routes
|
||||
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
|
||||
},
|
||||
}))
|
||||
e.Use(middleware.Recover())
|
||||
@@ -47,6 +47,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
// custom error handler
|
||||
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
||||
if c.Response().Committed {
|
||||
if app.IsDebug() {
|
||||
log.Println("HTTPErrorHandler response was already committed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,7 +166,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
e.GET(
|
||||
strings.TrimRight(trailedAdminPath, "/"),
|
||||
func(c echo.Context) error {
|
||||
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -224,12 +227,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
if totalAdmins == 0 && !hasInstallerParam {
|
||||
// redirect to the installer page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
|
||||
}
|
||||
|
||||
if totalAdmins != 0 && hasInstallerParam {
|
||||
// redirect to the home page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
|
||||
// clear the installer param
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
|
||||
@@ -136,3 +136,76 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "non /api/* route (exact match)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/custom",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "non /api/* route (with trailing slash)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/custom/",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "/api/* route (exact match)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/custom",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "/api/* route (with trailing slash)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/custom/",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -159,7 +159,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 NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
|
||||
+288
-17
@@ -45,7 +45,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":8`,
|
||||
`"totalItems":10`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -73,10 +73,10 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":8`,
|
||||
`"totalItems":10`,
|
||||
`"items":[{`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"kpv709sk2lqbqk8"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsListRequest": 1,
|
||||
@@ -231,7 +231,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + using the collection name",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo1",
|
||||
Url: "/api/collections/demo5",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
@@ -244,13 +244,13 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureDeletedFiles(app, "wsmn24bux7wo113")
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + using the collection id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/wsmn24bux7wo113",
|
||||
Url: "/api/collections/9n89pl5vkct6330",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
@@ -263,7 +263,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureDeletedFiles(app, "wsmn24bux7wo113")
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -292,6 +292,22 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + deleting a view",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/view2",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -520,6 +536,133 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"options":{"minPasswordLength":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "trying to create view collection with invalid options",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"options":{"query": "invalid"}
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"options":{"query":{"code":"validation_invalid_view_query`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating view collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"options": {
|
||||
"query": "select 1 as id from _admins"
|
||||
}
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"view"`,
|
||||
`"schema":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// indexes
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "creating base collection with invalid indexes",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"base",
|
||||
"schema":[
|
||||
{"type":"text","name":"test"}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test1 on new (test)",
|
||||
"create index idx_test2 on new (missing)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"indexes":{"1":{"code":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating base collection with valid indexes (+ random table name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"base",
|
||||
"schema":[
|
||||
{"type":"text","name":"test"}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test1 on new (test)",
|
||||
"create index idx_test2 on anything (id, test)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"base"`,
|
||||
`"indexes":[`,
|
||||
`idx_test1`,
|
||||
`idx_test2`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{"idx_test1", "idx_test2"}
|
||||
for name := range indexes {
|
||||
if !list.ExistInSlice(name, expected) {
|
||||
t.Fatalf("Missing index %q", name)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -660,7 +803,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "updating base collection with reserved auth fields",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo1",
|
||||
Url: "/api/collections/demo4",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{"type":"text","name":"email"},
|
||||
@@ -681,7 +824,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"demo1"`,
|
||||
`"name":"demo4"`,
|
||||
`"type":"base"`,
|
||||
`"schema":[{`,
|
||||
`"email"`,
|
||||
@@ -751,6 +894,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
|
||||
// rel field change displayFields propagation
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "renaming a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
@@ -830,6 +974,122 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "trying to update view collection with invalid options",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/view1",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"options":{"query": "invalid"}
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"options":{"query":{"code":"validation_invalid_view_query`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "updating view collection",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/view2",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"view2_update",
|
||||
"schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"options": {
|
||||
"query": "select 2 as id, created, updated, email from _admins"
|
||||
}
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"view2_update"`,
|
||||
`"type":"view"`,
|
||||
`"schema":[{`,
|
||||
`"name":"email"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// base model fields are not part of the schema
|
||||
`"name":"id"`,
|
||||
`"name":"created"`,
|
||||
`"name":"updated"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// indexes
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "updating base collection with invalid indexes",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2",
|
||||
Body: strings.NewReader(`{
|
||||
"indexes": [
|
||||
"create unique idx_test1 on demo1 (text)",
|
||||
"create index idx_test2 on demo2 (id, title)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"indexes":{"0":{"code":"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "updating base collection with valid indexes (+ random table name)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2",
|
||||
Body: strings.NewReader(`{
|
||||
"indexes": [
|
||||
"create unique index idx_test1 on demo2 (title)",
|
||||
"create index idx_test2 on anything (active)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"demo2"`,
|
||||
`"indexes":[`,
|
||||
"CREATE UNIQUE INDEX `idx_test1`",
|
||||
"CREATE INDEX `idx_test2`",
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{"idx_test1", "idx_test2"}
|
||||
for name := range indexes {
|
||||
if !list.ExistInSlice(name, expected) {
|
||||
t.Fatalf("Missing index %q", name)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -838,6 +1098,8 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionImport(t *testing.T) {
|
||||
totalCollections := 10
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -874,7 +1136,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 8
|
||||
expected := totalCollections
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -895,14 +1157,14 @@ func TestCollectionImport(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelBeforeDelete": 7,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 8
|
||||
expected := totalCollections
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -944,7 +1206,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 8
|
||||
expected := totalCollections
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -974,6 +1236,9 @@ func TestCollectionImport(t *testing.T) {
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -997,10 +1262,16 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 11
|
||||
|
||||
expected := totalCollections + 3
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
|
||||
indexes, err := app.Dao().TableIndexes("import2")
|
||||
if err != nil || indexes["idx_test"] == "" {
|
||||
t.Fatalf("Missing index %s (%v)", "idx_test", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1084,8 +1355,8 @@ func TestCollectionImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
"OnModelBeforeDelete": 8,
|
||||
"OnModelAfterDelete": 8,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+24
-7
@@ -1,6 +1,8 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -16,6 +18,7 @@ func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
}
|
||||
|
||||
@@ -45,15 +48,27 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
if fileField == nil {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
options, _ := fileField.Options.(*schema.FileOptions)
|
||||
|
||||
baseFilesPath := record.BaseFilesPath()
|
||||
|
||||
// fetch the original view file field related record
|
||||
if collection.IsView() {
|
||||
fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
|
||||
if err != nil {
|
||||
return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
|
||||
}
|
||||
baseFilesPath = fileRecord.BaseFilesPath()
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
originalPath := record.BaseFilesPath() + "/" + filename
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
servedName := filename
|
||||
|
||||
@@ -70,13 +85,10 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
|
||||
// add thumb size as file suffix
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
|
||||
// check if the thumb exists:
|
||||
// - if doesn't exist - create a new thumb with the specified thumb size
|
||||
// - if exists - compare last modified dates to determine whether the thumb should be recreated
|
||||
tAttrs, tAttrsErr := fs.Attributes(servedPath)
|
||||
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
|
||||
// create a new thumb if it doesn exists
|
||||
if exists, _ := fs.Exists(servedPath); !exists {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
|
||||
servedPath = originalPath // fallback to the original
|
||||
}
|
||||
@@ -92,6 +104,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
event.ServedPath = servedPath
|
||||
event.ServedName = servedName
|
||||
|
||||
// clickjacking shouldn't be a concern when serving uploaded files,
|
||||
// so it safe to unset the global X-Frame-Options to allow files embedding
|
||||
// (note: it is out of the hook to allow users to customize the behavior)
|
||||
c.Response().Header().Del("X-Frame-Options")
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
|
||||
@@ -179,6 +179,14 @@ func TestFileDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
// clone for the HEAD test (the same as the original scenario but without body)
|
||||
head := scenario
|
||||
head.Method = http.MethodHead
|
||||
head.Name = ("(HEAD) " + scenario.Name)
|
||||
head.ExpectedContent = nil
|
||||
head.Test(t)
|
||||
|
||||
// regular request test
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -59,9 +59,12 @@ func RequireGuestOnly() echo.MiddlewareFunc {
|
||||
// specifying their names.
|
||||
//
|
||||
// Example:
|
||||
// apis.RequireRecordAuth()
|
||||
//
|
||||
// apis.RequireRecordAuth()
|
||||
//
|
||||
// Or:
|
||||
// apis.RequireRecordAuth("users", "supervisors")
|
||||
//
|
||||
// apis.RequireRecordAuth("users", "supervisors")
|
||||
//
|
||||
// To restrict the auth record only to the loaded context collection,
|
||||
// use [apis.RequireSameContextRecordAuth()] instead.
|
||||
@@ -83,7 +86,6 @@ func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// RequireSameContextRecordAuth middleware requires a request to have
|
||||
// a valid record Authorization header.
|
||||
//
|
||||
@@ -261,7 +263,7 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
|
||||
}
|
||||
|
||||
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
|
||||
return NewBadRequestError("Invalid collection type.", nil)
|
||||
return NewBadRequestError("Unsupported collection type.", nil)
|
||||
}
|
||||
|
||||
c.Set(ContextCollectionKey, collection)
|
||||
@@ -319,7 +321,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
model := &models.Request{
|
||||
Url: httpRequest.URL.RequestURI(),
|
||||
Method: strings.ToLower(httpRequest.Method),
|
||||
Method: strings.ToUpper(httpRequest.Method),
|
||||
Status: status,
|
||||
Auth: requestAuth,
|
||||
UserIp: realUserIp(httpRequest, ip),
|
||||
|
||||
+20
-7
@@ -129,6 +129,16 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
codeVerifier := security.RandomString(43)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
urlOpts := []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
}
|
||||
|
||||
if name == auth.NameApple {
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
|
||||
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
|
||||
}
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
@@ -137,9 +147,8 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
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
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,10 +177,17 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.ProviderName = form.Provider
|
||||
event.IsNewRecord = false
|
||||
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
event.IsNewRecord = true
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -204,14 +220,11 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
})
|
||||
})
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
|
||||
return func(data *forms.RecordOAuth2LoginData) error {
|
||||
event.Record = data.Record
|
||||
event.OAuth2User = data.OAuth2User
|
||||
event.ProviderClient = data.ProviderClient
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
data.Record = e.Record
|
||||
|
||||
+10
-12
@@ -26,14 +26,13 @@ func bindRecordCrudApi(app core.App, rg *echo.Group) {
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app),
|
||||
)
|
||||
|
||||
subGroup.GET("/records", api.list)
|
||||
subGroup.POST("/records", api.create)
|
||||
subGroup.GET("/records/:id", api.view)
|
||||
subGroup.PATCH("/records/:id", api.update)
|
||||
subGroup.DELETE("/records/:id", api.delete)
|
||||
subGroup.GET("/records", api.list, LoadCollectionContext(app))
|
||||
subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
|
||||
subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
|
||||
subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
|
||||
subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
|
||||
}
|
||||
|
||||
type recordApi struct {
|
||||
@@ -73,16 +72,13 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
|
||||
records := []*models.Record{}
|
||||
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
|
||||
|
||||
result.Items = records
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
@@ -221,6 +217,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
@@ -309,6 +306,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
|
||||
+125
-2
@@ -256,7 +256,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
|
||||
// auth collection checks
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "check email visibility as guest",
|
||||
@@ -403,6 +403,63 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
|
||||
// view collection
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "public view records",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view2/records?filter=state=false",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"id":"al1h9ijdeojtsjy"`,
|
||||
`"id":"imy661ixudk5izi"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"created"`,
|
||||
`"updated"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "guest that doesn't match the view collection list rule",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view1/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":0`,
|
||||
`"totalItems":0`,
|
||||
`"items":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that matches the view collection list rule",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view1/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":1`,
|
||||
`"items":[{`,
|
||||
`"id":"84nmscqy84lsi1t"`,
|
||||
`"bool":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -531,7 +588,7 @@ func TestRecordCrudView(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
|
||||
// auth collection checks
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "check email visibility as guest",
|
||||
@@ -629,6 +686,49 @@ func TestRecordCrudView(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
|
||||
// view collection
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "public view record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view2/records/84nmscqy84lsi1t",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"84nmscqy84lsi1t"`,
|
||||
`"state":true`,
|
||||
`"file_many":["`,
|
||||
`"rel_many":["`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"created"`,
|
||||
`"updated"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "guest that doesn't match the view collection view rule",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that matches the view collection view rule",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/view1/records/84nmscqy84lsi1t",
|
||||
RequestHeaders: map[string]string{
|
||||
// users, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"84nmscqy84lsi1t"`,
|
||||
`"bool":true`,
|
||||
`"text":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -690,6 +790,13 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "trying to delete a view collection record",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/view1/records/imy661ixudk5izi",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection record delete",
|
||||
Method: http.MethodDelete,
|
||||
@@ -885,6 +992,14 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "trying to create a new view collection record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/view1/records",
|
||||
Body: strings.NewReader(`{"text":"new"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "submit nil body",
|
||||
Method: http.MethodPost,
|
||||
@@ -1428,6 +1543,14 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "trying to update a view collection record",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/view1/records/imy661ixudk5izi",
|
||||
Body: strings.NewReader(`{"text":"new"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "submit nil body",
|
||||
Method: http.MethodPatch,
|
||||
|
||||
+12
-3
@@ -30,9 +30,18 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
Headers: map[string]any{},
|
||||
}
|
||||
|
||||
// extract the first value of all headers and normalizes the keys
|
||||
// ("X-Token" is converted to "x_token")
|
||||
for k, v := range c.Request().Header {
|
||||
if len(v) > 0 {
|
||||
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestRequestData(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set("X-Token-Test", "123")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -38,6 +39,12 @@ func TestRequestData(t *testing.T) {
|
||||
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
|
||||
}
|
||||
|
||||
rawHeaders, _ := json.Marshal(result.Headers)
|
||||
expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
|
||||
if v := string(rawHeaders); v != expectedHeaders {
|
||||
t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
|
||||
}
|
||||
|
||||
rawQuery, _ := json.Marshal(result.Query)
|
||||
expectedQuery := `{"test":"123"}`
|
||||
if v := string(rawQuery); v != expectedQuery {
|
||||
|
||||
+27
-1
@@ -22,6 +22,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
|
||||
subGroup.PATCH("", api.set)
|
||||
subGroup.POST("/test/s3", api.testS3)
|
||||
subGroup.POST("/test/email", api.testEmail)
|
||||
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
|
||||
}
|
||||
|
||||
type settingsApi struct {
|
||||
@@ -121,8 +122,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
// form error
|
||||
return NewBadRequestError("Failed to send the test email.", fErr)
|
||||
}
|
||||
|
||||
@@ -132,3 +133,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
|
||||
form := forms.NewAppleClientSecretCreate(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
// generate
|
||||
secret, err := form.Submit()
|
||||
if err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
return NewBadRequestError("Invalid client secret data.", fErr)
|
||||
}
|
||||
|
||||
// secret generation error
|
||||
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"secret": secret,
|
||||
})
|
||||
}
|
||||
|
||||
+149
-9
@@ -1,6 +1,12 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -61,8 +67,11 @@ func TestSettingsList(t *testing.T) {
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -133,8 +142,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -194,8 +206,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -318,8 +333,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
if len(app.TestMailer.LastMessage.To) != 1 {
|
||||
t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
|
||||
@@ -349,8 +368,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
if len(app.TestMailer.LastMessage.To) != 1 {
|
||||
t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
|
||||
@@ -380,8 +403,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
if len(app.TestMailer.LastMessage.To) != 1 {
|
||||
t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
|
||||
@@ -401,3 +428,116 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
privatePem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedKey,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid body)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (empty json)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"clientId":{"code":"validation_required"`,
|
||||
`"teamId":{"code":"validation_required"`,
|
||||
`"keyId":{"code":"validation_required"`,
|
||||
`"privateKey":{"code":"validation_required"`,
|
||||
`"duration":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid data)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{
|
||||
"clientId": "",
|
||||
"teamId": "123456789",
|
||||
"keyId": "123456789",
|
||||
"privateKey": "invalid",
|
||||
"duration": -1
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"clientId":{"code":"validation_required"`,
|
||||
`"teamId":{"code":"validation_length_invalid"`,
|
||||
`"keyId":{"code":"validation_length_invalid"`,
|
||||
`"privateKey":{"code":"validation_match_invalid"`,
|
||||
`"duration":{"code":"validation_min_greater_equal_than_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid data)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(fmt.Sprintf(`{
|
||||
"clientId": "123",
|
||||
"teamId": "1234567890",
|
||||
"keyId": "1234567891",
|
||||
"privateKey": %q,
|
||||
"duration": 1
|
||||
}`, privatePem)),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"secret":"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -90,11 +91,20 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
if httpsAddr != "" {
|
||||
schema = "https"
|
||||
}
|
||||
regular := color.New()
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr))
|
||||
regular.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, serverConfig.Addr),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDataMaxOpenConns int = 100
|
||||
DefaultDataMaxOpenConns int = 120
|
||||
DefaultDataMaxIdleConns int = 20
|
||||
DefaultLogsMaxOpenConns int = 10
|
||||
DefaultLogsMaxIdleConns int = 2
|
||||
@@ -472,7 +472,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
if app.settings.S3.Enabled {
|
||||
if app.settings != nil && app.settings.S3.Enabled {
|
||||
return filesystem.NewS3(
|
||||
app.settings.S3.Bucket,
|
||||
app.settings.S3.Region,
|
||||
|
||||
+14
-9
@@ -1,17 +1,17 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
@@ -160,15 +160,17 @@ type RecordViewEvent struct {
|
||||
type RecordCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
UploadedFiles map[string][]*filesystem.File
|
||||
}
|
||||
|
||||
type RecordUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
UploadedFiles map[string][]*filesystem.File
|
||||
}
|
||||
|
||||
type RecordDeleteEvent struct {
|
||||
@@ -203,9 +205,12 @@ type RecordAuthWithPasswordEvent struct {
|
||||
type RecordAuthWithOAuth2Event struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
HttpContext echo.Context
|
||||
ProviderName string
|
||||
ProviderClient auth.Provider
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
IsNewRecord bool
|
||||
}
|
||||
|
||||
type RecordAuthRefreshEvent struct {
|
||||
|
||||
+48
-87
@@ -4,19 +4,13 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const DefaultMaxFailRetries = 5
|
||||
|
||||
// New creates a new Dao instance with the provided db builder
|
||||
// (for both async and sync db operations).
|
||||
func New(db dbx.Builder) *Dao {
|
||||
@@ -27,8 +21,10 @@ func New(db dbx.Builder) *Dao {
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
MaxLockRetries: 8,
|
||||
ModelQueryTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +35,13 @@ type Dao struct {
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
|
||||
// @todo delete after removing Block and Continue
|
||||
sem *semaphore.Weighted
|
||||
mux sync.RWMutex
|
||||
// MaxLockRetries specifies the default max "database is locked" auto retry attempts.
|
||||
MaxLockRetries int
|
||||
|
||||
// ModelQueryTimeout is the default max duration of a running ModelQuery().
|
||||
//
|
||||
// This field has no effect if an explicit query context is already specified.
|
||||
ModelQueryTimeout time.Duration
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
@@ -74,55 +74,24 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Block acquires a lock and blocks all other go routines that uses
|
||||
// the Dao instance until dao.Continue() is called, effectively making
|
||||
// the concurrent requests to perform synchronous db operations.
|
||||
//
|
||||
// This method should be used only as a last resort and as a workaround
|
||||
// for the SQLITE_BUSY error when mixing read&write in a transaction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func someLongRunningTransaction() error {
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// defer cancel()
|
||||
// if err := app.Dao().Block(ctx); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer app.Dao().Continue()
|
||||
//
|
||||
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
|
||||
// // some long running read&write transaction...
|
||||
// })
|
||||
// }
|
||||
func (dao *Dao) Block(ctx context.Context) error {
|
||||
if dao.sem == nil {
|
||||
dao.mux.Lock()
|
||||
dao.sem = semaphore.NewWeighted(1)
|
||||
dao.mux.Unlock()
|
||||
}
|
||||
// Clone returns a new Dao with the same configuration options as the current one.
|
||||
func (dao *Dao) Clone() *Dao {
|
||||
clone := *dao
|
||||
|
||||
return dao.sem.Acquire(ctx, 1)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Continue releases the previously acquired Block() lock.
|
||||
func (dao *Dao) Continue() {
|
||||
if dao.sem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dao.sem.Release(1)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new query with preset Select and From fields
|
||||
// based on the provided model argument.
|
||||
// ModelQuery creates a new preconfigured select query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
tableName := m.TableName()
|
||||
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
|
||||
|
||||
return dao.DB().
|
||||
Select("{{" + tableName + "}}.*").
|
||||
From(tableName).
|
||||
WithBuildHook(func(query *dbx.Query) {
|
||||
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
|
||||
})
|
||||
}
|
||||
|
||||
// FindById finds a single db record with the specified id and
|
||||
@@ -148,6 +117,8 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
// ---
|
||||
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
|
||||
txDao := New(txOrDB)
|
||||
txDao.MaxLockRetries = dao.MaxLockRetries
|
||||
txDao.ModelQueryTimeout = dao.ModelQueryTimeout
|
||||
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
|
||||
@@ -224,7 +195,7 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
return errors.New("ID is not set")
|
||||
}
|
||||
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
return err
|
||||
@@ -240,20 +211,23 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}, DefaultMaxFailRetries)
|
||||
})
|
||||
}
|
||||
|
||||
// Save upserts (update or create if primary key is not set) the provided model.
|
||||
// Save persists the provided model in the database.
|
||||
//
|
||||
// If m.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a model for update you can use m.MarkAsNotNew().
|
||||
func (dao *Dao) Save(m models.Model) error {
|
||||
if m.IsNew() {
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
return retryDao.create(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
})
|
||||
}
|
||||
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
return retryDao.update(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) update(m models.Model) error {
|
||||
@@ -347,32 +321,19 @@ func (dao *Dao) create(m models.Model) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
|
||||
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
|
||||
retryDao := dao
|
||||
attempts := 1
|
||||
|
||||
Retry:
|
||||
if attempts == 2 {
|
||||
// assign new Dao without the before hooks to avoid triggering
|
||||
// the already fired before callbacks multiple times
|
||||
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
}
|
||||
return baseLockRetry(func(attempt int) error {
|
||||
if attempt == 2 {
|
||||
// assign new Dao without the before hooks to avoid triggering
|
||||
// the already fired before callbacks multiple times
|
||||
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
}
|
||||
|
||||
// execute
|
||||
err := op(retryDao)
|
||||
|
||||
if err != nil &&
|
||||
attempts < maxRetries &&
|
||||
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
|
||||
attempts++
|
||||
goto Retry
|
||||
}
|
||||
|
||||
return err
|
||||
return op(retryDao)
|
||||
}, dao.MaxLockRetries)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// default retries intervals (in ms)
|
||||
var defaultRetryIntervals = []int{100, 250, 350, 500, 700, 1000}
|
||||
|
||||
func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
|
||||
return func(q *dbx.Query, op func() error) error {
|
||||
if q.Context() == nil {
|
||||
cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer func() {
|
||||
cancel()
|
||||
//nolint:staticcheck
|
||||
q.WithContext(nil) // reset
|
||||
}()
|
||||
q.WithContext(cancelCtx)
|
||||
}
|
||||
|
||||
return baseLockRetry(func(attempt int) error {
|
||||
return op()
|
||||
}, maxRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func baseLockRetry(op func(attempt int) error, maxRetries int) error {
|
||||
attempt := 1
|
||||
|
||||
Retry:
|
||||
err := op(attempt)
|
||||
|
||||
if err != nil &&
|
||||
attempt <= maxRetries &&
|
||||
// we are checking the err message to handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(getDefaultRetryInterval(attempt))
|
||||
attempt++
|
||||
goto Retry
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func getDefaultRetryInterval(attempt int) time.Duration {
|
||||
if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
|
||||
return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
|
||||
}
|
||||
|
||||
return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetDefaultRetryInterval(t *testing.T) {
|
||||
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
|
||||
t.Fatalf("Expected 1000ms, got %v", i)
|
||||
}
|
||||
|
||||
if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
|
||||
t.Fatalf("Expected 1000ms, got %v", i)
|
||||
}
|
||||
|
||||
if i := getDefaultRetryInterval(3); i.Milliseconds() != 500 {
|
||||
t.Fatalf("Expected 500ms, got %v", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLockRetry(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
err error
|
||||
failUntilAttempt int
|
||||
expectedAttempts int
|
||||
}{
|
||||
{nil, 3, 1},
|
||||
{errors.New("test"), 3, 1},
|
||||
{errors.New("database is locked"), 3, 3},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
lastAttempt := 0
|
||||
|
||||
err := baseLockRetry(func(attempt int) error {
|
||||
lastAttempt = attempt
|
||||
|
||||
if attempt < s.failUntilAttempt {
|
||||
return s.err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, s.failUntilAttempt+2)
|
||||
|
||||
if lastAttempt != s.expectedAttempts {
|
||||
t.Errorf("[%d] Expected lastAttempt to be %d, got %d", i, s.expectedAttempts, lastAttempt)
|
||||
}
|
||||
|
||||
if s.failUntilAttempt == s.expectedAttempts && err != nil {
|
||||
t.Errorf("[%d] Expected nil, got err %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
|
||||
t.Errorf("[%d] Expected error %q, got nil", i, s.err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package daos_test
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -39,6 +40,86 @@ func TestNewMultiDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoClone(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
hookCalls := map[string]int{}
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
}
|
||||
|
||||
clone := dao.Clone()
|
||||
clone.MaxLockRetries = 3
|
||||
clone.ModelQueryTimeout = 4
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["NewAfterCreateFunc"]++
|
||||
}
|
||||
|
||||
if dao.MaxLockRetries == clone.MaxLockRetries {
|
||||
t.Fatal("Expected different MaxLockRetries")
|
||||
}
|
||||
|
||||
if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
|
||||
t.Fatal("Expected different ModelQueryTimeout")
|
||||
}
|
||||
|
||||
// trigger hooks
|
||||
dao.BeforeDeleteFunc(nil, nil)
|
||||
dao.BeforeUpdateFunc(nil, nil)
|
||||
dao.BeforeCreateFunc(nil, nil)
|
||||
dao.AfterDeleteFunc(nil, nil)
|
||||
dao.AfterUpdateFunc(nil, nil)
|
||||
dao.AfterCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil)
|
||||
clone.BeforeUpdateFunc(nil, nil)
|
||||
clone.BeforeCreateFunc(nil, nil)
|
||||
clone.AfterDeleteFunc(nil, nil)
|
||||
clone.AfterUpdateFunc(nil, nil)
|
||||
clone.AfterCreateFunc(nil, nil)
|
||||
|
||||
expectations := []struct {
|
||||
hook string
|
||||
total int
|
||||
}{
|
||||
{"BeforeDeleteFunc", 2},
|
||||
{"BeforeUpdateFunc", 2},
|
||||
{"BeforeCreateFunc", 2},
|
||||
{"AfterDeleteFunc", 2},
|
||||
{"AfterUpdateFunc", 2},
|
||||
{"AfterCreateFunc", 1},
|
||||
{"NewAfterCreateFunc", 1},
|
||||
}
|
||||
|
||||
for _, e := range expectations {
|
||||
if hookCalls[e.hook] != e.total {
|
||||
t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -71,6 +152,24 @@ func TestDaoModelQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQueryCancellation(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
dao := daos.New(testApp.DB())
|
||||
|
||||
m := &models.Admin{}
|
||||
|
||||
if err := dao.ModelQuery(m).One(m); err != nil {
|
||||
t.Fatalf("Failed to execute control query: %v", err)
|
||||
}
|
||||
|
||||
dao.ModelQueryTimeout = 0 * time.Millisecond
|
||||
if err := dao.ModelQuery(m).One(m); err == nil {
|
||||
t.Fatal("Expected to be cancelled, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoFindById(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
+181
-28
@@ -1,6 +1,8 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -129,21 +131,39 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
|
||||
return err
|
||||
}
|
||||
if total := len(result); total > 0 {
|
||||
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
|
||||
names := make([]string, 0, len(result))
|
||||
for ref := range result {
|
||||
names = append(names, ref.Name)
|
||||
}
|
||||
return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// delete the related records table
|
||||
if err := txDao.DeleteTable(collection.Name); err != nil {
|
||||
return err
|
||||
// delete the related view or records table
|
||||
if collection.IsView() {
|
||||
if err := txDao.DeleteView(collection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := txDao.DeleteTable(collection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// trigger views resave to check for dependencies
|
||||
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
|
||||
return fmt.Errorf("The collection has a view dependency - %w", err)
|
||||
}
|
||||
|
||||
return txDao.Delete(collection)
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCollection upserts the provided Collection model and updates
|
||||
// SaveCollection persists the provided Collection model and updates
|
||||
// its related records table schema.
|
||||
//
|
||||
// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
|
||||
func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
var oldCollection *models.Collection
|
||||
|
||||
@@ -157,20 +177,41 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
txErr := dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// set default collection type
|
||||
if collection.Type == "" {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
switch collection.Type {
|
||||
case models.CollectionTypeView:
|
||||
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// sync the changes with the related records table
|
||||
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sync the changes with the related records table
|
||||
return txDao.SyncRecordTableSchema(collection, oldCollection)
|
||||
return nil
|
||||
})
|
||||
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// trigger an update for all views with changed schema as a result of the current collection save
|
||||
// (ignoring view errors to allow users to update the query from the UI)
|
||||
dao.resaveViewsWithChangedSchema(collection.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportCollections imports the provided collections list within a single transaction.
|
||||
@@ -183,7 +224,7 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
func (dao *Dao) ImportCollections(
|
||||
importedCollections []*models.Collection,
|
||||
deleteMissing bool,
|
||||
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
|
||||
afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
|
||||
) error {
|
||||
if len(importedCollections) == 0 {
|
||||
return errors.New("No collections to import")
|
||||
@@ -191,7 +232,7 @@ func (dao *Dao) ImportCollections(
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
existingCollections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
|
||||
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&existingCollections); err != nil {
|
||||
return err
|
||||
}
|
||||
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
|
||||
@@ -247,6 +288,17 @@ func (dao *Dao) ImportCollections(
|
||||
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
|
||||
}
|
||||
|
||||
// delete the related records table or view
|
||||
if existing.IsView() {
|
||||
if err := txDao.DeleteView(existing.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := txDao.DeleteTable(existing.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// delete the collection
|
||||
if err := txDao.Delete(existing); err != nil {
|
||||
return err
|
||||
@@ -261,29 +313,130 @@ func (dao *Dao) ImportCollections(
|
||||
}
|
||||
}
|
||||
|
||||
if beforeRecordsSync != nil {
|
||||
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
|
||||
// sync record tables
|
||||
for _, imported := range importedCollections {
|
||||
if imported.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
existing := mappedExisting[imported.GetId()]
|
||||
|
||||
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// delete the record tables of the deleted collections
|
||||
if deleteMissing {
|
||||
for _, existing := range existingCollections {
|
||||
if mappedImported[existing.GetId()] != nil {
|
||||
continue // exist
|
||||
}
|
||||
// sync views
|
||||
for _, imported := range importedCollections {
|
||||
if !imported.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := txDao.DeleteTable(existing.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
existing := mappedExisting[imported.GetId()]
|
||||
|
||||
if err := txDao.saveViewCollection(imported, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sync the upserted collections with the related records table
|
||||
for _, imported := range importedCollections {
|
||||
existing := mappedExisting[imported.GetId()]
|
||||
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
|
||||
if afterSync != nil {
|
||||
if err := afterSync(txDao, mappedImported, mappedExisting); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// saveViewCollection persists the provided View collection changes:
|
||||
// - deletes the old related SQL view (if any)
|
||||
// - creates a new SQL view with the latest newCollection.Options.Query
|
||||
// - generates a new schema based on newCollection.Options.Query
|
||||
// - updates newCollection.Schema based on the generated view table info and query
|
||||
// - saves the newCollection
|
||||
//
|
||||
// This method returns an error if newCollection is not a "view".
|
||||
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
|
||||
if !newCollection.IsView() {
|
||||
return errors.New("not a view collection")
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
query := newCollection.ViewOptions().Query
|
||||
|
||||
// generate collection schema from the query
|
||||
viewSchema, err := txDao.CreateViewSchema(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old renamed view
|
||||
if oldCollection != nil {
|
||||
if err := txDao.DeleteView(oldCollection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
if err := txDao.SaveView(newCollection.Name, query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newCollection.Schema = viewSchema
|
||||
|
||||
return txDao.Save(newCollection)
|
||||
})
|
||||
}
|
||||
|
||||
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
|
||||
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for _, collection := range collections {
|
||||
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
|
||||
continue
|
||||
}
|
||||
|
||||
// clone the existing schema so that it is safe for temp modifications
|
||||
oldSchema, err := collection.Schema.Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate a new schema from the query
|
||||
newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unset the schema field ids to exclude from the comparison
|
||||
for _, f := range oldSchema.Fields() {
|
||||
f.Id = ""
|
||||
}
|
||||
for _, f := range newSchema.Fields() {
|
||||
f.Id = ""
|
||||
}
|
||||
|
||||
encodedNewSchema, err := json.Marshal(newSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encodedOldSchema, err := json.Marshal(oldSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
|
||||
continue // no changes
|
||||
}
|
||||
|
||||
if err := txDao.saveViewCollection(collection, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+130
-35
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestCollectionQuery(t *testing.T) {
|
||||
@@ -45,16 +46,16 @@ func TestFindCollectionsByType(t *testing.T) {
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if len(collections) != scenario.expectTotal {
|
||||
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
|
||||
t.Errorf("[%d] Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.Type != scenario.collectionType {
|
||||
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
|
||||
t.Errorf("[%d] Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,11 +81,11 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
|
||||
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
|
||||
t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +156,7 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
for i, f := range fields {
|
||||
if !list.ExistInSlice(f.Name, expectedFields) {
|
||||
t.Fatalf("(%d) Didn't expect field %v", i, f)
|
||||
t.Fatalf("[%d] Didn't expect field %v", i, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,21 +166,34 @@ func TestDeleteCollection(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
c0 := &models.Collection{}
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
colEmpty := &models.Collection{}
|
||||
|
||||
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
|
||||
colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3.System = true
|
||||
if err := app.Dao().Save(c3); err != nil {
|
||||
colSystem.System = true
|
||||
if err := app.Dao().Save(colSystem); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -187,18 +201,29 @@ func TestDeleteCollection(t *testing.T) {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{c0, true},
|
||||
{c1, false},
|
||||
{c2, true}, // is part of a reference
|
||||
{c3, true}, // system
|
||||
{colEmpty, true},
|
||||
{colAuth, false},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{colView1, true}, // view2 depend on it
|
||||
{colView2, false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().DeleteCollection(scenario.model)
|
||||
hasErr := err != nil
|
||||
for i, s := range scenarios {
|
||||
err := app.Dao().DeleteCollection(s.model)
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if app.Dao().HasTable(s.model.Name) {
|
||||
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +259,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if the records table has the schema fields
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
columns, err := app.Dao().TableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -244,7 +269,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
}
|
||||
for i, c := range columns {
|
||||
if !list.ExistInSlice(c, expectedColumns) {
|
||||
t.Fatalf("(%d) Didn't expect record column %s", i, c)
|
||||
t.Fatalf("[%d] Didn't expect record column %s", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +298,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
|
||||
// check if the records table has the schema fields
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
columns, err := app.Dao().TableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -282,12 +307,74 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
for i, c := range columns {
|
||||
if !list.ExistInSlice(c, expectedColumns) {
|
||||
t.Fatalf("(%d) Didn't expect record column %s", i, c)
|
||||
t.Fatalf("[%d] Didn't expect record column %s", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// indirect update of a field used in view should cause view(s) update
|
||||
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// update MaxSelect fields
|
||||
{
|
||||
relMany := collection.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
relManyOpt.MaxSelect = types.Pointer(1)
|
||||
|
||||
fileOne := collection.Schema.GetFieldByName("file_one")
|
||||
fileOneOpt := fileOne.Options.(*schema.FileOptions)
|
||||
fileOneOpt.MaxSelect = 10
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// check view1 schema
|
||||
{
|
||||
view1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relMany := view1.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
|
||||
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
|
||||
}
|
||||
|
||||
fileOne := view1.Schema.GetFieldByName("file_one")
|
||||
fileOneOpt := fileOne.Options.(*schema.FileOptions)
|
||||
if fileOneOpt.MaxSelect != 10 {
|
||||
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
|
||||
}
|
||||
}
|
||||
|
||||
// check view2 schema
|
||||
{
|
||||
view2, err := app.Dao().FindCollectionByNameOrId("view2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relMany := view2.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
|
||||
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCollections(t *testing.T) {
|
||||
totalCollections := 10
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
jsonData string
|
||||
@@ -302,7 +389,7 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
@@ -312,7 +399,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 10,
|
||||
expectCollectionsCount: totalCollections + 2,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@@ -324,7 +411,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@@ -336,7 +423,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: totalCollections + 1,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
@@ -372,7 +459,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
@@ -415,7 +502,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"name":"demo1_rename",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -442,11 +529,19 @@ func TestImportCollections(t *testing.T) {
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_new_view",
|
||||
"name": "new_view",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select id from demo2"
|
||||
}
|
||||
}
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 3,
|
||||
expectCollectionsCount: 4,
|
||||
},
|
||||
{
|
||||
name: "test with deleteMissing: false",
|
||||
@@ -501,7 +596,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: totalCollections + 1,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"nologin": 1,
|
||||
@@ -509,7 +604,7 @@ func TestImportCollections(t *testing.T) {
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo5": 5,
|
||||
"demo5": 6,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
||||
@@ -12,8 +12,8 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.ExternalAuth{})
|
||||
}
|
||||
|
||||
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
|
||||
/// linked to the provided auth record.
|
||||
// FindAllExternalAuthsByRecord returns all ExternalAuth models
|
||||
// linked to the provided auth record.
|
||||
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
|
||||
auths := []*models.ExternalAuth{}
|
||||
|
||||
|
||||
+111
-34
@@ -20,7 +20,74 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
tableName := collection.Name
|
||||
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
|
||||
|
||||
return dao.DB().Select(selectCols).From(tableName)
|
||||
return dao.DB().
|
||||
Select(selectCols).
|
||||
From(tableName).
|
||||
WithBuildHook(func(query *dbx.Query) {
|
||||
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
default:
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
*v = records
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
@@ -34,10 +101,8 @@ func (dao *Dao) FindRecordById(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableName := collection.Name
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{tableName + ".id": recordId})
|
||||
AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
|
||||
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
@@ -48,12 +113,13 @@ func (dao *Dao) FindRecordById(
|
||||
}
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
if err := query.Limit(1).One(row); err != nil {
|
||||
record := &models.Record{}
|
||||
|
||||
if err := query.Limit(1).One(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindRecordsByIds finds all Record models by the provided ids.
|
||||
@@ -83,12 +149,13 @@ func (dao *Dao) FindRecordsByIds(
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]dbx.NullStringMap, 0, len(recordIds))
|
||||
if err := query.All(&rows); err != nil {
|
||||
records := make([]*models.Record, 0, len(recordIds))
|
||||
|
||||
if err := query.All(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordsFromNullStringMaps(collection, rows), nil
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
@@ -117,35 +184,38 @@ func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expres
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
var records []*models.Record
|
||||
|
||||
if err := query.All(&rows); err != nil {
|
||||
if err := query.All(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordsFromNullStringMaps(collection, rows), nil
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// FindFirstRecordByData returns the first found record matching
|
||||
// the provided key-value pair.
|
||||
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
|
||||
func (dao *Dao) FindFirstRecordByData(
|
||||
collectionNameOrId string,
|
||||
key string,
|
||||
value any,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
@@ -177,9 +247,9 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
normalizedVal = append(types.JsonArray[string]{}, val...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
normalizedVal = append(types.JsonArray[any]{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
@@ -248,18 +318,17 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
|
||||
@@ -274,20 +343,19 @@ func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username str
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(username),
|
||||
})).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
|
||||
@@ -318,7 +386,10 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
return username
|
||||
}
|
||||
|
||||
// SaveRecord upserts the provided Record model.
|
||||
// SaveRecord persists the provided Record model in the database.
|
||||
//
|
||||
// If record.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a record for update you can use record.MarkAsNotNew().
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
@@ -396,18 +467,24 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
if refCollection.IsView() {
|
||||
continue // skip view collections
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup in v0.12+
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
query := dao.RecordQuery(refCollection).Distinct(true)
|
||||
|
||||
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
|
||||
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
|
||||
} else {
|
||||
query.InnerJoin(fmt.Sprintf(
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
}
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
|
||||
+14
-2
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -86,7 +87,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
|
||||
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
|
||||
}
|
||||
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
|
||||
if indirectRelFieldOptions.IsMultiple() {
|
||||
// for now don't allow multi-relation indirect fields expand
|
||||
// due to eventual poor query performance with large data sets.
|
||||
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
|
||||
@@ -126,7 +127,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
MaxSelect: nil,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if indirectRelField.Unique {
|
||||
if isRelFieldUnique(indirectRel, indirectRelField.Name) {
|
||||
relFieldOptions.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
// indirect relation
|
||||
@@ -269,3 +270,14 @@ func normalizeExpands(paths []string) []string {
|
||||
|
||||
return list.ToUniqueStringSlice(result)
|
||||
}
|
||||
|
||||
func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
|
||||
for _, idx := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(idx)
|
||||
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -362,3 +362,57 @@ func TestExpandRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// non-unique indirect expand
|
||||
{
|
||||
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
|
||||
result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// mock a unique constraint for the rel_one_cascade field
|
||||
{
|
||||
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
|
||||
|
||||
if err := app.Dao().SaveCollection(demo4); err != nil {
|
||||
t.Fatalf("Failed to mock unique constraint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// non-unique indirect expand
|
||||
{
|
||||
errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
|
||||
result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+235
-61
@@ -2,10 +2,14 @@ package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -15,70 +19,67 @@ import (
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// create
|
||||
// -----------------------------------------------------------
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
// create table
|
||||
if _, err := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.createCollectionIndexes(newCollection)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// update
|
||||
// -----------------------------------------------------------
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
@@ -86,9 +87,14 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
deletedFieldNames := []string{}
|
||||
renamedFieldNames := map[string]string{}
|
||||
|
||||
// drop old indexes (if any)
|
||||
if err := txDao.dropCollectionIndex(oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
_, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,7 +108,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to drop column %s - %w", oldField.Name, err)
|
||||
}
|
||||
|
||||
deletedFieldNames = append(deletedFieldNames, oldField.Name)
|
||||
@@ -125,7 +131,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to add column %s - %w", field.Name, err)
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
@@ -134,7 +140,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to rename column %s - %w", oldField.Name, err)
|
||||
}
|
||||
|
||||
renamedFieldNames[oldField.Name] = field.Name
|
||||
@@ -149,11 +155,109 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
|
||||
if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txDao.createCollectionIndexes(newCollection)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
|
||||
if newCollection.IsView() || oldCollection == nil {
|
||||
return nil // view or not an update
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for _, newField := range newCollection.Schema.Fields() {
|
||||
oldField := oldCollection.Schema.GetFieldById(newField.Id)
|
||||
if oldField == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var isNewMultiple bool
|
||||
if opt, ok := newField.Options.(schema.MultiValuer); ok {
|
||||
isNewMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
var isOldMultiple bool
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
if isOldMultiple == isNewMultiple {
|
||||
continue // no change
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if !isOldMultiple && isNewMultiple {
|
||||
// single -> multiple (convert to array)
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
))
|
||||
} else {
|
||||
// multiple -> single (keep only the last element)
|
||||
//
|
||||
// note: for file fields the actual files are not deleted
|
||||
// allowing additional custom handling via migration.
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
}
|
||||
@@ -196,3 +300,73 @@ func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedF
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
|
||||
if collection.IsView() {
|
||||
return nil // views don't have indexes
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for _, raw := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
|
||||
if collection.IsView() {
|
||||
return nil // views don't have indexes
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// drop new indexes in case a duplicated index name is used
|
||||
if err := txDao.dropCollectionIndex(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// upsert new indexes
|
||||
//
|
||||
// note: we are returning validation errors because the indexes cannot be
|
||||
// validated in a form, aka. before persisting the related collection
|
||||
// record table changes
|
||||
errs := validation.Errors{}
|
||||
for i, idx := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(idx)
|
||||
|
||||
// ensure that the index is always for the current collection
|
||||
parsed.TableName = collection.Name
|
||||
|
||||
if !parsed.IsValid() {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
"Invalid CREATE INDEX expression.",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return validation.Errors{"indexes": errs}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+162
-22
@@ -1,12 +1,16 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
@@ -36,15 +40,17 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
name string
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedColumns []string
|
||||
expectedIndexesCount int
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
"new base collection",
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
@@ -55,11 +61,11 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
0,
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
"new auth collection",
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
@@ -69,51 +75,185 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
4,
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
"no changes",
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
3,
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
"renamed table, deleted column, renamed columnd and new column",
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
1,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
t.Errorf("[%s] %v", s.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
if !app.Dao().HasTable(s.newCollection.Name) {
|
||||
t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
cols, _ := app.Dao().TableColumns(s.newCollection.Name)
|
||||
if len(cols) != len(s.expectedColumns) {
|
||||
t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
if !list.ExistInSlice(c, s.expectedColumns) {
|
||||
t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
|
||||
}
|
||||
}
|
||||
|
||||
indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
|
||||
|
||||
if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
|
||||
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock field changes
|
||||
{
|
||||
selectOneField := collection.Schema.GetFieldByName("select_one")
|
||||
opt := selectOneField.Options.(*schema.SelectOptions)
|
||||
opt.MaxSelect = 2
|
||||
}
|
||||
{
|
||||
selectManyField := collection.Schema.GetFieldByName("select_many")
|
||||
opt := selectManyField.Options.(*schema.SelectOptions)
|
||||
opt.MaxSelect = 1
|
||||
}
|
||||
{
|
||||
fileOneField := collection.Schema.GetFieldByName("file_one")
|
||||
opt := fileOneField.Options.(*schema.FileOptions)
|
||||
opt.MaxSelect = 2
|
||||
}
|
||||
{
|
||||
fileManyField := collection.Schema.GetFieldByName("file_many")
|
||||
opt := fileManyField.Options.(*schema.FileOptions)
|
||||
opt.MaxSelect = 1
|
||||
}
|
||||
{
|
||||
relOneField := collection.Schema.GetFieldByName("rel_one")
|
||||
opt := relOneField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(2)
|
||||
}
|
||||
{
|
||||
relManyField := collection.Schema.GetFieldByName("rel_many")
|
||||
opt := relManyField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type expectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
recordId string
|
||||
expected expectation
|
||||
}{
|
||||
{
|
||||
"imy661ixudk5izi",
|
||||
expectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
},
|
||||
},
|
||||
{
|
||||
"al1h9ijdeojtsjy",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"84nmscqy84lsi1t",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := new(expectation)
|
||||
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-6
@@ -36,6 +36,91 @@ func TestRecordQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordQueryOneWithRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := "84nmscqy84lsi1t"
|
||||
|
||||
q := app.Dao().RecordQuery(collection).
|
||||
Where(dbx.HashExp{"id": id})
|
||||
|
||||
record := &models.Record{}
|
||||
if err := q.One(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if record.GetString("id") != id {
|
||||
t.Fatalf("Expected record with id %q, got %q", id, record.GetString("id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id1 := "84nmscqy84lsi1t"
|
||||
id2 := "al1h9ijdeojtsjy"
|
||||
|
||||
{
|
||||
records := []models.Record{}
|
||||
|
||||
q := app.Dao().RecordQuery(collection).
|
||||
Where(dbx.HashExp{"id": []any{id1, id2}}).
|
||||
OrderBy("created asc")
|
||||
|
||||
if err := q.All(&records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("Expected %d records, got %d", 2, len(records))
|
||||
}
|
||||
|
||||
if records[0].Id != id1 {
|
||||
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
|
||||
}
|
||||
|
||||
if records[1].Id != id2 {
|
||||
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
records := []*models.Record{}
|
||||
|
||||
q := app.Dao().RecordQuery(collection).
|
||||
Where(dbx.HashExp{"id": []any{id1, id2}}).
|
||||
OrderBy("created asc")
|
||||
|
||||
if err := q.All(&records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("Expected %d records, got %d", 2, len(records))
|
||||
}
|
||||
|
||||
if records[0].Id != id1 {
|
||||
t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
|
||||
}
|
||||
|
||||
if records[1].Id != id2 {
|
||||
t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -663,13 +748,13 @@ func TestDeleteRecord(t *testing.T) {
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
|
||||
expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyPart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
|
||||
}
|
||||
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
|
||||
expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
|
||||
if !strings.Contains(joinedQueries, expectedRelOnePart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.DB().Delete(tableName, expr).Execute()
|
||||
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+66
-5
@@ -1,16 +1,19 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// HasTable checks if a table with the provided name exists (case insensitive).
|
||||
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
||||
func (dao *Dao) HasTable(tableName string) bool {
|
||||
var exists bool
|
||||
|
||||
err := dao.DB().Select("count(*)").
|
||||
From("sqlite_schema").
|
||||
AndWhere(dbx.HashExp{"type": "table"}).
|
||||
AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
@@ -18,8 +21,8 @@ func (dao *Dao) HasTable(tableName string) bool {
|
||||
return err == nil && exists
|
||||
}
|
||||
|
||||
// GetTableColumns returns all column names of a single table by its name.
|
||||
func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
|
||||
// TableColumns returns all column names of a single table by its name.
|
||||
func (dao *Dao) TableColumns(tableName string) ([]string, error) {
|
||||
columns := []string{}
|
||||
|
||||
err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
@@ -29,9 +32,67 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
|
||||
return columns, err
|
||||
}
|
||||
|
||||
// TableInfo returns the `table_info` pragma result for the specified table.
|
||||
func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
info := []*models.TableInfoRow{}
|
||||
|
||||
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
Bind(dbx.Params{"tableName": tableName}).
|
||||
All(&info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mattn/go-sqlite3 doesn't throw an error on invalid or missing table
|
||||
// so we additionally have to check whether the loaded info result is nonempty
|
||||
if len(info) == 0 {
|
||||
return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// TableIndexes returns a name grouped map with all non empty index of the specified table.
|
||||
//
|
||||
// Note: This method doesn't return an error on nonexisting table.
|
||||
func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
|
||||
indexes := []struct {
|
||||
Name string
|
||||
Sql string
|
||||
}{}
|
||||
|
||||
err := dao.DB().Select("name", "sql").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.NewExp("sql is not null")).
|
||||
AndWhere(dbx.HashExp{
|
||||
"type": "index",
|
||||
"tbl_name": tableName,
|
||||
}).
|
||||
All(&indexes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(indexes))
|
||||
|
||||
for _, idx := range indexes {
|
||||
result[idx.Name] = idx.Sql
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteTable drops the specified table.
|
||||
//
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
func (dao *Dao) DeleteTable(tableName string) error {
|
||||
_, err := dao.DB().DropTable(tableName).Execute()
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
"DROP TABLE IF EXISTS {{%s}}",
|
||||
tableName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+87
-13
@@ -3,6 +3,7 @@ package daos_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,17 +24,18 @@ func TestHasTable(t *testing.T) {
|
||||
{"_admins", true},
|
||||
{"demo3", true},
|
||||
{"DEMO3", true}, // table names are case insensitives by default
|
||||
{"view1", true}, // view
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().HasTable(scenario.tableName)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTableColumns(t *testing.T) {
|
||||
func TestTableColumns(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -45,21 +47,50 @@ func TestGetTableColumns(t *testing.T) {
|
||||
{"_params", []string{"id", "key", "value", "created", "updated"}},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
columns, _ := app.Dao().GetTableColumns(scenario.tableName)
|
||||
for i, s := range scenarios {
|
||||
columns, _ := app.Dao().TableColumns(s.tableName)
|
||||
|
||||
if len(columns) != len(scenario.expected) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns)
|
||||
if len(columns) != len(s.expected) {
|
||||
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, c := range columns {
|
||||
if !list.ExistInSlice(c, scenario.expected) {
|
||||
t.Errorf("(%d) Didn't expect column %s", i, c)
|
||||
if !list.ExistInSlice(c, s.expected) {
|
||||
t.Errorf("[%d] Didn't expect column %s", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableInfo(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
tableName string
|
||||
expected string
|
||||
}{
|
||||
{"", "null"},
|
||||
{"missing", "null"},
|
||||
{
|
||||
"_admins",
|
||||
`[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
rows, _ := app.Dao().TableInfo(s.tableName)
|
||||
|
||||
raw, _ := json.Marshal(rows)
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != s.expected {
|
||||
t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTable(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -69,16 +100,17 @@ func TestDeleteTable(t *testing.T) {
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"test", true},
|
||||
{"test", false}, // missing tables are ignored
|
||||
{"_admins", false},
|
||||
{"demo3", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().DeleteTable(scenario.tableName)
|
||||
for i, s := range scenarios {
|
||||
err := app.Dao().DeleteTable(s.tableName)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,3 +139,45 @@ func TestVacuum(t *testing.T) {
|
||||
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableIndexes(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
table string
|
||||
expectError bool
|
||||
expectIndexes []string
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
false,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
false,
|
||||
[]string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().TableIndexes(s.table)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
|
||||
}
|
||||
|
||||
if len(s.expectIndexes) != len(result) {
|
||||
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range s.expectIndexes {
|
||||
if result[name] == "" {
|
||||
t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+587
@@ -0,0 +1,587 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/tokenizer"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// DeleteView drops the specified view name.
|
||||
//
|
||||
// This method is a no-op if a view with the provided name doesn't exist.
|
||||
//
|
||||
// Be aware that this method is vulnerable to SQL injection and the
|
||||
// "name" argument must come only from trusted input!
|
||||
func (dao *Dao) DeleteView(name string) error {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
"DROP VIEW IF EXISTS {{%s}}",
|
||||
name,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveView creates (or updates already existing) persistent SQL view.
|
||||
//
|
||||
// Be aware that this method is vulnerable to SQL injection and the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// delete old view (if exists)
|
||||
if err := txDao.DeleteView(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trimmed := strings.Trim(selectQuery, ";")
|
||||
|
||||
// try to eagerly detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(trimmed)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
//
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution.
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
|
||||
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch the view table info to ensure that the view was created
|
||||
// because missing tables or columns won't return an error
|
||||
if _, err := txDao.TableInfo(name); err != nil {
|
||||
// manually cleanup previously created view in case the func
|
||||
// is called in a nested transaction and the error is discarded
|
||||
txDao.DeleteView(name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateViewSchema creates a new view schema from the provided select query.
|
||||
//
|
||||
// There are some caveats:
|
||||
// - The select query must have an "id" column.
|
||||
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
|
||||
func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
|
||||
result := schema.NewSchema()
|
||||
|
||||
suggestedFields, err := dao.parseQueryToFields(selectQuery)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// note wrap in a transaction in case the selectQuery contains
|
||||
// multiple statements allowing us to rollback on any error
|
||||
txErr := dao.RunInTransaction(func(txDao *Dao) error {
|
||||
tempView := "_temp_" + security.PseudorandomString(5)
|
||||
// create a temp view with the provided query
|
||||
if err := txDao.SaveView(tempView, selectQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
defer txDao.DeleteView(tempView)
|
||||
|
||||
// extract the generated view table info
|
||||
info, err := txDao.TableInfo(tempView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hasId bool
|
||||
|
||||
for _, row := range info {
|
||||
if row.Name == schema.FieldNameId {
|
||||
hasId = true
|
||||
}
|
||||
|
||||
if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
|
||||
continue // skip base model fields since they are not part of the schema
|
||||
}
|
||||
|
||||
var field *schema.SchemaField
|
||||
|
||||
if f, ok := suggestedFields[row.Name]; ok {
|
||||
field = f.field
|
||||
} else {
|
||||
field = defaultViewField(row.Name)
|
||||
}
|
||||
|
||||
result.AddField(field)
|
||||
}
|
||||
|
||||
if !hasId {
|
||||
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return result, txErr
|
||||
}
|
||||
|
||||
// FindRecordByViewFile returns the original models.Record of the
|
||||
// provided view collection file.
|
||||
func (dao *Dao) FindRecordByViewFile(
|
||||
viewCollectionNameOrId string,
|
||||
fileFieldName string,
|
||||
filename string,
|
||||
) (*models.Record, error) {
|
||||
view, err := dao.FindCollectionByNameOrId(viewCollectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !view.IsView() {
|
||||
return nil, errors.New("not a view collection")
|
||||
}
|
||||
|
||||
var findFirstNonViewQueryFileField func(int) (*queryField, error)
|
||||
findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
|
||||
// check the level depth to prevent infinite circular recursion
|
||||
// (the limit is arbitrary and may change in the future)
|
||||
if level > 5 {
|
||||
return nil, errors.New("reached the max recursion level of view collection file field queries")
|
||||
}
|
||||
|
||||
queryFields, err := dao.parseQueryToFields(view.ViewOptions().Query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range queryFields {
|
||||
if item.collection == nil ||
|
||||
item.original == nil ||
|
||||
item.field.Name != fileFieldName {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.collection.IsView() {
|
||||
view = item.collection
|
||||
fileFieldName = item.original.Name
|
||||
return findFirstNonViewQueryFileField(level + 1)
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("no query file field found")
|
||||
}
|
||||
|
||||
qf, err := findFirstNonViewQueryFileField(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanFieldName := inflector.Columnify(qf.original.Name)
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
query := dao.RecordQuery(qf.collection).Limit(1)
|
||||
|
||||
if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
|
||||
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
|
||||
} else {
|
||||
query.InnerJoin(fmt.Sprintf(
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
|
||||
cleanFieldName, cleanFieldName, cleanFieldName,
|
||||
), dbx.HashExp{"_je_file.value": filename})
|
||||
}
|
||||
|
||||
if err := query.One(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Raw query to schema helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type queryField struct {
|
||||
// field is the final resolved field.
|
||||
field *schema.SchemaField
|
||||
|
||||
// collection refers to the original field's collection model.
|
||||
// It could be nil if the found query field is not from a collection schema.
|
||||
collection *models.Collection
|
||||
|
||||
// original is the original found collection field.
|
||||
// It could be nil if the found query field is not from a collection schema.
|
||||
original *schema.SchemaField
|
||||
}
|
||||
|
||||
func defaultViewField(name string) *schema.SchemaField {
|
||||
return &schema.SchemaField{
|
||||
Name: name,
|
||||
Type: schema.FieldTypeJson,
|
||||
}
|
||||
}
|
||||
|
||||
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
|
||||
p := new(identifiersParser)
|
||||
if err := p.parse(selectQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
collections, err := dao.findCollectionsByIdentifiers(p.tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]*queryField, len(p.columns))
|
||||
|
||||
var mainTable identifier
|
||||
|
||||
if len(p.tables) > 0 {
|
||||
mainTable = p.tables[0]
|
||||
}
|
||||
|
||||
for _, col := range p.columns {
|
||||
colLower := strings.ToLower(col.original)
|
||||
|
||||
// numeric expression cast
|
||||
if strings.Contains(colLower, "(") &&
|
||||
(strings.HasPrefix(colLower, "count(") ||
|
||||
strings.HasPrefix(colLower, "total(") ||
|
||||
strings.Contains(colLower, " as numeric") ||
|
||||
strings.Contains(colLower, " as real") ||
|
||||
strings.Contains(colLower, " as int") ||
|
||||
strings.Contains(colLower, " as integer") ||
|
||||
strings.Contains(colLower, " as decimal")) {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(col.original, ".")
|
||||
|
||||
var fieldName string
|
||||
var collection *models.Collection
|
||||
|
||||
if len(parts) == 2 {
|
||||
fieldName = parts[1]
|
||||
collection = collections[parts[0]]
|
||||
} else {
|
||||
fieldName = parts[0]
|
||||
collection = collections[mainTable.alias]
|
||||
}
|
||||
|
||||
// fallback to the default field if the found column is not from a collection schema
|
||||
if collection == nil {
|
||||
result[col.alias] = &queryField{
|
||||
field: defaultViewField(col.alias),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if fieldName == "*" {
|
||||
return nil, errors.New("dynamic column names are not supported")
|
||||
}
|
||||
|
||||
// find the first field by name (case insensitive)
|
||||
var field *schema.SchemaField
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
if strings.EqualFold(f.Name, fieldName) {
|
||||
field = f
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if field != nil {
|
||||
clone := *field
|
||||
clone.Name = col.alias
|
||||
result[col.alias] = &queryField{
|
||||
field: &clone,
|
||||
collection: collection,
|
||||
original: field,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if fieldName == schema.FieldNameId {
|
||||
// convert to relation since it is a direct id reference
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: collection.Id,
|
||||
},
|
||||
},
|
||||
collection: collection,
|
||||
}
|
||||
} else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeDate,
|
||||
},
|
||||
collection: collection,
|
||||
}
|
||||
} else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
collection: collection,
|
||||
}
|
||||
} else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
collection: collection,
|
||||
}
|
||||
} else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
collection: collection,
|
||||
}
|
||||
} else {
|
||||
result[col.alias] = &queryField{
|
||||
field: defaultViewField(col.alias),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
|
||||
names := make([]any, 0, len(tables))
|
||||
|
||||
for _, table := range tables {
|
||||
if strings.Contains(table.alias, "(") {
|
||||
continue // skip expressions
|
||||
}
|
||||
names = append(names, table.original)
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]*models.Collection, len(names))
|
||||
collections := make([]*models.Collection, 0, len(names))
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.In("name", names...)).
|
||||
All(&collections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
for _, collection := range collections {
|
||||
if collection.Name == table.original {
|
||||
result[table.alias] = collection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Raw query identifiers parser
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
|
||||
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
|
||||
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
|
||||
|
||||
type identifier struct {
|
||||
original string
|
||||
alias string
|
||||
}
|
||||
|
||||
type identifiersParser struct {
|
||||
columns []identifier
|
||||
tables []identifier
|
||||
}
|
||||
|
||||
func (p *identifiersParser) parse(selectQuery string) error {
|
||||
str := strings.Trim(selectQuery, ";")
|
||||
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
|
||||
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
|
||||
str = commentsReplaceRegex.ReplaceAllString(str, "")
|
||||
|
||||
tk := tokenizer.NewFromString(str)
|
||||
tk.Separators(',', ' ', '\n', '\t')
|
||||
tk.KeepSeparator(true)
|
||||
|
||||
var skip bool
|
||||
var partType string
|
||||
var activeBuilder *strings.Builder
|
||||
var selectParts strings.Builder
|
||||
var fromParts strings.Builder
|
||||
var joinParts strings.Builder
|
||||
|
||||
for {
|
||||
token, err := tk.Scan()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
trimmed := strings.ToLower(strings.TrimSpace(token))
|
||||
|
||||
switch trimmed {
|
||||
case "select":
|
||||
skip = false
|
||||
partType = "select"
|
||||
activeBuilder = &selectParts
|
||||
case "from":
|
||||
skip = false
|
||||
partType = "from"
|
||||
activeBuilder = &fromParts
|
||||
case "_join_":
|
||||
skip = false
|
||||
|
||||
// the previous part was also a join
|
||||
if partType == "join" {
|
||||
joinParts.WriteString(",")
|
||||
}
|
||||
|
||||
partType = "join"
|
||||
activeBuilder = &joinParts
|
||||
case "_discard_":
|
||||
// do nothing...
|
||||
skip = true
|
||||
default:
|
||||
isJoin := partType == "join"
|
||||
|
||||
if isJoin && trimmed == "on" {
|
||||
skip = true
|
||||
}
|
||||
|
||||
if !skip && activeBuilder != nil {
|
||||
activeBuilder.WriteString(" ")
|
||||
activeBuilder.WriteString(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selects, err := extractIdentifiers(selectParts.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
froms, err := extractIdentifiers(fromParts.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
joins, err := extractIdentifiers(joinParts.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.columns = selects
|
||||
p.tables = froms
|
||||
p.tables = append(p.tables, joins...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractIdentifiers(rawExpression string) ([]identifier, error) {
|
||||
rawTk := tokenizer.NewFromString(rawExpression)
|
||||
rawTk.Separators(',')
|
||||
|
||||
rawIdentifiers, err := rawTk.ScanAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]identifier, 0, len(rawIdentifiers))
|
||||
|
||||
for _, rawIdentifier := range rawIdentifiers {
|
||||
tk := tokenizer.NewFromString(rawIdentifier)
|
||||
tk.Separators(' ', '\n', '\t')
|
||||
|
||||
parts, err := tk.ScanAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := identifierFromParts(parts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, resolved)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func identifierFromParts(parts []string) (identifier, error) {
|
||||
var result identifier
|
||||
|
||||
switch len(parts) {
|
||||
case 3:
|
||||
if !strings.EqualFold(parts[1], "as") {
|
||||
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
|
||||
}
|
||||
|
||||
result.original = parts[0]
|
||||
result.alias = parts[2]
|
||||
case 2:
|
||||
result.original = parts[0]
|
||||
result.alias = parts[1]
|
||||
case 1:
|
||||
subParts := strings.Split(parts[0], ".")
|
||||
result.original = parts[0]
|
||||
result.alias = subParts[len(subParts)-1]
|
||||
default:
|
||||
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
|
||||
}
|
||||
|
||||
result.original = trimRawIdentifier(result.original)
|
||||
result.alias = trimRawIdentifier(result.alias)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trimRawIdentifier(rawIdentifier string) string {
|
||||
const trimChars = "`\"[];"
|
||||
|
||||
parts := strings.Split(rawIdentifier, ".")
|
||||
|
||||
for i := range parts {
|
||||
parts[i] = strings.Trim(parts[i], trimChars)
|
||||
}
|
||||
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"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/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func ensureNoTempViews(app core.App, t *testing.T) {
|
||||
var total int
|
||||
|
||||
err := app.Dao().DB().Select("count(*)").
|
||||
From("sqlite_schema").
|
||||
AndWhere(dbx.HashExp{"type": "view"}).
|
||||
AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
|
||||
Limit(1).
|
||||
Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check for temp views: %v", err)
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
t.Fatalf("Expected all temp views to be deleted, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteView(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
viewName string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"demo1", true}, // not a view table
|
||||
{"missing", false}, // missing or already deleted
|
||||
{"view1", false}, // existing
|
||||
{"VieW1", false}, // view names are case insensitives
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := app.Dao().DeleteView(s.viewName)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestSaveView(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
scenarioName string
|
||||
viewName string
|
||||
query string
|
||||
expectError bool
|
||||
expectColumns []string
|
||||
}{
|
||||
{
|
||||
"empty name and query",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"empty name",
|
||||
"",
|
||||
"select * from _admins",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"empty query",
|
||||
"123Test",
|
||||
"",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid query",
|
||||
"123Test",
|
||||
"123 456",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing table",
|
||||
"123Test",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"non select query",
|
||||
"123Test",
|
||||
"drop table _admins",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"multiple select queries",
|
||||
"123Test",
|
||||
"select *, count(id) as c from _admins; select * from demo1;",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"try to break the parent parenthesis",
|
||||
"123Test",
|
||||
"select *, count(id) as c from `_admins`)",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"simple select query (+ trimmed semicolon)",
|
||||
"123Test",
|
||||
";select *, count(id) as c from _admins;",
|
||||
false,
|
||||
[]string{
|
||||
"id", "created", "updated",
|
||||
"passwordHash", "tokenKey", "email",
|
||||
"lastResetSentAt", "avatar", "c",
|
||||
},
|
||||
},
|
||||
{
|
||||
"update old view with new query",
|
||||
"123Test",
|
||||
"select 1 as test from _admins",
|
||||
false,
|
||||
[]string{"test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
_, err := txDao.CreateViewSchema("select id from missing")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
expectError bool
|
||||
expectFields map[string]string // name-type pairs
|
||||
}{
|
||||
{
|
||||
"empty query",
|
||||
"",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid query",
|
||||
"test 123456",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing table",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"query with wildcard column",
|
||||
"select a.id, a.* from demo1 a",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"query without id",
|
||||
"select text, url, created, updated from demo1",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"query with comments",
|
||||
`
|
||||
select
|
||||
-- test single line
|
||||
id,
|
||||
text,
|
||||
/* multi
|
||||
line comment */
|
||||
url, created, updated from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"text": schema.FieldTypeText,
|
||||
"url": schema.FieldTypeUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with all fields and quoted identifiers",
|
||||
`
|
||||
select
|
||||
"id",
|
||||
"created",
|
||||
"updated",
|
||||
[text],
|
||||
` + "`bool`" + `,
|
||||
"url",
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"demo1"."file_many",
|
||||
` + "`demo1`." + "`number`" + ` number_alias,
|
||||
"email",
|
||||
"datetime",
|
||||
"json",
|
||||
"rel_one",
|
||||
"rel_many"
|
||||
from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with indirect relations fields",
|
||||
"select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
|
||||
false,
|
||||
map[string]string{
|
||||
"bid": schema.FieldTypeRelation,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with multiple froms, joins and style of aliasses",
|
||||
`
|
||||
select
|
||||
a.id as id,
|
||||
b.id as bid,
|
||||
lj.id cid,
|
||||
ij.id as did,
|
||||
a.bool,
|
||||
_admins.id as eid,
|
||||
_admins.email
|
||||
from demo1 a, demo2 as b
|
||||
left join demo3 lj on lj.id = 123
|
||||
inner join demo4 as ij on ij.id = 123
|
||||
join _admins
|
||||
where 1=1
|
||||
group by a.id
|
||||
limit 10
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"bid": schema.FieldTypeRelation,
|
||||
"cid": schema.FieldTypeRelation,
|
||||
"did": schema.FieldTypeRelation,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"eid": schema.FieldTypeJson, // not from collection
|
||||
"email": schema.FieldTypeJson, // not from collection
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with numeric casts",
|
||||
`select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
cast(a.id as int) cast_int,
|
||||
cast(a.id as integer) cast_integer,
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric,
|
||||
avg(a.id) avg,
|
||||
sum(a.id) sum,
|
||||
total(a.id) total,
|
||||
min(a.id) min,
|
||||
max(a.id) max
|
||||
from demo1 a`,
|
||||
false,
|
||||
map[string]string{
|
||||
"count": schema.FieldTypeNumber,
|
||||
"total": schema.FieldTypeNumber,
|
||||
"cast_int": schema.FieldTypeNumber,
|
||||
"cast_integer": schema.FieldTypeNumber,
|
||||
"cast_real": schema.FieldTypeNumber,
|
||||
"cast_decimal": schema.FieldTypeNumber,
|
||||
"cast_numeric": schema.FieldTypeNumber,
|
||||
// json because they are nullable
|
||||
"sum": schema.FieldTypeJson,
|
||||
"avg": schema.FieldTypeJson,
|
||||
"min": schema.FieldTypeJson,
|
||||
"max": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with reserved auth collection fields",
|
||||
`
|
||||
select
|
||||
a.id,
|
||||
a.username,
|
||||
a.email,
|
||||
a.emailVisibility,
|
||||
a.verified,
|
||||
demo1.id relid
|
||||
from users a
|
||||
left join demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"username": schema.FieldTypeText,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"emailVisibility": schema.FieldTypeBool,
|
||||
"verified": schema.FieldTypeBool,
|
||||
"relid": schema.FieldTypeRelation,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with unknown fields and aliases",
|
||||
`select
|
||||
id,
|
||||
id as id2,
|
||||
text as text_alias,
|
||||
url as url_alias,
|
||||
"demo1"."bool" as bool_alias,
|
||||
number as number_alias,
|
||||
created created_alias,
|
||||
updated updated_alias,
|
||||
123 as custom
|
||||
from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"text_alias": schema.FieldTypeText,
|
||||
"url_alias": schema.FieldTypeUrl,
|
||||
"bool_alias": schema.FieldTypeBool,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"created_alias": schema.FieldTypeDate,
|
||||
"updated_alias": schema.FieldTypeDate,
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().CreateViewSchema(s.query)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(s.expectFields) != len(result.Fields()) {
|
||||
serialized, _ := json.Marshal(result)
|
||||
t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
|
||||
continue
|
||||
}
|
||||
|
||||
for name, typ := range s.expectFields {
|
||||
field := result.GetFieldByName(name)
|
||||
|
||||
if field == nil {
|
||||
t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Type != typ {
|
||||
t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
totalLevels := 6
|
||||
|
||||
// create collection view mocks
|
||||
fileOneAlias := "file_one one0"
|
||||
fileManyAlias := "file_many many0"
|
||||
mockCollections := make([]*models.Collection, 0, totalLevels)
|
||||
for i := 0; i <= totalLevels; i++ {
|
||||
view := new(models.Collection)
|
||||
view.Type = models.CollectionTypeView
|
||||
view.Name = fmt.Sprintf("_test_view%d", i)
|
||||
view.SetOptions(&models.CollectionViewOptions{
|
||||
Query: fmt.Sprintf(
|
||||
"select id, %s, %s from %s",
|
||||
fileOneAlias,
|
||||
fileManyAlias,
|
||||
prevCollection.Name,
|
||||
),
|
||||
})
|
||||
|
||||
// save view
|
||||
if err := app.Dao().SaveCollection(view); err != nil {
|
||||
t.Fatalf("Failed to save view%d: %v", i, err)
|
||||
}
|
||||
|
||||
mockCollections = append(mockCollections, view)
|
||||
prevCollection = view
|
||||
fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
|
||||
fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
|
||||
}
|
||||
|
||||
fileOneName := "test_d61b33QdDU.txt"
|
||||
fileManyName := "test_QZFjKjXchk.txt"
|
||||
expectedRecordId := "84nmscqy84lsi1t"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionNameOrId string
|
||||
fileFieldName string
|
||||
filename string
|
||||
expectError bool
|
||||
expectRecordId string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"a",
|
||||
fileOneName,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"non-view collection",
|
||||
"demo1",
|
||||
"file_one",
|
||||
fileOneName,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"view collection after the max recursion limit",
|
||||
mockCollections[totalLevels-1].Name,
|
||||
fmt.Sprintf("one%d", totalLevels-1),
|
||||
fileOneName,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"first view collection (single file)",
|
||||
mockCollections[0].Name,
|
||||
"one0",
|
||||
fileOneName,
|
||||
false,
|
||||
expectedRecordId,
|
||||
},
|
||||
{
|
||||
"first view collection (many files)",
|
||||
mockCollections[0].Name,
|
||||
"many0",
|
||||
fileManyName,
|
||||
false,
|
||||
expectedRecordId,
|
||||
},
|
||||
|
||||
{
|
||||
"last view collection before the recursion limit (single file)",
|
||||
mockCollections[totalLevels-2].Name,
|
||||
fmt.Sprintf("one%d", totalLevels-2),
|
||||
fileOneName,
|
||||
false,
|
||||
expectedRecordId,
|
||||
},
|
||||
{
|
||||
"last view collection before the recursion limit (many files)",
|
||||
mockCollections[totalLevels-2].Name,
|
||||
fmt.Sprintf("many%d", totalLevels-2),
|
||||
fileManyName,
|
||||
false,
|
||||
expectedRecordId,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
record, err := app.Dao().FindRecordByViewFile(
|
||||
s.collectionNameOrId,
|
||||
s.fileFieldName,
|
||||
s.filename,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Id != s.expectRecordId {
|
||||
t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -52,6 +53,14 @@ func main() {
|
||||
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
|
||||
)
|
||||
|
||||
var queryTimeout int
|
||||
app.RootCmd.PersistentFlags().IntVar(
|
||||
&queryTimeout,
|
||||
"queryTimeout",
|
||||
30,
|
||||
"the default SELECT queries timeout in seconds",
|
||||
)
|
||||
|
||||
app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -70,6 +79,11 @@ func main() {
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// serves static files from the provided public dir (if exists)
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
|
||||
|
||||
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
|
||||
//
|
||||
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
|
||||
type AppleClientSecretCreate struct {
|
||||
app core.App
|
||||
|
||||
// ClientId is the identifier of your app (aka. Service ID).
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
|
||||
// TeamId is a 10-character string associated with your developer account
|
||||
// (usually could be found next to your name in the Apple Developer site).
|
||||
TeamId string `form:"teamId" json:"teamId"`
|
||||
|
||||
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
|
||||
// private key associated with your developer account.
|
||||
KeyId string `form:"keyId" json:"keyId"`
|
||||
|
||||
// PrivateKey is the private key associated to your app.
|
||||
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
|
||||
PrivateKey string `form:"privateKey" json:"privateKey"`
|
||||
|
||||
// Duration specifies how long the generated JWT token should be considered valid.
|
||||
// The specified value must be in seconds and max 15777000 (~6months).
|
||||
Duration int `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
|
||||
// config created from the provided [core.App] instances.
|
||||
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
|
||||
form := &AppleClientSecretCreate{
|
||||
app: app,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AppleClientSecretCreate) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.ClientId, validation.Required),
|
||||
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
|
||||
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates the form and returns a new Apple Client Secret JWT.
|
||||
func (form *AppleClientSecretCreate) Submit() (string, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
claims := &jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
|
||||
Subject: form.ClientId,
|
||||
Issuer: form.TeamId,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = form.KeyId
|
||||
|
||||
return token.SignedString(signKey)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
privatePem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedKey,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
formData map[string]any
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
map[string]any{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid data",
|
||||
map[string]any{
|
||||
"clientId": "",
|
||||
"teamId": "123456789",
|
||||
"keyId": "123456789",
|
||||
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
|
||||
"duration": -1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid data",
|
||||
map[string]any{
|
||||
"clientId": "123",
|
||||
"teamId": "1234567890",
|
||||
"keyId": "1234567891",
|
||||
"privateKey": string(privatePem),
|
||||
"duration": 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewAppleClientSecretCreate(app)
|
||||
|
||||
rawData, marshalErr := json.Marshal(s.formData)
|
||||
if marshalErr != nil {
|
||||
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal(rawData, form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
secret, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
t.Errorf("[%s] Expected non-empty secret", s.name)
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
|
||||
}
|
||||
|
||||
if alg := token.Header["alg"]; alg != "ES256" {
|
||||
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
|
||||
}
|
||||
|
||||
if kid := token.Header["kid"]; kid != form.KeyId {
|
||||
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
-43
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
@@ -26,17 +27,18 @@ type CollectionUpsert struct {
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
ListRule *string `form:"listRule" json:"listRule"`
|
||||
ViewRule *string `form:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
|
||||
ListRule *string `form:"listRule" json:"listRule"`
|
||||
ViewRule *string `form:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||
@@ -57,6 +59,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
|
||||
form.Type = form.collection.Type
|
||||
form.Name = form.collection.Name
|
||||
form.System = form.collection.System
|
||||
form.Indexes = form.collection.Indexes
|
||||
form.ListRule = form.collection.ListRule
|
||||
form.ViewRule = form.collection.ViewRule
|
||||
form.CreateRule = form.collection.CreateRule
|
||||
@@ -69,7 +72,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
|
||||
}
|
||||
|
||||
clone, _ := form.collection.Schema.Clone()
|
||||
if clone != nil {
|
||||
if clone != nil && form.Type != models.CollectionTypeView {
|
||||
form.Schema = *clone
|
||||
} else {
|
||||
form.Schema = schema.Schema{}
|
||||
@@ -86,6 +89,16 @@ func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *CollectionUpsert) Validate() error {
|
||||
isAuth := form.Type == models.CollectionTypeAuth
|
||||
isView := form.Type == models.CollectionTypeView
|
||||
|
||||
// generate schema from the query (overwriting any explicit user defined schema)
|
||||
if isView {
|
||||
options := models.CollectionViewOptions{}
|
||||
if err := decodeOptions(form.Options, &options); err != nil {
|
||||
return err
|
||||
}
|
||||
form.Schema, _ = form.dao.CreateViewSchema(options.Query)
|
||||
}
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
@@ -104,7 +117,11 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.Field(
|
||||
&form.Type,
|
||||
validation.Required,
|
||||
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
|
||||
validation.In(
|
||||
models.CollectionTypeBase,
|
||||
models.CollectionTypeAuth,
|
||||
models.CollectionTypeView,
|
||||
),
|
||||
validation.By(form.ensureNoTypeChange),
|
||||
),
|
||||
validation.Field(
|
||||
@@ -115,23 +132,33 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.By(form.ensureNoSystemNameChange),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
// validates using the type's own validation rules + some collection's specific
|
||||
// validates using the type's own validation rules + some collection's specifics
|
||||
validation.Field(
|
||||
&form.Schema,
|
||||
validation.By(form.checkMinSchemaFields),
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.checkRelationFields),
|
||||
validation.When(
|
||||
isAuth,
|
||||
validation.By(form.ensureNoAuthFieldName),
|
||||
),
|
||||
validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
|
||||
),
|
||||
validation.Field(&form.ListRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
|
||||
validation.Field(
|
||||
&form.CreateRule,
|
||||
validation.When(isView, validation.Nil),
|
||||
validation.By(form.checkRule),
|
||||
),
|
||||
validation.Field(
|
||||
&form.UpdateRule,
|
||||
validation.When(isView, validation.Nil),
|
||||
validation.By(form.checkRule),
|
||||
),
|
||||
validation.Field(
|
||||
&form.DeleteRule,
|
||||
validation.When(isView, validation.Nil),
|
||||
validation.By(form.checkRule),
|
||||
),
|
||||
validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
|
||||
validation.Field(&form.Options, validation.By(form.checkOptions)),
|
||||
)
|
||||
}
|
||||
@@ -149,11 +176,6 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
|
||||
}
|
||||
|
||||
// ensure that there is no existing table name with the same name
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -222,7 +244,28 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
continue
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.NewError(
|
||||
"validation_schema_invalid_relation_field_options",
|
||||
"The relation field has invalid field options.",
|
||||
)},
|
||||
}
|
||||
}
|
||||
|
||||
// prevent collectionId change
|
||||
oldField := form.collection.Schema.GetFieldById(field.Id)
|
||||
if oldField != nil {
|
||||
oldOptions, _ := oldField.Options.(*schema.RelationOptions)
|
||||
if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_relation_change",
|
||||
"The relation collection cannot be changed.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
@@ -288,13 +331,15 @@ func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
return nil // auth collections doesn't require having additional schema fields
|
||||
}
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
v, ok := value.(schema.Schema)
|
||||
if !ok || len(v.Fields()) == 0 {
|
||||
return validation.ErrRequired
|
||||
switch form.Type {
|
||||
case models.CollectionTypeAuth, models.CollectionTypeView:
|
||||
return nil // no schema fields constraint
|
||||
default:
|
||||
if len(v.Fields()) == 0 {
|
||||
return validation.ErrRequired
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,18 +385,52 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkIndexes(value any) error {
|
||||
v, _ := value.(types.JsonArray[string])
|
||||
|
||||
if form.Type == models.CollectionTypeView && len(v) > 0 {
|
||||
return validation.NewError(
|
||||
"validation_indexes_not_supported",
|
||||
"The collection doesn't support indexes.",
|
||||
)
|
||||
}
|
||||
|
||||
for i, rawIndex := range v {
|
||||
parsed := dbutils.ParseIndex(rawIndex)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
"Invalid CREATE INDEX expression.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// note: we don't check the index table because it is always
|
||||
// overwritten by the daos.SyncRecordTableSchema to allow
|
||||
// easier partial modifications (eg. changing only the collection name).
|
||||
// if !strings.EqualFold(parsed.TableName, form.Name) {
|
||||
// return validation.Errors{
|
||||
// strconv.Itoa(i): validation.NewError(
|
||||
// "validation_invalid_index_table",
|
||||
// fmt.Sprintf("The index table must be the same as the collection name."),
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
v, _ := value.(types.JsonMap)
|
||||
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
raw, err := v.MarshalJSON()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
switch form.Type {
|
||||
case models.CollectionTypeAuth:
|
||||
options := models.CollectionAuthOptions{}
|
||||
if err := json.Unmarshal(raw, &options); err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
if err := decodeOptions(v, &options); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check the generic validations
|
||||
@@ -363,6 +442,39 @@ func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
if err := form.checkRule(options.ManageRule); err != nil {
|
||||
return validation.Errors{"manageRule": err}
|
||||
}
|
||||
case models.CollectionTypeView:
|
||||
options := models.CollectionViewOptions{}
|
||||
if err := decodeOptions(v, &options); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check the generic validations
|
||||
if err := options.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check the query option
|
||||
if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
|
||||
return validation.Errors{
|
||||
"query": validation.NewError(
|
||||
"validation_invalid_view_query",
|
||||
fmt.Sprintf("Invalid query - %s", err.Error()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeOptions(options types.JsonMap, result any) error {
|
||||
raw, err := options.MarshalJSON()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, result); err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -398,7 +510,17 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col
|
||||
form.collection.Name = form.Name
|
||||
}
|
||||
|
||||
form.collection.Schema = form.Schema
|
||||
// view schema is autogenerated on save and cannot have indexes
|
||||
if !form.collection.IsView() {
|
||||
form.collection.Schema = form.Schema
|
||||
|
||||
// normalize indexes format
|
||||
form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
|
||||
for i, rawIdx := range form.Indexes {
|
||||
form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
|
||||
}
|
||||
}
|
||||
|
||||
form.collection.ListRule = form.ListRule
|
||||
form.collection.ViewRule = form.ViewRule
|
||||
form.collection.CreateRule = form.CreateRule
|
||||
|
||||
+182
-15
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -22,7 +23,7 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
collection.Name = "test_name"
|
||||
collection.Type = "test_type"
|
||||
collection.System = true
|
||||
listRule := "testview"
|
||||
listRule := "test_list"
|
||||
collection.ListRule = &listRule
|
||||
viewRule := "test_view"
|
||||
collection.ViewRule = &viewRule
|
||||
@@ -98,6 +99,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}{
|
||||
{"empty create (base)", "", "{}", []string{"name", "schema"}},
|
||||
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
|
||||
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
|
||||
{"empty update", "demo2", "{}", []string{}},
|
||||
{
|
||||
"create failure",
|
||||
@@ -113,9 +115,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
"deleteRule": "missing = '123'",
|
||||
"indexes": ["create index '' on '' ()"]
|
||||
}`,
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
|
||||
},
|
||||
{
|
||||
"create failure - existing name",
|
||||
@@ -188,7 +191,7 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check type options validators",
|
||||
"create failure - check auth options validators",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
@@ -200,6 +203,16 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"options"},
|
||||
},
|
||||
{
|
||||
"create failure - check view options validators",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "view",
|
||||
"options": { "query": "invalid query" }
|
||||
}`,
|
||||
[]string{"options"},
|
||||
},
|
||||
{
|
||||
"create success",
|
||||
"",
|
||||
@@ -223,7 +236,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": "test1='123' && emailVisibility = true",
|
||||
"createRule": "test1='123' && email != ''",
|
||||
"updateRule": "test1='123' && username != ''",
|
||||
"deleteRule": "test1='123' && id != ''"
|
||||
"deleteRule": "test1='123' && id != ''",
|
||||
"indexes": ["create index idx_test_new on anything (test1)"]
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -234,9 +248,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"url"},
|
||||
{"id":"b123456","name":"test2","type":"bool"}
|
||||
]
|
||||
],
|
||||
"indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
[]string{"schema", "indexes"},
|
||||
},
|
||||
{
|
||||
"update success - rename fields to existing field names (aka. reusing field names)",
|
||||
@@ -280,6 +295,26 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"type"},
|
||||
},
|
||||
{
|
||||
"update failure - changing relation collection",
|
||||
"users",
|
||||
`{
|
||||
"schema": [
|
||||
{
|
||||
"id": "lkeigvv3",
|
||||
"name": "rel",
|
||||
"type": "relation",
|
||||
"options": {
|
||||
"collectionId": "wzlqyes4orhoygb",
|
||||
"cascadeDelete": false,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"update failure - all fields",
|
||||
"demo2",
|
||||
@@ -295,9 +330,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'",
|
||||
"options": {"test": 123}
|
||||
"options": {"test": 123},
|
||||
"indexes": ["create index '' from demo2 on (id)"]
|
||||
}`,
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
|
||||
},
|
||||
{
|
||||
"update success - update all fields",
|
||||
@@ -313,7 +349,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"createRule": "test='123' && email != ''",
|
||||
"updateRule": "test='123' && username != ''",
|
||||
"deleteRule": "test='123' && id != ''",
|
||||
"options": {"minPasswordLength": 10}
|
||||
"options": {"minPasswordLength": 10},
|
||||
"indexes": [
|
||||
"create index idx_clients_test1 on anything (id, email, test)",
|
||||
"create unique index idx_clients_test2 on clients (id, username, email)"
|
||||
]
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -340,7 +380,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
"deleteRule": null,
|
||||
"indexes": []
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -356,6 +397,101 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
|
||||
// view tests
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
"view create failure",
|
||||
"",
|
||||
`{
|
||||
"name": "upsert_view",
|
||||
"type": "view",
|
||||
"listRule": "id='123' && verified = true",
|
||||
"viewRule": "id='123' && emailVisibility = true",
|
||||
"schema": [
|
||||
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
|
||||
],
|
||||
"options": {
|
||||
"query": "select id, email from users; drop table _admins;"
|
||||
},
|
||||
"indexes": ["create index idx_test_view on upsert_view (id)"]
|
||||
}`,
|
||||
[]string{
|
||||
"listRule",
|
||||
"viewRule",
|
||||
"options",
|
||||
"indexes", // views don't have indexes
|
||||
},
|
||||
},
|
||||
{
|
||||
"view create success",
|
||||
"",
|
||||
`{
|
||||
"name": "upsert_view",
|
||||
"type": "view",
|
||||
"listRule": "id='123' && verified = true",
|
||||
"viewRule": "id='123' && emailVisibility = true",
|
||||
"schema": [
|
||||
{"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
|
||||
],
|
||||
"options": {
|
||||
"query": "select id, emailVisibility, verified from users"
|
||||
}
|
||||
}`,
|
||||
[]string{
|
||||
// "schema", should be overwritten by an autogenerated from the query
|
||||
},
|
||||
},
|
||||
{
|
||||
"view update failure (schema autogeneration and rule fields check)",
|
||||
"upsert_view",
|
||||
`{
|
||||
"name": "upsert_view_2",
|
||||
"listRule": "id='456' && verified = true",
|
||||
"viewRule": "id='456'",
|
||||
"createRule": "id='123'",
|
||||
"updateRule": "id='123'",
|
||||
"deleteRule": "id='123'",
|
||||
"schema": [
|
||||
{"id":"abc123","name":"verified","type":"bool"}
|
||||
],
|
||||
"options": {
|
||||
"query": "select 1 as id"
|
||||
}
|
||||
}`,
|
||||
[]string{
|
||||
"listRule", // missing field (ignoring the old or explicit schema)
|
||||
"createRule", // not allowed
|
||||
"updateRule", // not allowed
|
||||
"deleteRule", // not allowed
|
||||
},
|
||||
},
|
||||
{
|
||||
"view update failure (check query identifiers format)",
|
||||
"upsert_view",
|
||||
`{
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"options": {
|
||||
"query": "select 1 as id, 2 as [invalid!@#]"
|
||||
}
|
||||
}`,
|
||||
[]string{
|
||||
"schema", // should fail due to invalid field name
|
||||
},
|
||||
},
|
||||
{
|
||||
"view update success",
|
||||
"upsert_view",
|
||||
`{
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"options": {
|
||||
"query": "select 1 as id, 2 as valid"
|
||||
}
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -454,10 +590,41 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
formSchema, _ := form.Schema.MarshalJSON()
|
||||
collectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
if string(formSchema) != string(collectionSchema) {
|
||||
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf(
|
||||
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
|
||||
importErr := txDao.ImportCollections(
|
||||
collections,
|
||||
form.DeleteMissing,
|
||||
form.beforeRecordsSync,
|
||||
form.afterSync,
|
||||
)
|
||||
if importErr == nil {
|
||||
return nil
|
||||
@@ -89,10 +89,10 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
|
||||
func (form *CollectionsImport) afterSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
|
||||
// refresh the actual persisted collections list
|
||||
refreshedCollections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
|
||||
if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&refreshedCollections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ func TestCollectionsImportValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmit(t *testing.T) {
|
||||
totalCollections := 10
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
jsonData string
|
||||
@@ -52,7 +54,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@@ -82,7 +84,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -101,7 +103,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -137,7 +139,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 11,
|
||||
expectCollectionsCount: totalCollections + 3,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
@@ -160,7 +162,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@@ -202,9 +204,9 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelBeforeDelete": 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -213,7 +215,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": [
|
||||
{
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"name":"demo2_rename",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -253,7 +255,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 10,
|
||||
expectCollectionsCount: totalCollections + 2,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@@ -341,8 +343,76 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
"OnModelBeforeDelete": totalCollections - 2,
|
||||
"OnModelAfterDelete": totalCollections - 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lazy system table name error",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"name": "_admins",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
"name":"active",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lazy view evaluation",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"name": "view_before",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select id, active from base_test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "base_test",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
"name":"active",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "view_after_new",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select id, active from base_test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "view_after_old",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select id from demo1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: totalCollections + 4,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 3,
|
||||
"OnModelAfterUpdate": 3,
|
||||
"OnModelBeforeCreate": 4,
|
||||
"OnModelAfterCreate": 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
@@ -16,9 +18,10 @@ import (
|
||||
|
||||
// RecordOAuth2LoginData defines the OA
|
||||
type RecordOAuth2LoginData struct {
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
ProviderClient auth.Provider
|
||||
}
|
||||
|
||||
// BeforeOAuth2RecordCreateFunc defines a callback function that will
|
||||
@@ -127,6 +130,11 @@ func (form *RecordOAuth2Login) Submit(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider.SetContext(ctx)
|
||||
|
||||
// load provider configuration
|
||||
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := providerConfig.SetupProvider(provider); err != nil {
|
||||
@@ -169,9 +177,10 @@ func (form *RecordOAuth2Login) Submit(
|
||||
}
|
||||
|
||||
interceptorData := &RecordOAuth2LoginData{
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
ProviderClient: provider,
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
|
||||
|
||||
+83
-32
@@ -235,6 +235,16 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesToUpload returns the parsed request files ready for upload.
|
||||
func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
|
||||
return form.filesToUpload
|
||||
}
|
||||
|
||||
// FilesToUpload returns the parsed request filenames ready to be deleted.
|
||||
func (form *RecordUpsert) FilesToDelete() []string {
|
||||
return form.filesToDelete
|
||||
}
|
||||
|
||||
// AddFiles adds the provided file(s) to the specified file field.
|
||||
//
|
||||
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
|
||||
@@ -246,9 +256,9 @@ func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
|
||||
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
|
||||
// form.AddFiles("documents", f1, f2)
|
||||
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
|
||||
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
|
||||
// form.AddFiles("documents", f1, f2)
|
||||
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
@@ -298,11 +308,11 @@ func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// // mark only only 2 files for removal
|
||||
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
|
||||
// // mark only only 2 files for removal
|
||||
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
|
||||
//
|
||||
// // mark all "documents" files for removal
|
||||
// form.AddFiles("documents")
|
||||
// // mark all "documents" files for removal
|
||||
// form.AddFiles("documents")
|
||||
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
@@ -419,7 +429,6 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
if len(submittedNames) == 0 && len(oldNames) > 0 {
|
||||
form.RemoveFiles(key)
|
||||
} else if len(oldNames) > 0 {
|
||||
|
||||
toDelete := []string{}
|
||||
|
||||
for _, name := range oldNames {
|
||||
@@ -731,19 +740,46 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
form.record.MarkAsNew()
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return fmt.Errorf("failed to process the uploaded files: %w", err)
|
||||
}
|
||||
dao := form.dao.Clone()
|
||||
|
||||
// persist the record model
|
||||
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
|
||||
// try to cleanup the successfully uploaded files
|
||||
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
|
||||
log.Println(err)
|
||||
// upload new files (if any)
|
||||
//
|
||||
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
|
||||
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
|
||||
// but BEFORE the actual record db persistence
|
||||
// ---
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to save the record: %w", saveErr)
|
||||
if m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// ---
|
||||
|
||||
// persist the record model
|
||||
if err := dao.SaveRecord(form.record); err != nil {
|
||||
preparedErr := form.prepareError(err)
|
||||
if _, ok := preparedErr.(validation.Errors); ok {
|
||||
return preparedErr
|
||||
}
|
||||
return fmt.Errorf("failed to save the record: %w", err)
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
@@ -758,25 +794,13 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) getFilesToUploadNames() []string {
|
||||
names := []string{}
|
||||
|
||||
for fieldKey := range form.filesToUpload {
|
||||
for _, file := range form.filesToUpload[fieldKey] {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToUpload() error {
|
||||
if len(form.filesToUpload) == 0 {
|
||||
return nil // no parsed file fields
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("the record is not persisted yet")
|
||||
return errors.New("the record doesn't have an id")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
@@ -824,7 +848,7 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return filenames, errors.New("the record doesn't have a unique ID")
|
||||
return filenames, errors.New("the record doesn't have an id")
|
||||
}
|
||||
|
||||
fs, err := form.app.NewFilesystem()
|
||||
@@ -857,3 +881,30 @@ func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string,
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
// prepareError parses the provided error and tries to return
|
||||
// user-friendly validation error(s).
|
||||
func (form *RecordUpsert) prepareError(err error) error {
|
||||
msg := strings.ToLower(err.Error())
|
||||
|
||||
validationErrs := validation.Errors{}
|
||||
|
||||
// check for unique constraint failure
|
||||
if strings.Contains(msg, "unique constraint failed") {
|
||||
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
|
||||
|
||||
c := form.record.Collection()
|
||||
for _, f := range c.Schema.Fields() {
|
||||
// blank space to unify multi-columns lookup
|
||||
if strings.Contains(msg+" ", fmt.Sprintf("%s.%s ", strings.ToLower(c.Name), f.Name)) {
|
||||
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(validationErrs) > 0 {
|
||||
return validationErrs
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -979,11 +979,33 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed0 := "test_d61b33QdDU.txt" // replaced
|
||||
removed1 := "300_WlbFWSGmW9.png"
|
||||
removed2 := "logo_vcfJJG5TAh.svg"
|
||||
|
||||
form.AddFiles("file_one", f1) // should replace the existin file
|
||||
|
||||
form.AddFiles("file_many", f2, f3) // should append
|
||||
|
||||
form.RemoveFiles("file_many", "300_WlbFWSGmW9.png", "logo_vcfJJG5TAh.svg") // should remove
|
||||
form.RemoveFiles("file_many", removed1, removed2) // should remove
|
||||
|
||||
filesToUpload := form.FilesToUpload()
|
||||
if v, ok := filesToUpload["file_one"]; !ok || len(v) != 1 {
|
||||
t.Fatalf("Expected filesToUpload[file_one] to have exactly 1 file, got %v", v)
|
||||
}
|
||||
if v, ok := filesToUpload["file_many"]; !ok || len(v) != 2 {
|
||||
t.Fatalf("Expected filesToUpload[file_many] to have exactly 2 file, got %v", v)
|
||||
}
|
||||
|
||||
filesToDelete := form.FilesToDelete()
|
||||
if len(filesToDelete) != 3 {
|
||||
t.Fatalf("Expected exactly 2 file to delete, got %v", filesToDelete)
|
||||
}
|
||||
for _, f := range []string{removed0, removed1, removed2} {
|
||||
if !list.ExistInSlice(f, filesToDelete) {
|
||||
t.Fatalf("Missing file %q from filesToDelete %v", f, filesToDelete)
|
||||
}
|
||||
}
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
|
||||
@@ -995,14 +1017,14 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
|
||||
}
|
||||
|
||||
// ensure files deletion
|
||||
if hasRecordFile(app, recordAfter, "test_d61b33QdDU.txt") {
|
||||
if hasRecordFile(app, recordAfter, removed0) {
|
||||
t.Fatalf("Expected the old file_one file to be deleted")
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "300_WlbFWSGmW9.png") {
|
||||
t.Fatalf("Expected 300_WlbFWSGmW9.png to be deleted")
|
||||
if hasRecordFile(app, recordAfter, removed1) {
|
||||
t.Fatalf("Expected %s to be deleted", removed1)
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "logo_vcfJJG5TAh.svg") {
|
||||
t.Fatalf("Expected logo_vcfJJG5TAh.svg to be deleted")
|
||||
if hasRecordFile(app, recordAfter, removed2) {
|
||||
t.Fatalf("Expected %s to be deleted", removed2)
|
||||
}
|
||||
|
||||
fileOne := recordAfter.GetStringSlice("file_one")
|
||||
@@ -1015,3 +1037,51 @@ func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
|
||||
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertUploadFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create with invalid file
|
||||
{
|
||||
new := models.NewRecord(collection)
|
||||
new.Id = "123456789012341"
|
||||
|
||||
form := forms.NewRecordUpsert(app, new)
|
||||
form.LoadData(map[string]any{"title": "new_test"})
|
||||
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
|
||||
|
||||
if err := form.Submit(); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
|
||||
t.Fatalf("Expected the inserted record to be deleted, found \n%v", r.PublicExport())
|
||||
}
|
||||
}
|
||||
|
||||
// update with invalid file
|
||||
{
|
||||
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.LoadData(map[string]any{"title": "update_test"})
|
||||
form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
|
||||
|
||||
if err := form.Submit(); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
|
||||
t.Fatalf("Expected the record changes to be reverted, got \n%v", r.PublicExport())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
// size is no more than the provided maxBytes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
||||
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
@@ -36,7 +37,8 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
// mimetype is within the provided allowed mime types.
|
||||
//
|
||||
// Example:
|
||||
// validMimeTypes := []string{"test/plain","image/jpeg"}
|
||||
//
|
||||
// validMimeTypes := []string{"test/plain","image/jpeg"}
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
||||
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
// Compare checks whether the provided model id exists.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// validation.Field(&form.Id, validation.By(validators.UniqueId(form.dao, tableName)))
|
||||
func UniqueId(dao *daos.Dao, tableName string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
|
||||
@@ -86,17 +86,6 @@ func (validator *RecordDataValidator) Validate(data map[string]any) error {
|
||||
errs[key] = err
|
||||
continue
|
||||
}
|
||||
|
||||
// check unique constraint
|
||||
if field.Unique && !validator.dao.IsRecordValueUnique(
|
||||
validator.record.Collection().Id,
|
||||
key,
|
||||
value,
|
||||
validator.record.GetId(),
|
||||
) {
|
||||
errs[key] = validation.NewError("validation_not_unique", "Value must be unique")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
@@ -362,6 +351,10 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
|
||||
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
|
||||
if options.MinSelect != nil && len(ids) < *options.MinSelect {
|
||||
return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", *options.MinSelect))
|
||||
}
|
||||
|
||||
if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
|
||||
}
|
||||
|
||||
@@ -99,16 +99,6 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(text) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
"field3": "test",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check min constraint",
|
||||
map[string]any{
|
||||
@@ -225,16 +215,6 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(number) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": 123,
|
||||
"field2": 123,
|
||||
"field3": 123,
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(number) check min constraint",
|
||||
map[string]any{
|
||||
@@ -345,16 +325,6 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(bool) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": true,
|
||||
"field2": true,
|
||||
"field3": true,
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(bool) valid data (only required)",
|
||||
map[string]any{
|
||||
@@ -441,16 +411,6 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field1", "field2", "field3"},
|
||||
},
|
||||
{
|
||||
"(email) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "test@example.com",
|
||||
"field2": "test@test.com",
|
||||
"field3": "test@example.com",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(email) check ExceptDomains constraint",
|
||||
map[string]any{
|
||||
@@ -557,16 +517,6 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field1", "field3"},
|
||||
},
|
||||
{
|
||||
"(url) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "http://example.com",
|
||||
"field2": "http://test.com",
|
||||
"field3": "http://example.com",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(url) check ExceptDomains constraint",
|
||||
map[string]any{
|
||||
@@ -695,16 +645,6 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(date) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "2029-01-01 01:01:01.123",
|
||||
"field2": "2029-01-01 01:01:01.123",
|
||||
"field3": "2029-01-01 01:01:01.123",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(date) check min date constraint",
|
||||
map[string]any{
|
||||
@@ -827,26 +767,6 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
nil,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(select) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": "b",
|
||||
"field3": []string{"a", "b", "c"},
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(select) check unique constraint - same elements but different order",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": "b",
|
||||
"field3": []string{"a", "c", "b"},
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(select) check Values constraint",
|
||||
map[string]any{
|
||||
@@ -977,16 +897,6 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(json) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": `{"test":123}`,
|
||||
"field2": `{"test":123}`,
|
||||
"field3": map[string]any{"test": 123},
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(json) check json text invalid obj, array and number normalizations",
|
||||
map[string]any{
|
||||
@@ -1212,6 +1122,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
Unique: true,
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MinSelect: types.Pointer(2),
|
||||
CollectionId: demo.Id,
|
||||
},
|
||||
},
|
||||
@@ -1259,11 +1170,10 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"check min constraint",
|
||||
map[string]any{
|
||||
"field1": relId1,
|
||||
"field2": relId2,
|
||||
"field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed
|
||||
"field3": []string{relId1},
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
// Compare checks whether the validated value matches another string.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password)))
|
||||
func Compare(valueToCompare string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
|
||||
@@ -4,86 +4,85 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.187
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.19
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.18
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.3.4
|
||||
github.com/domodwyer/mailyak/v3 v3.5.0
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
|
||||
github.com/fatih/color v1.14.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/pocketbase/dbx v1.9.0
|
||||
github.com/pocketbase/dbx v1.10.0
|
||||
github.com/spf13/cast v1.5.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
gocloud.dev v0.28.0
|
||||
golang.org/x/crypto v0.5.0
|
||||
golang.org/x/net v0.5.0
|
||||
golang.org/x/oauth2 v0.4.0
|
||||
golang.org/x/sync v0.1.0
|
||||
modernc.org/sqlite v1.20.3
|
||||
gocloud.dev v0.29.0
|
||||
golang.org/x/crypto v0.7.0
|
||||
golang.org/x/net v0.8.0
|
||||
golang.org/x/oauth2 v0.6.0
|
||||
modernc.org/sqlite v1.21.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.3 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go v1.44.234 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.49 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.60 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/image v0.3.0 // indirect
|
||||
golang.org/x/mod v0.7.0 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/term v0.4.0 // indirect
|
||||
golang.org/x/text v0.6.0 // indirect
|
||||
golang.org/x/image v0.6.0 // indirect
|
||||
golang.org/x/mod v0.9.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/term v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.5.0 // indirect
|
||||
golang.org/x/tools v0.7.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.108.0 // indirect
|
||||
google.golang.org/api v0.114.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa // indirect
|
||||
google.golang.org/grpc v1.52.3 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633 // indirect
|
||||
google.golang.org/grpc v1.54.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.2 // indirect
|
||||
modernc.org/libc v1.22.3 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: admin.Email},
|
||||
To: []mail.Address{{Address: admin.Email}},
|
||||
Subject: "Reset admin password",
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
+3
-3
@@ -32,7 +32,7 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: authRecord.Email()},
|
||||
To: []mail.Address{{Address: authRecord.Email()}},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: authRecord.Email()},
|
||||
To: []mail.Address{{Address: authRecord.Email()}},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
Name: app.Settings().Meta.SenderName,
|
||||
Address: app.Settings().Meta.SenderAddress,
|
||||
},
|
||||
To: mail.Address{Address: newEmail},
|
||||
To: []mail.Address{{Address: newEmail}},
|
||||
Subject: subject,
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
+13
-45
@@ -19,12 +19,7 @@ const Layout = `
|
||||
font-family: Source Sans Pro, sans-serif, emoji;
|
||||
}
|
||||
body {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
p {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
font-family: Source Sans Pro, sans-serif, emoji;
|
||||
padding: 20px 30px;
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
@@ -32,6 +27,11 @@ const Layout = `
|
||||
em, i {
|
||||
font-style: italic;
|
||||
}
|
||||
p {
|
||||
display: block;
|
||||
margin: 10px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
small {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
@@ -58,54 +58,22 @@ const Layout = `
|
||||
color: #fff !important;
|
||||
background: #16161a !important;
|
||||
text-decoration: none !important;
|
||||
line-height: 45px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
line-height: 40px;
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
padding: 0 30px;
|
||||
margin: 10px 0;
|
||||
padding: 0 20px;
|
||||
margin: 5px 0;
|
||||
font-family: Source Sans Pro, sans-serif, emoji;;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wrapper {
|
||||
display: block;
|
||||
width: 460px;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
font-family: inherit;
|
||||
}
|
||||
.content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
-webkit-box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
|
||||
-moz-box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
|
||||
box-shadow: 0px 2px 30px 0px rgb(0,0,0,0.05);
|
||||
}
|
||||
.footer {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: #666f75;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="content">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "content" .}}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -42,8 +42,8 @@ func init() {
|
||||
[[tokenKey]] TEXT UNIQUE NOT NULL,
|
||||
[[passwordHash]] TEXT NOT NULL,
|
||||
[[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL
|
||||
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE {{_collections}} (
|
||||
@@ -52,14 +52,15 @@ func init() {
|
||||
[[type]] TEXT DEFAULT "base" NOT NULL,
|
||||
[[name]] TEXT UNIQUE NOT NULL,
|
||||
[[schema]] JSON DEFAULT "[]" NOT NULL,
|
||||
[[indexes]] JSON DEFAULT "[]" NOT NULL,
|
||||
[[listRule]] TEXT DEFAULT NULL,
|
||||
[[viewRule]] TEXT DEFAULT NULL,
|
||||
[[createRule]] TEXT DEFAULT NULL,
|
||||
[[updateRule]] TEXT DEFAULT NULL,
|
||||
[[deleteRule]] TEXT DEFAULT NULL,
|
||||
[[options]] JSON DEFAULT "{}" NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL
|
||||
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE {{_params}} (
|
||||
@@ -76,8 +77,8 @@ func init() {
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// This migration replaces the "authentikAuth" setting with "oidc".
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
_, err := db.NewQuery(`
|
||||
UPDATE {{_params}}
|
||||
SET [[value]] = replace([[value]], '"authentikAuth":', '"oidcAuth":')
|
||||
WHERE [[key]] = 'settings'
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}, func(db dbx.Builder) error {
|
||||
_, err := db.NewQuery(`
|
||||
UPDATE {{_params}}
|
||||
SET [[value]] = replace([[value]], '"oidcAuth":', '"authentikAuth":')
|
||||
WHERE [[key]] = 'settings'
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.IsView() {
|
||||
// skip view collections
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range c.Schema.Fields() {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if opt.IsMultiple() {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
} else {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trigger view query update after the records normalization
|
||||
// (ignore save error in case of invalid query to allow users to change it from the UI)
|
||||
for _, c := range collections {
|
||||
if !c.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
dao.SaveCollection(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
// Adds _collections indexes column (if not already).
|
||||
//
|
||||
// Note: This migration will be deleted once schema.SchemaField.Unuique is removed.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
// cleanup failed remaining/"dangling" temp views to prevent
|
||||
// errors during the indexes upsert
|
||||
// ---
|
||||
tempViews := []string{}
|
||||
viewsErr := db.Select("name").
|
||||
From("sqlite_schema").
|
||||
AndWhere(dbx.HashExp{"type": "view"}).
|
||||
AndWhere(dbx.NewExp(`[[name]] LIKE '\_temp\_%' ESCAPE '\'`)).
|
||||
Column(&tempViews)
|
||||
if viewsErr != nil {
|
||||
return viewsErr
|
||||
}
|
||||
for _, name := range tempViews {
|
||||
if err := dao.DeleteView(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
cols, err := dao.TableColumns("_collections")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, col := range cols {
|
||||
if col == "indexes" {
|
||||
return nil // already existing (probably via the init migration)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.AddColumn("_collections", "indexes", `JSON DEFAULT "[]" NOT NULL`).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().AndWhere(dbx.NewExp("type != 'view'")).All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type indexInfo struct {
|
||||
Sql string `db:"sql"`
|
||||
IndexName string `db:"name"`
|
||||
TableName string `db:"tbl_name"`
|
||||
}
|
||||
|
||||
indexesQuery := db.NewQuery(`SELECT * FROM sqlite_master WHERE type = "index" and sql is not null`)
|
||||
rawIndexes := []indexInfo{}
|
||||
if err := indexesQuery.All(&rawIndexes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexesByTableName := map[string][]indexInfo{}
|
||||
for _, idx := range rawIndexes {
|
||||
indexesByTableName[idx.TableName] = append(indexesByTableName[idx.TableName], idx)
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
c.Indexes = nil // reset
|
||||
|
||||
excludeIndexes := []string{
|
||||
"_" + c.Id + "_email_idx",
|
||||
"_" + c.Id + "_username_idx",
|
||||
"_" + c.Id + "_tokenKey_idx",
|
||||
}
|
||||
|
||||
// convert custom indexes into the related collections
|
||||
for _, idx := range indexesByTableName[c.Name] {
|
||||
if strings.Contains(idx.IndexName, "sqlite_autoindex_") ||
|
||||
list.ExistInSlice(idx.IndexName, excludeIndexes) {
|
||||
continue
|
||||
}
|
||||
|
||||
// drop old index (it will be recreated with the collection)
|
||||
if _, err := db.DropIndex(idx.TableName, idx.IndexName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Indexes = append(c.Indexes, idx.Sql)
|
||||
}
|
||||
|
||||
// convert unique fields to indexes
|
||||
FieldsLoop:
|
||||
for _, f := range c.Schema.Fields() {
|
||||
if !f.Unique {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, idx := range indexesByTableName[c.Name] {
|
||||
parsed := dbutils.ParseIndex(idx.Sql)
|
||||
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, f.Name) {
|
||||
continue FieldsLoop // already added
|
||||
}
|
||||
}
|
||||
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
`CREATE UNIQUE INDEX "idx_unique_%s" on "%s" ("%s")`,
|
||||
f.Id,
|
||||
c.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if len(c.Indexes) > 0 {
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
_, err := db.DropColumn("_collections", "indexes").Execute()
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -20,8 +20,8 @@ func init() {
|
||||
[[referer]] TEXT DEFAULT "" NOT NULL,
|
||||
[[userAgent]] TEXT DEFAULT "" NOT NULL,
|
||||
[[meta]] JSON DEFAULT "{}" NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL
|
||||
[[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
|
||||
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX _request_status_idx on {{_requests}} ([[status]]);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// This migration normalizes the request logs method to UPPERCASE (eg. "get" => "GET").
|
||||
func init() {
|
||||
LogsMigrations.Register(func(db dbx.Builder) error {
|
||||
_, err := db.NewQuery("UPDATE {{_requests}} SET method=UPPER(method)").Execute()
|
||||
|
||||
return err
|
||||
}, func(db dbx.Builder) error {
|
||||
_, err := db.NewQuery("UPDATE {{_requests}} SET method=LOWER(method)").Execute()
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -48,7 +48,7 @@ func (m *Admin) SetPassword(password string) error {
|
||||
}
|
||||
|
||||
// hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+39
-6
@@ -17,15 +17,17 @@ var (
|
||||
const (
|
||||
CollectionTypeBase = "base"
|
||||
CollectionTypeAuth = "auth"
|
||||
CollectionTypeView = "view"
|
||||
)
|
||||
|
||||
type Collection struct {
|
||||
BaseModel
|
||||
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
System bool `db:"system" json:"system"`
|
||||
Schema schema.Schema `db:"schema" json:"schema"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
System bool `db:"system" json:"system"`
|
||||
Schema schema.Schema `db:"schema" json:"schema"`
|
||||
Indexes types.JsonArray[string] `db:"indexes" json:"indexes"`
|
||||
|
||||
// rules
|
||||
ListRule *string `db:"listRule" json:"listRule"`
|
||||
@@ -52,11 +54,16 @@ func (m *Collection) IsBase() bool {
|
||||
return m.Type == CollectionTypeBase
|
||||
}
|
||||
|
||||
// IsBase checks if the current collection has "auth" type.
|
||||
// IsAuth checks if the current collection has "auth" type.
|
||||
func (m *Collection) IsAuth() bool {
|
||||
return m.Type == CollectionTypeAuth
|
||||
}
|
||||
|
||||
// IsView checks if the current collection has "view" type.
|
||||
func (m *Collection) IsView() bool {
|
||||
return m.Type == CollectionTypeView
|
||||
}
|
||||
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
type alias Collection // prevent recursion
|
||||
@@ -82,6 +89,14 @@ func (m *Collection) AuthOptions() CollectionAuthOptions {
|
||||
return result
|
||||
}
|
||||
|
||||
// ViewOptions decodes the current collection options and returns them
|
||||
// as new [CollectionViewOptions] instance.
|
||||
func (m *Collection) ViewOptions() CollectionViewOptions {
|
||||
result := CollectionViewOptions{}
|
||||
m.DecodeOptions(&result)
|
||||
return result
|
||||
}
|
||||
|
||||
// NormalizeOptions updates the current collection options with a
|
||||
// new normalized state based on the collection type.
|
||||
func (m *Collection) NormalizeOptions() error {
|
||||
@@ -89,6 +104,8 @@ func (m *Collection) NormalizeOptions() error {
|
||||
switch m.Type {
|
||||
case CollectionTypeAuth:
|
||||
typedOptions = m.AuthOptions()
|
||||
case CollectionTypeView:
|
||||
typedOptions = m.ViewOptions()
|
||||
default:
|
||||
typedOptions = m.BaseOptions()
|
||||
}
|
||||
@@ -143,7 +160,7 @@ func (m *Collection) SetOptions(typedOptions any) error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// CollectionAuthOptions defines the "base" Collection.Options fields.
|
||||
// CollectionBaseOptions defines the "base" Collection.Options fields.
|
||||
type CollectionBaseOptions struct {
|
||||
}
|
||||
|
||||
@@ -152,6 +169,8 @@ func (o CollectionBaseOptions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// CollectionAuthOptions defines the "auth" Collection.Options fields.
|
||||
type CollectionAuthOptions struct {
|
||||
ManageRule *string `form:"manageRule" json:"manageRule"`
|
||||
@@ -184,3 +203,17 @@ func (o CollectionAuthOptions) Validate() error {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// CollectionViewOptions defines the "view" Collection.Options fields.
|
||||
type CollectionViewOptions struct {
|
||||
Query string `form:"query" json:"query"`
|
||||
}
|
||||
|
||||
// Validate implements [validation.Validatable] interface.
|
||||
func (o CollectionViewOptions) Validate() error {
|
||||
return validation.ValidateStruct(&o,
|
||||
validation.Field(&o.Query, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
+104
-16
@@ -75,33 +75,33 @@ func TestCollectionMarshalJSON(t *testing.T) {
|
||||
{
|
||||
"no type",
|
||||
models.Collection{Name: "test"},
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
},
|
||||
{
|
||||
"unknown type + non empty options",
|
||||
models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}, Indexes: types.JsonArray[string]{"idx_test"}},
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"indexes":["idx_test"],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
},
|
||||
{
|
||||
"base type + non empty options",
|
||||
models.Collection{Name: "test", Type: models.CollectionTypeBase, ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
`{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"indexes":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
|
||||
},
|
||||
{
|
||||
"auth type + non empty options",
|
||||
models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
|
||||
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
|
||||
`{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := s.collection.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Errorf("(%s) Unexpected error %v", s.name, err)
|
||||
t.Errorf("[%s] Unexpected error %v", s.name, err)
|
||||
continue
|
||||
}
|
||||
if string(result) != s.expected {
|
||||
t.Errorf("(%s) Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
|
||||
t.Errorf("[%s] Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestCollectionBaseOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
if strEncoded := string(encoded); strEncoded != s.expected {
|
||||
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,52 @@ func TestCollectionAuthOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
if strEncoded := string(encoded); strEncoded != s.expected {
|
||||
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionViewOptions(t *testing.T) {
|
||||
options := types.JsonMap{"query": "select id from demo1", "minPasswordLength": 4}
|
||||
expectedSerialization := `{"query":"select id from demo1"}`
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collection models.Collection
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"no type",
|
||||
models.Collection{Options: options},
|
||||
expectedSerialization,
|
||||
},
|
||||
{
|
||||
"unknown type",
|
||||
models.Collection{Type: "anything", Options: options},
|
||||
expectedSerialization,
|
||||
},
|
||||
{
|
||||
"different type",
|
||||
models.Collection{Type: models.CollectionTypeBase, Options: options},
|
||||
expectedSerialization,
|
||||
},
|
||||
{
|
||||
"view type",
|
||||
models.Collection{Type: models.CollectionTypeView, Options: options},
|
||||
expectedSerialization,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.collection.ViewOptions()
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if strEncoded := string(encoded); strEncoded != s.expected {
|
||||
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +263,7 @@ func TestNormalizeOptions(t *testing.T) {
|
||||
|
||||
for _, s := range scenarios {
|
||||
if err := s.collection.NormalizeOptions(); err != nil {
|
||||
t.Errorf("(%s) Unexpected error %v", s.name, err)
|
||||
t.Errorf("[%s] Unexpected error %v", s.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -228,7 +273,7 @@ func TestNormalizeOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
if strEncoded := string(encoded); strEncoded != s.expected {
|
||||
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +331,7 @@ func TestSetOptions(t *testing.T) {
|
||||
|
||||
for _, s := range scenarios {
|
||||
if err := s.collection.SetOptions(s.options); err != nil {
|
||||
t.Errorf("(%s) Unexpected error %v", s.name, err)
|
||||
t.Errorf("[%s] Unexpected error %v", s.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -296,7 +341,7 @@ func TestSetOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
if strEncoded := string(encoded); strEncoded != s.expected {
|
||||
t.Errorf("(%s) Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,18 +423,61 @@ func TestCollectionAuthOptionsValidate(t *testing.T) {
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%s) Failed to parse errors %v", s.name, result)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(errs) != len(s.expectedErrors) {
|
||||
t.Errorf("(%s) Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
|
||||
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
|
||||
continue
|
||||
}
|
||||
|
||||
for key := range errs {
|
||||
if !list.ExistInSlice(key, s.expectedErrors) {
|
||||
t.Errorf("(%s) Unexpected error key %q in \n%v", s.name, key, errs)
|
||||
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionViewOptionsValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
options models.CollectionViewOptions
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
models.CollectionViewOptions{},
|
||||
[]string{"query"},
|
||||
},
|
||||
{
|
||||
"valid data",
|
||||
models.CollectionViewOptions{
|
||||
Query: "test123",
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.options.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(errs) != len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
|
||||
continue
|
||||
}
|
||||
|
||||
for key := range errs {
|
||||
if !list.ExistInSlice(key, s.expectedErrors) {
|
||||
t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -59,6 +59,9 @@ func nullStringMapValue(data dbx.NullStringMap, key string) any {
|
||||
|
||||
// NewRecordFromNullStringMap initializes a single new Record model
|
||||
// with data loaded from the provided NullStringMap.
|
||||
//
|
||||
// Note that this method is intended to load and Scan data from a database
|
||||
// result and calls PostScan() which marks the record as "not new".
|
||||
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
|
||||
resultMap := make(map[string]any, len(data))
|
||||
|
||||
@@ -89,6 +92,9 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
|
||||
|
||||
// NewRecordsFromNullStringMaps initializes a new Record model for
|
||||
// each row in the provided NullStringMap slice.
|
||||
//
|
||||
// Note that this method is intended to load and Scan data from a database
|
||||
// result and calls PostScan() for each record marking them as "not new".
|
||||
func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
|
||||
result := make([]*Record, len(rows))
|
||||
|
||||
@@ -469,8 +475,12 @@ func (m *Record) PublicExport() map[string]any {
|
||||
|
||||
// export base model fields
|
||||
result[schema.FieldNameId] = m.GetId()
|
||||
result[schema.FieldNameCreated] = m.GetCreated()
|
||||
result[schema.FieldNameUpdated] = m.GetUpdated()
|
||||
if created := m.GetCreated(); !m.Collection().IsView() || !created.IsZero() {
|
||||
result[schema.FieldNameCreated] = created
|
||||
}
|
||||
if updated := m.GetUpdated(); !m.Collection().IsView() || !updated.IsZero() {
|
||||
result[schema.FieldNameUpdated] = updated
|
||||
}
|
||||
|
||||
// add helper collection reference fields
|
||||
result[schema.FieldNameCollectionId] = m.collection.Id
|
||||
@@ -556,7 +566,7 @@ func (m *Record) ReplaceModifers(data map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// legacy file field modifiers (kept for backward compatability)
|
||||
// legacy file field modifiers (kept for backward compatibility)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
var oldNames []string
|
||||
@@ -620,16 +630,16 @@ func (m *Record) getNormalizeDataValueForDB(key string) any {
|
||||
switch ids := val.(type) {
|
||||
case []string:
|
||||
// encode string slice
|
||||
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
|
||||
return append(types.JsonArray[string]{}, ids...)
|
||||
case []int:
|
||||
// encode int slice
|
||||
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
|
||||
return append(types.JsonArray[int]{}, ids...)
|
||||
case []float64:
|
||||
// encode float64 slice
|
||||
return append(types.JsonArray{}, list.ToInterfaceSlice(ids)...)
|
||||
return append(types.JsonArray[float64]{}, ids...)
|
||||
case []any:
|
||||
// encode interface slice
|
||||
return append(types.JsonArray{}, ids...)
|
||||
return append(types.JsonArray[any]{}, ids...)
|
||||
default:
|
||||
// no changes
|
||||
return val
|
||||
@@ -854,7 +864,7 @@ func (m *Record) SetPassword(password string) error {
|
||||
}
|
||||
|
||||
// hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 13)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type RequestData struct {
|
||||
Method string `json:"method"`
|
||||
Query map[string]any `json:"query"`
|
||||
Data map[string]any `json:"data"`
|
||||
Headers map[string]any `json:"headers"`
|
||||
AuthRecord *Record `json:"authRecord"`
|
||||
Admin *Admin `json:"admin"`
|
||||
}
|
||||
|
||||
@@ -131,15 +131,19 @@ type SchemaField struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Required bool `form:"required" json:"required"`
|
||||
Unique bool `form:"unique" json:"unique"`
|
||||
Options any `form:"options" json:"options"`
|
||||
|
||||
// Deprecated: This field is no-op and will be removed in future versions.
|
||||
// Please use the collection.Indexes field to define a unique constraint.
|
||||
Unique bool `form:"unique" json:"unique"`
|
||||
|
||||
Options any `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// ColDefinition returns the field db column type definition as string.
|
||||
func (f *SchemaField) ColDefinition() string {
|
||||
switch f.Type {
|
||||
case FieldTypeNumber:
|
||||
return "REAL DEFAULT 0"
|
||||
return "NUMERIC DEFAULT 0"
|
||||
case FieldTypeBool:
|
||||
return "BOOLEAN DEFAULT FALSE"
|
||||
case FieldTypeJson:
|
||||
@@ -321,7 +325,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*SelectOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if !options.IsMultiple() {
|
||||
if len(val) > 0 {
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
@@ -333,7 +337,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*FileOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if !options.IsMultiple() {
|
||||
if len(val) > 0 {
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
@@ -345,7 +349,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*RelationOptions)
|
||||
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
|
||||
if !options.IsMultiple() {
|
||||
if len(ids) > 0 {
|
||||
return ids[len(ids)-1] // the last selected
|
||||
}
|
||||
@@ -386,8 +390,7 @@ func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, m
|
||||
}
|
||||
case FieldTypeFile:
|
||||
// note: file for now supports only the subtract modifier
|
||||
switch modifier {
|
||||
case FieldValueModifierSubtract:
|
||||
if modifier == FieldValueModifierSubtract {
|
||||
resolvedValue = list.SubtractSlice(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue),
|
||||
@@ -400,7 +403,12 @@ func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, m
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// FieldOptions interfaces that defines common methods that every field options struct has.
|
||||
// MultiValuer defines common interface methods that every multi-valued (eg. with MaxSelect) field option struct has.
|
||||
type MultiValuer interface {
|
||||
IsMultiple() bool
|
||||
}
|
||||
|
||||
// FieldOptions defines common interface methods that every field option struct has.
|
||||
type FieldOptions interface {
|
||||
Validate() error
|
||||
}
|
||||
@@ -565,6 +573,12 @@ func (o SelectOptions) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// IsMultiple implements MultiValuer interface and checks whether the
|
||||
// current field options support multiple values.
|
||||
func (o SelectOptions) IsMultiple() bool {
|
||||
return o.MaxSelect > 1
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type JsonOptions struct {
|
||||
@@ -576,6 +590,8 @@ func (o JsonOptions) Validate() error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ MultiValuer = (*FileOptions)(nil)
|
||||
|
||||
type FileOptions struct {
|
||||
MaxSelect int `form:"maxSelect" json:"maxSelect"`
|
||||
MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
|
||||
@@ -594,8 +610,16 @@ func (o FileOptions) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// IsMultiple implements MultiValuer interface and checks whether the
|
||||
// current field options support multiple values.
|
||||
func (o FileOptions) IsMultiple() bool {
|
||||
return o.MaxSelect > 1
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ MultiValuer = (*RelationOptions)(nil)
|
||||
|
||||
type RelationOptions struct {
|
||||
// CollectionId is the id of the related collection.
|
||||
CollectionId string `form:"collectionId" json:"collectionId"`
|
||||
@@ -604,6 +628,12 @@ type RelationOptions struct {
|
||||
// in case of delete of all linked relations.
|
||||
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
||||
|
||||
// MinSelect indicates the min number of allowed relation records
|
||||
// that could be linked to the main model.
|
||||
//
|
||||
// If nil no limits are applied.
|
||||
MinSelect *int `form:"minSelect" json:"minSelect"`
|
||||
|
||||
// MaxSelect indicates the max number of allowed relation records
|
||||
// that could be linked to the main model.
|
||||
//
|
||||
@@ -615,12 +645,24 @@ type RelationOptions struct {
|
||||
}
|
||||
|
||||
func (o RelationOptions) Validate() error {
|
||||
minVal := 0
|
||||
if o.MinSelect != nil {
|
||||
minVal = *o.MinSelect
|
||||
}
|
||||
|
||||
return validation.ValidateStruct(&o,
|
||||
validation.Field(&o.CollectionId, validation.Required),
|
||||
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(1)),
|
||||
validation.Field(&o.MinSelect, validation.NilOrNotEmpty, validation.Min(1)),
|
||||
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
|
||||
)
|
||||
}
|
||||
|
||||
// IsMultiple implements MultiValuer interface and checks whether the
|
||||
// current field options support multiple values.
|
||||
func (o RelationOptions) IsMultiple() bool {
|
||||
return o.MaxSelect == nil || *o.MaxSelect > 1
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestSchemaFieldColDefinition(t *testing.T) {
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber, Name: "test"},
|
||||
"REAL DEFAULT 0",
|
||||
"NUMERIC DEFAULT 0",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
|
||||
@@ -509,7 +509,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
false,
|
||||
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"maxSelect":null,"displayFields":null}}`,
|
||||
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"minSelect":null,"maxSelect":null,"displayFields":null}}`,
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeUser},
|
||||
@@ -1983,6 +1983,28 @@ func TestSelectOptionsValidate(t *testing.T) {
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
}
|
||||
|
||||
func TestSelectOptionsIsMultiple(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
maxSelect int
|
||||
expect bool
|
||||
}{
|
||||
{-1, false},
|
||||
{0, false},
|
||||
{1, false},
|
||||
{2, true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
opt := schema.SelectOptions{
|
||||
MaxSelect: s.maxSelect,
|
||||
}
|
||||
|
||||
if v := opt.IsMultiple(); v != s.expect {
|
||||
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonOptionsValidate(t *testing.T) {
|
||||
scenarios := []fieldOptionsScenario{
|
||||
{
|
||||
@@ -2053,6 +2075,28 @@ func TestFileOptionsValidate(t *testing.T) {
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
}
|
||||
|
||||
func TestFileOptionsIsMultiple(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
maxSelect int
|
||||
expect bool
|
||||
}{
|
||||
{-1, false},
|
||||
{0, false},
|
||||
{1, false},
|
||||
{2, true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
opt := schema.FileOptions{
|
||||
MaxSelect: s.maxSelect,
|
||||
}
|
||||
|
||||
if v := opt.IsMultiple(); v != s.expect {
|
||||
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelationOptionsValidate(t *testing.T) {
|
||||
scenarios := []fieldOptionsScenario{
|
||||
{
|
||||
@@ -2088,3 +2132,26 @@ func TestRelationOptionsValidate(t *testing.T) {
|
||||
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
}
|
||||
|
||||
func TestRelationOptionsIsMultiple(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
maxSelect *int
|
||||
expect bool
|
||||
}{
|
||||
{nil, true},
|
||||
{types.Pointer(-1), false},
|
||||
{types.Pointer(0), false},
|
||||
{types.Pointer(1), false},
|
||||
{types.Pointer(2), true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
opt := schema.RelationOptions{
|
||||
MaxSelect: s.maxSelect,
|
||||
}
|
||||
|
||||
if v := opt.IsMultiple(); v != s.expect {
|
||||
t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-28
@@ -15,6 +15,10 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// SecretMask is the default settings secrets replacement value
|
||||
// (see Settings.RedactClone()).
|
||||
const SecretMask string = "******"
|
||||
|
||||
// Settings defines common app configuration options.
|
||||
type Settings struct {
|
||||
mux sync.RWMutex
|
||||
@@ -47,8 +51,11 @@ type Settings struct {
|
||||
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
|
||||
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
|
||||
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
|
||||
AuthentikAuth AuthProviderConfig `form:"authentikAuth" json:"authentikAuth"`
|
||||
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
|
||||
OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
|
||||
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
|
||||
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
|
||||
AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
|
||||
}
|
||||
|
||||
// New creates and returns a new default Settings instance.
|
||||
@@ -138,10 +145,19 @@ func New() *Settings {
|
||||
LivechatAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
AuthentikAuth: AuthProviderConfig{
|
||||
GiteaAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
GiteaAuth: AuthProviderConfig{
|
||||
OIDCAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
OIDC2Auth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
OIDC3Auth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
AppleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
@@ -176,8 +192,11 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.StravaAuth),
|
||||
validation.Field(&s.GiteeAuth),
|
||||
validation.Field(&s.LivechatAuth),
|
||||
validation.Field(&s.AuthentikAuth),
|
||||
validation.Field(&s.GiteaAuth),
|
||||
validation.Field(&s.OIDCAuth),
|
||||
validation.Field(&s.OIDC2Auth),
|
||||
validation.Field(&s.OIDC3Auth),
|
||||
validation.Field(&s.AppleAuth),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -211,8 +230,6 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mask := "******"
|
||||
|
||||
sensitiveFields := []*string{
|
||||
&clone.Smtp.Password,
|
||||
&clone.S3.Secret,
|
||||
@@ -235,14 +252,17 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.StravaAuth.ClientSecret,
|
||||
&clone.GiteeAuth.ClientSecret,
|
||||
&clone.LivechatAuth.ClientSecret,
|
||||
&clone.AuthentikAuth.ClientSecret,
|
||||
&clone.GiteaAuth.ClientSecret,
|
||||
&clone.OIDCAuth.ClientSecret,
|
||||
&clone.OIDC2Auth.ClientSecret,
|
||||
&clone.OIDC3Auth.ClientSecret,
|
||||
&clone.AppleAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
for _, v := range sensitiveFields {
|
||||
if v != nil && *v != "" {
|
||||
*v = mask
|
||||
*v = SecretMask
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,21 +276,24 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
return map[string]AuthProviderConfig{
|
||||
auth.NameGoogle: s.GoogleAuth,
|
||||
auth.NameFacebook: s.FacebookAuth,
|
||||
auth.NameGithub: s.GithubAuth,
|
||||
auth.NameGitlab: s.GitlabAuth,
|
||||
auth.NameDiscord: s.DiscordAuth,
|
||||
auth.NameTwitter: s.TwitterAuth,
|
||||
auth.NameMicrosoft: s.MicrosoftAuth,
|
||||
auth.NameSpotify: s.SpotifyAuth,
|
||||
auth.NameKakao: s.KakaoAuth,
|
||||
auth.NameTwitch: s.TwitchAuth,
|
||||
auth.NameStrava: s.StravaAuth,
|
||||
auth.NameGitee: s.GiteeAuth,
|
||||
auth.NameLivechat: s.LivechatAuth,
|
||||
auth.NameAuthentik: s.AuthentikAuth,
|
||||
auth.NameGitea: s.GiteaAuth,
|
||||
auth.NameGoogle: s.GoogleAuth,
|
||||
auth.NameFacebook: s.FacebookAuth,
|
||||
auth.NameGithub: s.GithubAuth,
|
||||
auth.NameGitlab: s.GitlabAuth,
|
||||
auth.NameDiscord: s.DiscordAuth,
|
||||
auth.NameTwitter: s.TwitterAuth,
|
||||
auth.NameMicrosoft: s.MicrosoftAuth,
|
||||
auth.NameSpotify: s.SpotifyAuth,
|
||||
auth.NameKakao: s.KakaoAuth,
|
||||
auth.NameTwitch: s.TwitchAuth,
|
||||
auth.NameStrava: s.StravaAuth,
|
||||
auth.NameGitee: s.GiteeAuth,
|
||||
auth.NameLivechat: s.LivechatAuth,
|
||||
auth.NameGitea: s.GiteaAuth,
|
||||
auth.NameOIDC: s.OIDCAuth,
|
||||
auth.NameOIDC + "2": s.OIDC2Auth,
|
||||
auth.NameOIDC + "3": s.OIDC3Auth,
|
||||
auth.NameApple: s.AppleAuth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,11 +504,11 @@ func (c LogsConfig) Validate() error {
|
||||
|
||||
type AuthProviderConfig struct {
|
||||
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"`
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
ClientSecret string `form:"clientSecret" json:"clientSecret"`
|
||||
AuthUrl string `form:"authUrl" json:"authUrl"`
|
||||
TokenUrl string `form:"tokenUrl" json:"tokenUrl"`
|
||||
UserApiUrl string `form:"userApiUrl" json:"userApiUrl"`
|
||||
}
|
||||
|
||||
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package settings_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -54,10 +55,16 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.GiteeAuth.ClientId = ""
|
||||
s.LivechatAuth.Enabled = true
|
||||
s.LivechatAuth.ClientId = ""
|
||||
s.AuthentikAuth.Enabled = true
|
||||
s.AuthentikAuth.ClientId = ""
|
||||
s.GiteaAuth.Enabled = true
|
||||
s.GiteaAuth.ClientId = ""
|
||||
s.OIDCAuth.Enabled = true
|
||||
s.OIDCAuth.ClientId = ""
|
||||
s.OIDC2Auth.Enabled = true
|
||||
s.OIDC2Auth.ClientId = ""
|
||||
s.OIDC3Auth.Enabled = true
|
||||
s.OIDC3Auth.ClientId = ""
|
||||
s.AppleAuth.Enabled = true
|
||||
s.AppleAuth.ClientId = ""
|
||||
|
||||
// check if Validate() is triggering the members validate methods.
|
||||
err := s.Validate()
|
||||
@@ -89,8 +96,11 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@@ -145,10 +155,16 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.GiteeAuth.ClientId = "gitee_test"
|
||||
s2.LivechatAuth.Enabled = true
|
||||
s2.LivechatAuth.ClientId = "livechat_test"
|
||||
s2.AuthentikAuth.Enabled = true
|
||||
s2.AuthentikAuth.ClientId = "authentik_test"
|
||||
s2.GiteaAuth.Enabled = true
|
||||
s2.GiteaAuth.ClientId = "gitea_test"
|
||||
s2.OIDCAuth.Enabled = true
|
||||
s2.OIDCAuth.ClientId = "oidc_test"
|
||||
s2.OIDC2Auth.Enabled = true
|
||||
s2.OIDC2Auth.ClientId = "oidc2_test"
|
||||
s2.OIDC3Auth.Enabled = true
|
||||
s2.OIDC3Auth.ClientId = "oidc3_test"
|
||||
s2.AppleAuth.Enabled = true
|
||||
s2.AppleAuth.ClientId = "apple_test"
|
||||
|
||||
if err := s1.Merge(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -199,47 +215,70 @@ func TestSettingsClone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsRedactClone(t *testing.T) {
|
||||
testSecret := "test_secret"
|
||||
|
||||
s1 := settings.New()
|
||||
s1.Meta.AppName = "test123" // control field
|
||||
s1.Smtp.Password = "test123"
|
||||
s1.Smtp.Tls = true
|
||||
s1.S3.Secret = "test123"
|
||||
s1.AdminAuthToken.Secret = "test123"
|
||||
s1.AdminPasswordResetToken.Secret = "test123"
|
||||
s1.RecordAuthToken.Secret = "test123"
|
||||
s1.RecordPasswordResetToken.Secret = "test123"
|
||||
s1.RecordEmailChangeToken.Secret = "test123"
|
||||
s1.RecordVerificationToken.Secret = "test123"
|
||||
s1.GoogleAuth.ClientSecret = "test123"
|
||||
s1.FacebookAuth.ClientSecret = "test123"
|
||||
s1.GithubAuth.ClientSecret = "test123"
|
||||
s1.GitlabAuth.ClientSecret = "test123"
|
||||
s1.DiscordAuth.ClientSecret = "test123"
|
||||
s1.TwitterAuth.ClientSecret = "test123"
|
||||
s1.MicrosoftAuth.ClientSecret = "test123"
|
||||
s1.SpotifyAuth.ClientSecret = "test123"
|
||||
s1.KakaoAuth.ClientSecret = "test123"
|
||||
s1.TwitchAuth.ClientSecret = "test123"
|
||||
s1.StravaAuth.ClientSecret = "test123"
|
||||
s1.GiteeAuth.ClientSecret = "test123"
|
||||
s1.LivechatAuth.ClientSecret = "test123"
|
||||
s1.AuthentikAuth.ClientSecret = "test123"
|
||||
s1.GiteaAuth.ClientSecret = "test123"
|
||||
|
||||
// control fields
|
||||
s1.Meta.AppName = "test123"
|
||||
|
||||
// secrets
|
||||
s1.Smtp.Password = testSecret
|
||||
s1.S3.Secret = testSecret
|
||||
s1.AdminAuthToken.Secret = testSecret
|
||||
s1.AdminPasswordResetToken.Secret = testSecret
|
||||
s1.RecordAuthToken.Secret = testSecret
|
||||
s1.RecordPasswordResetToken.Secret = testSecret
|
||||
s1.RecordEmailChangeToken.Secret = testSecret
|
||||
s1.RecordVerificationToken.Secret = testSecret
|
||||
s1.GoogleAuth.ClientSecret = testSecret
|
||||
s1.FacebookAuth.ClientSecret = testSecret
|
||||
s1.GithubAuth.ClientSecret = testSecret
|
||||
s1.GitlabAuth.ClientSecret = testSecret
|
||||
s1.DiscordAuth.ClientSecret = testSecret
|
||||
s1.TwitterAuth.ClientSecret = testSecret
|
||||
s1.MicrosoftAuth.ClientSecret = testSecret
|
||||
s1.SpotifyAuth.ClientSecret = testSecret
|
||||
s1.KakaoAuth.ClientSecret = testSecret
|
||||
s1.TwitchAuth.ClientSecret = testSecret
|
||||
s1.StravaAuth.ClientSecret = testSecret
|
||||
s1.GiteeAuth.ClientSecret = testSecret
|
||||
s1.LivechatAuth.ClientSecret = testSecret
|
||||
s1.GiteaAuth.ClientSecret = testSecret
|
||||
s1.OIDCAuth.ClientSecret = testSecret
|
||||
s1.OIDC2Auth.ClientSecret = testSecret
|
||||
s1.OIDC3Auth.ClientSecret = testSecret
|
||||
s1.AppleAuth.ClientSecret = testSecret
|
||||
|
||||
s1Bytes, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s2, err := s1.RedactClone()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(s2)
|
||||
s2Bytes, err := json.Marshal(s2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"},"livechatAuth":{"enabled":false,"clientSecret":"******"},"authentikAuth":{"enabled":false,"clientSecret":"******"},"giteaAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
if bytes.Equal(s1Bytes, s2Bytes) {
|
||||
t.Fatalf("Expected the 2 settings to differ, got \n%s", s2Bytes)
|
||||
}
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
|
||||
if strings.Contains(string(s2Bytes), testSecret) {
|
||||
t.Fatalf("Expected %q secret to be replaced with mask, got \n%s", testSecret, s2Bytes)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(s2Bytes), settings.SecretMask) {
|
||||
t.Fatalf("Expected the secrets to be replaced with the secret mask, got \n%s", s2Bytes)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(s2Bytes), `"appName":"test123"`) {
|
||||
t.Fatalf("Missing control field in \n%s", s2Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +289,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.FacebookAuth.ClientId = "facebook_test"
|
||||
s.GithubAuth.ClientId = "github_test"
|
||||
s.GitlabAuth.ClientId = "gitlab_test"
|
||||
s.GitlabAuth.Enabled = true
|
||||
s.GitlabAuth.Enabled = true // control
|
||||
s.DiscordAuth.ClientId = "discord_test"
|
||||
s.TwitterAuth.ClientId = "twitter_test"
|
||||
s.MicrosoftAuth.ClientId = "microsoft_test"
|
||||
@@ -260,8 +299,11 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.StravaAuth.ClientId = "strava_test"
|
||||
s.GiteeAuth.ClientId = "gitee_test"
|
||||
s.LivechatAuth.ClientId = "livechat_test"
|
||||
s.AuthentikAuth.ClientId = "authentik_test"
|
||||
s.GiteaAuth.ClientId = "gitea_test"
|
||||
s.OIDCAuth.ClientId = "oidc_test"
|
||||
s.OIDC2Auth.ClientId = "oidc2_test"
|
||||
s.OIDC3Auth.ClientId = "oidc3_test"
|
||||
s.AppleAuth.ClientId = "apple_test"
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
@@ -272,21 +314,24 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
encodedStr := string(encoded)
|
||||
|
||||
expectedParts := []string{
|
||||
`"discord":{"enabled":false,"clientId":"discord_test"}`,
|
||||
`"facebook":{"enabled":false,"clientId":"facebook_test"}`,
|
||||
`"github":{"enabled":false,"clientId":"github_test"}`,
|
||||
`"gitlab":{"enabled":true,"clientId":"gitlab_test"}`,
|
||||
`"google":{"enabled":false,"clientId":"google_test"}`,
|
||||
`"microsoft":{"enabled":false,"clientId":"microsoft_test"}`,
|
||||
`"spotify":{"enabled":false,"clientId":"spotify_test"}`,
|
||||
`"twitter":{"enabled":false,"clientId":"twitter_test"}`,
|
||||
`"kakao":{"enabled":false,"clientId":"kakao_test"}`,
|
||||
`"twitch":{"enabled":false,"clientId":"twitch_test"}`,
|
||||
`"strava":{"enabled":false,"clientId":"strava_test"}`,
|
||||
`"gitee":{"enabled":false,"clientId":"gitee_test"}`,
|
||||
`"livechat":{"enabled":false,"clientId":"livechat_test"}`,
|
||||
`"authentik":{"enabled":false,"clientId":"authentik_test"}`,
|
||||
`"gitea":{"enabled":false,"clientId":"gitea_test"}`,
|
||||
`"discord":{"enabled":false,"clientId":"discord_test"`,
|
||||
`"facebook":{"enabled":false,"clientId":"facebook_test"`,
|
||||
`"github":{"enabled":false,"clientId":"github_test"`,
|
||||
`"gitlab":{"enabled":true,"clientId":"gitlab_test"`,
|
||||
`"google":{"enabled":false,"clientId":"google_test"`,
|
||||
`"microsoft":{"enabled":false,"clientId":"microsoft_test"`,
|
||||
`"spotify":{"enabled":false,"clientId":"spotify_test"`,
|
||||
`"twitter":{"enabled":false,"clientId":"twitter_test"`,
|
||||
`"kakao":{"enabled":false,"clientId":"kakao_test"`,
|
||||
`"twitch":{"enabled":false,"clientId":"twitch_test"`,
|
||||
`"strava":{"enabled":false,"clientId":"strava_test"`,
|
||||
`"gitee":{"enabled":false,"clientId":"gitee_test"`,
|
||||
`"livechat":{"enabled":false,"clientId":"livechat_test"`,
|
||||
`"gitea":{"enabled":false,"clientId":"gitea_test"`,
|
||||
`"oidc":{"enabled":false,"clientId":"oidc_test"`,
|
||||
`"oidc2":{"enabled":false,"clientId":"oidc2_test"`,
|
||||
`"oidc3":{"enabled":false,"clientId":"oidc3_test"`,
|
||||
`"apple":{"enabled":false,"clientId":"apple_test"`,
|
||||
}
|
||||
for _, p := range expectedParts {
|
||||
if !strings.Contains(encodedStr, p) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
type TableInfoRow struct {
|
||||
// the `db:"pk"` tag has special semantic so we cannot rename
|
||||
// the original field without specifying a custom mapper
|
||||
PK int
|
||||
|
||||
Index int `db:"cid"`
|
||||
Name string `db:"name"`
|
||||
Type string `db:"type"`
|
||||
NotNull bool `db:"notnull"`
|
||||
DefaultValue types.JsonRaw `db:"dflt_value"`
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/process"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -28,7 +29,6 @@ type migrations struct {
|
||||
options *MigrationsOptions
|
||||
}
|
||||
|
||||
//
|
||||
// MustRegisterMigrations registers the migrations loader plugin to
|
||||
// the provided app instance and panics if it fails.
|
||||
//
|
||||
@@ -70,6 +70,7 @@ func RegisterMigrations(app core.App, options *MigrationsOptions) error {
|
||||
vm := NewBaseVM()
|
||||
registry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
|
||||
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
|
||||
m.AppMigrations.Register(up, down, file)
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
//
|
||||
// 1. JS Migrations loader:
|
||||
//
|
||||
// jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
// Dir: "custom_js_migrations_dir_path", // default to "pb_data/../pb_migrations"
|
||||
// })
|
||||
// jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
// Dir: "custom_js_migrations_dir_path", // default to "pb_data/../pb_migrations"
|
||||
// })
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
// TemplateLang: migratecmd.TemplateLangJS, // default to migratecmd.TemplateLangGo
|
||||
// Automigrate: true,
|
||||
// Dir: "migrations_dir_path", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go)
|
||||
// })
|
||||
// migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
// TemplateLang: migratecmd.TemplateLangJS, // default to migratecmd.TemplateLangGo
|
||||
// Automigrate: true,
|
||||
// Dir: "migrations_dir_path", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go)
|
||||
// })
|
||||
//
|
||||
// Note: To allow running JS migrations you'll need to enable first
|
||||
// [jsvm.MustRegisterMigrations].
|
||||
// Note: To allow running JS migrations you'll need to enable first
|
||||
// [jsvm.MustRegisterMigrations].
|
||||
package migratecmd
|
||||
|
||||
import (
|
||||
@@ -123,6 +123,7 @@ func (p *plugin) createCommand() *cobra.Command {
|
||||
- down [number] - reverts the last [number] applied migrations
|
||||
- create name - creates new blank migration template file
|
||||
- collections - creates new migration file with snapshot of the local collections configuration
|
||||
- history-sync - ensures that the _migrations history table doesn't have references to deleted migration files
|
||||
`
|
||||
|
||||
command := &cobra.Command{
|
||||
|
||||
@@ -31,6 +31,9 @@ migrate((db) => {
|
||||
"type": "auth",
|
||||
"system": true,
|
||||
"schema": [],
|
||||
"indexes": [
|
||||
"create index test on new_name (id)"
|
||||
],
|
||||
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
|
||||
"viewRule": "id = \"1\"",
|
||||
"createRule": null,
|
||||
@@ -81,6 +84,9 @@ func init() {
|
||||
"type": "auth",
|
||||
"system": true,
|
||||
"schema": [],
|
||||
"indexes": [
|
||||
"create index test on new_name (id)"
|
||||
],
|
||||
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
|
||||
"viewRule": "id = \"1\"",
|
||||
"createRule": null,
|
||||
@@ -143,6 +149,7 @@ func init() {
|
||||
collection.Updated = collection.Created
|
||||
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
|
||||
collection.ViewRule = types.Pointer(`id = "1"`)
|
||||
collection.Indexes = types.JsonArray[string]{"create index test on new_name (id)"}
|
||||
collection.SetOptions(models.CollectionAuthOptions{
|
||||
ManageRule: types.Pointer("created > 0"),
|
||||
MinPasswordLength: 20,
|
||||
@@ -201,6 +208,9 @@ migrate((db) => {
|
||||
"type": "auth",
|
||||
"system": false,
|
||||
"schema": [],
|
||||
"indexes": [
|
||||
"create index test on test456 (id)"
|
||||
],
|
||||
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
|
||||
"viewRule": "id = \"1\"",
|
||||
"createRule": null,
|
||||
@@ -255,6 +265,9 @@ func init() {
|
||||
"type": "auth",
|
||||
"system": false,
|
||||
"schema": [],
|
||||
"indexes": [
|
||||
"create index test on test456 (id)"
|
||||
],
|
||||
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
|
||||
"viewRule": "id = \"1\"",
|
||||
"createRule": null,
|
||||
@@ -305,6 +318,7 @@ func init() {
|
||||
collection.Updated = collection.Created
|
||||
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
|
||||
collection.ViewRule = types.Pointer(`id = "1"`)
|
||||
collection.Indexes = types.JsonArray[string]{"create index test on test456 (id)"}
|
||||
collection.SetOptions(models.CollectionAuthOptions{
|
||||
ManageRule: types.Pointer("created > 0"),
|
||||
MinPasswordLength: 20,
|
||||
@@ -367,6 +381,9 @@ migrate((db) => {
|
||||
collection.listRule = null
|
||||
collection.deleteRule = "updated > 0 && @request.auth.id != ''"
|
||||
collection.options = {}
|
||||
collection.indexes = [
|
||||
"create index test1 on test456_update (f1_name)"
|
||||
]
|
||||
|
||||
// remove
|
||||
collection.schema.removeField("f3_id")
|
||||
@@ -419,6 +436,9 @@ migrate((db) => {
|
||||
"onlyEmailDomains": null,
|
||||
"requireEmail": false
|
||||
}
|
||||
collection.indexes = [
|
||||
"create index test1 on test456 (f1_name)"
|
||||
]
|
||||
|
||||
// add
|
||||
collection.schema.addField(new SchemaField({
|
||||
@@ -488,6 +508,10 @@ func init() {
|
||||
json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options)
|
||||
collection.SetOptions(options)
|
||||
|
||||
json.Unmarshal([]byte(` + "`" + `[
|
||||
"create index test1 on test456_update (f1_name)"
|
||||
]` + "`" + `), &collection.Indexes)
|
||||
|
||||
// remove
|
||||
collection.Schema.RemoveField("f3_id")
|
||||
|
||||
@@ -554,6 +578,10 @@ func init() {
|
||||
}` + "`" + `), &options)
|
||||
collection.SetOptions(options)
|
||||
|
||||
json.Unmarshal([]byte(` + "`" + `[
|
||||
"create index test1 on test456 (f1_name)"
|
||||
]` + "`" + `), &collection.Indexes)
|
||||
|
||||
// add
|
||||
del_f3_name := &schema.SchemaField{}
|
||||
json.Unmarshal([]byte(` + "`" + `{
|
||||
@@ -614,6 +642,7 @@ func init() {
|
||||
collection.Updated = collection.Created
|
||||
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0")
|
||||
collection.ViewRule = types.Pointer(`id = "1"`)
|
||||
collection.Indexes = types.JsonArray[string]{"create index test1 on test456 (f1_name)"}
|
||||
collection.SetOptions(models.CollectionAuthOptions{
|
||||
ManageRule: types.Pointer("created > 0"),
|
||||
MinPasswordLength: 20,
|
||||
@@ -652,6 +681,9 @@ func init() {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`)
|
||||
collection.ListRule = nil
|
||||
collection.Indexes = types.JsonArray[string]{
|
||||
"create index test1 on test456_update (f1_name)",
|
||||
}
|
||||
collection.NormalizeOptions()
|
||||
collection.Schema.RemoveField("f3_id")
|
||||
collection.Schema.AddField(&schema.SchemaField{
|
||||
|
||||
@@ -196,6 +196,20 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection)
|
||||
downParts = append(downParts, fmt.Sprintf("%s.options = %s", varName, rawOldOptions))
|
||||
}
|
||||
|
||||
// Indexes
|
||||
rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, " ", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, " ", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !bytes.Equal(rawNewIndexes, rawOldIndexes) {
|
||||
upParts = append(upParts, fmt.Sprintf("%s.indexes = %s", varName, rawNewIndexes))
|
||||
downParts = append(downParts, fmt.Sprintf("%s.indexes = %s", varName, rawOldIndexes))
|
||||
}
|
||||
|
||||
// ensure new line between regular and collection fields
|
||||
if len(upParts) > 0 {
|
||||
upParts[len(upParts)-1] += "\n"
|
||||
@@ -571,6 +585,21 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection)
|
||||
downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
|
||||
}
|
||||
|
||||
// Indexes
|
||||
rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, "\t\t", "\t")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, "\t\t", "\t")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !bytes.Equal(rawNewIndexes, rawOldIndexes) {
|
||||
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawNewIndexes)), varName))
|
||||
// ---
|
||||
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawOldIndexes)), varName))
|
||||
}
|
||||
|
||||
// Schema
|
||||
// ---------------------------------------------------------------
|
||||
// deleted fields
|
||||
|
||||
@@ -512,44 +512,65 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
cleanFieldName := inflector.Columnify(field.Name)
|
||||
newCollectionName := relCollection.Name
|
||||
prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
|
||||
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
|
||||
newCollectionName := relCollection.Name
|
||||
|
||||
if !options.IsMultiple() {
|
||||
r.resolver.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
|
||||
)
|
||||
} else {
|
||||
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
|
||||
r.resolver.registerJoin(jsonEach(prefixedFieldName), jeAlias, nil)
|
||||
r.resolver.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
|
||||
)
|
||||
}
|
||||
|
||||
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
|
||||
jePair := r.activeTableAlias + "." + cleanFieldName
|
||||
r.resolver.registerJoin(jsonEach(jePair), jeAlias, nil)
|
||||
r.resolver.registerJoin(
|
||||
inflector.Columnify(newCollectionName),
|
||||
newTableAlias,
|
||||
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
|
||||
)
|
||||
r.activeCollectionName = newCollectionName
|
||||
r.activeTableAlias = newTableAlias
|
||||
// ---
|
||||
|
||||
// join the relation to the multi-match subquery
|
||||
// ---
|
||||
if options.MaxSelect == nil || *options.MaxSelect != 1 {
|
||||
if options.IsMultiple() {
|
||||
r.withMultiMatch = true
|
||||
}
|
||||
|
||||
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
|
||||
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
r.multiMatchActiveTableAlias = newTableAlias2
|
||||
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
|
||||
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: jsonEach(jePair2),
|
||||
tableAlias: jeAlias2,
|
||||
},
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
|
||||
},
|
||||
)
|
||||
if !options.IsMultiple() {
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
|
||||
r.multiMatch.joins = append(
|
||||
r.multiMatch.joins,
|
||||
&join{
|
||||
tableName: jsonEach(prefixedFieldName2),
|
||||
tableAlias: jeAlias2,
|
||||
},
|
||||
&join{
|
||||
tableName: inflector.Columnify(newCollectionName),
|
||||
tableAlias: newTableAlias2,
|
||||
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
r.multiMatchActiveTableAlias = newTableAlias2
|
||||
// ---
|
||||
}
|
||||
|
||||
|
||||
@@ -42,15 +42,17 @@ var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
// RecordFieldResolver defines a custom search resolver struct for
|
||||
// managing Record model search fields.
|
||||
//
|
||||
// Usually used together with `search.Provider`. Example:
|
||||
// resolver := resolvers.NewRecordFieldResolver(
|
||||
// app.Dao(),
|
||||
// myCollection,
|
||||
// &models.RequestData{...},
|
||||
// true,
|
||||
// )
|
||||
// provider := search.NewProvider(resolver)
|
||||
// ...
|
||||
// Usually used together with `search.Provider`.
|
||||
// Example:
|
||||
//
|
||||
// resolver := resolvers.NewRecordFieldResolver(
|
||||
// app.Dao(),
|
||||
// myCollection,
|
||||
// &models.RequestData{...},
|
||||
// true,
|
||||
// )
|
||||
// provider := search.NewProvider(resolver)
|
||||
// ...
|
||||
type RecordFieldResolver struct {
|
||||
dao *daos.Dao
|
||||
baseCollection *models.Collection
|
||||
@@ -82,6 +84,7 @@ func NewRecordFieldResolver(
|
||||
`^\@request\.auth\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.data\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.query\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.headers\.\w+$`,
|
||||
`^\@collection\.\w+\.[\w\.\:]*\w+$`,
|
||||
},
|
||||
}
|
||||
@@ -90,6 +93,7 @@ func NewRecordFieldResolver(
|
||||
if r.requestData != nil {
|
||||
r.staticRequestData["method"] = r.requestData.Method
|
||||
r.staticRequestData["query"] = r.requestData.Query
|
||||
r.staticRequestData["headers"] = r.requestData.Headers
|
||||
r.staticRequestData["data"] = r.requestData.Data
|
||||
r.staticRequestData["auth"] = nil
|
||||
if r.requestData.AuthRecord != nil {
|
||||
@@ -125,17 +129,18 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
//
|
||||
// Example of some resolvable fieldName formats:
|
||||
//
|
||||
// id
|
||||
// someSelect.each
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.query.filter
|
||||
// @request.auth.someRelation.name
|
||||
// @request.data.someRelation.name
|
||||
// @request.data.someField
|
||||
// @request.data.someSelect:each
|
||||
// @request.data.someField:isset
|
||||
// @collection.product.name
|
||||
// id
|
||||
// someSelect.each
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.query.filter
|
||||
// @request.headers.x_token
|
||||
// @request.auth.someRelation.name
|
||||
// @request.data.someRelation.name
|
||||
// @request.data.someField
|
||||
// @request.data.someSelect:each
|
||||
// @request.data.someField:isset
|
||||
// @collection.product.name
|
||||
func (r *RecordFieldResolver) Resolve(fieldName string) (*search.ResolverResult, error) {
|
||||
return parseAndRun(fieldName, r)
|
||||
}
|
||||
@@ -247,10 +252,8 @@ func extractNestedMapVal(m map[string]any, keys ...string) (any, error) {
|
||||
return nil, fmt.Errorf("at least one key should be provided")
|
||||
}
|
||||
|
||||
var result any
|
||||
var ok bool
|
||||
|
||||
if result, ok = m[keys[0]]; !ok {
|
||||
result, ok := m[keys[0]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid key path - missing key %q", keys[0])
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
+1
@@ -0,0 +1 @@
|
||||
{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":{"original-filename":"300_UhLKX91HVb.png"},"md5":"zZhZjzVvCvpcxtMAJie3GQ=="}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
+1
@@ -0,0 +1 @@
|
||||
{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null,"md5":"R7TqfvF8HP3C4+FO2eZ9tg=="}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25.536" y="13.4861" width="1.71467" height="16.7338" transform="rotate(45.9772 25.536 13.4861)" fill="white"/>
|
||||
<path d="M26 14H36.8C37.4628 14 38 14.5373 38 15.2V36.8C38 37.4628 37.4628 38 36.8 38H15.2C14.5373 38 14 37.4628 14 36.8V26" fill="white"/>
|
||||
<path d="M26 14H36.8C37.4628 14 38 14.5373 38 15.2V36.8C38 37.4628 37.4628 38 36.8 38H15.2C14.5373 38 14 37.4628 14 36.8V26" stroke="#16161a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26 14V3.2C26 2.53726 25.4628 2 24.8 2H3.2C2.53726 2 2 2.53726 2 3.2V24.8C2 25.4628 2.53726 26 3.2 26H14" fill="white"/>
|
||||
<path d="M26 14V3.2C26 2.53726 25.4628 2 24.8 2H3.2C2.53726 2 2 2.53726 2 3.2V24.8C2 25.4628 2.53726 26 3.2 26H14" stroke="#16161a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 20C9.44772 20 9 19.5523 9 19V8C9 7.44772 9.44772 7 10 7H13.7531C14.4801 7 15.1591 7.07311 15.7901 7.21932C16.4348 7.35225 16.9904 7.58487 17.4568 7.91718C17.9369 8.2362 18.3141 8.6682 18.5885 9.21319C18.8628 9.74489 19 10.4029 19 11.1871C19 11.9448 18.856 12.6028 18.5679 13.161C18.2936 13.7193 17.9163 14.1779 17.4362 14.5368C16.9561 14.8957 16.4005 15.1616 15.7695 15.3344C15.1385 15.5072 14.4664 15.5936 13.7531 15.5936H13.0247C12.4724 15.5936 12.0247 16.0413 12.0247 16.5936V19C12.0247 19.5523 11.577 20 11.0247 20H10ZM12.0247 12.2607C12.0247 12.813 12.4724 13.2607 13.0247 13.2607H13.5679C15.214 13.2607 16.037 12.5695 16.037 11.1871C16.037 10.5092 15.8244 10.0307 15.3992 9.75153C14.9877 9.47239 14.3772 9.33282 13.5679 9.33282H13.0247C12.4724 9.33282 12.0247 9.78054 12.0247 10.3328V12.2607Z" fill="#16161a"/>
|
||||
<path d="M22 33C21.4477 33 21 32.5523 21 32V21C21 20.4477 21.4477 20 22 20H25.4877C26.1844 20 26.8265 20.0532 27.4139 20.1595C28.015 20.2526 28.5342 20.4254 28.9713 20.6779C29.4085 20.9305 29.75 21.2628 29.9959 21.6748C30.2555 22.0869 30.3852 22.6053 30.3852 23.2301C30.3852 23.5225 30.3374 23.8149 30.2418 24.1074C30.1598 24.3998 30.0232 24.6723 29.832 24.9248C29.6407 25.1774 29.4016 25.4034 29.1148 25.6028C28.837 25.7958 28.5081 25.939 28.1279 26.0323C28.1058 26.0378 28.0902 26.0575 28.0902 26.0802V26.0802C28.0902 26.1039 28.1073 26.1242 28.1306 26.1286C29.0669 26.3034 29.7774 26.6332 30.2623 27.1181C30.7541 27.6099 31 28.2945 31 29.1718C31 29.8364 30.8702 30.408 30.6107 30.8865C30.3511 31.365 29.9891 31.7638 29.5246 32.0828C29.0601 32.3885 28.5137 32.6212 27.8852 32.7807C27.2705 32.9269 26.6011 33 25.8771 33H22ZM24.0123 24.2239C24.0123 24.7762 24.46 25.2239 25.0123 25.2239H25.3443C26.082 25.2239 26.6148 25.0844 26.9426 24.8052C27.2705 24.5261 27.4344 24.1339 27.4344 23.6288C27.4344 23.1503 27.2637 22.8113 26.9221 22.612C26.5943 22.3993 26.0751 22.2929 25.3648 22.2929H25.0123C24.46 22.2929 24.0123 22.7407 24.0123 23.2929V24.2239ZM24.0123 29.7071C24.0123 30.2593 24.46 30.7071 25.0123 30.7071H25.6311C27.2432 30.7071 28.0492 30.1222 28.0492 28.9525C28.0492 28.3809 27.8511 27.9688 27.4549 27.7163C27.0724 27.4637 26.4645 27.3374 25.6311 27.3374H25.0123C24.46 27.3374 24.0123 27.7851 24.0123 28.3374V29.7071Z" fill="#16161a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
+1
@@ -0,0 +1 @@
|
||||
{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/svg+xml","user.metadata":{"original-filename":"logo_vcfJJG5TAh.svg"},"md5":"9/B7afas4c3O6vbFcbpOug=="}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// MockMultipartData creates a mocked multipart/form-data payload.
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// data, mp, err := tests.MockMultipartData(
|
||||
// map[string]string{"title": "new"},
|
||||
// "file1",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var _ Provider = (*Apple)(nil)
|
||||
|
||||
// NameApple is the unique name of the Apple provider.
|
||||
const NameApple string = "apple"
|
||||
|
||||
// Apple allows authentication via Apple OAuth2.
|
||||
//
|
||||
// [OIDC differences]: https://bitbucket.org/openid/connect/src/master/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md
|
||||
type Apple struct {
|
||||
*baseProvider
|
||||
|
||||
jwksUrl string
|
||||
}
|
||||
|
||||
// NewAppleProvider creates a new Apple provider instance with some defaults.
|
||||
func NewAppleProvider() *Apple {
|
||||
return &Apple{
|
||||
baseProvider: &baseProvider{
|
||||
scopes: nil, // custom scopes are currently not supported since they require a POST redirect
|
||||
ctx: context.Background(),
|
||||
authUrl: "https://appleid.apple.com/auth/authorize",
|
||||
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||
},
|
||||
jwksUrl: "https://appleid.apple.com/auth/keys",
|
||||
}
|
||||
}
|
||||
|
||||
// FetchAuthUser returns an AuthUser instance based on the provided token.
|
||||
//
|
||||
// API reference: https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse.
|
||||
func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||
data, err := p.FetchRawUserData(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawUser := map[string]any{}
|
||||
if err := json.Unmarshal(data, &rawUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extracted := struct {
|
||||
Id string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified any `json:"email_verified"` // could be string or bool
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &AuthUser{
|
||||
Id: extracted.Id,
|
||||
RawUser: rawUser,
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
}
|
||||
|
||||
if cast.ToBool(extracted.EmailVerified) {
|
||||
user.Email = extracted.Email
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// FetchRawUserData implements Provider.FetchRawUserData interface.
|
||||
//
|
||||
// Apple doesn't have a UserInfo endpoint and claims about users
|
||||
// are instead included in the "id_token" (https://openid.net/specs/openid-connect-core-1_0.html#id_tokenExample)
|
||||
func (p *Apple) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
|
||||
idToken, _ := token.Extra("id_token").(string)
|
||||
|
||||
claims, err := p.parseAndVerifyIdToken(idToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(claims)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
|
||||
if idToken == "" {
|
||||
return nil, errors.New("empty id_token")
|
||||
}
|
||||
|
||||
// extract the token header params and claims
|
||||
// ---
|
||||
claims := jwt.MapClaims{}
|
||||
t, _, err := jwt.NewParser().ParseUnverified(idToken, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
|
||||
// ---
|
||||
if !claims.VerifyIssuer("https://appleid.apple.com", true) {
|
||||
return nil, errors.New("iss must be https://appleid.apple.com")
|
||||
}
|
||||
|
||||
if !claims.VerifyAudience(p.clientId, true) {
|
||||
return nil, errors.New("aud must be the developer's client_id")
|
||||
}
|
||||
|
||||
// fetch the public key set
|
||||
// ---
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return nil, errors.New("missing kid header value")
|
||||
}
|
||||
|
||||
key, err := p.fetchJWK(kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// decode the key params per RFC 7518 (https://tools.ietf.org/html/rfc7518#section-6.3)
|
||||
// and construct a valid publicKey from them
|
||||
// ---
|
||||
exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "="))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "="))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicKey := &rsa.PublicKey{
|
||||
// https://tools.ietf.org/html/rfc7517#appendix-A.1
|
||||
E: int(big.NewInt(0).SetBytes(exponent).Uint64()),
|
||||
N: big.NewInt(0).SetBytes(modulus),
|
||||
}
|
||||
|
||||
// verify the id_token
|
||||
// ---
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg}))
|
||||
|
||||
parsedToken, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("the parsed id_token is invalid")
|
||||
}
|
||||
|
||||
type jwk struct {
|
||||
Kty string
|
||||
Kid string
|
||||
Use string
|
||||
Alg string
|
||||
N string
|
||||
E string
|
||||
}
|
||||
|
||||
func (p *Apple) fetchJWK(kid string) (*jwk, error) {
|
||||
req, err := http.NewRequestWithContext(p.ctx, "GET", p.jwksUrl, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// http.Client.Get doesn't treat non 2xx responses as error
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to verify the provided id_token (%d):\n%s",
|
||||
res.StatusCode,
|
||||
string(rawBody),
|
||||
)
|
||||
}
|
||||
|
||||
jwks := struct {
|
||||
Keys []*jwk
|
||||
}{}
|
||||
if err := json.Unmarshal(rawBody, &jwks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.Kid == kid {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("jwk with kid %q was not found", kid)
|
||||
}
|
||||
+15
-2
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -21,6 +22,12 @@ type AuthUser struct {
|
||||
|
||||
// Provider defines a common interface for an OAuth2 client.
|
||||
type Provider interface {
|
||||
// Scopes returns the context associated with the provider (if any).
|
||||
Context() context.Context
|
||||
|
||||
// SetContext assigns the specified context to the current provider.
|
||||
SetContext(ctx context.Context)
|
||||
|
||||
// Scopes returns the provider access permissions that will be requested.
|
||||
Scopes() []string
|
||||
|
||||
@@ -112,10 +119,16 @@ func NewProviderByName(name string) (Provider, error) {
|
||||
return NewGiteeProvider(), nil
|
||||
case NameLivechat:
|
||||
return NewLivechatProvider(), nil
|
||||
case NameAuthentik:
|
||||
return NewAuthentikProvider(), nil
|
||||
case NameGitea:
|
||||
return NewGiteaProvider(), nil
|
||||
case NameOIDC:
|
||||
return NewOIDCProvider(), nil
|
||||
case NameOIDC + "2":
|
||||
return NewOIDCProvider(), nil
|
||||
case NameOIDC + "3":
|
||||
return NewOIDCProvider(), nil
|
||||
case NameApple:
|
||||
return NewAppleProvider(), nil
|
||||
default:
|
||||
return nil, errors.New("Missing provider " + name)
|
||||
}
|
||||
|
||||
+36
-9
@@ -136,15 +136,6 @@ func TestNewProviderByName(t *testing.T) {
|
||||
t.Error("Expected to be instance of *auth.Livechat")
|
||||
}
|
||||
|
||||
// authentik
|
||||
p, err = auth.NewProviderByName(auth.NameAuthentik)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, got error %v", err)
|
||||
}
|
||||
if _, ok := p.(*auth.Authentik); !ok {
|
||||
t.Error("Expected to be instance of *auth.Authentik")
|
||||
}
|
||||
|
||||
// gitea
|
||||
p, err = auth.NewProviderByName(auth.NameGitea)
|
||||
if err != nil {
|
||||
@@ -153,4 +144,40 @@ func TestNewProviderByName(t *testing.T) {
|
||||
if _, ok := p.(*auth.Gitea); !ok {
|
||||
t.Error("Expected to be instance of *auth.Gitea")
|
||||
}
|
||||
|
||||
// oidc
|
||||
p, err = auth.NewProviderByName(auth.NameOIDC)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, got error %v", err)
|
||||
}
|
||||
if _, ok := p.(*auth.OIDC); !ok {
|
||||
t.Error("Expected to be instance of *auth.OIDC")
|
||||
}
|
||||
|
||||
// oidc2
|
||||
p, err = auth.NewProviderByName(auth.NameOIDC + "2")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, got error %v", err)
|
||||
}
|
||||
if _, ok := p.(*auth.OIDC); !ok {
|
||||
t.Error("Expected to be instance of *auth.OIDC")
|
||||
}
|
||||
|
||||
// oidc3
|
||||
p, err = auth.NewProviderByName(auth.NameOIDC + "3")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, got error %v", err)
|
||||
}
|
||||
if _, ok := p.(*auth.OIDC); !ok {
|
||||
t.Error("Expected to be instance of *auth.OIDC")
|
||||
}
|
||||
|
||||
// apple
|
||||
p, err = auth.NewProviderByName(auth.NameApple)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil, got error %v", err)
|
||||
}
|
||||
if _, ok := p.(*auth.Apple); !ok {
|
||||
t.Error("Expected to be instance of *auth.Apple")
|
||||
}
|
||||
}
|
||||
|
||||
+38
-27
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
// baseProvider defines common fields and methods used by OAuth2 client providers.
|
||||
type baseProvider struct {
|
||||
ctx context.Context
|
||||
scopes []string
|
||||
clientId string
|
||||
clientSecret string
|
||||
@@ -20,94 +21,104 @@ type baseProvider struct {
|
||||
userApiUrl string
|
||||
}
|
||||
|
||||
// Scopes implements Provider.Scopes interface.
|
||||
// Context implements Provider.Context() interface method.
|
||||
func (p *baseProvider) Context() context.Context {
|
||||
return p.ctx
|
||||
}
|
||||
|
||||
// SetContext implements Provider.SetContext() interface method.
|
||||
func (p *baseProvider) SetContext(ctx context.Context) {
|
||||
p.ctx = ctx
|
||||
}
|
||||
|
||||
// Scopes implements Provider.Scopes() interface method.
|
||||
func (p *baseProvider) Scopes() []string {
|
||||
return p.scopes
|
||||
}
|
||||
|
||||
// SetScopes implements Provider.SetScopes interface.
|
||||
// SetScopes implements Provider.SetScopes() interface method.
|
||||
func (p *baseProvider) SetScopes(scopes []string) {
|
||||
p.scopes = scopes
|
||||
}
|
||||
|
||||
// ClientId implements Provider.ClientId interface.
|
||||
// ClientId implements Provider.ClientId() interface method.
|
||||
func (p *baseProvider) ClientId() string {
|
||||
return p.clientId
|
||||
}
|
||||
|
||||
// SetClientId implements Provider.SetClientId interface.
|
||||
// SetClientId implements Provider.SetClientId() interface method.
|
||||
func (p *baseProvider) SetClientId(clientId string) {
|
||||
p.clientId = clientId
|
||||
}
|
||||
|
||||
// ClientSecret implements Provider.ClientSecret interface.
|
||||
// ClientSecret implements Provider.ClientSecret() interface method.
|
||||
func (p *baseProvider) ClientSecret() string {
|
||||
return p.clientSecret
|
||||
}
|
||||
|
||||
// SetClientSecret implements Provider.SetClientSecret interface.
|
||||
// SetClientSecret implements Provider.SetClientSecret() interface method.
|
||||
func (p *baseProvider) SetClientSecret(secret string) {
|
||||
p.clientSecret = secret
|
||||
}
|
||||
|
||||
// RedirectUrl implements Provider.RedirectUrl interface.
|
||||
// RedirectUrl implements Provider.RedirectUrl() interface method.
|
||||
func (p *baseProvider) RedirectUrl() string {
|
||||
return p.redirectUrl
|
||||
}
|
||||
|
||||
// SetRedirectUrl implements Provider.SetRedirectUrl interface.
|
||||
// SetRedirectUrl implements Provider.SetRedirectUrl() interface method.
|
||||
func (p *baseProvider) SetRedirectUrl(url string) {
|
||||
p.redirectUrl = url
|
||||
}
|
||||
|
||||
// AuthUrl implements Provider.AuthUrl interface.
|
||||
// AuthUrl implements Provider.AuthUrl() interface method.
|
||||
func (p *baseProvider) AuthUrl() string {
|
||||
return p.authUrl
|
||||
}
|
||||
|
||||
// SetAuthUrl implements Provider.SetAuthUrl interface.
|
||||
// SetAuthUrl implements Provider.SetAuthUrl() interface method.
|
||||
func (p *baseProvider) SetAuthUrl(url string) {
|
||||
p.authUrl = url
|
||||
}
|
||||
|
||||
// TokenUrl implements Provider.TokenUrl interface.
|
||||
// TokenUrl implements Provider.TokenUrl() interface method.
|
||||
func (p *baseProvider) TokenUrl() string {
|
||||
return p.tokenUrl
|
||||
}
|
||||
|
||||
// SetTokenUrl implements Provider.SetTokenUrl interface.
|
||||
// SetTokenUrl implements Provider.SetTokenUrl() interface method.
|
||||
func (p *baseProvider) SetTokenUrl(url string) {
|
||||
p.tokenUrl = url
|
||||
}
|
||||
|
||||
// UserApiUrl implements Provider.UserApiUrl interface.
|
||||
// UserApiUrl implements Provider.UserApiUrl() interface method.
|
||||
func (p *baseProvider) UserApiUrl() string {
|
||||
return p.userApiUrl
|
||||
}
|
||||
|
||||
// SetUserApiUrl implements Provider.SetUserApiUrl interface.
|
||||
// SetUserApiUrl implements Provider.SetUserApiUrl() interface method.
|
||||
func (p *baseProvider) SetUserApiUrl(url string) {
|
||||
p.userApiUrl = url
|
||||
}
|
||||
|
||||
// BuildAuthUrl implements Provider.BuildAuthUrl interface.
|
||||
// BuildAuthUrl implements Provider.BuildAuthUrl() interface method.
|
||||
func (p *baseProvider) BuildAuthUrl(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
return p.oauth2Config().AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
// FetchToken implements Provider.FetchToken interface.
|
||||
// FetchToken implements Provider.FetchToken() interface method.
|
||||
func (p *baseProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.oauth2Config().Exchange(context.Background(), code, opts...)
|
||||
return p.oauth2Config().Exchange(p.ctx, code, opts...)
|
||||
}
|
||||
|
||||
// Client implements Provider.Client interface.
|
||||
// Client implements Provider.Client() interface method.
|
||||
func (p *baseProvider) Client(token *oauth2.Token) *http.Client {
|
||||
return p.oauth2Config().Client(context.Background(), token)
|
||||
return p.oauth2Config().Client(p.ctx, token)
|
||||
}
|
||||
|
||||
// FetchRawUserData implements Provider.FetchRawUserData interface.
|
||||
// FetchRawUserData implements Provider.FetchRawUserData() interface method.
|
||||
func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", p.userApiUrl, nil)
|
||||
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userApiUrl, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,23 +130,23 @@ func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
|
||||
func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token) ([]byte, error) {
|
||||
client := p.Client(token)
|
||||
|
||||
response, err := client.Do(req)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer res.Body.Close()
|
||||
|
||||
result, err := io.ReadAll(response.Body)
|
||||
result, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// http.Client.Get doesn't treat non 2xx responses as error
|
||||
if response.StatusCode >= 400 {
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf(
|
||||
"Failed to fetch OAuth2 user profile via %s (%d):\n%s",
|
||||
"failed to fetch OAuth2 user profile via %s (%d):\n%s",
|
||||
p.userApiUrl,
|
||||
response.StatusCode,
|
||||
res.StatusCode,
|
||||
string(result),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestContext(t *testing.T) {
|
||||
b := baseProvider{}
|
||||
|
||||
before := b.Scopes()
|
||||
if before != nil {
|
||||
t.Errorf("Expected nil context, got %v", before)
|
||||
}
|
||||
|
||||
b.SetContext(context.Background())
|
||||
|
||||
after := b.Scopes()
|
||||
if after != nil {
|
||||
t.Error("Expected non-nil context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopes(t *testing.T) {
|
||||
b := baseProvider{}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
@@ -22,6 +23,7 @@ func NewDiscordProvider() *Discord {
|
||||
// https://discord.com/developers/docs/topics/oauth2
|
||||
// https://discord.com/developers/docs/resources/user#get-current-user
|
||||
return &Discord{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"identify", "email"},
|
||||
authUrl: "https://discord.com/api/oauth2/authorize",
|
||||
tokenUrl: "https://discord.com/api/oauth2/token",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -20,6 +21,7 @@ type Facebook struct {
|
||||
// NewFacebookProvider creates new Facebook provider instance with some defaults.
|
||||
func NewFacebookProvider() *Facebook {
|
||||
return &Facebook{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"email"},
|
||||
authUrl: facebook.Endpoint.AuthURL,
|
||||
tokenUrl: facebook.Endpoint.TokenURL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
@@ -20,6 +21,7 @@ type Gitea struct {
|
||||
// NewGiteaProvider creates new Gitea provider instance with some defaults.
|
||||
func NewGiteaProvider() *Gitea {
|
||||
return &Gitea{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"read:user", "user:email"},
|
||||
authUrl: "https://gitea.com/login/oauth/authorize",
|
||||
tokenUrl: "https://gitea.com/login/oauth/access_token",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -22,6 +23,7 @@ type Gitee struct {
|
||||
// NewGiteeProvider creates new Gitee provider instance with some defaults.
|
||||
func NewGiteeProvider() *Gitee {
|
||||
return &Gitee{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"user_info", "emails"},
|
||||
authUrl: "https://gitee.com/oauth/authorize",
|
||||
tokenUrl: "https://gitee.com/oauth/token",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user