Compare commits

...
154 Commits
Author SHA1 Message Date
Gani Georgiev 74306c8092 updated go deps 2023-04-25 18:31:27 +03:00
Gani Georgiev a5b3cc0f34 use relative oauth2 path redirect to support subpath proxy deployments 2023-04-25 18:29:36 +03:00
Gani Georgiev 0478f84867 updated /api/oauth2-redirect error messages 2023-04-25 11:52:56 +03:00
Gani Georgiev 0b5e189563 added OAuth2 redirect fallback message to notify the user to go back to the app 2023-04-25 08:36:02 +03:00
Gani Georgiev e61d48fe7b [#2349] fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source 2023-04-24 15:43:23 +03:00
Gani Georgiev cae3315e46 [#2349] fixed view query SELECT DISTINCT parsing 2023-04-24 12:54:57 +03:00
Gani Georgiev a2a170ad82 updated go deps 2023-04-21 10:30:01 +03:00
Gani Georgiev 92dcee7250 skip Cache-Control header for the Admin UI root path 2023-04-21 10:28:28 +03:00
Gani Georgiev bd2521b14b added cache-control header for the admin ui assets 2023-04-21 01:19:57 +03:00
Gani Georgiev 0c63e0e219 added editor loading placeholder 2023-04-21 00:42:41 +03:00
Gani Georgiev d407ca2163 updated ui/dist 2023-04-20 23:59:59 +03:00
Gani Georgiev 5c57be8469 removed tinymce autoresize transition 2023-04-20 23:55:50 +03:00
Gani Georgiev 4b0e614c68 updated changelog 2023-04-20 17:42:38 +03:00
Gani Georgiev 5c95a43042 move relations load to the end of the execution queue t 2023-04-20 17:38:04 +03:00
Gani Georgiev 8240addfab fixed rtl editor menu item typo 2023-04-20 15:36:42 +03:00
Gani Georgiev e1cfe7fc4a [#2327] enabled rtl for the editor field 2023-04-20 15:33:02 +03:00
Gani Georgiev 01f4765c09 added Command+S quick save alias and fixed relations draft restore 2023-04-20 14:38:24 +03:00
Gani Georgiev 818857dea2 [#2325] trigger the related record realtime events on custom record model change 2023-04-20 10:44:20 +03:00
Gani Georgiev fdf4f6d3bd fixed ctrl+s not getting triggered in the editor field 2023-04-19 10:33:24 +03:00
Gani Georgiev 0420a00c18 updated ui/dist 2023-04-18 06:32:01 +03:00
Gani Georgiev b39d607c8a updated changelog and package.json 2023-04-18 06:30:05 +03:00
Gani Georgiev abea28a1a9 fixed minor typos 2023-04-17 22:22:41 +03:00
Gani Georgiev a7d5a0640c allowed specifying non-context auth model for the file token endpoint 2023-04-17 22:05:09 +03:00
Gani Georgiev c937c06688 updated ui/dist 2023-04-17 20:59:04 +03:00
Gani Georgiev d11cec2226 updated cascade delete tooltip 2023-04-17 16:33:26 +03:00
Gani Georgiev c179b4c473 updated oauth2 docs and added create api rule tooltip 2023-04-17 16:29:56 +03:00
Gani Georgiev 00b04db5cf added ctrl+s record form shortcut and updated user external auths panel with the new provider logos 2023-04-17 14:35:49 +03:00
Gani Georgiev 1b8776926e updated protected files visualization 2023-04-17 13:28:41 +03:00
Gani Georgiev 5eb54c7a3d store unsaved record changes in local storage 2023-04-17 12:43:48 +03:00
Gani Georgiev 6127350e91 added eagerRequestDataCache middleware 2023-04-15 14:44:07 +03:00
Gani Georgiev 177230a765 renamed private to protected 2023-04-15 13:27:42 +03:00
Gani Georgiev 8cb0d980ad updated dart record view example and the relation cascade delete label 2023-04-15 11:27:07 +03:00
Gani Georgiev d44c2bb1a9 removed implicit autofocus as it is causing keyboard navigation issues 2023-04-15 11:23:59 +03:00
Gani Georgiev 3423433d01 updated ui/dist 2023-04-15 00:50:10 +03:00
Gani Georgiev 646b6a925b limit the number of displayed thumbs in the records listing 2023-04-15 00:47:49 +03:00
Gani Georgiev 5ddf9cd443 don't skip temp indexes migration if the indexes column is already created 2023-04-15 00:44:19 +03:00
Gani Georgiev 0351f3a1ad updated overlay panel autofocus 2023-04-14 15:28:24 +03:00
Gani Georgiev 53c735d00b added simple loose wildcard search term support in the Admin UI 2023-04-14 14:45:50 +03:00
Gani Georgiev 25769e971a added import.meta.env.BASE_URL prefix for the auth provider logos 2023-04-14 13:32:59 +03:00
Gani Georgiev aba6279feb Merge branch 'master' into develop 2023-04-14 12:58:03 +03:00
Gani Georgiev ac4a961a10 added unique error test for the record update api 2023-04-13 23:04:24 +03:00
Gani Georgiev 8317ae2e6b [#2287] fixed unique field detailed error not being returned on DrySubmit failure 2023-04-13 22:37:10 +03:00
Gani Georgiev b347c8d80c [#2246] added drop files support for the file field 2023-04-13 16:46:50 +03:00
Gani Georgiev af5f808144 updated OAuth2 providers ui 2023-04-13 15:38:12 +03:00
Gani Georgiev a77b62e5bd added extra table name checks in the overwritten dao hooks in case of duplicated ids 2023-04-12 16:46:08 +03:00
Gani Georgiev c77467a6a2 synced with master 2023-04-12 16:31:36 +03:00
Gani Georgiev 35e433f26b updated changelog and ui/dist 2023-04-12 16:23:53 +03:00
Gani Georgiev 59f23d3d23 [#2277] added check for nil dao hooks 2023-04-12 16:09:35 +03:00
Gani Georgiev 6a69a035a7 synced with master 2023-04-12 14:27:17 +03:00
Gani Georgiev b8373b23a4 updated changelog 2023-04-12 10:31:11 +03:00
Gani Georgiev 25c1db40ac [#2272] fixed panic on list.ExistInSliceWithRegex cache 2023-04-12 10:27:22 +03:00
Gani Georgiev 7b48e9d1c1 updated go deps 2023-04-10 23:00:36 +03:00
Gani Georgiev b537085bca updated test scenario name 2023-04-10 22:58:07 +03:00
Gani Georgiev 3e5b021dd8 fixed oauth2SubscriptionRedirect test 2023-04-10 22:51:59 +03:00
Gani Georgiev 151dbafc86 updated ui/dist 2023-04-10 22:33:13 +03:00
Gani Georgiev dc72d5adee [#55] added OAuth2 subscription redirect handler 2023-04-10 22:27:00 +03:00
Gani Georgiev c826514eca synced with master 2023-04-07 11:39:45 +03:00
Gani Georgiev b7407edcae updated go.mod 2023-04-06 20:22:50 +03:00
Gani Georgiev 1420d717e3 [#2231] revert the aws-sdk-go-v2 change 2023-04-06 20:17:22 +03:00
Gani Georgiev f5f8c35a17 [#2221] fixed Admin UI Logs meta field visualization in Firefox 2023-04-05 19:43:41 +03:00
Gani Georgiev 733d7dacdb [#215] updated the admin ui to allow displaying private files 2023-04-05 13:23:22 +03:00
Gani Georgiev cd45854792 [#215] added Admin UI Private file option toggle 2023-04-04 21:38:03 +03:00
Gani Georgiev ba7000125b [#215] enabled Settings.AdminFileToken validations and added more tests 2023-04-04 20:47:03 +03:00
Gani Georgiev 64c3e3b3c5 [#215] added server-side handlers for serving private files 2023-04-04 20:33:35 +03:00
Gani Georgiev 9f76ad234c [#2205] fixed Record.WithUnknownData() typo 2023-04-03 22:01:59 +03:00
Gani Georgiev 8ca439e5f7 updated ui/dist 2023-04-03 21:11:04 +03:00
Gani Georgiev f31a3b133c revert part of the old COALESCE handling to support missing joined relation fields comparison with empty string 2023-04-03 20:27:52 +03:00
Gani Georgiev 850fe353da updated changelog 2023-04-01 13:00:28 +03:00
Gani Georgiev 16a6b32a76 updated go deps 2023-04-01 12:55:42 +03:00
Gani Georgiev a1487c3235 updated the record file handling for the before update hook too 2023-04-01 12:51:00 +03:00
Gani Georgiev 5255515e26 updated ui/dist 2023-04-01 12:38:04 +03:00
Gani Georgiev 0b90ce43c3 Merge branch 'develop' 2023-04-01 12:36:10 +03:00
Gani Georgiev 899b8217e0 fix realtime events firing before files upload completion 2023-04-01 11:47:01 +03:00
Gani Georgiev 69bf9779d9 added Dao.Clone() helper 2023-04-01 11:45:08 +03:00
Gani Georgiev 48d6803d17 check only the existence of the thumb and add ContentType metadata when creating the thumb 2023-03-31 23:06:22 +03:00
Gani Georgiev 216efb95a8 updated Dao.Save godoc 2023-03-30 21:19:14 +03:00
Gani GeorgievandYuxiang Gao 29a264e132 [#1346] upgraded to aws-sdk-v2
Co-authored-by: Yuxiang Gao <yuxiang-gao@outlook.com>
2023-03-30 16:10:13 +03:00
Gani Georgiev 976a9e2f27 updated tinymce 2023-03-30 14:42:28 +03:00
Gani Georgiev 005e77151d removed unused tinymce themes 2023-03-30 14:35:43 +03:00
Gani Georgiev f12467f2b3 updated list api docs 2023-03-30 12:25:33 +03:00
Gani Georgiev fe03dd0789 use backtick in index column preset 2023-03-30 12:17:56 +03:00
Gani Georgiev ff1f99436a prevent closing the datefield options on calendar select and updated collections upsert save handler 2023-03-28 09:06:16 +03:00
Gani Georgiev 3ea02c945d updated changelog and formatting 2023-03-28 07:59:37 +03:00
Gani Georgiev 4c903684d8 added linux cgo target 2023-03-27 21:39:46 +03:00
Gani Georgiev d0239f25ed updated changelog and ui/dist 2023-03-27 19:44:09 +03:00
Gani Georgiev 05adb8c018 update ui deps 2023-03-27 16:17:37 +03:00
Gani Georgiev c901c9ab7d updated go deps 2023-03-27 16:16:09 +03:00
Gani Georgiev c9fba9972a cleanup old remaining temp views 2023-03-27 16:07:47 +03:00
Gani Georgiev 2c40811fac added required field marker 2023-03-27 16:07:20 +03:00
Gani Georgiev b81112f82e minor ui adjustments 2023-03-26 22:49:33 +03:00
Gani Georgiev 0acf8198a4 revert record upsert changes on upload failure 2023-03-26 22:49:18 +03:00
Gani Georgiev 5ece360808 autoselect new field names 2023-03-26 20:32:41 +03:00
Gani Georgiev 3a5d3d521f added ProviderName and ProviderClient fields to core.RecordAuthWithOAuth2Event 2023-03-26 19:32:23 +03:00
Gani Georgiev f024de3cc4 updated ui/dist 2023-03-26 19:19:44 +03:00
Gani Georgiev a4abe9e2cb removed unused AuthProviderConfig.Meta field 2023-03-25 23:34:00 +02:00
Gani Georgiev 5678339af0 added migrate history-sync command 2023-03-25 21:48:19 +02:00
Gani Georgiev e5a22b8bd8 added a flag indicating OAuth2 auth record creation 2023-03-25 15:18:28 +02:00
Gani Georgiev be0ee7f66c fixed generateAppleClientSecret call 2023-03-24 22:48:32 +02:00
Gani Georgiev 78a9f7429c updated ui/dist 2023-03-24 22:30:57 +02:00
Gani Georgiev d8b4daf47f updated rule field styles 2023-03-23 22:59:02 +02:00
Gani Georgiev 7a2360d785 [#2029] enable any id aliased column in a view query to be detected as relation field 2023-03-23 19:51:06 +02:00
Gani Georgiev 67ecebe935 [#1939] removed redundant COALESCE normalizations 2023-03-23 19:30:35 +02:00
Gani Georgiev 5cbdbf6c10 updated ui/dist 2023-03-23 16:15:19 +02:00
Gani Georgiev 0710214701 added new field indicator 2023-03-23 10:42:27 +02:00
Gani Georgiev 9736a45e80 renamed daos.GetTableColumns and daos.GetTableInfo for consistency 2023-03-22 17:15:17 +02:00
Gani Georgiev 923fc26a31 changed types.JsonArray to support generics 2023-03-22 17:12:44 +02:00
Gani Georgiev a79f3a7c56 removed test js file 2023-03-22 16:53:34 +02:00
Gani Georgiev 8430944650 deprecated SchemaField.Unique 2023-03-22 15:47:34 +02:00
Gani Georgiev 9b54fd3516 added debug log for already committed response error 2023-03-22 15:42:35 +02:00
Gani Georgiev 226352f884 replaced indirect expand field.Unique with unique index constraint check 2023-03-21 21:38:28 +02:00
Gani Georgiev 2a682a10b2 preload tinymce 2023-03-21 18:52:13 +02:00
Gani Georgiev b67549209a [#2118] added option to explicitly set the record id from the Admin UI 2023-03-21 17:56:31 +02:00
Gani Georgiev 1126a0e16f updated changelog and ui/dist 2023-03-21 15:55:42 +02:00
Gani Georgiev 17472cb40a minor internal indexes handling adjustments and test 2023-03-21 15:31:20 +02:00
Gani Georgiev 981de64c7f added Index.Build helper method 2023-03-21 14:26:44 +02:00
Gani Georgiev 1b45e23c81 removed unnecessary helper method and updated index parser regex 2023-03-19 22:15:18 +02:00
Gani Georgiev 29621303df updated indexes column migration to load unique fields and custom indexes 2023-03-19 22:13:52 +02:00
Gani Georgiev a0ec5707d1 (no tests) collection indexes scaffoldings 2023-03-19 16:18:33 +02:00
Gani Georgiev 695c20a969 decreased bcrypt round hash for admins to 12 for consistency with the auth records 2023-03-19 16:10:11 +02:00
Gani Georgiev 95bb2eb871 update automigrate templates to check collection indexes 2023-03-19 16:02:29 +02:00
Gani Georgiev 971916c20d removed unnecessary mail layou css class 2023-03-19 10:16:23 +02:00
Gani Georgiev 44f5172db7 added create index sql parser 2023-03-19 10:15:26 +02:00
Gani Georgiev 5fd103481c updated ui/dist 2023-03-19 10:14:44 +02:00
Gani Georgiev 82abd7e0b0 minor collection ui adjustments 2023-03-19 08:17:36 +02:00
Gani Georgiev 361cfb56b7 [#1904] simplified default mail template styles 2023-03-16 22:35:03 +02:00
Gani Georgiev 4d16d0e16e new schema and indexes ui 2023-03-16 19:21:16 +02:00
Gani Georgiev 254e691e92 [#2072] registered RemoveTrailingSlash middleware only for the /api/* routes 2023-03-15 18:09:49 +02:00
Gani Georgiev e735d9d21b synced with master 2023-03-14 02:04:34 +02:00
Gani Georgiev 3eb3d0bbdf lowered auth record bcrypt round factor to 12 2023-03-13 15:52:51 +02:00
Gani Georgiev f180af7af7 synced with master 2023-03-12 18:09:16 +02:00
Gani Georgiev d046811df7 optimized single relation lookups 2023-03-07 23:28:35 +02:00
Gani Georgiev 4768e07c0b added multiple->single value conversion warning 2023-03-06 23:24:50 +02:00
Gani Georgiev 0547202382 use nonconcurrent db when deleting log requests 2023-03-06 23:24:31 +02:00
Gani Georgiev 684f91f7e5 added indirect view update by source collection field change 2023-03-06 20:10:18 +02:00
Gani Georgiev bce4094134 updated changelog 2023-03-06 15:32:46 +02:00
Gani Georgiev 6a60bc1df3 meged apple-oauth2 2023-03-06 15:30:35 +02:00
Gani Georgiev 19b7a7a74f updated ui/dist 2023-03-06 15:29:03 +02:00
Gani Georgiev 8de0e61cf4 synced with master 2023-03-06 15:27:17 +02:00
Gani Georgiev 5344ec83fa normalized values on maxSelect change 2023-03-06 15:20:07 +02:00
Gani Georgiev 65aa114103 added google OAuth2 verified_email check 2023-03-05 19:19:11 +02:00
Gani Georgiev 8728161288 sync with latest changes 2023-03-05 16:16:07 +02:00
Gani Georgiev 2420b2804a synced with master 2023-03-05 16:12:51 +02:00
Gani Georgiev 2791628d96 minor updates to the rule fields ui 2023-03-05 15:18:51 +02:00
Gani Georgiev 01f0af7af6 added errors slide transition 2023-03-04 13:06:24 +02:00
Gani Georgiev e8d61e7b45 simplified rules ui 2023-03-04 13:05:55 +02:00
Gani Georgiev a5ac83c7b0 use jwt.ParseECPrivateKeyFromPEM instead of the custom one 2023-03-02 21:31:27 +02:00
Gani Georgiev a67c14c368 added support for @request.headers.* 2023-03-02 18:56:18 +02:00
Gani Georgiev 19faa0d8e7 updated calendar styles 2023-03-02 16:10:56 +02:00
Gani Georgiev 56a77fd5ff updated migrations comment 2023-03-02 15:17:29 +02:00
Gani Georgiev 07727dbde6 [#1956] normalized _requests.method to UPPERCASE 2023-03-02 15:15:00 +02:00
Gani Georgiev a3d26a73c3 removed unnecessary struct pointer 2023-03-02 14:07:46 +02:00
Gani Georgiev b328827705 added generate-client-secret api test 2023-03-01 23:45:54 +02:00
Gani Georgiev f5e5fae773 added apple oauth2 integration 2023-03-01 23:29:51 +02:00
Gani Georgiev 41f01bab0d fixed collections import message typo 2023-02-28 16:50:25 +02:00
395 changed files with 10542 additions and 10836 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+156
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+195
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
+119
View File
@@ -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)
}
}
+32
View File
@@ -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
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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,
})
}
+128
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+87
View File
@@ -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)
}
+117
View File
@@ -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
View File
@@ -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
+44 -10
View File
@@ -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
}
}
}
}
+1 -1
View File
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
expectError: true,
expectCollectionsCount: totalCollections,
expectEvents: map[string]int{
"OnModelBeforeDelete": 5,
"OnModelBeforeDelete": 4,
},
},
{
+15 -6
View File
@@ -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
View File
@@ -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
}
+62
View File
@@ -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())
}
}
}
}
-11
View File
@@ -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 {
-100
View File
@@ -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{
+42 -42
View File
@@ -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
+363 -240
View File
File diff suppressed because it is too large Load Diff
+13 -45
View File
@@ -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>
`
+7 -6
View File
@@ -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
})
}
+141
View File
@@ -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
})
}
+2 -2
View File
@@ -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
View File
@@ -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
}
+5 -4
View File
@@ -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"`
+5 -5
View File
@@ -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
View File
@@ -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
}
+2 -2
View File
@@ -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 {
+1
View File
@@ -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"`
}
+38 -6
View File
@@ -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+
+68 -1
View File
@@ -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)
}
}
}
+27 -6
View File
@@ -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,
}
}
+16
View File
@@ -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) {
+1
View File
@@ -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{
+32
View File
@@ -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{
+29
View File
@@ -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
+46 -25
View File
@@ -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
// ---
}
+5 -4
View File
@@ -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
+1
View File
@@ -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)
}
+8
View File
@@ -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.
+9
View File
@@ -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,
)
}
+23
View File
@@ -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)
}
}
+17
View File
@@ -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,
)
}
+23
View File
@@ -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)
}
}
+222
View File
@@ -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)
}
+9
View File
@@ -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)
}
+9
View File
@@ -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
View File
@@ -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),
)
}
+17
View File
@@ -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{}
+2
View File
@@ -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",
+2
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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
+2
View File
@@ -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",
},
+2
View File
@@ -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,
+2
View File
@@ -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",
+194
View File
@@ -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