mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74306c8092 | ||
|
|
a5b3cc0f34 | ||
|
|
0478f84867 | ||
|
|
0b5e189563 | ||
|
|
e61d48fe7b | ||
|
|
cae3315e46 | ||
|
|
a2a170ad82 | ||
|
|
92dcee7250 | ||
|
|
bd2521b14b | ||
|
|
0c63e0e219 | ||
|
|
d407ca2163 | ||
|
|
5c57be8469 | ||
|
|
4b0e614c68 | ||
|
|
5c95a43042 | ||
|
|
8240addfab | ||
|
|
e1cfe7fc4a | ||
|
|
01f4765c09 | ||
|
|
818857dea2 | ||
|
|
fdf4f6d3bd | ||
|
|
0420a00c18 | ||
|
|
b39d607c8a | ||
|
|
abea28a1a9 | ||
|
|
a7d5a0640c | ||
|
|
c937c06688 | ||
|
|
d11cec2226 | ||
|
|
c179b4c473 | ||
|
|
00b04db5cf | ||
|
|
1b8776926e | ||
|
|
5eb54c7a3d | ||
|
|
6127350e91 | ||
|
|
177230a765 | ||
|
|
8cb0d980ad | ||
|
|
d44c2bb1a9 | ||
|
|
3423433d01 | ||
|
|
646b6a925b | ||
|
|
5ddf9cd443 | ||
|
|
0351f3a1ad | ||
|
|
53c735d00b | ||
|
|
25769e971a | ||
|
|
aba6279feb | ||
|
|
ac4a961a10 | ||
|
|
8317ae2e6b | ||
|
|
b347c8d80c | ||
|
|
af5f808144 | ||
|
|
a77b62e5bd | ||
|
|
c77467a6a2 | ||
|
|
35e433f26b | ||
|
|
59f23d3d23 | ||
|
|
6a69a035a7 | ||
|
|
b8373b23a4 | ||
|
|
25c1db40ac | ||
|
|
7b48e9d1c1 | ||
|
|
b537085bca | ||
|
|
3e5b021dd8 | ||
|
|
151dbafc86 | ||
|
|
dc72d5adee | ||
|
|
c826514eca | ||
|
|
b7407edcae | ||
|
|
1420d717e3 | ||
|
|
f5f8c35a17 | ||
|
|
733d7dacdb | ||
|
|
cd45854792 | ||
|
|
ba7000125b | ||
|
|
64c3e3b3c5 | ||
|
|
9f76ad234c | ||
|
|
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 | ||
|
|
3eb3d0bbdf | ||
|
|
f180af7af7 | ||
|
|
d046811df7 | ||
|
|
4768e07c0b | ||
|
|
0547202382 | ||
|
|
684f91f7e5 | ||
|
|
bce4094134 | ||
|
|
6a60bc1df3 | ||
|
|
19b7a7a74f | ||
|
|
8de0e61cf4 | ||
|
|
5344ec83fa | ||
|
|
65aa114103 | ||
|
|
8728161288 | ||
|
|
2420b2804a | ||
|
|
2791628d96 | ||
|
|
01f0af7af6 | ||
|
|
e8d61e7b45 | ||
|
|
a5ac83c7b0 | ||
|
|
a67c14c368 | ||
|
|
19faa0d8e7 | ||
|
|
56a77fd5ff | ||
|
|
07727dbde6 | ||
|
|
a3d26a73c3 | ||
|
|
b328827705 | ||
|
|
f5e5fae773 | ||
|
|
41f01bab0d |
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.20.0'
|
||||
go-version: '>=1.20.3'
|
||||
|
||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||
@@ -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*
|
||||
|
||||
+143
-2
@@ -1,6 +1,147 @@
|
||||
## v0.15.2
|
||||
|
||||
- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
|
||||
|
||||
- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
|
||||
|
||||
- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
|
||||
|
||||
|
||||
## v0.15.1
|
||||
|
||||
- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
|
||||
|
||||
- Fixed `Ctrl + S` in the `editor` field not propagating the quick save shortcut to the parent form.
|
||||
|
||||
- Added `⌘ + S` alias for the record quick save shortcut (_I have no Mac device to test it but it should work based on [`e.metaKey` docs](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey)_).
|
||||
|
||||
- Enabled RTL for the TinyMCE editor ([#2327](https://github.com/pocketbase/pocketbase/issues/2327)).
|
||||
|
||||
- Reduced the record form vertical layout shifts and slightly improved the rendering speed when loading multiple `relation` fields.
|
||||
|
||||
- Enabled Admin UI assets cache.
|
||||
|
||||
|
||||
## v0.15.0
|
||||
|
||||
- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
|
||||
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
|
||||
The manual code-token exchange flow is still supported but the SDK methods is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
|
||||
For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
|
||||
|
||||
- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
|
||||
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
|
||||
It works with a short lived (~5min) file token passed as query param with the file url.
|
||||
For more details and example, you could check https://pocketbase.io/docs/files-handling/#private-files.
|
||||
|
||||
- **!** Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
|
||||
|
||||
- Added simple loose wildcard search term support in the Admin UI.
|
||||
|
||||
- Added auto "draft" to allow restoring previous record state in case of accidental reload or power outage.
|
||||
|
||||
- Added `Ctrl + S` shortcut to save the record changes without closing the panel.
|
||||
|
||||
- Added "drop files" support for the file upload field.
|
||||
|
||||
- Refreshed the OAuth2 Admin UI.
|
||||
|
||||
|
||||
## v0.14.5
|
||||
|
||||
- Added checks for `nil` hooks in `forms.RecordUpsert` when used with custom `Dao` ([#2277](https://github.com/pocketbase/pocketbase/issues/2277)).
|
||||
|
||||
- Fixed unique detailed field error not returned on record create failure ([#2287](https://github.com/pocketbase/pocketbase/discussions/2287)).
|
||||
|
||||
|
||||
## v0.14.4
|
||||
|
||||
- Fixed concurrent map write pannic on `list.ExistInSliceWithRegex()` cache ([#2272](https://github.com/pocketbase/pocketbase/issues/2272)).
|
||||
|
||||
|
||||
## v0.14.3
|
||||
|
||||
- Fixed Admin UI Logs `meta` visualization in Firefox ([#2221](https://github.com/pocketbase/pocketbase/issues/2221)).
|
||||
|
||||
- Downgraded to v1 of the `aws/aws-sdk-go` package since v2 has compatibility issues with GCS ([#2231](https://github.com/pocketbase/pocketbase/issues/2231)).
|
||||
|
||||
- Upgraded the GitHub action to use [min Go 1.20.3](https://github.com/golang/go/issues?q=milestone%3AGo1.20.3+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `net/http` security fixes.
|
||||
|
||||
|
||||
## v0.14.2
|
||||
|
||||
- Reverted part of the old `COALESCE` handling as a fallback to support 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 allow lazy evaluation during bulk import.
|
||||
- Removed eager unique collection name check to support lazy validation during bulk import.
|
||||
|
||||
|
||||
## v0.13.3
|
||||
@@ -1079,7 +1220,7 @@ Please check the individual SDK package changelog and apply the necessary change
|
||||
- Added option to return serialized custom `models.Record` fields data:
|
||||
```go
|
||||
func (m *Record) UnknownData() map[string]any
|
||||
func (m *Record) WithUnkownData(state bool)
|
||||
func (m *Record) WithUnknownData(state bool)
|
||||
```
|
||||
|
||||
- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
|
||||
|
||||
+24
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -118,6 +121,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// note: it is after the OnBeforeServe hook to ensure that the implicit
|
||||
// cache is after any user custom defined middlewares
|
||||
e.Use(eagerRequestDataCache(app))
|
||||
|
||||
// catch all any route
|
||||
api.Any("/*", func(c echo.Context) error {
|
||||
return echo.ErrNotFound
|
||||
@@ -173,6 +180,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
trailedAdminPath+"*",
|
||||
echo.StaticDirectoryHandler(ui.DistDirFS, false),
|
||||
installerRedirect(app),
|
||||
uiCacheControl(),
|
||||
middleware.Gzip(),
|
||||
)
|
||||
|
||||
@@ -192,6 +200,20 @@ func updateTotalAdminsCache(app core.App) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func uiCacheControl() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// add default Cache-Control header for all Admin UI resources
|
||||
// (ignoring the root admin path)
|
||||
if c.Request().URL.Path != trailedAdminPath {
|
||||
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer admin UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
@@ -2,12 +2,15 @@ package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func Test404(t *testing.T) {
|
||||
@@ -136,3 +139,156 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerRequestDataCache(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "[UNKNOWN] unsupported eager cached request method",
|
||||
Method: "UNKNOWN",
|
||||
Url: "/custom",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: "UNKNOWN",
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
|
||||
if err := c.Bind(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// since the unknown method is not eager cache support
|
||||
// it should fail reading the json body twice
|
||||
r := apis.RequestData(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "" {
|
||||
t.Fatalf("Expected empty request data body, got, %v", r.Data)
|
||||
}
|
||||
|
||||
return c.String(200, data.Name)
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
// supported eager cache request methods
|
||||
supportedMethods := []string{"POST", "PUT", "PATCH", "DELETE"}
|
||||
for _, m := range supportedMethods {
|
||||
scenarios = append(
|
||||
scenarios,
|
||||
tests.ApiScenario{
|
||||
Name: fmt.Sprintf("[%s] valid cached json body request", m),
|
||||
Method: http.MethodPost,
|
||||
Url: "/custom",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
|
||||
if err := c.Bind(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestData(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return c.String(200, data.Name)
|
||||
},
|
||||
})
|
||||
},
|
||||
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)
|
||||
|
||||
+150
-2
@@ -586,6 +586,83 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"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 {
|
||||
@@ -951,6 +1028,68 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"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 {
|
||||
@@ -958,7 +1097,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionImport(t *testing.T) {
|
||||
func TestCollectionsImport(t *testing.T) {
|
||||
totalCollections := 10
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -1018,7 +1157,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelBeforeDelete": 4,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collections := []*models.Collection{}
|
||||
@@ -1097,6 +1236,9 @@ func TestCollectionImport(t *testing.T) {
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1120,10 +1262,16 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+133
-6
@@ -1,13 +1,23 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
@@ -18,6 +28,7 @@ func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
subGroup.POST("/token", api.fileToken)
|
||||
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
}
|
||||
@@ -26,6 +37,37 @@ type fileApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *fileApi) fileToken(c echo.Context) error {
|
||||
event := new(core.FileTokenEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil {
|
||||
event.Model = admin
|
||||
event.Token, _ = tokens.NewAdminFileToken(api.app, admin)
|
||||
} else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil {
|
||||
event.Model = record
|
||||
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.Model == nil || e.Token == "" {
|
||||
return NewBadRequestError("Failed to generate file token.", nil)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnFileAfterTokenRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
@@ -49,7 +91,21 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
options, _ := fileField.Options.(*schema.FileOptions)
|
||||
options, ok := fileField.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
return NewBadRequestError("", errors.New("Failed to load file options."))
|
||||
}
|
||||
|
||||
// check whether the request is authorized to view the protected file
|
||||
if options.Protected {
|
||||
token := c.QueryParam("token")
|
||||
|
||||
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
|
||||
|
||||
if !api.canAccessRecord(adminOrAuthRecord, record, record.Collection().ViewRule) {
|
||||
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
|
||||
}
|
||||
}
|
||||
|
||||
baseFilesPath := record.BaseFilesPath()
|
||||
|
||||
@@ -87,11 +143,8 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
servedName = thumbSize + "_" + filename
|
||||
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
|
||||
}
|
||||
@@ -122,3 +175,77 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
|
||||
fileToken = strings.TrimSpace(fileToken)
|
||||
if fileToken == "" {
|
||||
return nil, errors.New("missing file token")
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
|
||||
tokenType := cast.ToString(claims["type"])
|
||||
|
||||
switch tokenType {
|
||||
case tokens.TypeAdmin:
|
||||
admin, err := api.app.Dao().FindAdminByToken(
|
||||
fileToken,
|
||||
api.app.Settings().AdminFileToken.Secret,
|
||||
)
|
||||
if err == nil && admin != nil {
|
||||
return admin, nil
|
||||
}
|
||||
case tokens.TypeAuthRecord:
|
||||
record, err := api.app.Dao().FindAuthRecordByToken(
|
||||
fileToken,
|
||||
api.app.Settings().RecordFileToken.Secret,
|
||||
)
|
||||
if err == nil && record != nil {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("missing or invalid file token")
|
||||
}
|
||||
|
||||
// @todo move to a helper and maybe combine with the realtime checks when refactoring the realtime service
|
||||
func (api *fileApi) canAccessRecord(adminOrAuthRecord models.Model, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := adminOrAuthRecord.(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = adminOrAuthRecord.(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,9 +8,85 @@ import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestFileToken(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with model and token via hook",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
|
||||
record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
e.Model = record
|
||||
e.Token = "test"
|
||||
return nil
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownload(t *testing.T) {
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
dataDirRelPath := "../tests/data/"
|
||||
@@ -176,6 +252,125 @@ func TestFileDownload(t *testing.T) {
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// protected file access checks
|
||||
{
|
||||
Name: "protected file - expired token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - admin with expired file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - admin with valid file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - guest without view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - guest with view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock public view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - auth record without view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock restricted user view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("@request.auth.verified = true")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - auth record with view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock user view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("@request.auth.verified = false")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file in view (view's View API rule failure)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file in view (view's View API rule success)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+17
-1
@@ -321,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),
|
||||
@@ -385,3 +385,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
|
||||
return fallbackIp
|
||||
}
|
||||
|
||||
// eagerRequestDataCache ensures that the request data is cached in the request
|
||||
// context to allow reading for example the json request body data more than once.
|
||||
func eagerRequestDataCache(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
switch c.Request().Method {
|
||||
// currently we are eagerly caching only the requests with body
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
RequestData(c)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-9
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -215,7 +216,9 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
|
||||
if clientModel != nil &&
|
||||
clientModel.TableName() == newModel.TableName() &&
|
||||
clientModel.GetId() == newModel.GetId() {
|
||||
client.Set(contextKey, newModel)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +230,9 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
|
||||
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == model.GetId() {
|
||||
if clientModel != nil &&
|
||||
clientModel.TableName() == model.TableName() &&
|
||||
clientModel.GetId() == model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
}
|
||||
@@ -238,7 +243,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
|
||||
func (api *realtimeApi) bindEvents() {
|
||||
// update the clients that has admin or auth record association
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
|
||||
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
@@ -251,8 +256,8 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
// remove the client(s) associated to the deleted admin or auth record
|
||||
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
|
||||
if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
|
||||
}
|
||||
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
@@ -263,7 +268,7 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -272,7 +277,7 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -281,7 +286,7 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -290,6 +295,33 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
}
|
||||
|
||||
// resolveRecord converts *if possible* the provided model interface to a Record.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
|
||||
record, _ = model.(*models.Record)
|
||||
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
if record == nil && !strings.HasPrefix(model.TableName(), "_") {
|
||||
record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
|
||||
if record, ok := model.(*models.Record); ok {
|
||||
collection = record.Collection()
|
||||
} else if !strings.HasPrefix(model.TableName(), "_") {
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
@@ -307,7 +339,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// emulate request data
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
|
||||
+111
-4
@@ -7,8 +7,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
@@ -253,7 +255,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = authRecord
|
||||
testApp.OnModelAfterDelete().Trigger(e)
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
@@ -282,7 +287,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
}
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = authRecord2
|
||||
testApp.OnModelAfterUpdate().Trigger(e)
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
@@ -305,7 +313,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAdminKey, admin)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = admin
|
||||
testApp.OnModelAfterDelete().Trigger(e)
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
@@ -334,10 +345,106 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
}
|
||||
admin2.Email = "new@example.com"
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = admin2
|
||||
testApp.OnModelAfterUpdate().Trigger(e)
|
||||
|
||||
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if clientAdmin.Email != admin2.Email {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom auth record model struct
|
||||
// -------------------------------------------------------------------
|
||||
var _ models.Model = (*CustomUser)(nil)
|
||||
|
||||
type CustomUser struct {
|
||||
models.BaseModel
|
||||
|
||||
Email string `db:"email" json:"email"`
|
||||
}
|
||||
|
||||
func (m *CustomUser) TableName() string {
|
||||
return "users" // the name of your collection
|
||||
}
|
||||
|
||||
func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
|
||||
model := &CustomUser{}
|
||||
|
||||
err := dao.ModelQuery(model).
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// delete the custom user (should unset the client auth record)
|
||||
if err := testApp.Dao().Delete(customUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// change its email
|
||||
customUser.Email = "new@example.com"
|
||||
if err := testApp.Dao().Save(customUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != customUser.Email {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
+63
-8
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -25,12 +27,15 @@ import (
|
||||
func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
api := recordAuthApi{app: app}
|
||||
|
||||
// global oauth2 subscription redirect handler
|
||||
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
|
||||
|
||||
// common collection record related routes
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app, models.CollectionTypeAuth),
|
||||
)
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
@@ -129,6 +134,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 +152,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 +182,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 +225,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
|
||||
@@ -615,3 +633,40 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const oauth2SubscriptionTopic = "@oauth2"
|
||||
|
||||
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
state := c.QueryParam("state")
|
||||
code := c.QueryParam("code")
|
||||
|
||||
if code == "" || state == "" {
|
||||
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
|
||||
}
|
||||
|
||||
client, err := api.app.SubscriptionsBroker().ClientById(state)
|
||||
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
|
||||
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
"state": state,
|
||||
"code": code,
|
||||
}
|
||||
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: oauth2SubscriptionTopic,
|
||||
Data: string(encodedData),
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -1144,3 +1146,120 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
c1 := subscriptions.NewDefaultClient()
|
||||
|
||||
c2 := subscriptions.NewDefaultClient()
|
||||
c2.Subscribe("@oauth2")
|
||||
|
||||
c3 := subscriptions.NewDefaultClient()
|
||||
c3.Subscribe("test1", "@oauth2")
|
||||
|
||||
c4 := subscriptions.NewDefaultClient()
|
||||
c4.Subscribe("test1", "test2")
|
||||
|
||||
c5 := subscriptions.NewDefaultClient()
|
||||
c5.Subscribe("@oauth2")
|
||||
c5.Discard()
|
||||
|
||||
beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(c1)
|
||||
app.SubscriptionsBroker().Register(c2)
|
||||
app.SubscriptionsBroker().Register(c3)
|
||||
app.SubscriptionsBroker().Register(c4)
|
||||
app.SubscriptionsBroker().Register(c5)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "no state query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "no code query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + c3.Id(),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing client",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=missing",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "discarded client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "client without @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
beforeTestFunc(t, app, e)
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
|
||||
go func() {
|
||||
defer cancelFunc()
|
||||
L:
|
||||
for {
|
||||
select {
|
||||
case <-c1.Channel():
|
||||
t.Error("Unexpected c1 message")
|
||||
break L
|
||||
case <-c2.Channel():
|
||||
t.Error("Unexpected c2 message")
|
||||
break L
|
||||
case msg := <-c3.Channel():
|
||||
if msg.Name != "@oauth2" {
|
||||
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
}
|
||||
|
||||
expectedParams := []string{`"state"`, `"code"`}
|
||||
for _, p := range expectedParams {
|
||||
if !strings.Contains(msg.Data, p) {
|
||||
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
|
||||
}
|
||||
}
|
||||
|
||||
break L
|
||||
case <-c4.Channel():
|
||||
t.Error("Unexpected c4 message")
|
||||
break L
|
||||
case <-c5.Channel():
|
||||
t.Error("Unexpected c5 message")
|
||||
break L
|
||||
case <-ctx.Done():
|
||||
t.Error("Context timeout reached")
|
||||
break L
|
||||
}
|
||||
}
|
||||
}()
|
||||
},
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,6 +1173,20 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unique field error check",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{
|
||||
"title":"test2"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"title":{`,
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -1747,6 +1761,24 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
`"id":{"code":"validation_in_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unique field error check",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/llvuca81nly1qls",
|
||||
Body: strings.NewReader(`{
|
||||
"title":"test2"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"title":{`,
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
|
||||
+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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,10 +49,12 @@ func TestSettingsList(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -65,6 +73,7 @@ func TestSettingsList(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -118,10 +127,12 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -139,6 +150,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -180,10 +192,12 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -202,6 +216,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -419,3 +434,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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -307,6 +307,25 @@ type App interface {
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
||||
|
||||
// OnFileBeforeTokenRequest hook is triggered before each file
|
||||
// token API request.
|
||||
//
|
||||
// If no token or model was submitted, e.Model and e.Token will be empty,
|
||||
// allowing you to implement your own custom model file auth implementation.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
|
||||
|
||||
// OnFileAfterTokenRequest hook is triggered after each
|
||||
// successful file token API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+44
-11
@@ -88,7 +88,9 @@ type BaseApp struct {
|
||||
onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
|
||||
|
||||
// file api event hooks
|
||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
|
||||
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
|
||||
|
||||
// admin api event hooks
|
||||
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
||||
@@ -223,7 +225,9 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
|
||||
|
||||
// file API event hooks
|
||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||
|
||||
// admin API event hooks
|
||||
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
||||
@@ -653,6 +657,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
|
||||
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
|
||||
return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
|
||||
return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -979,34 +991,55 @@ func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeCreate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeUpdate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeDelete().Trigger(e)
|
||||
}
|
||||
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-20
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
@@ -11,10 +12,29 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
_ hook.Tagger = (*BaseModelEvent)(nil)
|
||||
_ hook.Tagger = (*BaseCollectionEvent)(nil)
|
||||
)
|
||||
|
||||
type BaseModelEvent struct {
|
||||
Model models.Model
|
||||
}
|
||||
|
||||
func (e *BaseModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
}
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
Collection *models.Collection
|
||||
}
|
||||
@@ -59,23 +79,10 @@ type ApiErrorEvent struct {
|
||||
// Model DAO events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
||||
|
||||
type ModelEvent struct {
|
||||
Dao *daos.Dao
|
||||
Model models.Model
|
||||
}
|
||||
BaseModelEvent
|
||||
|
||||
func (e *ModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -206,9 +213,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 {
|
||||
@@ -377,6 +387,13 @@ type CollectionsImportEvent struct {
|
||||
// File API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FileTokenEvent struct {
|
||||
BaseModelEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Token string
|
||||
}
|
||||
|
||||
type FileDownloadEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
|
||||
+13
-1
@@ -74,6 +74,13 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Clone returns a new Dao with the same configuration options as the current one.
|
||||
func (dao *Dao) Clone() *Dao {
|
||||
clone := *dao
|
||||
|
||||
return &clone
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -110,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
|
||||
@@ -205,7 +214,10 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 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.lockRetry(func(retryDao *Dao) error {
|
||||
|
||||
@@ -40,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()
|
||||
|
||||
+91
-9
@@ -1,6 +1,8 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -148,12 +150,20 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -167,7 +177,7 @@ 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
|
||||
@@ -175,7 +185,9 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
|
||||
switch collection.Type {
|
||||
case models.CollectionTypeView:
|
||||
return txDao.saveViewCollection(collection, oldCollection)
|
||||
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
@@ -183,9 +195,23 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
|
||||
// sync the changes with the related records table
|
||||
return txDao.SyncRecordTableSchema(collection, oldCollection)
|
||||
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -331,8 +357,8 @@ func (dao *Dao) ImportCollections(
|
||||
// - saves the newCollection
|
||||
//
|
||||
// This method returns an error if newCollection is not a "view".
|
||||
func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
if newCollection.IsAuth() {
|
||||
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
|
||||
if !newCollection.IsView() {
|
||||
return errors.New("not a view collection")
|
||||
}
|
||||
|
||||
@@ -340,13 +366,13 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti
|
||||
query := newCollection.ViewOptions().Query
|
||||
|
||||
// generate collection schema from the query
|
||||
schema, err := txDao.CreateViewSchema(query)
|
||||
viewSchema, err := txDao.CreateViewSchema(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old renamed view
|
||||
if oldCollection != nil && newCollection.Name != oldCollection.Name {
|
||||
if oldCollection != nil {
|
||||
if err := txDao.DeleteView(oldCollection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -357,8 +383,64 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti
|
||||
return err
|
||||
}
|
||||
|
||||
newCollection.Schema = schema
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+71
-4
@@ -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) {
|
||||
@@ -186,7 +187,12 @@ func TestDeleteCollection(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -199,7 +205,8 @@ func TestDeleteCollection(t *testing.T) {
|
||||
{colAuth, false},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{colView, false},
|
||||
{colView1, true}, // view2 depend on it
|
||||
{colView2, false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -252,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)
|
||||
}
|
||||
@@ -291,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)
|
||||
}
|
||||
@@ -305,6 +312,66 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
+13
-8
@@ -247,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
|
||||
}
|
||||
@@ -386,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() == "" {
|
||||
@@ -472,14 +475,16 @@ func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -748,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
|
||||
}
|
||||
|
||||
+34
-4
@@ -21,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})").
|
||||
@@ -32,8 +32,8 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
|
||||
return columns, err
|
||||
}
|
||||
|
||||
// GetTableInfo returns the `table_info` pragma result for the specified table.
|
||||
func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
// 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})").
|
||||
@@ -52,6 +52,36 @@ func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
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.
|
||||
|
||||
+46
-4
@@ -35,7 +35,7 @@ func TestHasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTableColumns(t *testing.T) {
|
||||
func TestTableColumns(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestGetTableColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
columns, _ := app.Dao().GetTableColumns(s.tableName)
|
||||
columns, _ := app.Dao().TableColumns(s.tableName)
|
||||
|
||||
if len(columns) != len(s.expected) {
|
||||
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
|
||||
@@ -63,7 +63,7 @@ func TestGetTableColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTableInfo(t *testing.T) {
|
||||
func TestTableInfo(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestGetTableInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
rows, _ := app.Dao().GetTableInfo(s.tableName)
|
||||
rows, _ := app.Dao().TableInfo(s.tableName)
|
||||
|
||||
raw, _ := json.Marshal(rows)
|
||||
rawStr := string(raw)
|
||||
@@ -139,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-15
@@ -63,7 +63,11 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
|
||||
// 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.GetTableInfo(name); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
|
||||
defer txDao.DeleteView(tempView)
|
||||
|
||||
// extract the generated view table info
|
||||
info, err := txDao.GetTableInfo(tempView)
|
||||
info, err := txDao.TableInfo(tempView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -189,15 +193,18 @@ func (dao *Dao) FindRecordByViewFile(
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(qf.collection).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
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}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
if err != nil {
|
||||
), dbx.HashExp{"_je_file.value": filename})
|
||||
}
|
||||
|
||||
if err := query.One(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -272,16 +279,13 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
|
||||
var fieldName string
|
||||
var collection *models.Collection
|
||||
var isMainTableField bool
|
||||
|
||||
if len(parts) == 2 {
|
||||
fieldName = parts[1]
|
||||
collection = collections[parts[0]]
|
||||
isMainTableField = parts[0] == mainTable.alias
|
||||
} else {
|
||||
fieldName = parts[0]
|
||||
collection = collections[mainTable.alias]
|
||||
isMainTableField = true
|
||||
}
|
||||
|
||||
// fallback to the default field if the found column is not from a collection schema
|
||||
@@ -307,6 +311,7 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
|
||||
if field != nil {
|
||||
clone := *field
|
||||
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
|
||||
clone.Name = col.alias
|
||||
result[col.alias] = &queryField{
|
||||
field: &clone,
|
||||
@@ -316,8 +321,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
continue
|
||||
}
|
||||
|
||||
if fieldName == schema.FieldNameId && !isMainTableField {
|
||||
// convert to relation since it is a direct id reference to non-maintable collection
|
||||
if fieldName == schema.FieldNameId {
|
||||
// convert to relation since it is a direct id reference
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
@@ -458,6 +463,8 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
skip = false
|
||||
partType = "select"
|
||||
activeBuilder = &selectParts
|
||||
case "distinct":
|
||||
continue // ignore as it is not important for the identifiers parsing
|
||||
case "from":
|
||||
skip = false
|
||||
partType = "from"
|
||||
@@ -473,7 +480,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
partType = "join"
|
||||
activeBuilder = &joinParts
|
||||
case "_discard_":
|
||||
// do nothing...
|
||||
// skip following tokens
|
||||
skip = true
|
||||
default:
|
||||
isJoin := partType == "join"
|
||||
|
||||
+79
-6
@@ -5,12 +5,33 @@ import (
|
||||
"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()
|
||||
@@ -34,6 +55,8 @@ func TestDeleteView(t *testing.T) {
|
||||
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) {
|
||||
@@ -78,7 +101,7 @@ func TestSaveView(t *testing.T) {
|
||||
{
|
||||
"missing table",
|
||||
"123Test",
|
||||
"select * from missing",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
@@ -136,7 +159,7 @@ func TestSaveView(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().GetTableInfo(s.viewName)
|
||||
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
|
||||
@@ -153,6 +176,24 @@ func TestSaveView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -179,7 +220,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"missing table",
|
||||
"select * from missing",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
@@ -354,11 +395,10 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
created created_alias,
|
||||
updated updated_alias,
|
||||
123 as custom
|
||||
from demo1
|
||||
`,
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeJson,
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"text_alias": schema.FieldTypeText,
|
||||
"url_alias": schema.FieldTypeUrl,
|
||||
"bool_alias": schema.FieldTypeBool,
|
||||
@@ -368,6 +408,37 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with distinct and reordered id column",
|
||||
`select distinct
|
||||
id as id2,
|
||||
id,
|
||||
123 as custom
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with aliasing the same field multiple times",
|
||||
`select
|
||||
a.id as id,
|
||||
a.text as alias1,
|
||||
a.text as alias2,
|
||||
b.text as alias3,
|
||||
b.text as alias4
|
||||
from demo1 a
|
||||
left join demo1 as b`,
|
||||
false,
|
||||
map[string]string{
|
||||
"alias1": schema.FieldTypeText,
|
||||
"alias2": schema.FieldTypeText,
|
||||
"alias3": schema.FieldTypeText,
|
||||
"alias4": schema.FieldTypeText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -403,6 +474,8 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-12
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -12,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"
|
||||
@@ -25,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
|
||||
@@ -56,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
|
||||
@@ -154,6 +158,7 @@ func (form *CollectionUpsert) Validate() error {
|
||||
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)),
|
||||
)
|
||||
}
|
||||
@@ -380,6 +385,44 @@ 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)
|
||||
|
||||
@@ -467,9 +510,15 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col
|
||||
form.collection.Name = form.Name
|
||||
}
|
||||
|
||||
// view schema is autogenerated on save
|
||||
// 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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -114,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",
|
||||
@@ -234,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{},
|
||||
},
|
||||
@@ -245,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)",
|
||||
@@ -326,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",
|
||||
@@ -344,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{},
|
||||
},
|
||||
@@ -371,7 +380,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
"deleteRule": null,
|
||||
"indexes": []
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -403,12 +413,14 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
],
|
||||
"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
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -592,6 +604,28 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelBeforeDelete": 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 {
|
||||
|
||||
+68
-9
@@ -707,7 +707,7 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
return form.prepareError(err)
|
||||
}
|
||||
|
||||
// restore record isNew state
|
||||
@@ -740,14 +740,46 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
form.record.MarkAsNew()
|
||||
}
|
||||
|
||||
// persist the record model
|
||||
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
|
||||
return fmt.Errorf("failed to save the record: %w", saveErr)
|
||||
}
|
||||
dao := form.dao.Clone()
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return fmt.Errorf("failed to process the uploaded files: %w", err)
|
||||
//
|
||||
// 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 form.dao.BeforeCreateFunc != nil {
|
||||
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if form.dao.BeforeUpdateFunc != nil {
|
||||
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// ---
|
||||
|
||||
// persist the record model
|
||||
if err := dao.SaveRecord(form.record); err != nil {
|
||||
return form.prepareError(err)
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
@@ -768,7 +800,7 @@ func (form *RecordUpsert) processFilesToUpload() error {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -816,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()
|
||||
@@ -849,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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -1037,3 +1038,64 @@ 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)
|
||||
}
|
||||
|
||||
testDaos := []*daos.Dao{
|
||||
app.Dao(), // with hooks
|
||||
daos.New(app.Dao().DB()), // without hooks
|
||||
}
|
||||
|
||||
for i, dao := range testDaos {
|
||||
// create with invalid file
|
||||
{
|
||||
prefix := fmt.Sprintf("%d-create", i)
|
||||
|
||||
new := models.NewRecord(collection)
|
||||
new.Id = "123456789012341"
|
||||
|
||||
form := forms.NewRecordUpsert(app, new)
|
||||
form.SetDao(dao)
|
||||
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.Fatalf("[%s] Expected error, got nil", prefix)
|
||||
}
|
||||
|
||||
if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
|
||||
t.Fatalf("[%s] Expected the inserted record to be deleted, found \n%v", prefix, r.PublicExport())
|
||||
}
|
||||
}
|
||||
|
||||
// update with invalid file
|
||||
{
|
||||
prefix := fmt.Sprintf("%d-update", i)
|
||||
|
||||
record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.SetDao(dao)
|
||||
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.Fatalf("[%s] Expected error, got nil", prefix)
|
||||
}
|
||||
|
||||
if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
|
||||
t.Fatalf("[%s] Expected the record changes to be reverted, got \n%v", prefix, r.PublicExport())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
@@ -1268,16 +1178,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
map[string]any{
|
||||
"field1": relId1,
|
||||
"field2": relId2,
|
||||
"field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check nonexisting collection id",
|
||||
map[string]any{
|
||||
|
||||
@@ -4,13 +4,13 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.209
|
||||
github.com/aws/aws-sdk-go v1.44.249
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.4.0
|
||||
github.com/domodwyer/mailyak/v3 v3.6.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.5.0
|
||||
@@ -18,71 +18,71 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
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.6.0
|
||||
golang.org/x/net v0.7.0
|
||||
golang.org/x/oauth2 v0.5.0
|
||||
modernc.org/sqlite v1.21.0
|
||||
github.com/spf13/cobra v1.7.0
|
||||
gocloud.dev v0.29.0
|
||||
golang.org/x/crypto v0.8.0
|
||||
golang.org/x/net v0.9.0
|
||||
golang.org/x/oauth2 v0.7.0
|
||||
modernc.org/sqlite v1.22.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.5 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.18.0 // 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.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.55 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 // 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.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.10 // 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-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.5.0 // indirect
|
||||
golang.org/x/mod v0.8.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/term v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
golang.org/x/image v0.7.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
golang.org/x/term v0.7.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
golang.org/x/tools v0.8.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.110.0 // indirect
|
||||
google.golang.org/api v0.119.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect
|
||||
google.golang.org/grpc v1.53.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // 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.3 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
|
||||
+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,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,141 @@
|
||||
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
|
||||
}
|
||||
|
||||
var hasIndexesColumn bool
|
||||
for _, col := range cols {
|
||||
if col == "indexes" {
|
||||
// already existing (probably via the init migration)
|
||||
hasIndexesColumn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasIndexesColumn {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ const (
|
||||
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"`
|
||||
|
||||
@@ -75,22 +75,22 @@ 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}}`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -279,9 +279,9 @@ func (m *Record) IgnoreEmailVisibility(state bool) {
|
||||
m.ignoreEmailVisibility = state
|
||||
}
|
||||
|
||||
// WithUnkownData toggles the export/serialization of unknown data fields
|
||||
// WithUnknownData toggles the export/serialization of unknown data fields
|
||||
// (false by default).
|
||||
func (m *Record) WithUnkownData(state bool) {
|
||||
func (m *Record) WithUnknownData(state bool) {
|
||||
m.exportUnknown = state
|
||||
}
|
||||
|
||||
@@ -630,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
|
||||
@@ -864,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
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ func TestRecordCleanCopy(t *testing.T) {
|
||||
// allow the special flags and options to check whether they will be ignored
|
||||
m.SetExpand(map[string]any{"test": 123})
|
||||
m.IgnoreEmailVisibility(true)
|
||||
m.WithUnkownData(true)
|
||||
m.WithUnknownData(true)
|
||||
|
||||
copy := m.CleanCopy()
|
||||
copyExport, _ := copy.MarshalJSON()
|
||||
@@ -1343,7 +1343,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
|
||||
|
||||
m.Load(data)
|
||||
m.IgnoreEmailVisibility(s.exportHidden)
|
||||
m.WithUnkownData(s.exportUnknown)
|
||||
m.WithUnknownData(s.exportUnknown)
|
||||
|
||||
exportResult, err := json.Marshal(m.PublicExport())
|
||||
if err != nil {
|
||||
|
||||
@@ -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,8 +131,12 @@ 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -399,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
|
||||
}
|
||||
@@ -564,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 {
|
||||
@@ -575,11 +590,14 @@ 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
|
||||
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
|
||||
Thumbs []string `form:"thumbs" json:"thumbs"`
|
||||
Protected bool `form:"protected" json:"protected"`
|
||||
}
|
||||
|
||||
func (o FileOptions) Validate() error {
|
||||
@@ -593,8 +611,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"`
|
||||
@@ -632,6 +658,12 @@ func (o RelationOptions) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// 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+
|
||||
|
||||
@@ -504,7 +504,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeFile},
|
||||
false,
|
||||
`{"system":false,"id":"","name":"","type":"file","required":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null}}`,
|
||||
`{"system":false,"id":"","name":"","type":"file","required":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null,"protected":false}}`,
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,12 @@ type Settings struct {
|
||||
|
||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||
AdminFileToken TokenConfig `form:"adminFileToken" json:"adminFileToken"`
|
||||
RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
|
||||
RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
|
||||
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
|
||||
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
|
||||
RecordFileToken TokenConfig `form:"recordFileToken" json:"recordFileToken"`
|
||||
|
||||
// Deprecated: Will be removed in v0.9+
|
||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||
@@ -55,6 +57,7 @@ type Settings struct {
|
||||
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.
|
||||
@@ -83,27 +86,35 @@ func New() *Settings {
|
||||
},
|
||||
AdminAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days,
|
||||
Duration: 1209600, // 14 days
|
||||
},
|
||||
AdminPasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
Duration: 1800, // 30 minutes
|
||||
},
|
||||
AdminFileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 300, // 5 minutes
|
||||
},
|
||||
RecordAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days,
|
||||
Duration: 1209600, // 14 days
|
||||
},
|
||||
RecordPasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
Duration: 1800, // 30 minutes
|
||||
},
|
||||
RecordVerificationToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 604800, // 7 days,
|
||||
Duration: 604800, // 7 days
|
||||
},
|
||||
RecordFileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 300, // 5 minutes
|
||||
},
|
||||
RecordEmailChangeToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
Duration: 1800, // 30 minutes
|
||||
},
|
||||
GoogleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
@@ -156,6 +167,9 @@ func New() *Settings {
|
||||
OIDC3Auth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
AppleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +183,12 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.Logs),
|
||||
validation.Field(&s.AdminAuthToken),
|
||||
validation.Field(&s.AdminPasswordResetToken),
|
||||
validation.Field(&s.AdminFileToken),
|
||||
validation.Field(&s.RecordAuthToken),
|
||||
validation.Field(&s.RecordPasswordResetToken),
|
||||
validation.Field(&s.RecordEmailChangeToken),
|
||||
validation.Field(&s.RecordVerificationToken),
|
||||
validation.Field(&s.RecordFileToken),
|
||||
validation.Field(&s.Smtp),
|
||||
validation.Field(&s.S3),
|
||||
validation.Field(&s.GoogleAuth),
|
||||
@@ -192,6 +208,7 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.OIDCAuth),
|
||||
validation.Field(&s.OIDC2Auth),
|
||||
validation.Field(&s.OIDC3Auth),
|
||||
validation.Field(&s.AppleAuth),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,10 +247,12 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.S3.Secret,
|
||||
&clone.AdminAuthToken.Secret,
|
||||
&clone.AdminPasswordResetToken.Secret,
|
||||
&clone.AdminFileToken.Secret,
|
||||
&clone.RecordAuthToken.Secret,
|
||||
&clone.RecordPasswordResetToken.Secret,
|
||||
&clone.RecordEmailChangeToken.Secret,
|
||||
&clone.RecordVerificationToken.Secret,
|
||||
&clone.RecordFileToken.Secret,
|
||||
&clone.GoogleAuth.ClientSecret,
|
||||
&clone.FacebookAuth.ClientSecret,
|
||||
&clone.GithubAuth.ClientSecret,
|
||||
@@ -251,6 +270,7 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.OIDCAuth.ClientSecret,
|
||||
&clone.OIDC2Auth.ClientSecret,
|
||||
&clone.OIDC3Auth.ClientSecret,
|
||||
&clone.AppleAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
@@ -287,6 +307,7 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
auth.NameOIDC: s.OIDCAuth,
|
||||
auth.NameOIDC + "2": s.OIDC2Auth,
|
||||
auth.NameOIDC + "3": s.OIDC3Auth,
|
||||
auth.NameApple: s.AppleAuth,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,12 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.S3.Endpoint = "invalid"
|
||||
s.AdminAuthToken.Duration = -10
|
||||
s.AdminPasswordResetToken.Duration = -10
|
||||
s.AdminFileToken.Duration = -10
|
||||
s.RecordAuthToken.Duration = -10
|
||||
s.RecordPasswordResetToken.Duration = -10
|
||||
s.RecordEmailChangeToken.Duration = -10
|
||||
s.RecordVerificationToken.Duration = -10
|
||||
s.RecordFileToken.Duration = -10
|
||||
s.GoogleAuth.Enabled = true
|
||||
s.GoogleAuth.ClientId = ""
|
||||
s.FacebookAuth.Enabled = true
|
||||
@@ -63,6 +65,8 @@ func TestSettingsValidate(t *testing.T) {
|
||||
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()
|
||||
@@ -77,10 +81,12 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
@@ -98,6 +104,7 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@@ -122,10 +129,12 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.S3.Endpoint = "test"
|
||||
s2.AdminAuthToken.Duration = 1
|
||||
s2.AdminPasswordResetToken.Duration = 2
|
||||
s2.AdminFileToken.Duration = 2
|
||||
s2.RecordAuthToken.Duration = 3
|
||||
s2.RecordPasswordResetToken.Duration = 4
|
||||
s2.RecordEmailChangeToken.Duration = 5
|
||||
s2.RecordVerificationToken.Duration = 6
|
||||
s2.RecordFileToken.Duration = 7
|
||||
s2.GoogleAuth.Enabled = true
|
||||
s2.GoogleAuth.ClientId = "google_test"
|
||||
s2.FacebookAuth.Enabled = true
|
||||
@@ -160,6 +169,8 @@ func TestSettingsMerge(t *testing.T) {
|
||||
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)
|
||||
@@ -222,10 +233,12 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.S3.Secret = testSecret
|
||||
s1.AdminAuthToken.Secret = testSecret
|
||||
s1.AdminPasswordResetToken.Secret = testSecret
|
||||
s1.AdminFileToken.Secret = testSecret
|
||||
s1.RecordAuthToken.Secret = testSecret
|
||||
s1.RecordPasswordResetToken.Secret = testSecret
|
||||
s1.RecordEmailChangeToken.Secret = testSecret
|
||||
s1.RecordVerificationToken.Secret = testSecret
|
||||
s1.RecordFileToken.Secret = testSecret
|
||||
s1.GoogleAuth.ClientSecret = testSecret
|
||||
s1.FacebookAuth.ClientSecret = testSecret
|
||||
s1.GithubAuth.ClientSecret = testSecret
|
||||
@@ -243,6 +256,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.OIDCAuth.ClientSecret = testSecret
|
||||
s1.OIDC2Auth.ClientSecret = testSecret
|
||||
s1.OIDC3Auth.ClientSecret = testSecret
|
||||
s1.AppleAuth.ClientSecret = testSecret
|
||||
|
||||
s1Bytes, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
@@ -297,6 +311,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.OIDCAuth.ClientId = "oidc_test"
|
||||
s.OIDC2Auth.ClientId = "oidc2_test"
|
||||
s.OIDC3Auth.ClientId = "oidc3_test"
|
||||
s.AppleAuth.ClientId = "apple_test"
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
@@ -324,6 +339,7 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
`"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) {
|
||||
|
||||
@@ -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
|
||||
// ---
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ func NewRecordFieldResolver(
|
||||
`^\@request\.auth\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.data\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.query\.[\w\.\:]*\w+$`,
|
||||
`^\@request\.headers\.\w+$`,
|
||||
`^\@collection\.\w+\.[\w\.\:]*\w+$`,
|
||||
},
|
||||
}
|
||||
@@ -92,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 {
|
||||
@@ -132,6 +134,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.query.filter
|
||||
// @request.headers.x_token
|
||||
// @request.auth.someRelation.name
|
||||
// @request.data.someRelation.name
|
||||
// @request.data.someField
|
||||
@@ -249,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
@@ -159,6 +159,7 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// @todo consider adding the response body to the AfterTestFunc args
|
||||
if scenario.AfterTestFunc != nil {
|
||||
scenario.AfterTestFunc(t, testApp, e)
|
||||
}
|
||||
|
||||
@@ -446,6 +446,14 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
|
||||
return t.registerEventCall("OnFileDownloadRequest")
|
||||
})
|
||||
|
||||
t.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
|
||||
return t.registerEventCall("OnFileBeforeTokenRequest")
|
||||
})
|
||||
|
||||
t.OnFileAfterTokenRequest().Add(func(e *core.FileTokenEvent) error {
|
||||
return t.registerEventCall("OnFileAfterTokenRequest")
|
||||
})
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -24,3 +24,12 @@ func NewAdminResetPasswordToken(app core.App, admin *models.Admin) (string, erro
|
||||
app.Settings().AdminPasswordResetToken.Duration,
|
||||
)
|
||||
}
|
||||
|
||||
// NewAdminFileToken generates and returns a new admin private file access token.
|
||||
func NewAdminFileToken(app core.App, admin *models.Admin) (string, error) {
|
||||
return security.NewToken(
|
||||
jwt.MapClaims{"id": admin.Id, "type": TypeAdmin},
|
||||
(admin.TokenKey + app.Settings().AdminFileToken.Secret),
|
||||
app.Settings().AdminFileToken.Duration,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,3 +52,26 @@ func TestNewAdminResetPasswordToken(t *testing.T) {
|
||||
t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdminFileToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, err := tokens.NewAdminFileToken(app, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tokenAdmin, _ := app.Dao().FindAdminByToken(
|
||||
token,
|
||||
app.Settings().AdminFileToken.Secret,
|
||||
)
|
||||
if tokenAdmin == nil || tokenAdmin.Id != admin.Id {
|
||||
t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,3 +76,20 @@ func NewRecordChangeEmailToken(app core.App, record *models.Record, newEmail str
|
||||
app.Settings().RecordEmailChangeToken.Duration,
|
||||
)
|
||||
}
|
||||
|
||||
// NewRecordFileToken generates and returns a new record private file access token.
|
||||
func NewRecordFileToken(app core.App, record *models.Record) (string, error) {
|
||||
if !record.Collection().IsAuth() {
|
||||
return "", errors.New("The record is not from an auth collection.")
|
||||
}
|
||||
|
||||
return security.NewToken(
|
||||
jwt.MapClaims{
|
||||
"id": record.Id,
|
||||
"type": TypeAuthRecord,
|
||||
"collectionId": record.Collection().Id,
|
||||
},
|
||||
(record.TokenKey() + app.Settings().RecordFileToken.Secret),
|
||||
app.Settings().RecordFileToken.Duration,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,3 +98,26 @@ func TestNewRecordChangeEmailToken(t *testing.T) {
|
||||
t.Fatalf("Expected auth record %v, got %v", user, tokenRecord)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRecordFileToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, err := tokens.NewRecordFileToken(app, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tokenRecord, _ := app.Dao().FindAuthRecordByToken(
|
||||
token,
|
||||
app.Settings().RecordFileToken.Secret,
|
||||
)
|
||||
if tokenRecord == nil || tokenRecord.Id != user.Id {
|
||||
t.Fatalf("Expected auth record %v, got %v", user, tokenRecord)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -120,6 +127,8 @@ func NewProviderByName(name string) (Provider, error) {
|
||||
return NewOIDCProvider(), nil
|
||||
case NameOIDC + "3":
|
||||
return NewOIDCProvider(), nil
|
||||
case NameApple:
|
||||
return NewAppleProvider(), nil
|
||||
default:
|
||||
return nil, errors.New("Missing provider " + name)
|
||||
}
|
||||
|
||||
@@ -171,4 +171,13 @@ func TestNewProviderByName(t *testing.T) {
|
||||
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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -22,6 +23,7 @@ type Github struct {
|
||||
// NewGithubProvider creates new Github provider instance with some defaults.
|
||||
func NewGithubProvider() *Github {
|
||||
return &Github{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"read:user", "user:email"},
|
||||
authUrl: github.Endpoint.AuthURL,
|
||||
tokenUrl: github.Endpoint.TokenURL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
@@ -20,6 +21,7 @@ type Gitlab struct {
|
||||
// NewGitlabProvider creates new Gitlab provider instance with some defaults.
|
||||
func NewGitlabProvider() *Gitlab {
|
||||
return &Gitlab{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"read_user"},
|
||||
authUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
|
||||
+11
-5
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -19,6 +20,7 @@ type Google struct {
|
||||
// NewGoogleProvider creates new Google provider instance with some defaults.
|
||||
func NewGoogleProvider() *Google {
|
||||
return &Google{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
@@ -42,10 +44,11 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||
}
|
||||
|
||||
extracted := struct {
|
||||
Id string
|
||||
Name string
|
||||
Email string
|
||||
Picture string
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Picture string `json:"picture"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
}{}
|
||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||
return nil, err
|
||||
@@ -54,12 +57,15 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||
user := &AuthUser{
|
||||
Id: extracted.Id,
|
||||
Name: extracted.Name,
|
||||
Email: extracted.Email,
|
||||
AvatarUrl: extracted.Picture,
|
||||
RawUser: rawUser,
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
}
|
||||
|
||||
if extracted.VerifiedEmail {
|
||||
user.Email = extracted.Email
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
@@ -21,6 +22,7 @@ type Kakao struct {
|
||||
// NewKakaoProvider creates a new Kakao provider instance with some defaults.
|
||||
func NewKakaoProvider() *Kakao {
|
||||
return &Kakao{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"account_email", "profile_nickname", "profile_image"},
|
||||
authUrl: kakao.Endpoint.AuthURL,
|
||||
tokenUrl: kakao.Endpoint.TokenURL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -19,6 +20,7 @@ type Livechat struct {
|
||||
// NewLivechatProvider creates new Livechat provider instance with some defaults.
|
||||
func NewLivechatProvider() *Livechat {
|
||||
return &Livechat{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{}, // default scopes are specified from the provider dashboard
|
||||
authUrl: "https://accounts.livechat.com/",
|
||||
tokenUrl: "https://accounts.livechat.com/token",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -21,6 +22,7 @@ type Microsoft struct {
|
||||
func NewMicrosoftProvider() *Microsoft {
|
||||
endpoints := microsoft.AzureADEndpoint("")
|
||||
return &Microsoft{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"User.Read"},
|
||||
authUrl: endpoints.AuthURL,
|
||||
tokenUrl: endpoints.TokenURL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -19,6 +20,7 @@ type OIDC struct {
|
||||
// NewOIDCProvider creates new OpenID Connect (OIDC) provider instance with some defaults.
|
||||
func NewOIDCProvider() *OIDC {
|
||||
return &OIDC{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{
|
||||
"openid", // minimal requirement to return the id
|
||||
"email",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -20,6 +21,7 @@ type Spotify struct {
|
||||
// NewSpotifyProvider creates a new Spotify provider instance with some defaults.
|
||||
func NewSpotifyProvider() *Spotify {
|
||||
return &Spotify{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{
|
||||
"user-read-private",
|
||||
// currently Spotify doesn't return information whether the email is verified or not
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
@@ -20,6 +21,7 @@ type Strava struct {
|
||||
// NewStravaProvider creates new Strava provider instance with some defaults.
|
||||
func NewStravaProvider() *Strava {
|
||||
return &Strava{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{
|
||||
"profile:read_all",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -22,6 +23,7 @@ type Twitch struct {
|
||||
// NewTwitchProvider creates new Twitch provider instance with some defaults.
|
||||
func NewTwitchProvider() *Twitch {
|
||||
return &Twitch{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{"user:read:email"},
|
||||
authUrl: twitch.Endpoint.AuthURL,
|
||||
tokenUrl: twitch.Endpoint.TokenURL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
@@ -19,6 +20,7 @@ type Twitter struct {
|
||||
// NewTwitterProvider creates new Twitter provider instance with some defaults.
|
||||
func NewTwitterProvider() *Twitter {
|
||||
return &Twitter{&baseProvider{
|
||||
ctx: context.Background(),
|
||||
scopes: []string{
|
||||
"users.read",
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package dbutils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/tokenizer"
|
||||
)
|
||||
|
||||
var (
|
||||
indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s+\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`)
|
||||
indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`)
|
||||
)
|
||||
|
||||
// IndexColumn represents a single parsed SQL index column.
|
||||
type IndexColumn struct {
|
||||
Name string `json:"name"` // identifier or expression
|
||||
Collate string `json:"collate"`
|
||||
Sort string `json:"sort"`
|
||||
}
|
||||
|
||||
// Index represents a single parsed SQL CREATE INDEX expression.
|
||||
type Index struct {
|
||||
Unique bool `json:"unique"`
|
||||
Optional bool `json:"optional"`
|
||||
SchemaName string `json:"schemaName"`
|
||||
IndexName string `json:"indexName"`
|
||||
TableName string `json:"tableName"`
|
||||
Columns []IndexColumn `json:"columns"`
|
||||
Where string `json:"where"`
|
||||
}
|
||||
|
||||
// IsValid checks if the current Index contains the minimum required fields to be considered valid.
|
||||
func (idx Index) IsValid() bool {
|
||||
return idx.IndexName != "" && idx.TableName != "" && len(idx.Columns) > 0
|
||||
}
|
||||
|
||||
// Build returns a "CREATE INDEX" SQL string from the current index parts.
|
||||
//
|
||||
// Returns empty string if idx.IsValid() is false.
|
||||
func (idx Index) Build() string {
|
||||
if !idx.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
|
||||
str.WriteString("CREATE ")
|
||||
|
||||
if idx.Unique {
|
||||
str.WriteString("UNIQUE ")
|
||||
}
|
||||
|
||||
str.WriteString("INDEX ")
|
||||
|
||||
if idx.Optional {
|
||||
str.WriteString("IF NOT EXISTS ")
|
||||
}
|
||||
|
||||
if idx.SchemaName != "" {
|
||||
str.WriteString("`")
|
||||
str.WriteString(idx.SchemaName)
|
||||
str.WriteString("`.")
|
||||
}
|
||||
|
||||
str.WriteString("`")
|
||||
str.WriteString(idx.IndexName)
|
||||
str.WriteString("` ")
|
||||
|
||||
str.WriteString("ON `")
|
||||
str.WriteString(idx.TableName)
|
||||
str.WriteString("` (")
|
||||
|
||||
if len(idx.Columns) > 1 {
|
||||
str.WriteString("\n ")
|
||||
}
|
||||
|
||||
var hasCol bool
|
||||
for _, col := range idx.Columns {
|
||||
trimmedColName := strings.TrimSpace(col.Name)
|
||||
if trimmedColName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasCol {
|
||||
str.WriteString(",\n ")
|
||||
}
|
||||
|
||||
if strings.Contains(col.Name, "(") || strings.Contains(col.Name, " ") {
|
||||
// most likely an expression
|
||||
str.WriteString(trimmedColName)
|
||||
} else {
|
||||
// regular identifier
|
||||
str.WriteString("`")
|
||||
str.WriteString(trimmedColName)
|
||||
str.WriteString("`")
|
||||
}
|
||||
|
||||
if col.Collate != "" {
|
||||
str.WriteString(" COLLATE ")
|
||||
str.WriteString(col.Collate)
|
||||
}
|
||||
|
||||
if col.Sort != "" {
|
||||
str.WriteString(" ")
|
||||
str.WriteString(strings.ToUpper(col.Sort))
|
||||
}
|
||||
|
||||
hasCol = true
|
||||
}
|
||||
|
||||
if hasCol && len(idx.Columns) > 1 {
|
||||
str.WriteString("\n")
|
||||
}
|
||||
|
||||
str.WriteString(")")
|
||||
|
||||
if idx.Where != "" {
|
||||
str.WriteString(" WHERE ")
|
||||
str.WriteString(idx.Where)
|
||||
}
|
||||
|
||||
return str.String()
|
||||
}
|
||||
|
||||
// ParseIndex parses the provided "CREATE INDEX" SQL string into Index struct.
|
||||
func ParseIndex(createIndexExpr string) Index {
|
||||
result := Index{}
|
||||
|
||||
matches := indexRegex.FindStringSubmatch(createIndexExpr)
|
||||
if len(matches) != 7 {
|
||||
return result
|
||||
}
|
||||
|
||||
trimChars := "`\"'[]\r\n\t\f\v "
|
||||
|
||||
// Unique
|
||||
// ---
|
||||
result.Unique = strings.TrimSpace(matches[1]) != ""
|
||||
|
||||
// Optional (aka. "IF NOT EXISTS")
|
||||
// ---
|
||||
result.Optional = strings.TrimSpace(matches[2]) != ""
|
||||
|
||||
// SchemaName and IndexName
|
||||
// ---
|
||||
nameTk := tokenizer.NewFromString(matches[3])
|
||||
nameTk.Separators('.')
|
||||
|
||||
nameParts, _ := nameTk.ScanAll()
|
||||
if len(nameParts) == 2 {
|
||||
result.SchemaName = strings.Trim(nameParts[0], trimChars)
|
||||
result.IndexName = strings.Trim(nameParts[1], trimChars)
|
||||
} else {
|
||||
result.IndexName = strings.Trim(nameParts[0], trimChars)
|
||||
}
|
||||
|
||||
// TableName
|
||||
// ---
|
||||
result.TableName = strings.Trim(matches[4], trimChars)
|
||||
|
||||
// Columns
|
||||
// ---
|
||||
columnsTk := tokenizer.NewFromString(matches[5])
|
||||
columnsTk.Separators(',')
|
||||
|
||||
rawColumns, _ := columnsTk.ScanAll()
|
||||
|
||||
result.Columns = make([]IndexColumn, 0, len(rawColumns))
|
||||
|
||||
for _, col := range rawColumns {
|
||||
colMatches := indexColumnRegex.FindStringSubmatch(col)
|
||||
if len(colMatches) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
trimmedName := strings.Trim(colMatches[1], trimChars)
|
||||
if trimmedName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result.Columns = append(result.Columns, IndexColumn{
|
||||
Name: trimmedName,
|
||||
Collate: strings.TrimSpace(colMatches[2]),
|
||||
Sort: strings.ToUpper(colMatches[3]),
|
||||
})
|
||||
}
|
||||
|
||||
// WHERE expression
|
||||
// ---
|
||||
result.Where = strings.TrimSpace(matches[6])
|
||||
|
||||
return result
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user