Compare commits

...
327 Commits
Author SHA1 Message Date
Gani Georgiev e3d2608d03 updated backport changelog 2026-03-16 19:01:40 +02:00
Gani Georgiev 650a4255cc updated changelog 2026-03-16 18:58:08 +02:00
Gani Georgiev de70af2584 updated npm deps and ui/dist 2026-03-16 18:52:51 +02:00
Gani Georgiev ba7ed78b73 updated modernc.org/sqlite to 1.46.2 (SQLite 3.51.3) 2026-03-16 18:45:54 +02:00
Gani Georgiev cea149cb6e updated jsvm types 2026-03-11 15:02:59 +02:00
Gani Georgiev 70d8d1ee9d replace the custom ratelimiter strategy with a fixed window 2026-03-11 11:25:15 +02:00
Gani Georgiev 29c2e209f4 updated ui/dist 2026-03-09 17:33:04 +02:00
Gani Georgiev ef465957ff fixed comment typo 2026-03-09 17:30:35 +02:00
Gani Georgiev ba8b51af58 [#7575] use memory+file buffer when rereading the request body (fix #7572) 2026-03-09 17:19:09 +02:00
Gani Georgiev 93e3eb3a35 regenerated jsvm types 2026-03-06 09:27:44 +02:00
Gani Georgiev cf77c0e7eb updated v0.22 changelog 2026-03-06 09:23:27 +02:00
Gani Georgiev d4578c3b8c bumped min Go GitHub action version to 1.26.1 2026-03-06 09:17:00 +02:00
Gani Georgiev c2f3fe6a52 fix typo 2026-03-05 10:57:34 +02:00
Gani Georgiev bb18799a0b applied lint typo fixes 2026-03-04 22:31:27 +02:00
Gani Georgiev 4a40c1b897 added todo for the extra rule constraint 2026-03-04 22:14:49 +02:00
Gani Georgiev e9d4b1fe77 limit the debug stack trace of FireAndForget to 2kb 2026-03-01 23:39:30 +02:00
Gani Georgiev 9cefd0128c updated changelog 2026-03-01 00:15:25 +02:00
Gani Georgiev d695e9d180 wrap extra client-side ListRule filter with existence check and replaced the map with a slice to minimize test flakiness 2026-03-01 00:09:05 +02:00
Gani Georgiev faf96896e7 fixed formatting 2026-02-28 23:44:12 +02:00
Gani Georgiev 72364bf4e0 fixed grammar 2026-02-27 09:06:59 +02:00
Gani Georgiev 969d4e2bfb updated ui/dist 2026-02-27 08:50:49 +02:00
Gani Georgiev 430d892e64 fixed grammar 2026-02-27 08:49:51 +02:00
Gani Georgiev fb8af7a8cf bumped npm deps 2026-02-27 08:27:57 +02:00
Gani Georgiev 6ecb412849 updated changelog 2026-02-24 23:02:58 +02:00
Gani Georgiev bc7080337e check the data again after the GetOrSet write lock 2026-02-24 23:02:11 +02:00
Gani Georgiev c157331721 updated the security policy 2026-02-24 22:09:34 +02:00
Gani Georgiev 27ab2d45e8 fixed typo 2026-02-24 15:19:25 +02:00
Gani Georgiev d5d8decf6e [#7543] documented the unmarshal jsvm helper 2026-02-24 15:14:25 +02:00
Gani Georgiev eb28f898d0 [#7538] set OnlyInt:true when a view column expression is loosely known to return int-only values 2026-02-22 11:06:08 +02:00
Gani Georgiev ab0edb80df updated CHANGELOG 2026-02-21 13:31:48 +02:00
Gani Georgiev 233dd2ac67 bumped app version 2026-02-18 20:00:31 +02:00
Gani Georgiev 21d58d389f updated golang.org/x/ deps 2026-02-18 19:44:13 +02:00
Gani Georgiev 7f21e31145 updated modernc.org/sqlite to 1.46.1 2026-02-18 19:24:38 +02:00
Gani Georgiev 19ad2f3b04 [#7532] added compositionend listener 2026-02-17 18:20:07 +02:00
Gani Georgiev f6675702ea [#7532] pause collection and fields name normalization while in IME mode 2026-02-17 17:29:38 +02:00
Gani Georgiev 3b9f2141fe updated jsvm types 2026-02-16 22:04:19 +02:00
Gani Georgiev 2b39757fbc bumped app version 2026-02-16 21:08:13 +02:00
Gani Georgiev 15091999e3 use temp dir for the $filesystem.local test 2026-02-14 12:08:57 +02:00
Gani Georgiev fcc680794c [#7526] added $filesystem.s3 and $filesystem.local JSVM bindings 2026-02-14 11:49:50 +02:00
Gani Georgiev bc72525013 [#7525] made Bearer prefix case-insensitive 2026-02-14 11:19:13 +02:00
Gani Georgiev 23ca5a77e1 fixed changelog typo 2026-02-13 21:00:25 +02:00
Gani Georgiev 266b56ecbb updated goja deps 2026-02-13 20:07:31 +02:00
Gani Georgiev ac8a4583cb updated test status codes to 2xx due to go 1.26.0 stricter checks 2026-02-13 20:02:55 +02:00
Gani Georgiev 1d72c8487e bumped app version 2026-02-13 17:21:34 +02:00
Gani Georgiev 97eb9b300b renamed arguments to make it more clear that they are dangeous 2026-02-13 16:40:29 +02:00
Gani Georgiev 5b2cae8509 [#7523] added Accept-Encoding:identity to the S3 requests 2026-02-13 16:16:12 +02:00
Gani Georgiev 5715e11e52 bumped go deps 2026-02-11 15:02:38 +02:00
Gani Georgiev 90e9fa705d bumped app version 2026-02-01 09:40:10 +02:00
Gani Georgiev d4101be9f6 updated go deps 2026-02-01 09:34:17 +02:00
Gani Georgiev 6cec679f02 updated changelog 2026-01-28 11:52:56 +02:00
Gani Georgiev 0e0b862bd7 silenced race detector errors per #7484 2026-01-28 11:52:08 +02:00
Gani Georgiev b2bf26122f updated changelog 2026-01-27 16:53:09 +02:00
Gani Georgiev adc5c3cf18 updated goja 2026-01-27 16:51:55 +02:00
Gani Georgiev 4a1e3aed6e added extra collection field exist check to minimize misuse and have a more clear error message 2026-01-27 16:46:04 +02:00
Gani Georgiev 55e24344ff updated modernc.org/sqlite to v1.44.3 2026-01-23 00:44:23 +02:00
Gani Georgiev 17086722f4 updated thumb style and title for non-previewable files 2026-01-23 00:34:58 +02:00
Gani Georgiev 9b036fb10f updated modernc.org/sqlite to 1.44.2 2026-01-18 18:57:18 +02:00
Gani Georgiev 8e3f0f2e32 regenerated jsvm types and bumped app version 2026-01-18 18:53:03 +02:00
Gani Georgiev 65750bca8d revert GROUP BY optimization 2026-01-18 18:38:40 +02:00
Gani Georgiev adb991eb02 fixed comment typos 2026-01-17 10:58:12 +02:00
Gani Georgiev d87a887673 fixed docs link in the changelog 2026-01-16 06:55:02 +02:00
Gani Georgiev f2eb295fa2 updated modernc.org deps 2026-01-16 06:27:55 +02:00
Gani Georgiev d11a9f9d99 updated JSVM types and bumped app version 2026-01-15 22:24:07 +02:00
Gani Georgiev bbd7f4e4ae replaced NoCoalesce with NullFallback and updated tests 2026-01-15 18:06:15 +02:00
Gani Georgiev b0a5bce4c4 updated 0.35.1 changelog to reflect the modernc.org/sqlite 1.42.0 retraction and updated the driver to 1.44.0 2026-01-15 14:45:26 +02:00
Gani Georgiev 9234cbf0d1 updated changelog 2026-01-15 14:34:45 +02:00
Gani Georgiev 6bf5eccfa7 added strftime filter function 2026-01-15 14:27:53 +02:00
Gani Georgiev 8bbd0c2d77 updated changelog 2026-01-13 22:30:31 +02:00
Gani Georgiev 2af60a85c5 fixed typo and updated changelog 2026-01-13 22:23:23 +02:00
Gani Georgiev 56e53e885c added more UpdateQuery tests 2026-01-13 22:15:50 +02:00
Gani Georgiev 3115f08d4a updated ui version 2026-01-13 18:56:23 +02:00
Gani Georgiev 0b9a646d41 use group by instead of distinct when possible 2026-01-13 18:47:32 +02:00
Gani Georgiev 5f8cce3558 [#7444] remove unnecessery subquery when resolving single back-relation fields 2026-01-13 14:54:35 +02:00
Gani Georgiev 3da2c00f32 updated modernc.org/sqlite to 1.43.0 2026-01-10 10:57:33 +02:00
Gani Georgiev 352ee72740 normalized multiple to single field confirmation detection 2026-01-06 18:33:44 +02:00
Gani Georgiev 98510103c3 normalized relations picker maxSelect value 2026-01-06 18:19:01 +02:00
Gani Georgiev 25c044a3a2 updated changelog 2026-01-03 14:25:04 +02:00
Gani Georgiev 0536c779d5 updated modernc.org/sqlite to 1.42.2 2026-01-03 11:23:26 +02:00
Gani Georgiev b1da83e516 fixed comment typo 2025-12-21 09:24:07 +02:00
Gani Georgiev 6b5d7b216b bumped go deps and updated jsvm types 2025-12-21 08:11:36 +02:00
Gani Georgiev 26d6a98355 updated ui/dist 2025-12-19 19:25:08 +02:00
Gani Georgiev 4abc018d7c upgraded to math/rand/v2 2025-12-19 19:08:56 +02:00
Gani Georgiev f0a9cbf31b updated modernc.org/sqlite to v1.41.0 2025-12-19 17:11:21 +02:00
Gani Georgiev d08da7594a cap the file name before normalization 2025-12-19 16:58:08 +02:00
Gani Georgiev c2d6530065 added test for file name normalization with leading dot 2025-12-19 16:30:11 +02:00
Gani Georgiev 94c4d4ec65 removed legacy and unnecessery call 2025-12-19 10:49:34 +02:00
Gani Georgiev be7ec34516 fixed error msg typo 2025-12-19 10:19:31 +02:00
Gani Georgiev 27cb36ffd7 [#7396] added nullable JSVM type helpers 2025-12-17 19:27:17 +02:00
Gani Georgiev e7af58efac updated goja 2025-12-12 09:05:04 +02:00
Gani Georgiev ab51b3c038 updated ui/dist 2025-12-12 08:55:08 +02:00
Gani Georgiev 5773f46fea replace TrimSuffix with Trim 2025-12-12 08:52:22 +02:00
Gani Georgiev 4d5b7cc1d0 updated go deps 2025-12-12 08:50:33 +02:00
Gani Georgiev 7286f34104 trim normalized file extension 2025-12-12 08:49:10 +02:00
Gani Georgiev 4399a6c1ab added small threshold for the cron time based tests 2025-12-12 08:48:29 +02:00
Gani Georgiev e89603497f store the correct image/png as attrs content type when generating a thumb fallback 2025-12-09 17:09:32 +02:00
Gani Georgiev abb6bcd6de [#7369] bumped JS SDK to fix Safari AbortError detection introduced with the previous release 2025-12-04 14:46:28 +02:00
Gani Georgiev 5604fe672e updated changelog 2025-12-02 20:20:41 +02:00
Gani Georgiev 7825baab13 updated v0.22 changelog and bumped actions/setup-go to v6 2025-12-02 20:20:27 +02:00
Gani Georgiev 68b9d0e403 updated go deps and bumped min go github version 2025-12-02 19:28:27 +02:00
Gani Georgiev 85232ed6e4 updated js sdk 2025-12-02 19:23:21 +02:00
Gani Georgiev d76d4089cf [#7357] added Copy raw JSON collection dropdown option 2025-12-02 08:21:20 +02:00
Gani Georgievandjb.muscat 2e5f8bff63 [#7353] added missing : char to the autocomplete regex
Co-authored-by: jb.muscat <jb.muscat@criteo.com>
2025-12-02 07:56:54 +02:00
Gani Georgiev 90d896e1cc updated changelog 2025-11-22 10:59:25 +02:00
Gani Georgiev c13d83adb1 updated jsvm types 2025-11-22 10:59:15 +02:00
Gani Georgiev 9b73295a7c added dumy request info to directly return an error on invalid API rule 2025-11-22 10:58:32 +02:00
Gani Georgiev 3c6ce2de74 updated go deps 2025-11-22 09:36:50 +02:00
Gani Georgiev 63b89533a9 use TEST_IP for the test auth alert email 2025-11-22 09:36:20 +02:00
Gani Georgiev 779059eca3 added short info about the backup restore steps 2025-11-22 09:36:02 +02:00
Gani Georgiev 9abdadf0dc updated modernc.org/sqlite 2025-11-18 22:24:49 +02:00
Gani Georgiev 6500b8c518 renamed outdated rate limit struct name and added reminder to reavulate the algorithm 2025-11-18 22:23:59 +02:00
Stephen Daves 91f1ca273d [#7333] updated CHANGELOG with typo fixed for geoDistance url 2025-11-18 07:39:28 +02:00
Gani Georgiev 6e739fd33d added :changed request body modifier 2025-11-17 13:58:43 +02:00
Gani Georgiev 2525f29c1c adde auth alert info as OnMailerRecordAuthAlertSend meta 2025-11-15 21:21:06 +02:00
Gani Georgiev 1dc5e061b8 updated changelog and ui/dist 2025-11-13 15:51:51 +02:00
Gani Georgiev 09d7f6a7c3 added missing mails.sendRecordAuthAlert jsvm binding 2025-11-13 10:45:33 +02:00
Gani Georgiev 1775585b68 updated goja and migrate timestamp 2025-11-13 09:59:14 +02:00
Gani Georgiev f4e6c5edee updated x/sync and other go deps 2025-11-12 15:17:36 +02:00
Gani Georgiev 94b11bf2c3 added test for escaped alert info 2025-11-12 15:15:54 +02:00
Gani Georgiev ddb8c88a37 replaced placeholder in migration and updated jsvm types 2025-11-10 17:59:11 +02:00
Gani Georgiev 0f5411d81c [#7314] added ALERT_INFO placeholder to the auth alert mail template 2025-11-10 17:56:36 +02:00
Gani Georgiev 423d234da1 added pk test for * character 2025-11-10 07:17:01 +02:00
Gani Georgiev 6184b31d82 [#7312] added extra id validations 2025-11-09 15:24:24 +02:00
Gani Georgiev 63a9d045a1 [#7312] excluded id from the excerpt list 2025-11-08 23:32:30 +02:00
Gani Georgiev 501ab0e6be updated modernc.org/sqlite to 1.40.0 2025-11-08 11:17:26 +02:00
Gani Georgiev 7ad08ef6bf updated changelog 2025-11-08 10:57:22 +02:00
Gani Georgiev 6210f361b0 bumped app version and updated ui/dist 2025-11-08 10:49:41 +02:00
Gani GeorgievandFelix fcb5b5dd67 [#7305] fixed deadlock when manually triggering the OnTerminate hook
Co-authored-by: Felix <FelixM@yer.tools>
2025-11-07 17:03:15 +02:00
Gani Georgiev 41607679a0 updated readme 2025-11-04 13:03:30 +02:00
Gani Georgiev ca7e5b7f7b updated jsvm types 2025-11-04 11:23:35 +02:00
Gani Georgiev 593721dcea flatten relation joins 2025-11-03 14:21:01 +02:00
Gani Georgiev 153ad12e64 set allowHiddenFields to prevent infinite recursion 2025-11-01 14:54:22 +02:00
Gani Georgiev 482fe2bce0 updated ui/dist 2025-10-31 22:41:52 +02:00
Gani Georgiev 48489b6a07 updated jsvm types 2025-10-31 22:24:47 +02:00
Gani Georgiev 67ee431585 add extra subquery check for client-side relation filtering 2025-10-31 22:22:28 +02:00
Gani Georgiev d5dcd01551 updated ui/dist 2025-10-23 22:33:12 +03:00
Gani Georgiev 749bf7815c updated changelog 2025-10-23 20:28:13 +03:00
Gani Georgiev ceae5e005f updated jsvm types and npm deps 2025-10-23 18:21:34 +03:00
Gani Georgiev 7b6b71e18d disallow client-side filtering and sorting of relations where the collection of the last targeted field has superusers only List/Search API rule 2025-10-23 17:22:47 +03:00
Gani Georgiev 885d907beb updated jsvm types 2025-10-23 13:15:03 +03:00
Gani Georgiev afb942bc41 updated thumb error message 2025-10-23 11:55:19 +03:00
Gani Georgiev 83a26d436e [#7268] added FileDownloadRequestEvent.ThumbError field 2025-10-23 11:48:59 +03:00
Gani Georgiev 7b52d0b56a [#7267] added tests.ApiScenario.DisableTestAppCleanup optional field 2025-10-23 11:13:49 +03:00
Gani Georgiev 5a8eae7089 add fallback in case the collection name in the response was stripped 2025-10-23 10:55:04 +03:00
Gani Georgiev 0bd712752f moved ValidateTokenSignature to jwk and added tests 2025-10-19 18:19:26 +03:00
Gani Georgiev 0b6157e1cc added no bounties note in the security policy 2025-10-19 15:49:10 +03:00
Gani Georgiev c8980edf85 updated npm deps 2025-10-19 15:41:07 +03:00
Gani Georgiev a7ebb98e20 wrap record info fields separatedly from link btn 2025-10-19 15:38:21 +03:00
Gani Georgiev 69be986132 limit max expanded presentable relations to 2 2025-10-19 14:03:28 +03:00
Gani Georgiev 58da159641 [#7252] support ed25519 oidc id_token signature validation 2025-10-19 13:49:39 +03:00
Gani Georgiev 8acb48b884 updated record-info-excerpt styles 2025-10-17 19:11:21 +03:00
Gani Georgiev 91b521595a [#7260] print nested presentable multiple relation fields 2025-10-17 14:43:36 +03:00
Gani Georgiev 52a53b5b91 updated ui/dist 2025-10-17 10:47:48 +03:00
Gani Georgiev 1137a35ded unify code-editor wrapper fields padding 2025-10-17 10:45:24 +03:00
Gani Georgiev 280005e35c [#7259] fixed json field overflow 2025-10-17 10:27:04 +03:00
Gani Georgiev 6656d9820a [#7223] workaround firefox overflow issue 2025-10-15 20:31:40 +03:00
Gani Georgiev 0d8b426b0c updated modernc.org/sqlite 2025-10-15 20:27:35 +03:00
Gani Georgiev acd12ce9dd [#7256] fixed legacy identitity field priority check when a username is a valid email address 2025-10-15 17:25:51 +03:00
Gani Georgiev 47d3da28d5 updated supported modernc.org build targets list 2025-10-08 20:08:16 +03:00
Gani Georgiev 6f8524961f updated go deps 2025-10-08 19:44:46 +03:00
Gani Georgiev fda6ad8d5d bumped min go action version 2025-10-08 17:55:23 +03:00
Gani Georgiev a8321498fd updated changelog 2025-10-03 09:06:03 +03:00
Gani Georgiev ca4902a808 updated ui/dist 2025-10-03 08:53:37 +03:00
Gani Georgiev 348ccfc580 removed explicit golangci go version and fallback to the one from go.mod 2025-10-02 21:01:30 +03:00
Gani Georgiev 77c05dbd2a support Uploader.MaxConcurrency=1 and updated tests 2025-10-02 20:52:36 +03:00
Gani Georgiev 44289a93a2 disable installer when running tests 2025-10-02 20:51:32 +03:00
Gani GeorgievandLoic B. 6b6d3b36d3 [#7208] exlude lost+found from the backups
Co-authored-by: Loic B. <lbndev@yahoo.fr>
2025-09-28 08:28:28 +03:00
Gani Georgiev e26905f8e2 updated go deps 2025-09-22 23:04:20 +03:00
Gani Georgiev 6ad42bde29 added DefaultClient.Send panic/recover handling as an extra precaution 2025-09-22 22:52:58 +03:00
Gani Georgiev 54fb4293c5 revert sqlite dep update 2025-09-22 21:30:54 +03:00
Gani Georgiev a8dd8be524 updated sqlite dep 2025-09-22 20:44:04 +03:00
Gani Georgiev 76a6b9834b fixed Message.WriteSSE example 2025-09-13 23:46:13 +03:00
Gani Georgiev 68ab174f69 wrap DefaultClient.Send with a single lock/unlock and rename mux to mu for consistency 2025-09-13 23:42:46 +03:00
Gani Georgiev a095549304 bumped action min go version 2025-09-07 08:02:36 +03:00
Gani Georgiev 6af4d88529 fixed active index label match 2025-09-07 08:01:36 +03:00
Gani Georgiev 5c9570c8de updated jsvm types 2025-09-06 21:45:20 +03:00
Gani Georgiev 546ea248df updated changelog 2025-09-06 21:45:04 +03:00
Gani Georgiev eda90d4555 check the default user cachedir if GOCACHE is not explicitly set 2025-09-06 21:20:13 +03:00
Gani Georgiev 40f2ba731c added osutils.IsProbablyGoRun 2025-09-06 19:52:51 +03:00
Gani Georgiev a088cf6379 updated jsvm types 2025-09-06 15:18:48 +03:00
Gani Georgiev 8d3ec418e9 enabled seconds in the datepicker 2025-09-06 15:13:06 +03:00
Gani Georgiev f2056f61bd added os.Root bindings to the JSVM 2025-09-06 14:51:27 +03:00
Gani Georgiev 6a5e449b3c bumped go deps 2025-09-06 14:22:07 +03:00
Gani Georgiev 1359a6f8fd [#7153] eagerly escape the S3 path in accordance with the S3 UriEncode signing rules 2025-09-06 11:59:32 +03:00
Gani Georgiev 28de01a188 updated changelog 2025-09-01 21:11:19 +03:00
Gani Georgiev 9e13418565 retry the random func to minimize tests flakiness 2025-08-31 23:35:01 +03:00
Gani Georgiev 172b1f96f7 [#7123] updated exp of test valid jwt tokens 2025-08-31 23:14:55 +03:00
Gani Georgiev 41cc4fd36b increased slightly the wait time to minimize tests flakiness 2025-08-31 20:31:45 +03:00
Gani Georgievandmashizora 45af9e201c [#7130] added Lark OAuth2 provider
Co-authored-by: mashizora <30516315+mashizora@users.noreply.github.com>
2025-08-30 12:57:14 +03:00
Gani Georgiev cc902f2df8 updated scaffold apis to use random id during the collections initialization and made index columns check on the UI case insensitive 2025-08-26 22:02:00 +03:00
Gani Georgiev 5e67ec1c1c [#7135] merge scaffold collection indexes 2025-08-26 21:09:44 +03:00
Gani Georgiev ee8c0a66b6 [#7135] merge scaffold collection indexes 2025-08-26 21:07:20 +03:00
Gani Georgiev 5d964c1b1d tidy go.sum 2025-08-23 09:26:08 +03:00
Gani Georgiev c0d37fc64a regenerated jsvm types 2025-08-23 09:20:51 +03:00
Gani Georgiev 58b564557f enabled __hooks in the jsvm migrations 2025-08-23 08:44:30 +03:00
Gani Georgiev 95787da4df updated changelog 2025-08-23 07:54:20 +03:00
Gani Georgiev b99095430a [#7125] registered missing jsvm migrations bindings 2025-08-23 07:45:28 +03:00
Gani Georgiev ad814c5a37 updated changelog 2025-08-22 22:25:27 +03:00
Gani Georgiev c6621ea1ed regenerated ui/dist and jsvm types 2025-08-22 21:32:03 +03:00
Gani Georgiev bda4baac15 updated go deps 2025-08-22 21:24:39 +03:00
Gani Georgiev a2b1b19342 updated random generator tests 2025-08-22 21:20:30 +03:00
Gani Georgiev 819ec1ad5c renamed to execve to make it more clear 2025-08-22 20:47:58 +03:00
Jesse Sivonen 3ca6321907 [#7116] exclude syscall.Exec for WASM 2025-08-22 20:44:13 +03:00
Gani Georgiev b8f18bd97d added more tests and extra debug log 2025-08-20 22:41:33 +03:00
Gani Georgiev 50dbb7f94f [#7090] try to forward the Apple OAuth2 redirect user's name to the auth handler 2025-08-16 21:30:43 +03:00
Gani Georgiev 09ce863a40 [#7098] fixed RateLimitRule.Audience code comment 2025-08-15 19:29:42 +03:00
Gani Georgiev 13cec96013 regenerated JSVM types 2025-08-12 21:46:18 +03:00
Gani Georgiev b1f1d19d7f bumped min go github action version 2025-08-09 10:13:51 +03:00
Gani Georgiev 5200f9c493 regenerated jsvm types 2025-08-02 08:40:44 +03:00
Gani Georgiev 2c8aa2e5fa removed duplicated CHANGELOG entry 2025-08-02 08:38:46 +03:00
Gani Georgiev eeae1f64ee updated changelog 2025-08-02 08:24:39 +03:00
Gani Georgiev 506172c495 removed unnecessary space 2025-08-02 08:24:27 +03:00
Gani Georgiev d75f5f663c [#7067] explain more clearly the DynamicModel caveats 2025-08-02 08:13:15 +03:00
Gani Georgiev 92e15c287e updated modernc.org/sqlite to 1.38.2 2025-08-02 08:05:06 +03:00
Gani GeorgievandBlake Patteson dd895dee01 [#7056] added Box OAuth2 provider
Co-authored-by: Blake Patteson <bpatteson@me.com>
2025-08-02 07:50:49 +03:00
Gani Georgiev 4b2e75992b updated ui/dist 2025-07-26 23:10:46 +03:00
Gani Georgiev c498c918ec updated go deps 2025-07-26 22:49:17 +03:00
Gani Georgiev 125e99e4c8 [#7035] updated the X/Twitter provider to return the confirmed_email field and to use the x.com domain 2025-07-26 22:45:02 +03:00
Gani Georgiev 5461120b04 [#7049] fixed list api example response 2025-07-25 19:56:41 +03:00
Gani Georgiev 4a7fb95650 updated jsvm types 2025-07-19 11:31:26 +03:00
Gani Georgiev 444fa78252 updated go deps 2025-07-19 11:01:35 +03:00
Gani Georgiev 641aa54cfc use the nonconcurrent pool for running PRAGMA optimize 2025-07-19 10:02:44 +03:00
Gani Georgiev f015911594 updated npm deps and clarified trusted proxy headers input label 2025-07-19 09:41:40 +03:00
Gani Georgiev fadb2e68a2 increased filesystem read buffer to speedup writes 2025-07-19 09:34:01 +03:00
Gani Georgiev 5ca79eb85d [#7022] added support for unmarshaling into interface fields 2025-07-18 23:11:05 +03:00
Gani Georgiev 62c8523070 updated ui/dist 2025-06-29 20:41:36 +03:00
Gani Georgiev 8debafa755 synced with master 2025-06-29 20:30:33 +03:00
Gani Georgiev 0089ceb904 [#6982] disable separator escaping for the page title 2025-06-29 20:28:36 +03:00
Gani Georgiev 6443f2f159 [#3233] added optional ServeEvent.Listener field 2025-06-29 15:41:55 +03:00
Gani Georgiev 0e12169546 updated TestRandomStringByRegex to avoid collisions 2025-06-29 11:51:21 +03:00
Gani Georgiev 9d7856a9eb fixed changelog copy/paste error 2025-06-29 11:33:43 +03:00
Gani Georgiev 306045fa2f updated ui/dist 2025-06-29 11:31:49 +03:00
Gani Georgiev a9c42d0282 [#718] enabled calling auth-refresh with impersonate token 2025-06-29 11:24:50 +03:00
Gani Georgiev f318f461ea updated npm deps 2025-06-26 22:21:17 +03:00
Gani Georgiev 51bc9f3982 [#6972] wrapped backup restore in a transaction as an extra precaution 2025-06-26 22:21:04 +03:00
Gani Georgiev 2c6f99418f added the triggered rate limit rule in the error log details 2025-06-25 20:32:58 +03:00
Gani Georgiev 3f3b77dcd4 print go run in the superuser create installer suggestion if temp dir location is detected 2025-06-24 08:56:53 +03:00
Gani Georgiev db679f9620 regenerated jsvm types 2025-06-21 10:51:52 +03:00
Gani Georgiev c76ee987bd fixed comment typo 2025-06-21 10:51:40 +03:00
Gani Georgiev 0d4da9c3be updated changelog 2025-06-21 10:30:35 +03:00
Gani Georgiev fab56f688d clarified batch max requests input 2025-06-21 10:29:54 +03:00
Felix 1610729d92 [#6947] fixed probability distribution in RandomStringByRegex 2025-06-21 08:03:21 +03:00
Gani Georgiev 6522bc55b1 fixed comment typo 2025-06-18 19:01:36 +03:00
Gani Georgiev c8776b7cd9 updated jsvm types 2025-06-17 21:23:19 +03:00
Gani Georgiev 262e78c04e [#6935] added toBytes JSVM helper 2025-06-17 21:15:34 +03:00
Gani Georgiev 0a66e5a286 bumped app version 2025-06-09 20:49:58 +03:00
Gani Georgiev cdfaed4fa3 updated Record.ToInt test to accomodate the latest cast update 2025-06-09 20:48:52 +03:00
Gani Georgiev 0f73679c3f updated Go deps 2025-06-09 20:03:05 +03:00
Gani Georgiev e09f71ae74 [#6914] skip empty range header 2025-06-09 19:51:06 +03:00
Gani Georgiev 17082de560 fixed legacy go comments 2025-06-09 19:49:46 +03:00
Gani Georgiev 88bb8c406e updated changelog 2025-05-24 00:44:38 +03:00
Gani Georgiev 2ab50cc77e updated monospace font with latin-ext charset 2025-05-24 00:28:44 +03:00
Gani Georgiev 0025ae80ad [#6869] updated fonts and dependencies 2025-05-24 00:06:00 +03:00
Azat Ismagilov 568c63b29f [#6860] support multiline cast expressions in view collections 2025-05-20 19:40:49 +03:00
Gani Georgiev 6e9a9489a7 added tooltip note about the extra record id field validations 2025-05-17 17:10:16 +03:00
Gani Georgiev e73077e7e7 [#6835] fixed json_each/json_array_length normalizations to properly check for array values 2025-05-13 21:26:33 +03:00
Gani Georgiev 0113fecca9 bumped app version 2025-05-11 00:13:56 +03:00
Gani Georgiev fbc378067d updated error comment 2025-05-04 20:56:40 +03:00
Gani Georgiev e80d64414b [#6792] added filesystem.System.GetReuploadableFile method 2025-05-03 18:37:07 +03:00
Gani Georgiev 7ffe9f63a5 changed the default json field max size to 1mb 2025-05-02 11:49:47 +03:00
Gani Georgiev 5dbd9821e8 soft-deprecated and replaced GetFile with GetReader 2025-05-02 11:27:32 +03:00
Gani Georgiev 87c6c5b483 fixed dev sql log replacements 2025-05-02 11:12:33 +03:00
Gani Georgiev 836fc77ddc [#6689] updated to automatically routes raw write SQL statements to the nonconcurrent db pool 2025-05-02 10:27:41 +03:00
Gani Georgiev 3ef752c232 merge with master 2025-04-28 16:51:18 +03:00
Gani Georgiev eb8dd80859 bumped app version 2025-04-28 14:51:11 +03:00
Gani Georgiev d97b5b1f6c [#6739] use rowid as count column for non-view collections to minimize the need of having the id field as covering index 2025-04-28 14:47:22 +03:00
Gani Georgiev 3885c93d59 [#6780] added temp semaphore to limit the number of goroutines when cleaning files 2025-04-28 14:47:11 +03:00
Gani Georgiev 5713cf422b [#6778] updated the excerpt modifier to properly account for multibyte characters 2025-04-28 12:47:13 +03:00
Gani Georgiev c7c590bace [#6778] updated the excerpt modifier to properly account for multibyte characters 2025-04-28 06:03:11 +03:00
Gani Georgiev fac0d5b899 use rowid as collections order field 2025-04-27 22:08:31 +03:00
Gani Georgiev 902e07e724 updated changelog and jsvm types 2025-04-27 16:33:24 +03:00
Gani Georgiev dc350f0a3e delay default response body write for *Request hooks wrapped in a transaction 2025-04-27 16:25:51 +03:00
Gani Georgiev 1a3efe96ac [#6739] use rowid as count column for non-view collections to minimize the need of having the id field as covering index 2025-04-21 20:55:09 +03:00
Gani Georgiev 18f152a0e7 Merge branch 'master' into develop 2025-04-21 16:34:33 +03:00
Gani Georgiev 9bee3bd0fd added API preview clarification for the geoPoint expected object format 2025-04-20 14:01:07 +03:00
Gani Georgiev b31a0ddcd3 bumped app version 2025-04-20 13:55:13 +03:00
Gani Georgiev 52dcb4192c Merge branch 'master' into develop 2025-04-20 13:41:44 +03:00
Gani Georgiev 2bffa0e2bb updated filesystem.CreateThumb tests 2025-04-20 13:40:24 +03:00
Gani Georgiev 8c0ef15ec2 Merge branch 'master' of github.com:pocketbase/pocketbase 2025-04-20 13:38:59 +03:00
Gani Georgiev 08b1b49e17 Merge branch 'master' of github.com:pocketbase/pocketbase into develop 2025-04-20 13:38:30 +03:00
Kev 🐶 5d46fb054e [#6744] added partial webp tumbs support 2025-04-20 13:36:45 +03:00
Gani Georgiev f9b2842deb updated changelog 2025-04-20 10:47:36 +03:00
Gani Georgiev 0426722e99 added JSVM GeoPointField constructor 2025-04-16 09:12:44 +03:00
Gani Georgiev 33abc0a802 updated geoPoint API preview response 2025-04-16 08:14:56 +03:00
Gani Georgiev 51cbe437e5 updated geoPoint nonempty tooltip info 2025-04-16 08:08:53 +03:00
Gani Georgiev a2b89d7344 updated changelog 2025-04-16 07:41:00 +03:00
Gani Georgiev 6988bf0d7c updated leaflet search autocomplete z-index 2025-04-15 22:23:55 +03:00
Gani Georgiev 02c975467c regenerated jsvm types 2025-04-15 21:58:03 +03:00
Gani Georgiev c7ebd68a92 updated changelog 2025-04-15 21:56:22 +03:00
Gani Georgiev 0876413d87 added types.GeoPoint.AsMap method 2025-04-15 21:43:08 +03:00
Gani Georgiev c0fe600d14 updated npm deps and bumped app version 2025-04-15 21:42:46 +03:00
Gani Georgiev aec4870acd reversed the default geoPoint column props for consistency with the types.GeoPoint marshalization 2025-04-15 21:08:38 +03:00
Gani Georgiev bf9a7b1e3d updated changelog and exposed search.TokenFunctions 2025-04-15 18:03:59 +03:00
Gani Georgiev 46186f84f0 [#6718] fixed collections import error response 2025-04-14 09:29:25 +03:00
Gani Georgiev 4cc797071b updated dependencies and golangci-lint to v2 2025-04-12 20:05:21 +03:00
Gani Georgiev d9af1475ef fixed NewBackupsFilesystem code comment 2025-04-10 09:00:32 +03:00
Gani Georgiev 3e3c316da1 updated changelog 2025-04-08 14:34:40 +03:00
Gani Georgiev 0efbbb0d10 [#6688] added optional timezone identifier argument to the JSVM DateTime constructor 2025-04-08 14:30:50 +03:00
Willow (GHOST) 5d32d22ff5 [#6690] updated patreon to use go oauth2/endpoints 2025-04-08 05:33:52 +03:00
Gani Georgiev e81d85ae7c fixed typo 2025-04-07 16:34:54 +03:00
Gani Georgiev b930de6ff5 added geoDistance calculation test 2025-04-07 12:41:13 +03:00
Gani Georgiev 628efb7a7f fixed geoDistance code comment 2025-04-07 11:58:09 +03:00
Gani Georgiev 201e2ccb05 [#6607] allowed manually changing the password from the create/update hooks 2025-04-05 13:36:31 +03:00
Gani Georgiev e49025c8e5 moved the Create and Manage API rule checks out of the OnRecordCreateRequest hook finalizer 2025-04-04 22:53:14 +03:00
Gani Georgiev 9f6010d38d [#6282] soft-deprecated $http.send result.raw field in favour of result.body 2025-04-04 15:23:34 +03:00
Gani Georgiev 409bcdaa96 [#6597] forced text/javascript content-type when serving .js/.mjs collection uploaded files 2025-04-03 16:17:16 +03:00
Gani Georgiev 2554192c06 added geoDistance docs and tests 2025-04-03 15:55:47 +03:00
Gani Georgiev d135b1e686 added more geoPoint field acccess tests 2025-04-02 23:25:42 +03:00
Gani Georgiev 4c5abd5bd9 added new geoPoint field 2025-04-02 11:52:50 +03:00
Gani Georgiev f3a836eb7c synced with master 2025-03-29 11:22:57 +02:00
Gani Georgiev fb5070d6ab bumped app version 2025-03-29 11:13:30 +02:00
Gani Georgiev c9f39ba167 synced with master 2025-03-29 10:46:42 +02:00
Gani Georgiev e29655aba9 bumped modernc.org/sqlite 2025-03-29 09:44:55 +02:00
Gani Georgiev 32de8ed04a [#6657] allow OIDC email_verified to be int or boolean string 2025-03-29 09:28:31 +02:00
Gani Georgiev 783a99d7cc synced with master 2025-03-28 21:07:16 +02:00
Gani Georgiev 42a008f828 updated changelog 2025-03-28 19:46:58 +02:00
Gani Georgiev d0d5f3d005 bumped app version 2025-03-28 19:32:07 +02:00
Gani Georgiev b5be7f2d3c [#6654] fixed S3 canonical uri parts escaping 2025-03-28 19:28:04 +02:00
Gani Georgiev 334a81c837 Merge branch 'master' into develop 2025-03-26 08:02:01 +02:00
Gani Georgiev d68786df9c updated changelog 2025-03-26 08:01:44 +02:00
Gani Georgiev 70f0e7e141 synced with master 2025-03-25 23:00:56 +02:00
Gani Georgiev 8e7e0aa5ff fixed logs clipboard data copy 2025-03-24 19:36:43 +02:00
Gani Georgiev e718b88391 synced with master 2025-03-24 18:26:25 +02:00
Gani Georgiev 953feb47fd synced with master 2025-03-22 06:35:26 +02:00
Gani Georgiev 60a4e3f4d2 updated default select field labels 2025-03-20 14:30:00 +02:00
Gani Georgiev d61ec398a2 [#4674] updated select field ui 2025-03-20 13:46:40 +02:00
Gani Georgiev 9f1946057f use gabriel-vasile/mimetype for the mail attachments 2025-03-18 00:52:33 +02:00
Gani Georgiev 824c7db388 updated auth collection create and update examples 2025-03-17 21:19:47 +02:00
337 changed files with 16708 additions and 7273 deletions
+86 -2
View File
@@ -1,5 +1,89 @@
# Security
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
All reports will be promptly addressed and you'll be credited in the fix release notes.
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io** or submit a private [GitHub Security advisory](https://github.com/pocketbase/pocketbase/security/advisories).
I try to be as responsive as possible and usually address security reports within a day or two, but if you didn't receive a reply from me for more than 5 days it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
In case the vulnerability is confirmed, within another couple days I'll try to submit a fix, GitHub security advisory and CVE with remediation steps and **minimal details** regarding the found exploit to minimize giving too much hints to malicious actors (you'll be credited both in the fix release notes and in the public report).
### Please:
- DO NOT use LLM as part of your report or email communication - it is extremely frustrating to spend an hour or more reading a wall of generated text, writing an elaborate reply and in the end to just receive another generic LLM prompt response in return.
- DO NOT reserve and publish MITRE CVE number on your own _(I prefer to do it through the GitHub Security advisory)_ and try to communicate first privately the details to better understand how the code is being used and whether the supposed vulnerability can be actually exploited in any real practical scenarios. Otherwise you are risking needlessly causing scaremongering and annoyance for users that rely on security scanners as part of their CI/CD pipeline.
- Wait before publicly disclosing and sharing details about the found vulnerability, **ideally at least 5 days after the fix**, to make it harder to exploit and give enough time for users to patch their instances _(you are free to provide a PoC and as much details as you want in your own blog/gist/etc.)_.
### Below is a list of common vulnerabilities that were previously reported but are NOT considered a security issue:
<details>
<summary><strong>Stored XSS</strong></summary>
This was discussed several times, both privately and [publicly](https://github.com/pocketbase/pocketbase/discussions/6694), but I remain on the opinion that it should be handled primarily on the client-side.
Modern browsers recently introduced a basic [`Sanitizer` interface](https://developer.mozilla.org/en-US/docs/Web/API/Sanitizer) that could help filtering HTML strings without external libraries.
Having also a default [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as meta tag or response header is always a good idea to minimize the risk of XSS.
</details>
<details>
<summary><strong>SQL injection in low level DB methods like <code>app.DeleteTable(dangerousName)</code></strong></summary>
This is working correctly and it is not an issue but it is a common report most likely found by LLM or some other automated tools that may have stumbled on the [NB! code comments](https://pkg.go.dev/github.com/pocketbase/pocketbase@master/core#BaseApp.DeleteTable).
Raw SQL statements, table and column names are not parameterized and they are vulnerable to SQL injection if used with untrusted input. The documentation as seen above already warns against it. In recent PocketBase releases, many of the arguments of these methods were also prefixed with `dangerous*` to make it even more clear that they should be used with caution.
</details>
<details>
<summary><strong>Race conditions</strong></summary>
To avoid DB locks PocketBase deliberately tries to minimize the use of DB transactions.
This means that operations like record update don't wrap out of the box for example the `SELECT` and `UPDATE` SQL statements in a single transaction, and this can technically lead to a race condition if multiple users edit the same record.
This is an accepted tradeoff and for the majority of cases it has no security implications.
This also apply for the read and delete of MFA and OTP records but for those cases, since they operate in a security sensitive context, they have an extra short-lived duration that is configurable from the collection settings _(there are also system cron jobs that takes care for deleting forgotten/expired entries to prevent accumulation of invalid records)_.
For the cases where transactions are really needed, users can utilize the [Batch Web API](https://pocketbase.io/docs/api-records/#batch-createupdateupsertdelete-records) or [create a transaction programmatically](https://pocketbase.io/docs/go-records/#transaction) _(with PocketBase v0.23+ it is also possible to wrap an entire hook chain in a single transaction)_.
</details>
<details>
<summary><strong>List/Search side-channel attacks</strong></summary>
Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
This is by design and it is accepted tradeoff between performance, security and usability.
If you are concerned about timing attacks and have security sensitive collection data such as `secret`, `code`, `token`, etc. then the general recommendation is to mark their related fields as "Hidden" in order to disallow use in client-side filters.
</details>
<details>
<summary><strong>Connecting to a vulnerable OAuth2 provider</strong></summary>
Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on user create _(need to be specified from the auth collection OAuth2 fields mapping)_ some security researchers raised a concern regarding a Blind SSRF but this implies that an attacker controls the OAuth2 vendor and this is a very serious assumption in the first place.
The entire OAuth2 flow relies that the application server (PocketBase) trusts the configured OAuth2 vendor.
If you suspect that an OAuth2 vendor is malicious and cannot be trusted then you MUST NOT use that OAuth2 vendor at all and you should report it.
If someone is able to tamper with the OAuth2 responses then the entire OAuth2 flow can be thrown out of the window because they will be practically able to authenticate as any of your existing users and the eventual avatar url probing request is the least of your problem.
_Nonetheless, in future PocketBase releases there will be [extra `localhost` domain like checks](https://github.com/orgs/pocketbase/projects/2/views/1?pane=issue&itemId=159545722) when assigning the OAuth2 avatar url to a `file` field that will further minimize the risk of internal network probing requests in case of a vulnerable OAuth2 provider._
</details>
<details>
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
Just for the past month, due to some corporate security scanners 5 different people raised concerns over [CVE-2023-36308](https://nvd.nist.gov/vuln/detail/CVE-2023-36308) but this is not really a vulnerability, especially not in PocketBase.
[`disintegration/imaging`](https://github.com/disintegration/imaging) is a direct PocketBase dependency responsible for the thumbs generation.
First, a panic (similar to exception in other languages) is NOT a security issue and Go programs usually have to be written defensively with that in mind. In PocketBase specifically all routes have auto panic-recover handling, no matter what the source of the panic is, so the worst case scenario would be an HTTP error response when attempting to access the thumb.
Second, the related issue that the CVE describes is probably caused by a bug in an outdated `golang.org/x/image` dependency listed in the `go.mod` of that package but PocketBase uses a newer patched version of it that is expected to take precedence.
Third, even if that issue is still available, with PocketBase it would have been triggerable ONLY if we supported TIFF thumbs generation but we don't. The supported thumbs formats at the moment are JPG, PNG, GIF (its first frame) and partially WebP (stored as PNG). All other images are served as it is, without any transformation.
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
</details>
+2 -2
View File
@@ -26,9 +26,9 @@ jobs:
node-version: 20.17.0
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '>=1.23.6'
go-version: '>=1.26.1'
# 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.
+442 -2
View File
@@ -1,8 +1,448 @@
## v0.36.7
- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
_Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
- Updated other minor Go and npm deps.
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
## v0.36.6
- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)).
- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value.
- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)).
- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB.
- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
- Typos and other minor doc fixes.
## v0.36.5
- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607).
- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_.
## v0.36.4
- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming).
- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)).
## v0.36.3
- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523).
_This should help fixing the 0-bytes file response when S3 API compression is enabled._
- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_.
- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_.
## v0.36.2
- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps.
- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_.
## v0.36.1
- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries
and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)).
_A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._
- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_.
- Fixed code comment typos.
## v0.36.0
- List query and API rules optimizations:
- Removed unnecessary correlated subquery expression when using back-relations via single `relation` field.
- Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe.
_This should help with having a more stable and predictable performance even if the collection records are on the larger side._
For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading,
please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed.
- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function.
It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html)
with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields.
Multi-match expressions are also supported and works the same as if the collection field is referenced, for example:
```js
// requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01"
strftime('%Y-%m', multiRel.created) ?= '2026-01'
// requires ALL multiRel records to have "created" date matching the formatted string "2026-01"
strftime('%Y-%m', multiRel.created) = '2026-01'
```
- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_:
- Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_.
- Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field.
- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_.
- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
## v0.35.1
- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_.
- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps).
## v0.35.0
- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)).
- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_.
- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization.
_This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._
- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps.
## v0.34.2
- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
## v0.34.1
- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite).
- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)).
- Updated Go deps and JS SDK.
- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
## v0.34.0
- Added `@request.body.someField:changed` modifier.
It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value.
Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent:
```js
// (old)
(@request.body.someField:isset = false || @request.body.someField = someField)
// (new)
@request.body.someField:changed = false
```
- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook.
- Updated the backup restore popup with a short info about the performed restore steps.
- Updated Go deps.
## v0.33.0
- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)).
_The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._
_The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._
- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)).
_⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._
- Updated Go deps.
## v0.32.0
- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations.
This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_.
So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_.
Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it.
For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields.
Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is.
An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.).
If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it.
- Increased the default SQLite `PRAGMA cache_size` to ~32MB.
- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools).
- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies.
- Updated `modernc.org/sqlite` to 1.40.0.
## v0.31.0
- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)).
- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome).
- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)).
- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)).
- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack.
_This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._
_Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._
- Regenerated JSVM types and updated npm and Go deps.
## v0.30.4
- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
## v0.30.3
- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
## v0.30.2
- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
## v0.30.1
- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
_If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
- Updated `modernc.org/sqlite` and other Go dependencies.
## v0.30.0
- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora).
- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)).
- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)).
- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`.
- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.).
- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies.
## v0.29.3
- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)).
- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05).
- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211).
_Note that WASM is not officially supported PocketBase build target and many things may not work as expected._
- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)).
- Regenerated JSVM types to include methods from structs with single generic parameter.
- Updated Go dependencies.
## v0.29.2
- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
## v0.29.1
- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)).
- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson).
- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3).
- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra).
## v0.29.0
- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens.
_When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._
- Added the triggered rate rimit rule in the error log `details`.
- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)).
- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)).
- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)).
- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.).
## v0.28.4
- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)).
- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools).
- Minor docs and typos fixes.
## v0.28.3
- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)).
- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1).
- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved).
## v0.28.2
- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
- Updated Go and npm dependencies.
## v0.28.1
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
## v0.28.0
- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
_For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
- ⚠️ Changed the default `json` field max size to 1MB.
_Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
_The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
_This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
## v0.27.2
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
## v0.27.1
- Updated example `geoPoint` API preview body data.
- Added JSVM `new GeoPointField({ ... })` constructor.
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
- Updated npm dev dependencies.
## v0.27.0
- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
```go
tests.ApiScenario{
Name: "Example test that checks a Create API rule failure"
Method: http.MethodPost,
URL: "/api/collections/example/records",
...
// old:
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
// new:
ExpectedEvents: map[string]int{"*": 0},
}
```
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
```js
// the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
// the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
```
- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
- Updated `modernc.org/sqlite` to 1.37.0.
- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
## v0.26.6
- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
- Updated `modernc.org/sqlite` to 1.36.3.
## v0.26.5
- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
## v0.26.4
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
- Fixed logs details clipboard copy.
- Fixed logs details copy to clipboard action.
- Updated `modernc.org/sqlite` to 1.36.2.
@@ -365,7 +805,7 @@ There are a lot of changes but to highlight some of the most notable ones:
- Admins are now system `_superusers` auth records.
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
- Batch/transactional Web API endpoint.
- Impersonate Web API endpoint (_it could be also used for generating fixed/non-refreshable superuser tokens, aka. "API keys"_).
- Impersonate Web API endpoint (_it could be also used for generating fixed/nonrenewable superuser tokens, aka. "API keys"_).
- Support for custom user request activity log attributes.
- One-Time Password (OTP) auth method (_via email code_).
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
+42
View File
@@ -2,6 +2,48 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.40
- (_Backported from v0.36.7_) Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
- (_Backported from v0.36.7_) Updated other minor Go and npm deps.
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
## v0.22.39
- (_Backported from v0.36.6_) Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
## v0.22.38
- (_Backported from v0.36.0_) Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
## v0.22.37
- (_Backported from v0.34.1_) - Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353)).
- (_Backported from v0.34.1_) Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
## v0.22.36
- (_Backported from v0.30.2_) Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
## v0.22.35
- (_Backported from v0.29.2_) Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
## v0.22.34
- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
## v0.22.33
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
+1 -1
View File
@@ -10,7 +10,7 @@ This document describes how to prepare a PR for a change in the main repository.
## Prerequisites
- Go 1.23+ (for making changes in the Go code)
- Go 1.25+ (for making changes in the Go code)
- Node 18+ (for making changes in the Admin UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
+4 -2
View File
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
Here is a minimal example:
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
1. Create a new project directory with the following `main.go` file inside it:
```go
@@ -92,7 +92,7 @@ _For more details please refer to [Extend with Go](https://pocketbase.io/docs/go
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
@@ -110,9 +110,11 @@ linux 386
linux amd64
linux arm
linux arm64
linux loong64
linux ppc64le
linux riscv64
linux s390x
windows 386
windows amd64
windows arm64
```
+7 -6
View File
@@ -49,7 +49,7 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
params["id"] = id // required for the path value
ir.Method = "PATCH"
ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
return recordUpdate(next)
return recordUpdate(false, next)
}
}
@@ -57,16 +57,16 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
// ---
ir.Method = "POST"
ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^POST /api/collections/(?P<collection>[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordCreate(next)
return recordCreate(false, next)
},
regexp.MustCompile(`^PATCH /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordUpdate(next)
return recordUpdate(false, next)
},
regexp.MustCompile(`^DELETE /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
return recordDelete(next)
return recordDelete(false, next)
},
}
@@ -88,7 +88,7 @@ func (brs batchRequestsForm) validate() error {
}
// NB! When the request is submitted as multipart/form-data,
// the regular fields data is expected to be submitted as serailized
// the regular fields data is expected to be submitted as serialized
// json under the @jsonPayload field and file keys need to follow the
// pattern "requests.N.fileField" or requests[N].fileField.
func batchTransaction(e *core.RequestEvent) error {
@@ -364,6 +364,7 @@ func processInternalRequest(
// assign request
event.Request = r
event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads
defer event.Request.Body.Close()
// assign response
rec := httptest.NewRecorder()
+22 -9
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
)
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
@@ -45,7 +46,9 @@ func collectionsList(e *core.RequestEvent) error {
event.Result = result
return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -60,7 +63,9 @@ func collectionView(e *core.RequestEvent) error {
event.Collection = collection
return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -98,7 +103,9 @@ func collectionCreate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -128,7 +135,9 @@ func collectionUpdate(e *core.RequestEvent) error {
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
}
return e.JSON(http.StatusOK, e.Collection)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Collection)
})
})
}
@@ -159,7 +168,9 @@ func collectionDelete(e *core.RequestEvent) error {
return e.BadRequestError(msg, err)
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -182,14 +193,16 @@ func collectionTruncate(e *core.RequestEvent) error {
}
func collectionScaffolds(e *core.RequestEvent) error {
randomId := security.RandomStringWithAlphabet(10, core.DefaultIdAlphabet) // could be used as part of the default indexes name
collections := map[string]*core.Collection{
core.CollectionTypeBase: core.NewBaseCollection(""),
core.CollectionTypeAuth: core.NewAuthCollection(""),
core.CollectionTypeView: core.NewViewCollection(""),
core.CollectionTypeBase: core.NewBaseCollection("", randomId),
core.CollectionTypeAuth: core.NewAuthCollection("", randomId),
core.CollectionTypeView: core.NewViewCollection("", randomId),
}
for _, c := range collections {
c.Id = "" // clear autogenerated id
c.Id = "" // clear random id
}
return e.JSON(http.StatusOK, collections)
+4 -2
View File
@@ -29,12 +29,14 @@ func collectionsImport(e *core.RequestEvent) error {
return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
if importErr == nil {
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
}
// validation failure
var validationErrors validation.Errors
if errors.As(err, &validationErrors) {
if errors.As(importErr, &validationErrors) {
return e.BadRequestError("Failed to import collections.", validationErrors)
}
+112
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -88,6 +89,7 @@ func TestCollectionsImport(t *testing.T) {
`import2`,
`fields`,
},
NotExpectedContent: []string{"Raw error:"},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
@@ -109,6 +111,71 @@ func TestCollectionsImport(t *testing.T) {
}
},
},
{
Name: "authorized as superuser + non-validator failure",
Method: http.MethodPut,
URL: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"fields": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
],
"indexes": [
"create index idx_test on import2 (test)"
]
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_collections_import_failure"`,
`Raw error:`,
`custom_error`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionsImportRequest": 1,
"OnCollectionCreate": 1,
"OnCollectionAfterCreateError": 1,
"OnModelCreate": 1,
"OnModelAfterCreateError": 1,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
return errors.New("custom_error")
})
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
collections := []*core.Collection{}
if err := app.CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := totalCollections
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
{
Name: "authorized as superuser + successful collections create",
Method: http.MethodPut,
@@ -249,6 +316,51 @@ func TestCollectionsImport(t *testing.T) {
}
},
},
{
Name: "OnCollectionsImportRequest tx body write check",
Method: http.MethodPut,
URL: "/api/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{"name": "test123"},
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"fields":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"required":true
}
],
"indexes": []
}
]
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
+94 -22
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"os"
"path/filepath"
@@ -130,6 +129,32 @@ func TestCollectionsList(t *testing.T) {
"OnCollectionsListRequest": 1,
},
},
{
Name: "OnCollectionsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionsListRequest().BindFunc(func(e *core.CollectionsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -205,6 +230,32 @@ func TestCollectionView(t *testing.T) {
"OnCollectionViewRequest": 1,
},
},
{
Name: "OnCollectionViewRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/wsmn24bux7wo113",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionViewRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnCollectionViewRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -361,7 +412,7 @@ func TestCollectionDelete(t *testing.T) {
},
},
{
Name: "OnCollectionAfterDeleteSuccessRequest error response",
Name: "OnCollectionDeleteRequest tx body write check",
Method: http.MethodDelete,
URL: "/api/collections/view2",
Headers: map[string]string{
@@ -369,15 +420,22 @@ func TestCollectionDelete(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionDeleteRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionDeleteRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionDeleteRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
@@ -656,7 +714,7 @@ func TestCollectionCreate(t *testing.T) {
},
},
{
Name: "OnCollectionCreateRequest error response",
Name: "OnCollectionCreateRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"base"}`),
@@ -665,15 +723,22 @@ func TestCollectionCreate(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionCreateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionCreateRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionCreateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// view
@@ -978,7 +1043,7 @@ func TestCollectionUpdate(t *testing.T) {
},
},
{
Name: "OnCollectionAfterUpdateSuccessRequest error response",
Name: "OnCollectionUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
@@ -987,15 +1052,22 @@ func TestCollectionUpdate(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnCollectionUpdateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionUpdateRequest": 1,
},
ExpectedEvents: map[string]int{"OnCollectionUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authorized as superuser + invalid data (eg. existing name)",
+27 -21
View File
@@ -19,7 +19,7 @@ import (
"golang.org/x/sync/singleflight"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"}
var defaultThumbSizes = []string{"100x100"}
// bindFileApi registers the file api endpoints and the corresponding handlers.
@@ -75,8 +75,8 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
event.Record = e.Auth
return e.App.OnFileTokenRequest().Trigger(event, func(e *core.FileTokenRequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"token": e.Token,
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, map[string]string{"token": e.Token})
})
})
}
@@ -142,8 +142,14 @@ func (api *fileApi) download(e *core.RequestEvent) error {
defer fsys.Close()
originalPath := baseFilesPath + "/" + filename
servedPath := originalPath
servedName := filename
event := new(core.FileDownloadRequestEvent)
event.RequestEvent = e
event.Collection = collection
event.Record = record
event.FileField = fileField
event.ServedPath = originalPath
event.ServedName = filename
// check for valid thumb size param
thumbSize := e.Request.URL.Query().Get("thumb")
@@ -157,34 +163,31 @@ func (api *fileApi) download(e *core.RequestEvent) error {
// check if it is an image
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix
servedName = thumbSize + "_" + filename
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
event.ServedName = thumbSize + "_" + filename
event.ServedPath = baseFilesPath + "/thumbs_" + filename + "/" + event.ServedName
// create a new thumb if it doesn't exist
if exists, _ := fsys.Exists(servedPath); !exists {
if err := api.createThumb(e, fsys, originalPath, servedPath, thumbSize); err != nil {
if exists, _ := fsys.Exists(event.ServedPath); !exists {
if err := api.createThumb(e, fsys, originalPath, event.ServedPath, thumbSize); err != nil {
e.App.Logger().Warn(
"Fallback to original - failed to create thumb "+servedName,
"Fallback to original - failed to create thumb "+event.ServedName,
slog.Any("error", err),
slog.String("original", originalPath),
slog.String("thumb", servedPath),
slog.String("thumb", event.ServedPath),
)
// fallback to the original
servedName = filename
servedPath = originalPath
event.ThumbError = err
event.ServedName = filename
event.ServedPath = originalPath
}
}
}
}
event := new(core.FileDownloadRequestEvent)
event.RequestEvent = e
event.Collection = collection
event.Record = record
event.FileField = fileField
event.ServedPath = servedPath
event.ServedName = servedName
if thumbSize != "" && event.ThumbError == nil && event.ServedPath == originalPath {
event.ThumbError = fmt.Errorf("the thumb size %q or the original file format are not supported", thumbSize)
}
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
@@ -192,7 +195,10 @@ func (api *fileApi) download(e *core.RequestEvent) error {
e.Response.Header().Del("X-Frame-Options")
return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
if err := fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName); err != nil {
err = execAfterSuccessTx(true, e.App, func() error {
return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
})
if err != nil {
return e.NotFoundError("", err)
}
+88 -24
View File
@@ -181,9 +181,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError == nil {
t.Fatal("Expected thumb error, got nil")
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -192,9 +200,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
@@ -203,9 +219,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
@@ -214,9 +238,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
@@ -225,9 +257,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
@@ -236,9 +276,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
@@ -247,9 +295,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError != nil {
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
@@ -258,9 +314,17 @@ func TestFileDownload(t *testing.T) {
},
},
{
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
if e.ThumbError == nil {
t.Fatal("Expected thumb error, got nil")
}
return e.Next()
})
},
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
+9 -1
View File
@@ -32,7 +32,7 @@ func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL st
_ = osutils.LaunchURL(url)
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
color.New(color.Bold).Add(color.FgCyan).Println(url)
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", executablePath())
return nil
}
@@ -86,3 +86,11 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
return record, nil
}
func executablePath() string {
if osutils.IsProbablyGoRun() {
return "go run ."
}
return os.Args[0]
}
+23 -7
View File
@@ -207,11 +207,13 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
func getAuthTokenFromRequest(e *core.RequestEvent) string {
token := e.Request.Header.Get("Authorization")
if token != "" {
// the schema prefix is not required and it is only for
// compatibility with the defaults of some HTTP clients
token = strings.TrimPrefix(token, "Bearer ")
// the "Bearer" schema prefix is not required by PocketBase and it is
// supported only for compatibility with the defaults of some HTTP clients
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
return token[7:]
}
return token
}
@@ -365,11 +367,25 @@ func logRequest(event *core.RequestEvent, err error) {
// parse the request error
if err != nil {
if apiErr, ok := err.(*router.ApiError); ok {
status = apiErr.Status
apiErr, isPlainApiError := err.(*router.ApiError)
if isPlainApiError || errors.As(err, &apiErr) {
// the status header wasn't written yet
if status == 0 {
status = apiErr.Status
}
var errMsg string
if isPlainApiError {
errMsg = apiErr.Message
} else {
// wrapped ApiError -> add the full serialized version
// of the original error since it could contain more information
errMsg = err.Error()
}
attrs = append(
attrs,
slog.String("error", apiErr.Message),
slog.String("error", errMsg),
slog.Any("details", apiErr.RawData()),
)
} else {
+14 -2
View File
@@ -112,9 +112,21 @@ func (r *limitedReader) Read(b []byte) (int, error) {
return n, nil
}
// explicit casts to ensure that the main struct methods will be invoked
// (extra precautions in case of nested interface wrapping erasure)
// ---
func (r *limitedReader) Reread() {
rr, ok := r.ReadCloser.(router.Rereader)
rereader, ok := r.ReadCloser.(router.Rereader)
if ok {
rr.Reread()
rereader.Reread()
}
}
func (r *limitedReader) Close() error {
closer, ok := r.ReadCloser.(io.Closer)
if ok {
return closer.Close()
}
return nil
}
+26 -45
View File
@@ -1,6 +1,7 @@
package apis
import (
"errors"
"sync"
"time"
@@ -107,28 +108,6 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
// -------------------------------------------------------------------
// @todo consider exporting as helper?
//
//nolint:unused
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
if !ok || rateLimiters == nil {
return false
}
rt, ok := rateLimiters.GetOk(rtId)
if !ok || rt == nil {
return false
}
client, ok := rt.getClient(e.RealIP())
if !ok || client == nil {
return false
}
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
}
// @todo consider exporting as helper?
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
switch rule.Audience {
@@ -153,7 +132,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
}
rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter {
return newRateLimiter(rule.MaxRequests, rule.Duration, rule.Duration+1800)
return newRateLimiter(rule.MaxRequests, rule.Duration, 1800)
})
if rt == nil {
e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
@@ -167,7 +146,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
}
if !rt.isAllowed(key) {
return e.TooManyRequestsError("", nil)
return e.TooManyRequestsError("", errors.New("triggered rate limit rule: "+rule.String()))
}
return nil
@@ -233,12 +212,12 @@ func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec
maxAllowed: maxAllowed,
interval: intervalInSec,
minDeleteInterval: minDeleteIntervalInSec,
clients: map[string]*fixedWindow{},
clients: map[string]*rateClient{},
}
}
type rateLimiter struct {
clients map[string]*fixedWindow
clients map[string]*rateClient
maxAllowed int
interval int64
@@ -249,7 +228,7 @@ type rateLimiter struct {
}
//nolint:unused
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
func (rt *rateLimiter) getClient(key string) (*rateClient, bool) {
rt.RLock()
client, ok := rt.clients[key]
rt.RUnlock()
@@ -268,7 +247,7 @@ func (rt *rateLimiter) isAllowed(key string) bool {
// check again in case the client was added by another request
client, ok = rt.clients[key]
if !ok {
client = newFixedWindow(rt.maxAllowed, rt.interval)
client = newRateClient(rt.maxAllowed, rt.interval)
rt.clients[key] = client
}
rt.Unlock()
@@ -294,7 +273,7 @@ func (rt *rateLimiter) clean() {
//
// @todo remove after https://github.com/golang/go/issues/20135
if rt.totalDeleted >= 300 {
shrunk := make(map[string]*fixedWindow, len(rt.clients))
shrunk := make(map[string]*rateClient, len(rt.clients))
for k, v := range rt.clients {
shrunk[k] = v
}
@@ -303,52 +282,54 @@ func (rt *rateLimiter) clean() {
}
}
func newFixedWindow(maxAllowed int, intervalInSec int64) *fixedWindow {
return &fixedWindow{
func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
return &rateClient{
maxAllowed: maxAllowed,
interval: intervalInSec,
}
}
type fixedWindow struct {
// @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
//
// rateClient implements fixed window rate limit strategy.
type rateClient struct {
// use plain Mutex instead of RWMutex since the operations are expected
// to be mostly writes (e.g. consume()) and it should perform better
sync.Mutex
maxAllowed int // the max allowed tokens per interval
available int // the total available tokens
interval int64 // in seconds
lastConsume int64 // the time of the last consume
maxAllowed int // the max allowed tokens per interval
available int // the total available tokens
start int64 // the start time of the current window
interval int64 // in seconds
}
// hasExpired checks whether it has been at least minElapsed seconds since the lastConsume time.
// hasExpired checks whether it has been at least minElapsed seconds after the last active window.
// (usually used to perform periodic cleanup of staled instances).
func (l *fixedWindow) hasExpired(relativeNow int64, minElapsed int64) bool {
func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
l.Lock()
defer l.Unlock()
return relativeNow-l.lastConsume > minElapsed
return relativeNow-(l.start+l.interval) > minElapsed
}
// consume decrease the current window allowance with 1 (if not exhausted already).
// consume decreases the current allowance with 1 (if not exhausted already).
//
// It returns false if the allowance has been already exhausted and the user
// has to wait until it resets back to its maxAllowed value.
func (l *fixedWindow) consume() bool {
func (l *rateClient) consume() bool {
l.Lock()
defer l.Unlock()
nowUnix := time.Now().Unix()
// reset consumed counter
if nowUnix-l.lastConsume >= l.interval {
// reset
if nowUnix-l.start >= l.interval {
l.available = l.maxAllowed
l.start = nowUnix
}
if l.available > 0 {
l.available--
l.lastConsume = nowUnix
return true
}
+11 -8
View File
@@ -74,7 +74,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
scenarios := []struct {
url string
wait float64
wait float64 // ms
authenticated bool
expectedStatus int
}{
@@ -85,10 +85,13 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 0, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 800, false, 200}, // (fixed window check) wait enough to ensure that it can't fit 2 requests in 1s
{"/rate/a", 800, false, 200},
{"/rate/a", 800, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429},
{"/rate/a", 1.1, false, 200},
{"/rate/a", 1000, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
@@ -96,7 +99,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
{"/rate/b", 1.1, false, 200},
{"/rate/b", 1000, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429},
@@ -118,7 +121,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/rate/guest", 0, false, 429},
// "guest" rule with regular user (should fallback to the /rate/ rule)
{"/rate/guest", 1, true, 200},
{"/rate/guest", 1000, true, 200},
{"/rate/guest", 0, true, 200},
{"/rate/guest", 0, true, 429},
{"/rate/guest", 0, true, 429},
@@ -126,10 +129,6 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
for _, s := range scenarios {
t.Run(s.url, func(t *testing.T) {
if s.wait > 0 {
time.Sleep(time.Duration(s.wait) * time.Second)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", s.url, nil)
@@ -147,6 +146,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
req.Header.Add("Authorization", token)
}
if s.wait > 0 {
time.Sleep(time.Duration(s.wait) * time.Millisecond)
}
mux.ServeHTTP(rec, req)
result := rec.Result()
+16
View File
@@ -224,6 +224,22 @@ func TestRequireAuth(t *testing.T) {
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record auth token with Bearer case-insensitive prefix",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
// regular user
"Authorization": "BeArEr eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
}).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
+9 -3
View File
@@ -213,7 +213,9 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
slog.Any("subscriptions", e.Subscriptions),
)
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -756,7 +758,7 @@ func realtimeCanAccessRecord(
var exists int
q := app.DB().Select("(1)").
q := app.ConcurrentDB().Select("(1)").
From(record.Collection().Name).
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
@@ -767,7 +769,11 @@ func realtimeCanAccessRecord(
}
q.AndWhere(expr)
resolver.UpdateQuery(q)
err = resolver.UpdateQuery(q)
if err != nil {
return false
}
err = q.Limit(1).Row(&exists)
+3 -1
View File
@@ -45,7 +45,9 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -136,7 +135,7 @@ func TestRecordConfirmEmailChange(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
Name: "OnRecordConfirmEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-email-change",
Body: strings.NewReader(`{
@@ -145,15 +144,22 @@ func TestRecordConfirmEmailChange(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmEmailChangeRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -43,7 +43,9 @@ func recordRequestEmailChange(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
}
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,33 @@ func TestRecordRequestEmailChange(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestEmailChangeRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-email-change",
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+2 -2
View File
@@ -26,10 +26,10 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
form := &impersonateForm{}
if err = e.BindBody(form); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
if err = form.validate(); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
return e.BadRequestError("An error occurred while validating the submitted data.", err)
}
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
+2 -2
View File
@@ -108,8 +108,8 @@ func recordRequestOTP(e *core.RequestEvent) error {
})
}
return e.JSON(http.StatusOK, map[string]string{
"otpId": otp.Id,
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
})
})
}
+25
View File
@@ -247,6 +247,31 @@ func TestRecordRequestOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-otp",
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+3 -1
View File
@@ -54,7 +54,9 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -282,7 +281,7 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
Name: "OnRecordConfirmPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
@@ -292,15 +291,22 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmPasswordResetRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -65,7 +65,9 @@ func recordRequestPasswordReset(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -101,6 +101,30 @@ func TestRecordRequestPasswordReset(t *testing.T) {
app.Store().Set(resendKey, struct{}{})
},
},
{
Name: "OnRecordRequestPasswordResetRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-password-reset",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+13 -7
View File
@@ -12,18 +12,24 @@ func recordAuthRefresh(e *core.RequestEvent) error {
return e.NotFoundError("Missing auth record context.", nil)
}
currentToken := getAuthTokenFromRequest(e)
claims, _ := security.ParseUnverifiedJWT(currentToken)
if v, ok := claims[core.TokenClaimRefreshable]; !ok || !cast.ToBool(v) {
return e.ForbiddenError("The current auth token is not refreshable.", nil)
}
event := new(core.RecordAuthRefreshRequestEvent)
event.RequestEvent = e
event.Collection = record.Collection()
event.Record = record
return e.App.OnRecordAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshRequestEvent) error {
return RecordAuthResponse(e.RequestEvent, e.Record, "", nil)
token := getAuthTokenFromRequest(e.RequestEvent)
// skip token renewal if the token's payload doesn't explicitly allow it (e.g. impersonate tokens)
claims, _ := security.ParseUnverifiedJWT(token) //
if v, ok := claims[core.TokenClaimRefreshable]; ok && cast.ToBool(v) {
var tokenErr error
token, tokenErr = e.Record.NewAuthToken()
if tokenErr != nil {
return e.InternalServerError("Failed to refresh auth token.", tokenErr)
}
}
return recordAuthResponse(e.RequestEvent, e.Record, token, "", nil)
})
}
+32 -12
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"testing"
@@ -74,6 +73,8 @@ func TestRecordAuthRefresh(t *testing.T) {
},
NotExpectedContent: []string{
`"missing":`,
// should return a different token
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedEvents: map[string]int{
"*": 0,
@@ -89,9 +90,21 @@ func TestRecordAuthRefresh(t *testing.T) {
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
ExpectedStatus: 200,
ExpectedContent: []string{
// should return the same token
`"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak"`,
`"record":`,
`"id":"4q1xlclmfloku33"`,
`"emailVisibility":false`,
`"email":"test@example.com"`, // the owner can always view their email address
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthRefreshRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
},
},
{
Name: "unverified auth record in onlyVerified collection",
@@ -130,23 +143,30 @@ func TestRecordAuthRefresh(t *testing.T) {
},
},
{
Name: "OnRecordAfterAuthRefreshRequest error response",
Name: "OnRecordAuthRefreshRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-refresh?expand=rel,missing",
URL: "/api/collections/users/auth-refresh",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthRefreshRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+8 -8
View File
@@ -42,19 +42,19 @@ func recordConfirmVerification(e *core.RequestEvent) error {
event.Record = record
return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
if wasVerified {
return e.NoContent(http.StatusNoContent)
}
if !wasVerified {
e.Record.SetVerified(true)
e.Record.SetVerified(true)
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
}
}
e.App.Store().Remove(getVerificationResendKey(e.Record))
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
+14 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -144,7 +143,7 @@ func TestRecordConfirmVerification(t *testing.T) {
},
},
{
Name: "OnRecordAfterConfirmVerificationRequest error response",
Name: "OnRecordConfirmVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
@@ -152,15 +151,22 @@ func TestRecordConfirmVerification(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmVerificationRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
+3 -1
View File
@@ -68,7 +68,9 @@ func recordRequestVerification(e *core.RequestEvent) error {
})
})
return e.NoContent(http.StatusNoContent)
return execAfterSuccessTx(true, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
})
}
@@ -118,6 +118,30 @@ func TestRecordRequestVerification(t *testing.T) {
}
},
},
{
Name: "OnRecordRequestVerificationRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/request-verification",
Body: strings.NewReader(`{"email":"test@example.com"}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+14
View File
@@ -15,6 +15,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"golang.org/x/oauth2"
@@ -90,6 +91,19 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
}
// Apple currently returns the user's name only as part of the first redirect data response
// so we try to assign the [apis.oauth2SubscriptionRedirect] forwarded name.
if form.Provider == auth.NameApple && authUser.Name == "" {
nameKey := oauth2RedirectAppleNameStoreKeyPrefix + form.Code
name, ok := e.App.Store().Get(nameKey).(string)
if ok {
e.App.Store().Remove(nameKey)
authUser.Name = name
} else {
e.App.Logger().Debug("Missing or already removed Apple user's name")
}
}
var authRecord *core.Record
// check for existing relation with the auth collection
+77 -3
View File
@@ -2,22 +2,29 @@ package apis
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
const (
oauth2SubscriptionTopic string = "@oauth2"
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
oauth2SubscriptionTopic string = "@oauth2"
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
oauth2RedirectAppleNameStoreKeyPrefix string = "@redirect_name_"
)
type oauth2RedirectData struct {
State string `form:"state" json:"state"`
Code string `form:"code" json:"code"`
Error string `form:"error" json:"error,omitempty"`
// returned by Apple only
AppleUser string `form:"user" json:"-"`
}
func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
@@ -52,6 +59,20 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
}
defer client.Unsubscribe(oauth2SubscriptionTopic)
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
// (see https://github.com/pocketbase/pocketbase/issues/7090)
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
nameErr := parseAndStoreAppleRedirectName(
e.App,
oauth2RedirectAppleNameStoreKeyPrefix+data.Code,
data.AppleUser,
)
if nameErr != nil {
// non-critical error
e.App.Logger().Debug("Failed to parse and load Apple Redirect name data", "error", nameErr)
}
}
encodedData, err := json.Marshal(data)
if err != nil {
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
@@ -72,3 +93,56 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
}
// parseAndStoreAppleRedirectName extracts the first and last name
// from serializedNameData and temporary store them in the app.Store.
//
// This is hacky workaround to forward safely and seamlessly the Apple
// redirect user's name back to the OAuth2 auth handler.
//
// Note that currently Apple is the only provider that behaves like this and
// for now it is unnecessary to check whether the redirect is coming from Apple or not.
//
// Ideally this shouldn't be needed and will be removed in the future
// once Apple adds a dedicated userinfo endpoint.
func parseAndStoreAppleRedirectName(app core.App, nameKey string, serializedNameData string) error {
if serializedNameData == "" {
return nil
}
// just in case to prevent storing large strings in memory
if len(nameKey) > 1000 {
return errors.New("nameKey is too large")
}
// https://developer.apple.com/documentation/signinwithapple/incorporating-sign-in-with-apple-into-other-platforms#Handle-the-response
extracted := struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
}{}
if err := json.Unmarshal([]byte(serializedNameData), &extracted); err != nil {
return err
}
fullName := extracted.Name.FirstName + " " + extracted.Name.LastName
// truncate just in case to prevent storing large strings in memory
if len(fullName) > 150 {
fullName = fullName[:150]
}
fullName = strings.TrimSpace(fullName)
if fullName == "" {
return nil
}
// store (and remove)
app.Store().Set(nameKey, fullName)
time.AfterFunc(1*time.Minute, func() {
app.Store().Remove(nameKey)
})
return nil
}
@@ -3,6 +3,7 @@ package apis_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"time"
@@ -266,6 +267,74 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
}
},
},
{
Name: "(POST) Apple user's name json (nameKey error)",
Method: http.MethodPost,
URL: "/api/oauth2-redirect",
Body: strings.NewReader(url.Values{
"code": []string{strings.Repeat("a", 986)},
"state": []string{clientStubs[8]["c3"].Id()},
"user": []string{
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
},
}.Encode()),
Headers: map[string]string{
"content-type": "application/x-www-form-urlencoded",
},
BeforeTestFunc: beforeTestFunc(clientStubs[8], map[string][]string{
"c3": {`"state":"` + clientStubs[8]["c3"].Id(), `"code":"` + strings.Repeat("a", 986) + `"`},
}),
ExpectedStatus: http.StatusSeeOther,
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[8]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
if storedName := app.Store().Get("@redirect_name_" + strings.Repeat("a", 986)); storedName != nil {
t.Fatalf("Didn't expect stored name, got %q", storedName)
}
},
},
{
Name: "(POST) Apple user's name json",
Method: http.MethodPost,
URL: "/api/oauth2-redirect",
Body: strings.NewReader(url.Values{
"code": []string{strings.Repeat("a", 985)},
"state": []string{clientStubs[9]["c3"].Id()},
"user": []string{
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
},
}.Encode()),
Headers: map[string]string{
"content-type": "application/x-www-form-urlencoded",
},
BeforeTestFunc: beforeTestFunc(clientStubs[9], map[string][]string{
"c3": {`"state":"` + clientStubs[9]["c3"].Id(), `"code":"` + strings.Repeat("a", 985) + `"`},
}),
ExpectedStatus: http.StatusSeeOther,
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkSuccessRedirect(t, app, res)
if clientStubs[9]["c3"].HasSubscription("@oauth2") {
t.Fatalf("Expected oauth2 subscription to be removed")
}
storedName, _ := app.Store().Get("@redirect_name_" + strings.Repeat("a", 985)).(string)
expectedName := "aaa " + strings.Repeat("b", 146)
if storedName != expectedName {
t.Fatalf("Expected stored name\n%q\ngot\n%q", expectedName, storedName)
}
},
},
}
for _, scenario := range scenarios {
+161
View File
@@ -1577,6 +1577,167 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordValidate": 4,
},
},
{
Name: "OnRecordAuthWithOAuth2Request tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
// stub linked provider
ea := core.NewExternalAuth(app)
ea.SetCollectionRef(user.Collection().Id)
ea.SetRecordRef(user.Id)
ea.SetProvider("test")
ea.SetProviderId("test_id")
if err := app.Save(ea); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// Apple AuthUser.Name assign checks
// -----------------------------------------------------------
{
Name: "store name with Apple provider",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "apple",
"code":"test_code",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers[auth.NameApple] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
app.Store().Set("@redirect_name_test_code", "test_store_name")
// add the test provider in the collection
users.MFA.Enabled = false
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: auth.NameApple,
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(users); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"meta":{`,
`"name":"test_store_name"`,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.Store().Has("@redirect_name_test_code") {
t.Fatal("Expected @redirect_name_test_code store key to be removed")
}
},
},
{
Name: "store name with non-Apple provider",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"test_code",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
app.Store().Set("@redirect_name_test_code", "test_store_name")
// add the test provider in the collection
users.MFA.Enabled = false
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(users); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
NotExpectedContent: []string{
`"name":"test_store_name"`,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if !app.Store().Has("@redirect_name_test_code") {
t.Fatal("Expected @redirect_name_test_code store key to NOT be deleted")
}
},
},
// rate limit checks
// -----------------------------------------------------------
+1 -1
View File
@@ -53,7 +53,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
}
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumeration protection
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
if err != nil {
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
+47
View File
@@ -419,6 +419,53 @@ func TestRecordAuthWithOTP(t *testing.T) {
}
},
},
{
Name: "OnRecordAuthWithOTPRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-otp",
Body: strings.NewReader(`{
"otpId":"` + strings.Repeat("a", 15) + `",
"password":"123456"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
// disable MFA
user.Collection().MFA.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
otp := core.NewOTP(app)
otp.Id = strings.Repeat("a", 15)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// rate limit checks
// -----------------------------------------------------------
+23 -14
View File
@@ -40,23 +40,32 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
if form.IdentityField != "" {
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
} else {
// prioritize email lookup
isEmail := is.EmailFormat.Validate(form.Identity) == nil
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
identityFields := collection.PasswordAuth.IdentityFields
// @todo consider removing with the stable release or moving it in the collection save
//
// prioritize email lookup to minimize breaking changes with earlier versions
if len(identityFields) > 1 && identityFields[0] != core.FieldNameEmail {
identityFields = slices.Clone(identityFields)
slices.SortStableFunc(identityFields, func(a, b string) int {
if a == "email" {
return -1
}
if b == "email" {
return 1
}
return 0
})
}
// search by the other identity fields
if !isEmail || foundErr != nil {
for _, name := range collection.PasswordAuth.IdentityFields {
if !isEmail && name == core.FieldNameEmail {
continue // no need to search by the email field if it is not an email
}
for _, name := range identityFields {
if name == core.FieldNameEmail && is.EmailFormat.Validate(form.Identity) != nil {
continue // no need to query the database if we know that the submitted value is not an email
}
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
if foundErr == nil {
break
}
}
}
+65 -8
View File
@@ -1,7 +1,6 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -82,7 +81,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "OnRecordAuthWithPasswordRequest error response",
Name: "OnRecordAuthWithPasswordRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
@@ -91,15 +90,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "valid identity field and invalid password",
@@ -206,6 +212,57 @@ func TestRecordAuthWithPassword(t *testing.T) {
"OnMailerRecordAuthAlertSend": 1,
},
},
{
// https://github.com/pocketbase/pocketbase/issues/7256
Name: "valid non-email identity field with a value that is a properly formatted email",
Method: http.MethodPost,
URL: "/api/collections/clients/auth-with-password",
Body: strings.NewReader(`{
"identity":"username_as_email@example.com",
"password":"1234567890"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
record, err := app.FindAuthRecordByEmail("clients", "test@example.com")
if err != nil {
t.Fatal(err)
}
record.Set("username", "username_as_email@example.com")
err = app.SaveNoValidate(record)
if err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"email":"test@example.com"`,
`"username":"username_as_email@example.com"`,
`"token":`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithPasswordRequest": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// authOrigin track
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnModelValidate": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordValidate": 1,
"OnMailerSend": 1,
"OnMailerRecordAuthAlertSend": 1,
},
},
{
Name: "unknown explicit identityField",
Method: http.MethodPost,
+130 -92
View File
@@ -28,9 +28,9 @@ func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
subGroup.GET("", recordsList)
subGroup.GET("/{id}", recordView)
subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(nil))
subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
subGroup.DELETE("/{id}", recordDelete(true, nil))
}
func recordsList(e *core.RequestEvent) error {
@@ -79,6 +79,11 @@ func recordsList(e *core.RequestEvent) error {
searchProvider := search.NewProvider(fieldsResolver).Query(query)
// use rowid when available to minimize the need of a covering index with the "id" field
if !collection.IsView() {
searchProvider.CountCol("_rowid_")
}
records := []*core.Record{}
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
if err != nil {
@@ -113,10 +118,12 @@ func recordsList(e *core.RequestEvent) error {
len(e.Records) == 0 &&
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
randomizedThrottle(150)
randomizedThrottle(500)
}
return e.JSON(http.StatusOK, e.Result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Result)
})
})
}
@@ -162,12 +169,18 @@ func recordView(e *core.RequestEvent) error {
ruleFunc := func(q *dbx.SelectQuery) error {
if !requestInfo.HasSuperuserAuth() && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
err = resolver.UpdateQuery(q)
if err != nil {
return err
}
}
return nil
}
@@ -187,11 +200,13 @@ func recordView(e *core.RequestEvent) error {
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
return e.JSON(http.StatusOK, e.Record)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
})
}
func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -251,6 +266,76 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
// check the request and record data against the create and manage rules
if !hasSuperuserAuth && collection.CreateRule != nil {
dummyRecord := record.Clone()
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
// set an id if it doesn't have already
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
if dummyRecord.Id == "" {
dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// export the dummy record data into db params
dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
}
dummyParams := make(dbx.Params, len(dummyExport))
selects := make([]string, 0, len(dummyExport))
var param string
for k, v := range dummyExport {
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
param = "__pb_create__" + k
dummyParams[param] = v
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
}
// shallow clone the current collection
dummyCollection := *collection
dummyCollection.Id += dummyRandomPart
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
// check non-empty create rule
if *dummyCollection.CreateRule != "" {
ruleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
err = resolver.UpdateQuery(ruleQuery)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule update query failure: %w", err))
}
var exists int
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || exists == 0 {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
// check for manage rule access
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -262,73 +347,6 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
// temporary save the record and check it against the create and manage rules
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
dummyRecord := e.Record.Clone()
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
// set an id if it doesn't have already
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
if dummyRecord.Id == "" {
dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
dummyRecord.SetVerified(false)
// export the dummy record data into db params
dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
}
dummyParams := make(dbx.Params, len(dummyExport))
selects := make([]string, 0, len(dummyExport))
var param string
for k, v := range dummyExport {
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
param = "__pb_create__" + k
dummyParams[param] = v
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
}
// shallow clone the current collection
dummyCollection := *e.Collection
dummyCollection.Id += dummyRandomPart
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
// check non-empty create rule
if *dummyCollection.CreateRule != "" {
ruleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
if err != nil {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
}
ruleQuery.AndWhere(expr)
resolver.UpdateQuery(ruleQuery)
var exists int
err = ruleQuery.Limit(1).Row(&exists)
if err != nil || exists == 0 {
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
}
}
// check for manage rule access
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
form.GrantManagerAccess()
}
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to create record", err))
@@ -339,7 +357,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -369,7 +389,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -419,12 +439,18 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
ruleFunc := func(q *dbx.SelectQuery) error {
if !hasSuperuserAuth && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
err = resolver.UpdateQuery(q)
if err != nil {
return err
}
}
return nil
}
@@ -441,6 +467,14 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
form.Load(data)
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
collection.Name + ".id": record.Id,
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
form.GrantManagerAccess()
}
var isOptFinalizerCalled bool
event := new(core.RecordRequestEvent)
@@ -452,15 +486,6 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
form.SetApp(e.App)
form.SetRecord(e.Record)
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
// note: use the original record id and not e.Record.Id because the record validations because may get overwritten
e.Collection.Name + ".id": e.Record.LastSavedPK(),
})
if !form.HasManageAccess() &&
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
form.GrantManagerAccess()
}
err := form.Submit()
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to update record.", err))
@@ -471,7 +496,9 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
err = e.JSON(http.StatusOK, e.Record)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.JSON(http.StatusOK, e.Record)
})
if err != nil {
return err
}
@@ -501,7 +528,7 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
}
}
func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
@@ -534,12 +561,18 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
ruleFunc := func(q *dbx.SelectQuery) error {
if !requestInfo.HasSuperuserAuth() && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
err = resolver.UpdateQuery(q)
if err != nil {
return err
}
}
return nil
}
@@ -561,7 +594,9 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
}
err = e.NoContent(http.StatusNoContent)
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
return e.NoContent(http.StatusNoContent)
})
if err != nil {
return err
}
@@ -718,7 +753,10 @@ func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection
}
query.AndWhere(expr)
resolver.UpdateQuery(query)
err = resolver.UpdateQuery(query)
if err != nil {
return false
}
var exists int
+136 -46
View File
@@ -2,7 +2,6 @@ package apis_test
import (
"bytes"
"errors"
"net/http"
"net/url"
"os"
@@ -383,10 +382,32 @@ func TestRecordCrudList(t *testing.T) {
},
},
{
Name: "multi-match - at least one of",
Name: "multi-match - at least one of (guest - non-satisfied relation filter API rule)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalPages":0`,
`"totalItems":0`,
`"items":[]`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordsListRequest": 1,
"OnRecordEnrich": 0,
},
},
{
Name: "multi-match - at least one of (clients)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
@@ -402,9 +423,13 @@ func TestRecordCrudList(t *testing.T) {
},
},
{
Name: "multi-match - all",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
Name: "multi-match - all (clients)",
Method: http.MethodGet,
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
Headers: map[string]string{
// clients, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -418,6 +443,32 @@ func TestRecordCrudList(t *testing.T) {
"OnRecordsListRequest": 1,
},
},
{
Name: "OnRecordsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo4/records",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -862,6 +913,32 @@ func TestRecordCrudView(t *testing.T) {
"OnRecordEnrich": 7,
},
},
{
Name: "OnRecordViewRequest tx body write check",
Method: http.MethodGet,
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
// -----------------------------------------------------------
@@ -1209,7 +1286,7 @@ func TestRecordCrudDelete(t *testing.T) {
},
},
{
Name: "OnRecordAfterDeleteSuccessRequest error response",
Name: "OnRecordDeleteRequest tx body write check",
Method: http.MethodDelete,
URL: "/api/collections/clients/records/o1y0dd0spd786md",
Headers: map[string]string{
@@ -1217,15 +1294,22 @@ func TestRecordCrudDelete(t *testing.T) {
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordDeleteRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authenticated record that match the collection delete rule",
@@ -1563,10 +1647,7 @@ func TestRecordCrudCreate(t *testing.T) {
Body: strings.NewReader(`{"title":"test123"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule failure check)",
@@ -1579,10 +1660,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
@@ -1731,10 +1809,7 @@ func TestRecordCrudCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
@@ -1801,21 +1876,31 @@ func TestRecordCrudCreate(t *testing.T) {
},
},
{
Name: "OnRecordAfterCreateSuccessRequest error response",
Name: "OnRecordCreateRequest tx body write check",
Method: http.MethodPost,
URL: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
// ID checks
@@ -1974,14 +2059,9 @@ func TestRecordCrudCreate(t *testing.T) {
"total+":4,
"total-":2
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordCreateRequest": 1,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "@request.body.field with compute modifers (rule pass check)",
@@ -2813,21 +2893,31 @@ func TestRecordCrudUpdate(t *testing.T) {
},
},
{
Name: "OnRecordAfterUpdateSuccessRequest error response",
Name: "OnRecordUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
return errors.New("error")
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordUpdateRequest": 1,
},
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "try to change the id of an existing record",
+56 -12
View File
@@ -15,6 +15,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/types"
)
const (
@@ -129,7 +130,9 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
result.Meta = e.Meta
}
return e.JSON(http.StatusOK, result)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, result)
})
})
}
@@ -158,7 +161,11 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
if err != nil {
return true, err
}
resolver.UpdateQuery(query)
err = resolver.UpdateQuery(query)
if err != nil {
return true, err
}
err = query.AndWhere(expr).Limit(1).Row(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -377,12 +384,18 @@ func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.Expan
if *relCollection.ViewRule != "" {
resolver := core.NewRecordFieldResolver(app, relCollection, requestInfoPtr, true)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
err = resolver.UpdateQuery(q)
if err != nil {
return err
}
}
return nil
@@ -455,18 +468,24 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
managedIds := []string{}
query := app.RecordQuery(collection).
Select(app.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(query)
query.AndWhere(expr)
if err := query.Column(&managedIds); err != nil {
err = resolver.UpdateQuery(query)
if err != nil {
return err
}
err = query.Column(&managedIds)
if err != nil {
return err
}
// ---
@@ -535,18 +554,43 @@ func firstApiError(errs ...error) *router.ApiError {
return router.NewInternalServerError("", errors.Join(errs...))
}
// execAfterSuccessTx ensures that fn is executed only after a successful transaction.
//
// If the current app instance is not a transactional or checkTx is false,
// then fn is directly executed.
//
// It could be usually used to allow propagating an error or writing
// custom response from within the wrapped transaction block.
func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
if txInfo := app.TxInfo(); txInfo != nil && checkTx {
txInfo.OnComplete(func(txErr error) error {
if txErr == nil {
return fn()
}
return nil
})
return nil
}
return fn()
}
// -------------------------------------------------------------------
const maxAuthOrigins = 5
func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
// generating fingerprint
// generate fingerprint
// ---
ip := e.RealIP()
userAgent := e.Request.UserAgent()
if len(userAgent) > 300 {
userAgent = userAgent[:300]
if len(userAgent) > 200 {
userAgent = userAgent[:200] + "..."
}
fingerprint := security.MD5(e.RealIP() + userAgent)
fingerprint := security.MD5(ip + userAgent)
alertInfo := fmt.Sprintf("%s - %s %s", types.NowDateTime().String(), ip, userAgent)
// ---
origins, err := e.App.FindAllAuthOriginsByRecord(authRecord)
@@ -586,7 +630,7 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
})
routine.FireAndForget(func() {
err := mails.SendRecordAuthAlert(e.App, authRecord)
err := mails.SendRecordAuthAlert(e.App, authRecord, alertInfo)
timer.Stop()
mailSent <- err
})
+17 -12
View File
@@ -86,7 +86,7 @@ func Serve(app core.App, config ServeConfig) error {
// add a default CSP
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
}
return e.Next()
@@ -153,13 +153,6 @@ func Serve(app core.App, config ServeConfig) error {
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
}
serveEvent := new(core.ServeEvent)
serveEvent.App = app
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
var listener net.Listener
// graceful shutdown
@@ -207,6 +200,13 @@ func Serve(app core.App, config ServeConfig) error {
var baseURL string
serveEvent := new(core.ServeEvent)
serveEvent.App = app
serveEvent.Router = pbRouter
serveEvent.Server = server
serveEvent.CertManager = certManager
serveEvent.InstallerFunc = DefaultInstallerFunc
// trigger the OnServe hook and start the tcp listener
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
handler, err := e.Router.BuildMux()
@@ -237,9 +237,13 @@ func Serve(app core.App, config ServeConfig) error {
}
}
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
if e.Listener == nil {
listener, err = net.Listen("tcp", addr)
if err != nil {
return err
}
} else {
listener = e.Listener
}
if e.InstallerFunc != nil {
@@ -259,7 +263,8 @@ func Serve(app core.App, config ServeConfig) error {
}
if listener == nil {
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
//nolint:staticcheck
return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
}
if config.ShowStartBanner {
+6 -2
View File
@@ -30,7 +30,9 @@ func settingsList(e *core.RequestEvent) error {
event.Settings = clone
return e.App.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListRequestEvent) error {
return e.JSON(http.StatusOK, e.Settings)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, e.Settings)
})
})
}
@@ -65,7 +67,9 @@ func settingsSet(e *core.RequestEvent) error {
return e.InternalServerError("Failed to clone app settings.", err)
}
return e.JSON(http.StatusOK, appSettings)
return execAfterSuccessTx(true, e.App, func() error {
return e.JSON(http.StatusOK, appSettings)
})
})
}
+54
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
@@ -58,6 +59,32 @@ func TestSettingsList(t *testing.T) {
"OnSettingsListRequest": 1,
},
},
{
Name: "OnSettingsListRequest tx body write check",
Method: http.MethodGet,
URL: "/api/settings",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnSettingsListRequest().BindFunc(func(e *core.SettingsListRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnSettingsListRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
@@ -176,6 +203,33 @@ func TestSettingsSet(t *testing.T) {
"OnSettingsReload": 1,
},
},
{
Name: "OnSettingsUpdateRequest tx body write check",
Method: http.MethodPatch,
URL: "/api/settings",
Body: strings.NewReader(validData),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.OnSettingsUpdateRequest().BindFunc(func(e *core.SettingsUpdateRequestEvent) error {
original := e.App
return e.App.RunInTransaction(func(txApp core.App) error {
e.App = txApp
defer func() { e.App = original }()
if err := e.Next(); err != nil {
return err
}
return e.BadRequestError("TX_ERROR", nil)
})
})
},
ExpectedStatus: 400,
ExpectedEvents: map[string]int{"OnSettingsUpdateRequest": 1},
ExpectedContent: []string{"TX_ERROR"},
},
}
for _, scenario := range scenarios {
+19 -19
View File
@@ -36,16 +36,16 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
}
superuser, err := app.FindAuthRecordByEmail(superusersCol, args[0])
@@ -57,7 +57,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to upsert superuser account: %w.", err)
return fmt.Errorf("failed to upsert superuser account: %w", err)
}
color.Green("Successfully saved superuser %q!", superuser.Email())
@@ -76,16 +76,16 @@ func superuserCreateCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
if err != nil {
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
}
superuser := core.NewRecord(superusersCol)
@@ -93,7 +93,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to create new superuser account: %w.", err)
return fmt.Errorf("failed to create new superuser account: %w", err)
}
color.Green("Successfully created new superuser %q!", superuser.Email())
@@ -112,22 +112,22 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) != 2 {
return errors.New("Missing email and password arguments.")
return errors.New("missing email and password arguments")
}
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Missing or invalid email address.")
return errors.New("missing or invalid email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
}
superuser.SetPassword(args[1])
if err := app.Save(superuser); err != nil {
return fmt.Errorf("Failed to change superuser %q password: %w.", superuser.Email(), err)
return fmt.Errorf("failed to change superuser %q password: %w", superuser.Email(), err)
}
color.Green("Successfully changed superuser %q password!", superuser.Email())
@@ -146,17 +146,17 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
return errors.New("invalid or missing email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
color.Yellow("Superuser %q is missing or already deleted.", args[0])
color.Yellow("superuser %q is missing or already deleted", args[0])
return nil
}
if err := app.Delete(superuser); err != nil {
return fmt.Errorf("Failed to delete superuser %q: %w.", superuser.Email(), err)
return fmt.Errorf("failed to delete superuser %q: %w", superuser.Email(), err)
}
color.Green("Successfully deleted superuser %q!", superuser.Email())
@@ -175,16 +175,16 @@ func superuserOTPCommand(app core.App) *cobra.Command {
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
return errors.New("Invalid or missing email address.")
return errors.New("invalid or missing email address")
}
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
if err != nil {
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
}
if !superuser.Collection().OTP.Enabled {
return errors.New("OTP is not enabled for the _superusers collection.")
return errors.New("OTP auth is not enabled for the _superusers collection")
}
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
@@ -196,7 +196,7 @@ func superuserOTPCommand(app core.App) *cobra.Command {
err = app.Save(otp)
if err != nil {
return fmt.Errorf("Failed to create OTP: %w", err)
return fmt.Errorf("failed to create OTP: %w", err)
}
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
+72 -29
View File
@@ -45,6 +45,12 @@ type App interface {
// IsTransactional checks if the current app instance is part of a transaction.
IsTransactional() bool
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
TxInfo() *TxAppInfo
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
@@ -91,7 +97,7 @@ type App interface {
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -140,46 +146,82 @@ type App interface {
// DB methods
// ---------------------------------------------------------------
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
DB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
ConcurrentDB() dbx.Builder
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
NonconcurrentDB() dbx.Builder
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything else
// to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
AuxDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db)..
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxConcurrentDB() dbx.Builder
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
AuxNonconcurrentDB() dbx.Builder
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
HasTable(tableName string) bool
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auxiliary.db.
AuxHasTable(tableName string) bool
// TableColumns returns all column names of a single table by its name.
@@ -198,48 +240,49 @@ type App interface {
// This method is a no-op if a table with the provided name doesn't exist.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
DeleteTable(tableName string) error
// "dangerousTableName" argument must come only from trusted input!
DeleteTable(dangerousTableName string) error
// DeleteView drops the specified view name.
//
// This method is a no-op if a view with the provided name doesn't exist.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
DeleteView(name string) error
// "dangerousViewName" argument must come only from trusted input!
DeleteView(dangerousViewName string) error
// SaveView creates (or updates already existing) persistent SQL view.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
SaveView(name string, selectQuery string) error
// NB! Be aware that this method is vulnerable to SQL injection and
// its arguments must come only from trusted input!
SaveView(dangerousViewName string, dangerousSelectQuery string) error
// CreateViewFields creates a new FieldsList from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
CreateViewFields(selectQuery string) (FieldsList, error)
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
// FindRecordByViewFile returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
// Vacuum executes VACUUM on the current app.DB() instance
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
Vacuum() error
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
AuxVacuum() error
// ---------------------------------------------------------------
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
ModelQuery(model Model) *dbx.SelectQuery
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
AuxModelQuery(model Model) *dbx.SelectQuery
@@ -1155,7 +1198,7 @@ type App interface {
// ---------------------------------------------------------------
// OnMailerSend hook is triggered every time when a new email is
// being send using the [App.NewMailClient()] instance.
// being sent using the [App.NewMailClient()] instance.
//
// It allows intercepting the email message or to use a custom mailer client.
OnMailerSend() *hook.Hook[*MailerEvent]
+153 -36
View File
@@ -12,7 +12,6 @@ import (
"regexp"
"runtime"
"strings"
"syscall"
"time"
"github.com/fatih/color"
@@ -26,6 +25,8 @@ import (
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
"golang.org/x/sync/semaphore"
)
const (
@@ -39,6 +40,9 @@ const (
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
// @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found"
)
// FilesManager defines an interface with common methods that files manager models should implement.
@@ -69,7 +73,7 @@ var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
config *BaseAppConfig
txInfo *txAppInfo
txInfo *TxAppInfo
store *store.Store[string, any]
cron *cron.Cron
settings *Settings
@@ -360,9 +364,17 @@ func (app *BaseApp) Logger() *slog.Logger {
return app.logger
}
// TxInfo returns the transaction associated with the current app instance (if any).
//
// Could be used if you want to execute indirectly a function after
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
func (app *BaseApp) TxInfo() *TxAppInfo {
return app.txInfo
}
// IsTransactional checks if the current app instance is part of a transaction.
func (app *BaseApp) IsTransactional() bool {
return app.txInfo != nil
return app.TxInfo() != nil
}
// IsBootstrapped checks if the application was initialized
@@ -466,44 +478,100 @@ func (app *BaseApp) ResetBootstrapState() error {
return nil
}
// DB returns the default app data db instance (pb_data/data.db).
// DB returns the default app data.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly ConcurrentDB() or NonconcurrentDB().
func (app *BaseApp) DB() dbx.Builder {
// transactional or both are nil
if app.concurrentDB == app.nonconcurrentDB {
return app.concurrentDB
}
return &dualDBBuilder{
concurrentDB: app.concurrentDB,
nonconcurrentDB: app.nonconcurrentDB,
}
}
// ConcurrentDB returns the concurrent app data.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) ConcurrentDB() dbx.Builder {
return app.concurrentDB
}
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply DB() as it will automatically
// route the query execution to ConcurrentDB() or NonconcurrentDB().
//
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
return app.nonconcurrentDB
}
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
// AuxDB returns the app auxiliary.db builder instance.
//
// To minimize SQLITE_BUSY errors, it automatically routes the
// SELECT queries to the underlying concurrent db pool and everything
// else to the nonconcurrent one.
//
// For more finer control over the used connections pools you can
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
func (app *BaseApp) AuxDB() dbx.Builder {
// transactional or both are nil
if app.auxConcurrentDB == app.auxNonconcurrentDB {
return app.auxConcurrentDB
}
return &dualDBBuilder{
concurrentDB: app.auxConcurrentDB,
nonconcurrentDB: app.auxNonconcurrentDB,
}
}
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
//
// This method is used mainly internally for executing db read
// operations in a concurrent/non-blocking manner.
//
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
return app.auxConcurrentDB
}
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
//
// The returned db instance is limited only to a single open connection,
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
// meaning that it can process only 1 db operation at a time (other queries queue up).
//
// This method is used mainly internally and in the tests to execute write
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
//
// For the majority of cases you would want to use the regular DB() method
// since it allows concurrent db read operations.
// Most users should use simply AuxDB() as it will automatically
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
//
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
return app.auxNonconcurrentDB
}
@@ -659,7 +727,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -707,7 +775,7 @@ func (app *BaseApp) Restart() error {
}
}()
return syscall.Exec(execPath, os.Args, os.Environ())
return execve(execPath, os.Args, os.Environ())
})
}
@@ -1145,14 +1213,14 @@ var sqlLogReplacements = []struct {
}{
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
{regexp.MustCompile(`([^'"])\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])`), "`$1"},
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
{regexp.MustCompile(`<nil>`), "NULL"},
}
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
// The query is still not suitable for execution and should be used only for log and debug purposes
// (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string {
@@ -1190,6 +1258,33 @@ func (app *BaseApp) initAuxDB() error {
return nil
}
// @todo remove after refactoring the FilesManager interface
func supportFiles(m Model) bool {
var collection *Collection
switch v := m.(type) {
case *Collection:
collection = v
case *Record:
collection = v.Collection()
case RecordProxy:
if v.ProxyRecord() != nil {
collection = v.ProxyRecord().Collection()
}
}
if collection == nil {
return true
}
for _, f := range collection.Fields {
if f.Type() == FieldTypeFile {
return true
}
}
return false
}
func (app *BaseApp) registerBaseHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
@@ -1206,26 +1301,44 @@ func (app *BaseApp) registerBaseHooks() {
return nil
}
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
if maxFilesDeleteWorkers <= 0 {
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
}
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
// try to delete the storage files from deleted Collection, Records, etc. model
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
Id: "__pbFilesManagerDelete__",
Func: func(e *ModelEvent) error {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
// run in the background for "optimistic" delete to avoid
// blocking the delete transaction
routine.FireAndForget(func() {
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
// note: for now assume no context cancellation
err := deleteSem.Acquire(context.Background(), 1)
if err != nil {
app.Logger().Error(
"Failed to delete storage prefix (couldn't acquire a worker)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
} else {
// run in the background for "optimistic" delete to avoid blocking the delete transaction
routine.FireAndForget(func() {
defer deleteSem.Release(1)
if err := deletePrefix(prefix); err != nil {
app.Logger().Error(
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
slog.String("prefix", prefix),
slog.String("error", err.Error()),
)
}
})
}
}
return e.Next()
@@ -1254,7 +1367,7 @@ func (app *BaseApp) registerBaseHooks() {
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
}
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
_, execErr = app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
if execErr != nil {
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
}
@@ -1295,7 +1408,7 @@ func getLoggerMinLevel(app App) slog.Level {
func (app *BaseApp) initLogger() error {
duration := 3 * time.Second
ticker := time.NewTicker(duration)
done := make(chan bool)
done := make(chan bool, 1)
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: getLoggerMinLevel(app),
@@ -1366,7 +1479,11 @@ func (app *BaseApp) initLogger() error {
ticker.Stop()
done <- true
// don't block in case OnTerminate is triggered more than once
select {
case done <- true:
default:
}
return e.Next()
},
+38 -24
View File
@@ -54,7 +54,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup generation
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
// generate a default name if missing
@@ -69,9 +69,9 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// archive pb_data in a temp directory, exluding the "backups" and the temp dirs
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
//
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
// ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
createErr := e.App.RunInTransaction(func(txApp App) error {
@@ -138,9 +138,9 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
//
// 4. Move the extracted dir content to the app "pb_data".
//
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
// 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
//
// If a failure occure during the restore process the dir changes are reverted.
// If a failure occur during the restore process the dir changes are reverted.
// If for whatever reason the revert is not possible, it panics.
//
// Note that if your pb_data has custom network mounts as subdirectories, then
@@ -159,7 +159,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup restore
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
return app.OnBackupRestore().Trigger(event, func(e *BackupEvent) error {
if runtime.GOOS == "windows" {
@@ -190,7 +190,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
// extract the zip
if e.App.Settings().Backups.S3.Enabled {
br, err := fsys.GetFile(name)
br, err := fsys.GetReader(name)
if err != nil {
return err
}
@@ -228,7 +228,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
} else {
// manually construct the local path to avoid creating a copy of the zip file
// since the blob reader currently doesn't implement ReaderAt
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
zipPath := filepath.Join(e.App.DataDir(), LocalBackupsDirName, filepath.Base(name))
err = archive.Extract(zipPath, extractedDataDir)
if err != nil {
@@ -242,29 +242,43 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, e.App.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
replaceErr := e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
// move the current pb_data content to a special temp location
// that will hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
if err := osutils.MoveDirContent(txApp.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
}
// move the extracted archive content to the app's pb_data
if err := osutils.MoveDirContent(extractedDataDir, txApp.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
}
return nil
})
})
if replaceErr != nil {
return replaceErr
}
revertDataDirChanges := func() error {
if err := osutils.MoveDirContent(e.App.DataDir(), extractedDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
return e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
if err := osutils.MoveDirContent(txApp.DataDir(), extractedDataDir, e.Exclude...); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, e.App.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
if err := osutils.MoveDirContent(oldTempDataDir, txApp.DataDir(), e.Exclude...); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
return nil
return nil
})
})
}
// restart the app
+201 -11
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"log/slog"
"os"
"slices"
"testing"
"time"
@@ -99,9 +100,11 @@ func TestBaseAppBootstrap(t *testing.T) {
}
nilChecksBeforeReset := []nilCheck{
{"[before] concurrentDB", app.DB(), false},
{"[before] db", app.DB(), false},
{"[before] concurrentDB", app.ConcurrentDB(), false},
{"[before] nonconcurrentDB", app.NonconcurrentDB(), false},
{"[before] auxConcurrentDB", app.AuxDB(), false},
{"[before] auxDB", app.AuxDB(), false},
{"[before] auxConcurrentDB", app.AuxConcurrentDB(), false},
{"[before] auxNonconcurrentDB", app.AuxNonconcurrentDB(), false},
{"[before] settings", app.Settings(), false},
{"[before] logger", app.Logger(), false},
@@ -116,9 +119,11 @@ func TestBaseAppBootstrap(t *testing.T) {
}
nilChecksAfterReset := []nilCheck{
{"[after] concurrentDB", app.DB(), true},
{"[after] db", app.DB(), true},
{"[after] concurrentDB", app.ConcurrentDB(), true},
{"[after] nonconcurrentDB", app.NonconcurrentDB(), true},
{"[after] auxConcurrentDB", app.AuxDB(), true},
{"[after] auxDB", app.AuxDB(), true},
{"[after] auxConcurrentDB", app.AuxConcurrentDB(), true},
{"[after] auxNonconcurrentDB", app.AuxNonconcurrentDB(), true},
{"[after] settings", app.Settings(), false},
{"[after] logger", app.Logger(), false},
@@ -128,7 +133,7 @@ func TestBaseAppBootstrap(t *testing.T) {
runNilChecks(nilChecksAfterReset)
}
func TestNewBaseAppIsTransactional(t *testing.T) {
func TestNewBaseAppTx(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
@@ -141,17 +146,34 @@ func TestNewBaseAppIsTransactional(t *testing.T) {
t.Fatal(err)
}
if app.IsTransactional() {
t.Fatalf("Didn't expect the app to be transactional")
mustNotHaveTx := func(app core.App) {
if app.IsTransactional() {
t.Fatalf("Didn't expect the app to be transactional")
}
if app.TxInfo() != nil {
t.Fatalf("Didn't expect the app.txInfo to be loaded")
}
}
app.RunInTransaction(func(txApp core.App) error {
if !txApp.IsTransactional() {
mustHaveTx := func(app core.App) {
if !app.IsTransactional() {
t.Fatalf("Expected the app to be transactional")
}
if app.TxInfo() == nil {
t.Fatalf("Expected the app.txInfo to be loaded")
}
}
mustNotHaveTx(app)
app.RunInTransaction(func(txApp core.App) error {
mustHaveTx(txApp)
return nil
})
mustNotHaveTx(app)
}
func TestBaseAppNewMailClient(t *testing.T) {
@@ -354,8 +376,8 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
}
// silence query logs
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
@@ -378,3 +400,171 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
})
}
}
func TestBaseAppDBDualBuilder(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
concurrentQueries := []string{}
nonconcurrentQueries := []string{}
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
type testQuery struct {
query string
isConcurrent bool
}
regularTests := []testQuery{
{" \n sEleCt 1", true},
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
{"create table t1(x int)", false},
{"insert into t1(x) values(1)", false},
{"update t1 set x = 2", false},
{"delete from t1", false},
}
txTests := []testQuery{
{"select 3", false},
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
{"create table t2(x int)", false},
{"insert into t2(x) values(1)", false},
{"update t2 set x = 2", false},
{"delete from t2", false},
}
for _, item := range regularTests {
_, err := app.DB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
app.RunInTransaction(func(txApp core.App) error {
for _, item := range txTests {
_, err := txApp.DB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
return nil
})
allTests := append(regularTests, txTests...)
for _, item := range allTests {
if item.isConcurrent {
if !slices.Contains(concurrentQueries, item.query) {
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
} else {
if !slices.Contains(nonconcurrentQueries, item.query) {
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
}
}
}
func TestBaseAppAuxDBDualBuilder(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
concurrentQueries := []string{}
nonconcurrentQueries := []string{}
app.AuxConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.AuxConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
nonconcurrentQueries = append(nonconcurrentQueries, sql)
}
type testQuery struct {
query string
isConcurrent bool
}
regularTests := []testQuery{
{" \n sEleCt 1", true},
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
{"create table t1(x int)", false},
{"insert into t1(x) values(1)", false},
{"update t1 set x = 2", false},
{"delete from t1", false},
}
txTests := []testQuery{
{"select 3", false},
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
{"create table t2(x int)", false},
{"insert into t2(x) values(1)", false},
{"update t2 set x = 2", false},
{"delete from t2", false},
}
for _, item := range regularTests {
_, err := app.AuxDB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
app.AuxRunInTransaction(func(txApp core.App) error {
for _, item := range txTests {
_, err := txApp.AuxDB().NewQuery(item.query).Execute()
if err != nil {
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
}
}
return nil
})
allTests := append(regularTests, txTests...)
for _, item := range allTests {
if item.isConcurrent {
if !slices.Contains(concurrentQueries, item.query) {
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
} else {
if !slices.Contains(nonconcurrentQueries, item.query) {
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
}
}
}
}
func TestBaseAppTriggerOnTerminate(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
event := new(core.TerminateEvent)
event.App = app
// trigger OnTerminate multiple times to ensure that it doesn't deadlock
// https://github.com/pocketbase/pocketbase/pull/7305
app.OnTerminate().Trigger(event)
app.OnTerminate().Trigger(event)
app.OnTerminate().Trigger(event)
}
+1 -1
View File
@@ -170,7 +170,7 @@ func TestImportCollections(t *testing.T) {
expectedCollectionFields := map[string]int{
core.CollectionNameAuthOrigins: 6,
"nologin": 10,
"demo1": 18,
"demo1": 19,
"demo2": 5,
"demo3": 5,
"demo4": 16,
+2 -2
View File
@@ -545,7 +545,7 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
// MarshalJSON implements the [json.Marshaler] interface.
//
// Note that non-type related fields are ignored from the serialization
// (ex. for "view" colections the "auth" fields are skipped).
// (ex. for "view" collections the "auth" fields are skipped).
func (m Collection) MarshalJSON() ([]byte, error) {
switch m.Type {
case CollectionTypeView:
@@ -1062,7 +1062,7 @@ func (c *Collection) fieldIndexName(field string) string {
} else if c.Name != "" {
name += c.Name
} else {
name += security.PseudorandomString(10)
name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
}
if len(name) > 64 {
+9 -7
View File
@@ -2,11 +2,12 @@ package core
// Common settings placeholder tokens
const (
EmailPlaceholderAppName string = "{APP_NAME}"
EmailPlaceholderAppURL string = "{APP_URL}"
EmailPlaceholderToken string = "{TOKEN}"
EmailPlaceholderOTP string = "{OTP}"
EmailPlaceholderOTPId string = "{OTP_ID}"
EmailPlaceholderAppName string = "{APP_NAME}"
EmailPlaceholderAppURL string = "{APP_URL}"
EmailPlaceholderToken string = "{TOKEN}"
EmailPlaceholderOTP string = "{OTP}"
EmailPlaceholderOTPId string = "{OTP_ID}"
EmailPlaceholderAlertInfo string = "{ALERT_INFO}"
)
var defaultVerificationTemplate = EmailTemplate{
@@ -65,9 +66,10 @@ var defaultOTPTemplate = EmailTemplate{
var defaultAuthAlertTemplate = EmailTemplate{
Subject: "Login from a new location",
Body: `<p>Hello,</p>
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location.</p>
<p>If this was you, you may disregard this email.</p>
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location:</p>
<p><em>` + EmailPlaceholderAlertInfo + `</em></p>
<p><strong>If this wasn't you, you should immediately change your ` + EmailPlaceholderAppName + ` account password to revoke access from all other locations.</strong></p>
<p>If this was you, you may disregard this email.</p>
<p>
Thanks,<br/>
` + EmailPlaceholderAppName + ` team
+4 -1
View File
@@ -1012,18 +1012,21 @@ func TestCollectionModelEventSync(t *testing.T) {
changeCollectionEventBefore := func(e *core.CollectionEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Collection = testCollections[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testCollections[2]
}
changeCollectionEventAfter := func(e *core.CollectionEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Collection = testCollections[3]
}
@@ -1620,7 +1623,7 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
var sql string
rowErr := app.DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
Bind(dbx.Params{"name": viewName}).
Row(&sql)
if rowErr != nil {
+2 -2
View File
@@ -38,7 +38,7 @@ func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection
q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
}
err := q.OrderBy("created ASC").All(&collections)
err := q.OrderBy("rowid ASC").All(&collections)
if err != nil {
return nil, err
}
@@ -220,7 +220,7 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
// cascade and file delete actions.
func (app *BaseApp) TruncateCollection(collection *Collection) error {
if collection.IsView() {
return errors.New("view collections cannot be truncated since they don't store their own records.")
return errors.New("view collections cannot be truncated since they don't store their own records")
}
return app.RunInTransaction(func(txApp App) error {
+2 -2
View File
@@ -153,7 +153,7 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
defer app.Cleanup()
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
@@ -272,7 +272,7 @@ func TestFindCachedCollectionReferences(t *testing.T) {
}
totalQueries := 0
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
totalQueries++
}
+3 -2
View File
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
// run optimize per the SQLite recommendations
// (https://www.sqlite.org/pragma.html#pragma_optimize)
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
_, optimizeErr := app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
if optimizeErr != nil {
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
}
@@ -310,7 +310,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
continue
}
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
if err != nil {
return err
}
}
+3 -3
View File
@@ -87,7 +87,7 @@ func (validator *collectionValidator) run() error {
validator.original.IsNew(),
validation.Length(1, 100),
validation.Match(DefaultIdRegex),
validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())),
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
).Else(
validation.By(validators.Equal(validator.original.Id)),
),
@@ -489,7 +489,7 @@ func (validator *collectionValidator) checkRule(value any) error {
return nil // nothing to check
}
r := NewRecordFieldResolver(validator.app, validator.new, nil, true)
r := NewRecordFieldResolver(validator.app, validator.new, &RequestInfo{}, true)
_, err := search.FilterData(vStr).BuildExpr(r)
if err != nil {
return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error())
@@ -558,7 +558,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
// ensure that the index name is not used in another collection
var usedTblName string
_ = cv.app.DB().Select("tbl_name").
_ = cv.app.ConcurrentDB().Select("tbl_name").
From("sqlite_master").
AndWhere(dbx.HashExp{"type": "index"}).
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
+3 -3
View File
@@ -319,8 +319,8 @@ func TestCollectionValidate(t *testing.T) {
c.ListRule = types.Pointer("!invalid")
c.ViewRule = types.Pointer("missing = 123")
c.CreateRule = types.Pointer("id = 123 && missing = 456")
c.UpdateRule = types.Pointer("(id = 123")
c.DeleteRule = types.Pointer("missing = 123")
c.UpdateRule = types.Pointer("@request.body.missing:changed = false")
c.DeleteRule = types.Pointer("(id=123")
return c, nil
},
expectedErrors: []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
@@ -333,7 +333,7 @@ func TestCollectionValidate(t *testing.T) {
c.ListRule = types.Pointer("")
c.ViewRule = types.Pointer("f1 = 123")
c.CreateRule = types.Pointer("id = 123 && f1 = 456")
c.UpdateRule = types.Pointer("(id = 123)")
c.UpdateRule = types.Pointer("(id = 123 && @request.body.f1:changed = false)")
c.DeleteRule = types.Pointer("f1 = 123")
return c, nil
},
+8 -8
View File
@@ -62,16 +62,16 @@ func crc32Checksum(str string) string {
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
}
// ModelQuery creates a new preconfigured select app.DB() query with preset
// ModelQuery creates a new preconfigured select data.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.DB(), m)
return app.modelQuery(app.ConcurrentDB(), m)
}
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
// SELECT, FROM and other common fields based on the provided model.
func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
return app.modelQuery(app.AuxDB(), m)
return app.modelQuery(app.AuxConcurrentDB(), m)
}
func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
@@ -151,7 +151,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -342,7 +342,7 @@ func (app *BaseApp) create(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -426,7 +426,7 @@ func (app *BaseApp) update(ctx context.Context, model Model, withValidations boo
if app.txInfo != nil {
// execute later after the transaction has completed
app.txInfo.onAfterFunc(func(txErr error) error {
app.txInfo.OnComplete(func(txErr error) error {
if app.txInfo != nil && app.txInfo.parent != nil {
event.App = app.txInfo.parent
}
@@ -484,7 +484,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
var exists int
rowErr := app.DB().Select("(1)").
rowErr := app.ConcurrentDB().Select("(1)").
From(collection.Name).
AndWhere(dbx.HashExp{"id": id}).
Limit(1).
+187
View File
@@ -0,0 +1,187 @@
package core
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/pocketbase/dbx"
)
var _ dbx.Builder = (*dualDBBuilder)(nil)
// note: expects both builder to use the same driver
type dualDBBuilder struct {
concurrentDB dbx.Builder
nonconcurrentDB dbx.Builder
}
// Select implements the [dbx.Builder.Select] interface method.
func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
return b.concurrentDB.Select(cols...)
}
// Model implements the [dbx.Builder.Model] interface method.
func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
return b.nonconcurrentDB.Model(data)
}
// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
return b.concurrentDB.GeneratePlaceholder(i)
}
// Quote implements the [dbx.Builder.Quote] interface method.
func (b *dualDBBuilder) Quote(str string) string {
return b.concurrentDB.Quote(str)
}
// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
return b.concurrentDB.QuoteSimpleTableName(table)
}
// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
return b.concurrentDB.QuoteSimpleColumnName(col)
}
// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
return b.concurrentDB.QueryBuilder()
}
// Insert implements the [dbx.Builder.Insert] interface method.
func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
return b.nonconcurrentDB.Insert(table, cols)
}
// Upsert implements the [dbx.Builder.Upsert] interface method.
func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
return b.nonconcurrentDB.Upsert(table, cols, constraints...)
}
// Update implements the [dbx.Builder.Update] interface method.
func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Update(table, cols, where)
}
// Delete implements the [dbx.Builder.Delete] interface method.
func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
return b.nonconcurrentDB.Delete(table, where)
}
// CreateTable implements the [dbx.Builder.CreateTable] interface method.
func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
return b.nonconcurrentDB.CreateTable(table, cols, options...)
}
// RenameTable implements the [dbx.Builder.RenameTable] interface method.
func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameTable(oldName, newName)
}
// DropTable implements the [dbx.Builder.DropTable] interface method.
func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
return b.nonconcurrentDB.DropTable(table)
}
// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
return b.nonconcurrentDB.TruncateTable(table)
}
// AddColumn implements the [dbx.Builder.AddColumn] interface method.
func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AddColumn(table, col, typ)
}
// DropColumn implements the [dbx.Builder.DropColumn] interface method.
func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
return b.nonconcurrentDB.DropColumn(table, col)
}
// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
}
// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
return b.nonconcurrentDB.AlterColumn(table, col, typ)
}
// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
}
// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropPrimaryKey(table, name)
}
// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
}
// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropForeignKey(table, name)
}
// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateIndex(table, name, cols...)
}
// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
}
// DropIndex implements the [dbx.Builder.DropIndex] interface method.
func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
return b.nonconcurrentDB.DropIndex(table, name)
}
// NewQuery implements the [dbx.Builder.NewQuery] interface method by
// routing the SELECT queries to the concurrent builder instance.
func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
// note: technically INSERT/UPDATE/DELETE could also have CTE but since
// it is rare for now this scase is ignored to avoid unnecessary complicating the checks
trimmed := trimLeftSpaces(str)
if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
return b.concurrentDB.NewQuery(str)
}
return b.nonconcurrentDB.NewQuery(str)
}
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
// note: similar to strings.Space() but without the right trim because it is not needed in our case
func trimLeftSpaces(str string) string {
start := 0
for ; start < len(str); start++ {
c := str[start]
if c >= utf8.RuneSelf {
return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
}
if asciiSpace[c] == 0 {
break
}
}
return str[start:]
}
// note: the prefix is expected to be ASCII
func hasPrefixFold(str, prefix string) bool {
if len(str) < len(prefix) {
return false
}
return strings.EqualFold(str[:len(prefix)], prefix)
}
+1 -1
View File
@@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
// Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
db, err := dbx.Open("sqlite", dbPath+pragmas)
if err != nil {
+15 -17
View File
@@ -11,7 +11,7 @@ import (
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
columns := []string{}
err := app.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
Column(&columns)
@@ -34,7 +34,7 @@ type TableInfoRow struct {
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
info := []*TableInfoRow{}
err := app.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
Bind(dbx.Params{"tableName": tableName}).
All(&info)
if err != nil {
@@ -59,7 +59,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
Sql string
}{}
err := app.DB().Select("name", "sql").
err := app.ConcurrentDB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.NewExp("sql is not null")).
AndWhere(dbx.HashExp{
@@ -85,26 +85,26 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
// This method is a no-op if a table with the provided name doesn't exist.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input!
func (app *BaseApp) DeleteTable(tableName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf(
// "dangerousTableName" argument must come only from trusted input!
func (app *BaseApp) DeleteTable(dangerousTableName string) error {
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}",
tableName,
dangerousTableName,
)).Execute()
return err
}
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
// in the current app.DB() instance.
// in the data.db.
func (app *BaseApp) HasTable(tableName string) bool {
return app.hasTable(app.DB(), tableName)
return app.hasTable(app.ConcurrentDB(), tableName)
}
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
// in the current app.AuxDB() instance.
// in the auixiliary.db.
func (app *BaseApp) AuxHasTable(tableName string) bool {
return app.hasTable(app.AuxDB(), tableName)
return app.hasTable(app.AuxConcurrentDB(), tableName)
}
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
@@ -120,16 +120,14 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
return err == nil && exists > 0
}
// Vacuum executes VACUUM on the current app.DB() instance
// in order to reclaim unused data db disk space.
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
func (app *BaseApp) Vacuum() error {
return app.vacuum(app.DB())
return app.vacuum(app.NonconcurrentDB())
}
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
// in order to reclaim unused auxiliary db disk space.
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
func (app *BaseApp) AuxVacuum() error {
return app.vacuum(app.AuxDB())
return app.vacuum(app.AuxNonconcurrentDB())
}
func (app *BaseApp) vacuum(db dbx.Builder) error {
+4 -4
View File
@@ -202,10 +202,10 @@ func TestVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
@@ -229,10 +229,10 @@ func TestAuxVacuum(t *testing.T) {
defer app.Cleanup()
calledQueries := []string{}
app.AuxDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.AuxDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
+4 -4
View File
@@ -38,12 +38,12 @@ func TestModelQuery(t *testing.T) {
modelsQuery := app.ModelQuery(&core.Collection{})
logsModelQuery := app.AuxModelQuery(&core.Collection{})
if app.DB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.DB()")
if app.ConcurrentDB() == modelsQuery.Info().Builder {
t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
}
if app.AuxDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxDB()")
if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
}
expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
+12 -5
View File
@@ -60,7 +60,7 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
clone.nonconcurrentDB = tx
}
clone.txInfo = &txAppInfo{
clone.txInfo = &TxAppInfo{
parent: app,
isForAuxDB: isForAuxDB,
}
@@ -68,22 +68,29 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
return &clone
}
type txAppInfo struct {
// TxAppInfo represents an active transaction context associated to an existing app instance.
type TxAppInfo struct {
parent *BaseApp
afterFuncs []func(txErr error) error
mu sync.Mutex
isForAuxDB bool
}
func (a *txAppInfo) onAfterFunc(fn func(txErr error) error) {
// OnComplete registers the provided callback that will be invoked
// once the related transaction ends (either completes successfully or rollbacked with an error).
//
// The callback receives the transaction error (if any) as its argument.
// Any additional errors returned by the OnComplete callbacks will be
// joined together with txErr when returning the final transaction result.
func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
a.mu.Lock()
defer a.mu.Unlock()
a.afterFuncs = append(a.afterFuncs, fn)
}
// note: can be called only once because txAppInfo is cleared
func (a *txAppInfo) runAfterFuncs(txErr error) error {
// note: can be called only once because TxAppInfo is cleared
func (a *TxAppInfo) runAfterFuncs(txErr error) error {
a.mu.Lock()
defer a.mu.Unlock()
+13
View File
@@ -2,6 +2,7 @@ package core
import (
"context"
"net"
"net/http"
"time"
@@ -104,6 +105,11 @@ type ServeEvent struct {
Server *http.Server
CertManager *autocert.Manager
// Listener allow specifying a custom network listener.
//
// Leave it nil to use the default net.Listen("tcp", e.Server.Addr).
Listener net.Listener
// InstallerFunc is the "installer" function that is called after
// successful server tcp bind but only if there is no explicit
// superuser record created yet.
@@ -378,6 +384,13 @@ type FileDownloadRequestEvent struct {
FileField *FileField
ServedPath string
ServedName string
// ThumbError indicates the a thumb wasn't able to be generated
// (e.g. because it didn't satisfy the support image formats or it timed out).
//
// Note that PocketBase fallbacks to the original file in case of a thumb error,
// but developers can check the field and provide their own custom thumb generation if necessary.
ThumbError error
}
// -------------------------------------------------------------------
+1 -1
View File
@@ -155,7 +155,7 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.DB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
result, err := app.FindFirstExternalAuthByExpr(s.expr)
hasErr := err != nil
+2 -4
View File
@@ -10,7 +10,6 @@ import (
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
@@ -48,7 +47,7 @@ var (
// FileField defines "file" type field for managing record file(s).
//
// Only the file name is stored as part of the record value.
// New files (aka. files to upload) are expected to be of *filesytem.File.
// New files (aka. files to upload) are expected to be of *filesystem.File.
//
// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
//
@@ -357,8 +356,7 @@ func (f *FileField) Intercept(
return nil
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
_, insideTransaction := app.DB().(*dbx.Tx)
if insideTransaction {
if app.IsTransactional() {
return actionFunc()
}
+148
View File
@@ -0,0 +1,148 @@
package core
import (
"context"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
Fields[FieldTypeGeoPoint] = func() Field {
return &GeoPointField{}
}
}
const FieldTypeGeoPoint = "geoPoint"
var (
_ Field = (*GeoPointField)(nil)
)
// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
//
// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
// The stored value is always converted to [types.GeoPoint].
// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
//
// Examples of updating a record's GeoPointField value programmatically:
//
// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
// record.Set("location", map[string]any{"lat":123, "lon":456})
// record.Set("location", []byte(`{"lat":123, "lon":456}`)
type GeoPointField struct {
// Name (required) is the unique name of the field.
Name string `form:"name" json:"name"`
// Id is the unique stable field identifier.
//
// It is automatically generated from the name when adding to a collection FieldsList.
Id string `form:"id" json:"id"`
// System prevents the renaming and removal of the field.
System bool `form:"system" json:"system"`
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"`
}
// Type implements [Field.Type] interface method.
func (f *GeoPointField) Type() string {
return FieldTypeGeoPoint
}
// GetId implements [Field.GetId] interface method.
func (f *GeoPointField) GetId() string {
return f.Id
}
// SetId implements [Field.SetId] interface method.
func (f *GeoPointField) SetId(id string) {
f.Id = id
}
// GetName implements [Field.GetName] interface method.
func (f *GeoPointField) GetName() string {
return f.Name
}
// SetName implements [Field.SetName] interface method.
func (f *GeoPointField) SetName(name string) {
f.Name = name
}
// GetSystem implements [Field.GetSystem] interface method.
func (f *GeoPointField) GetSystem() bool {
return f.System
}
// SetSystem implements [Field.SetSystem] interface method.
func (f *GeoPointField) SetSystem(system bool) {
f.System = system
}
// GetHidden implements [Field.GetHidden] interface method.
func (f *GeoPointField) GetHidden() bool {
return f.Hidden
}
// SetHidden implements [Field.SetHidden] interface method.
func (f *GeoPointField) SetHidden(hidden bool) {
f.Hidden = hidden
}
// ColumnType implements [Field.ColumnType] interface method.
func (f *GeoPointField) ColumnType(app App) string {
return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
}
// PrepareValue implements [Field.PrepareValue] interface method.
func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
point := types.GeoPoint{}
err := point.Scan(raw)
return point, err
}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
val, ok := record.GetRaw(f.Name).(types.GeoPoint)
if !ok {
return validators.ErrUnsupportedValueType
}
// zero value
if val.Lat == 0 && val.Lon == 0 {
if f.Required {
return validation.ErrRequired
}
return nil
}
if val.Lat < -90 || val.Lat > 90 {
return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
}
if val.Lon < -180 || val.Lon > 180 {
return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
}
return nil
}
// ValidateSettings implements [Field.ValidateSettings] interface method.
func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
)
}
+202
View File
@@ -0,0 +1,202 @@
package core_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestGeoPointFieldBaseMethods(t *testing.T) {
testFieldBaseMethods(t, core.FieldTypeGeoPoint)
}
func TestGeoPointFieldColumnType(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
if v := f.ColumnType(app); v != expected {
t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
}
}
func TestGeoPointFieldPrepareValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
f := &core.GeoPointField{}
record := core.NewRecord(core.NewBaseCollection("test"))
scenarios := []struct {
raw any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{[]byte{}, `{"lon":0,"lat":0}`},
{map[string]any{}, `{"lon":0,"lat":0}`},
{types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
{[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
{map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
{map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
v, err := f.PrepareValue(record, s.raw)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
}
})
}
}
func TestGeoPointFieldValidateValue(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
scenarios := []struct {
name string
field *core.GeoPointField
record func() *core.Record
expectError bool
}{
{
"invalid raw value",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", 123)
return record
},
true,
},
{
"zero field value (non-required)",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
false,
},
{
"zero field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{})
return record
},
true,
},
{
"non-zero Lat field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 1})
return record
},
false,
},
{
"non-zero Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 1})
return record
},
false,
},
{
"non-zero Lat-Lon field value (required)",
&core.GeoPointField{Name: "test", Required: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
return record
},
false,
},
{
"lat < -90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: -90.1})
return record
},
true,
},
{
"lat > 90",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lat: 90.1})
return record
},
true,
},
{
"lon < -180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: -180.1})
return record
},
true,
},
{
"lon > 180",
&core.GeoPointField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.GeoPoint{Lon: 180.1})
return record
},
true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
err := s.field.ValidateValue(context.Background(), app, s.record())
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
}
+2 -2
View File
@@ -20,7 +20,7 @@ func init() {
const FieldTypeJSON = "json"
const DefaultJSONFieldMaxSize int64 = 5 << 20
const DefaultJSONFieldMaxSize int64 = 1 << 20
var (
_ Field = (*JSONField)(nil)
@@ -53,7 +53,7 @@ type JSONField struct {
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
//
// If zero, a default limit of 5MB is applied.
// If zero, a default limit of 1MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
// Required will require the field value to be non-empty JSON value
+1 -1
View File
@@ -146,7 +146,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
&core.JSONField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (5<<20))+`"`))
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
return record
},
true,
+1 -1
View File
@@ -219,7 +219,7 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
}
var total int
_ = app.DB().
_ = app.ConcurrentDB().
Select("count(*)").
From(relCollection.Name).
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
+40 -15
View File
@@ -31,6 +31,22 @@ var (
_ RecordInterceptor = (*TextField)(nil)
)
var forbiddenPKCharacters = []string{
".", "/", `\`, "|", `"`, "'", "`",
"<", ">", ":", "?", "*", "%", "$",
"\000", "\t", "\n", "\r", " ",
}
// (see largestReservedPKLength)
var caseInsensitiveReservedPKs = []string{
// reserved Windows files names
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
}
const largestReservedPKLength = 4
// TextField defines "text" type field for storing any string value.
//
// The respective zero record field value is empty string.
@@ -155,8 +171,6 @@ func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
return cast.ToString(raw), nil
}
var forbiddenPKChars = []string{"/", "\\"}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
newVal, ok := record.GetRaw(f.Name).(string)
@@ -178,15 +192,6 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
return nil
}
} else {
// disallow PK special characters no matter of the Pattern validator to minimize
// side-effects when the primary key is used for example in a directory path
for _, c := range forbiddenPKChars {
if strings.Contains(newVal, c) {
return validation.NewError("validation_pk_forbidden", "The record primary key contains forbidden characters.").
SetParams(map[string]any{"forbidden": c})
}
}
// this technically shouldn't be necessarily but again to
// minimize misuse of the Pattern validator that could cause
// side-effects on some platforms check for duplicates in a case-insensitive manner
@@ -194,7 +199,7 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
if f.Pattern != defaultLowercaseRecordIdPattern {
var exists int
err := app.DB().
err := app.ConcurrentDB().
Select("(1)").
From(record.TableName()).
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
@@ -226,7 +231,7 @@ func (f *TextField) ValidatePlainValue(value string) error {
length := len([]rune(value))
if f.Min > 0 && length < f.Min {
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s)").
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s).").
SetParams(map[string]any{"min": f.Min})
}
@@ -236,14 +241,34 @@ func (f *TextField) ValidatePlainValue(value string) error {
}
if max > 0 && length > max {
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s).").
SetParams(map[string]any{"max": max})
}
if f.Pattern != "" {
match, _ := regexp.MatchString(f.Pattern, value)
if !match {
return validation.NewError("validation_invalid_format", "Invalid value format")
return validation.NewError("validation_invalid_format", "Invalid value format.")
}
}
// additional primary key checks to minimize eventual filesystem compatibility issues
// because the primary key is often used as a file/directory name
if f.PrimaryKey && f.Pattern != defaultLowercaseRecordIdPattern {
for _, ch := range forbiddenPKCharacters {
if strings.Contains(value, ch) {
return validation.NewError("validation_forbidden_pk_character", "'{{.ch}}' is not a valid primary key character.").
SetParams(map[string]any{"ch": ch})
}
}
if largestReservedPKLength >= length {
for _, reserved := range caseInsensitiveReservedPKs {
if strings.EqualFold(value, reserved) {
return validation.NewError("validation_reserved_pk", "The primary key '{{.reserved}}' is reserved and cannot be used.").
SetParams(map[string]any{"reserved": reserved})
}
}
}
}
+94 -4
View File
@@ -129,7 +129,7 @@ func TestTextFieldValidateValue(t *testing.T) {
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "/")
record.SetRaw("test", "abc/")
return record
},
false,
@@ -139,7 +139,37 @@ func TestTextFieldValidateValue(t *testing.T) {
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "\\")
record.SetRaw("test", "abc\\")
return record
},
false,
},
{
"special forbidden character . (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "abc.")
return record
},
false,
},
{
"special forbidden character ' ' (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "ab c")
return record
},
false,
},
{
"special forbidden character * (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "abc*")
return record
},
false,
@@ -149,7 +179,7 @@ func TestTextFieldValidateValue(t *testing.T) {
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "/")
record.SetRaw("test", "abc/")
return record
},
true,
@@ -159,11 +189,71 @@ func TestTextFieldValidateValue(t *testing.T) {
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "\\")
record.SetRaw("test", "abc\\")
return record
},
true,
},
{
"special forbidden character . (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "abc.")
return record
},
true,
},
{
"special forbidden character ' ' (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "ab c")
return record
},
true,
},
{
"special forbidden character * (primaryKey; used in the realtime events too)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "abc*")
return record
},
true,
},
{
"reserved pk literal (non-primaryKey)",
&core.TextField{Name: "test", PrimaryKey: false},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "aUx")
return record
},
false,
},
{
"reserved pk literal (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "aUx")
return record
},
true,
},
{
"reserved pk literal (non-exact match, primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", "aUx-")
return record
},
false,
},
{
"zero field value (primaryKey)",
&core.TextField{Name: "test", PrimaryKey: true},
+1 -1
View File
@@ -186,7 +186,7 @@ func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
return extractedFields, nil
}
// try to unmarshal first into a new fieds list
// try to unmarshal first into a new fields list
// (assuming that rawJSON is array of objects)
err := json.Unmarshal(rawJSON, &extractedFields)
if err != nil {
+4 -4
View File
@@ -55,10 +55,10 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
}
// silence query logs
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.concurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.concurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.nonconcurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
app.nonconcurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
app.Settings().Logs.MinLevel = testLogLevel
if err := app.Save(app.Settings()); err != nil {
+5 -5
View File
@@ -112,7 +112,7 @@ func (r *MigrationsRunner) Run(args ...string) error {
color.Green("The %s table was synced with the available migrations.", r.tableName)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
return fmt.Errorf("unsupported command: %q", cmd)
}
}
@@ -151,12 +151,12 @@ func (r *MigrationsRunner) Up() ([]string, error) {
// ignore empty Up action
if m.Up != nil {
if err := m.Up(txApp); err != nil {
return fmt.Errorf("Failed to apply migration %s: %w", m.File, err)
return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
}
}
if err := r.saveAppliedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
}
applied = append(applied, m.File)
@@ -204,12 +204,12 @@ func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
// ignore empty Down action
if m.Down != nil {
if err := m.Down(txApp); err != nil {
return fmt.Errorf("Failed to revert migration %s: %w", m.File, err)
return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
}
}
if err := r.saveRevertedMigration(txApp, m.File); err != nil {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err)
return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
}
reverted = append(reverted, m.File)
+210 -22
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
@@ -17,12 +18,18 @@ import (
// filter modifiers
const (
eachModifier string = "each"
issetModifier string = "isset"
lengthModifier string = "length"
lowerModifier string = "lower"
eachModifier string = "each"
issetModifier string = "isset"
lengthModifier string = "length"
lowerModifier string = "lower"
changedModifier string = "changed"
)
type ruleJoin struct {
collection *Collection
tableAlias string
}
// ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
@@ -46,8 +53,12 @@ type RecordFieldResolver struct {
requestInfo *RequestInfo
staticRequestInfo map[string]any
allowedFields []string
joins []*join
joins []*search.Join
allowHiddenFields bool
// ---
listRuleJoins []ruleJoin
joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
baseCollectionAlias string
}
// AllowedFields returns a copy of the resolver's allowed fields.
@@ -82,7 +93,7 @@ func NewRecordFieldResolver(
baseCollection: baseCollection,
requestInfo: requestInfo,
allowHiddenFields: allowHiddenFields, // note: it is not based only on the requestInfo.auth since it could be used by a non-request internal method
joins: []*join{},
joins: []*search.Join{},
allowedFields: []string{
`^\w+[\w\.\:]*$`,
`^\@request\.context$`,
@@ -115,18 +126,80 @@ func NewRecordFieldResolver(
return r
}
// @todo think of a better a way how to call it automatically after BuildExpr
//
// UpdateQuery implements `search.FieldResolver` interface.
//
// Conditionally updates the provided search query based on the
// resolved fields (eg. dynamically joining relations).
func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
if len(r.joins) > 0 {
query.Distinct(true)
r.updateQueryWithDeduplicateConstraint(query)
for _, join := range r.joins {
query.LeftJoin(
(join.tableName + " " + join.tableAlias),
join.on,
(join.TableName + " " + join.TableAlias),
join.On,
)
}
}
// note: for now the joins are not applied for multi-match conditions to avoid excessive checks
if len(r.listRuleJoins) > 0 {
for _, join := range r.listRuleJoins {
err := r.updateQueryWithCollectionListRule(join.collection, join.tableAlias, query)
if err != nil {
return err
}
}
}
return nil
}
func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, tableAlias string, query *dbx.SelectQuery) error {
if r.allowHiddenFields || c == nil || c.ListRule == nil || *c.ListRule == "" {
return nil
}
cloneR := *r
cloneR.joins = []*search.Join{}
cloneR.baseCollection = c
cloneR.baseCollectionAlias = tableAlias
cloneR.allowHiddenFields = true
cloneR.joinAliasSuffix = security.PseudorandomString(8)
// The extra "id='' || (\nRULE\n)" concatenated part on its own
// doesn't make much sense because all records are required to have an id,
// but it is necessary to properly resolve client-side filters when
// referencing missing relations (the "\n" is for leading and trailing comments).
//
// Consider the client-side filter: "a.name != '' || b.name != ''",
// where both "a" and "b" ref collections have non-empty ListRule.
// Without the empty check the query will always evaluate to FALSE
// when one of the "a" or "b" relation fields are empty,
// even if for example "a.name != ''" is true.
expr, err := search.FilterData("id='' || (\n" + *c.ListRule + "\n)").BuildExpr(&cloneR)
if err != nil {
return fmt.Errorf("failed to build %q ListRule join subquery filter expression: %w", c.Name, err)
}
// Bind the extra rule expression at the top query level for performance and security reasons
// (it is more strict and minimizes the risk of data disclosure from a side-channel attack).
//
// @todo Investigate with the refactoring if there is a way to group it
// together with the client-side constraint that invoked it and benchmark
// it with many (tag.name="1"||...) like statements to evaluate the impact of applying the check many times.
// If not feasible - document it as caveat and maybe add --dev log.
query.AndWhere(expr)
if len(cloneR.joins) > 0 {
r.updateQueryWithDeduplicateConstraint(query)
for _, j := range cloneR.joins {
query.LeftJoin(
(j.TableName + " " + j.TableAlias),
j.On,
)
}
}
@@ -134,6 +207,79 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
return nil
}
func (r *RecordFieldResolver) updateQueryWithDeduplicateConstraint(query *dbx.SelectQuery) {
query.Distinct(true)
// @todo Research better options for generic rows deduplication.
//
// Disable the GROUP BY conditional checks for now since it prevents
// proper utilization of ORDER BY indexes (and maybe others)
// (https://github.com/pocketbase/pocketbase/discussions/7461)
// info := query.Info()
// if info.Distinct {
// return
// }
// // already has the group by registered
// var groupByCol = r.baseCollection.Name
// if r.baseCollectionAlias != "" {
// groupByCol = r.baseCollectionAlias
// }
// groupByCol += ".id"
// if len(info.GroupBy) > 0 && info.GroupBy[0] == groupByCol {
// return
// }
// // when deemed safe (GROUP BY could have different execution order compared to DISTINCT),
// // prefer GROUP BY to deduplicate only on the id field instead of all columns
// // so that the size of a single row wouldn't matter that much
// if preferGroupBy(info, groupByCol) {
// query.GroupBy(groupByCol)
// } else {
// query.Distinct(true)
// }
}
func preferGroupBy(info *dbx.QueryInfo, fullUnquotedGroupByCol string) bool {
if len(info.GroupBy) != 0 {
return false
}
if info.Having != nil {
return false
}
// dbx fallbacks to * if not set
if len(info.Selects) == 0 {
return true
}
if len(info.Selects) != 1 {
return false
}
identifier := info.Selects[0]
if identifier == "*" || identifier == fullUnquotedGroupByCol {
return true
}
// try again as direct col match in an unquoted column format
identifier = inflector.Columnify(identifier)
if identifier == fullUnquotedGroupByCol {
return true
}
// remains table.* to check
// (aliased columns for now are ignored as they could be represented by expressions)
if !strings.HasSuffix(identifier, ".*") {
return false
}
return strings.HasPrefix(fullUnquotedGroupByCol, strings.TrimSuffix(identifier, "*"))
}
// Resolve implements `search.FieldResolver` interface.
//
// Example of some resolvable fieldName formats:
@@ -186,7 +332,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
switch v := resultVal.(type) {
case nil:
return &search.ResolverResult{Identifier: "NULL"}, nil
// no further processing is needed...
case string:
// check if it is a number field and explicitly try to cast to
// float in case of a numeric string value was used
@@ -202,7 +348,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
// no further processing is needed...
default:
// non-plain value
// try casting to string (in case for exampe fmt.Stringer is implemented)
// try casting to string (in case for example fmt.Stringer is implemented)
val, castErr := cast.ToStringE(v)
// if that doesn't work, try encoding it
@@ -216,8 +362,20 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
resultVal = val
}
placeholder := "f" + security.PseudorandomString(8)
// unsupported modifier
// @todo consider deprecating with the introduction of filter functions
if modifier != "" && modifier != lowerModifier {
return nil, fmt.Errorf("invalid modifier sequence %s:%s", lastProp, modifier)
}
// no need to wrap as placeholder if we already know that it is null
if resultVal == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
placeholder := "f" + security.PseudorandomString(10)
// @todo consider deprecating with the introduction of filter functions
if modifier == lowerModifier {
return &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})",
@@ -239,23 +397,52 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collec
return getCollectionByModelOrIdentifier(r.app, collectionNameOrId)
}
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
join := &join{
tableName: tableName,
tableAlias: tableAlias,
on: on,
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error {
newJoin := &search.Join{
TableName: tableName,
TableAlias: tableAlias,
On: on,
}
// (see updateQueryWithCollectionListRule)
if !r.allowHiddenFields {
c, _ := r.loadCollection(tableName)
// ignore non-collections since the table name could be an expression (e.g. json) or some other subquery
if c != nil {
// treat all fields as if they are hidden
if c.ListRule == nil {
return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name)
}
r.registerRuleJoin(c, newJoin.TableAlias)
}
}
// replace existing join
for i, j := range r.joins {
if j.tableAlias == join.tableAlias {
r.joins[i] = join
return
if j.TableAlias == newJoin.TableAlias {
r.joins[i] = newJoin
return nil
}
}
// register new join
r.joins = append(r.joins, join)
r.joins = append(r.joins, newJoin)
return nil
}
func (r *RecordFieldResolver) registerRuleJoin(collection *Collection, tableAlias string) {
// replace existing
for i, j := range r.listRuleJoins {
if j.tableAlias == tableAlias {
r.listRuleJoins[i].collection = collection
return
}
}
// register new
r.listRuleJoins = append(r.listRuleJoins, ruleJoin{collection, tableAlias})
}
type mapExtractor interface {
@@ -395,7 +582,8 @@ func splitModifier(combined string) (string, string, error) {
case issetModifier,
eachModifier,
lengthModifier,
lowerModifier:
lowerModifier,
changedModifier:
return parts[0], parts[1], nil
}
-70
View File
@@ -1,70 +0,0 @@
package core
import (
"fmt"
"strings"
"github.com/pocketbase/dbx"
)
var _ dbx.Expression = (*multiMatchSubquery)(nil)
// join defines the specification for a single SQL JOIN clause.
type join struct {
tableName string
tableAlias string
on dbx.Expression
}
// multiMatchSubquery defines a record multi-match subquery expression.
type multiMatchSubquery struct {
baseTableAlias string
fromTableName string
fromTableAlias string
valueIdentifier string
joins []*join
params dbx.Params
}
// Build converts the expression into a SQL fragment.
//
// Implements [dbx.Expression] interface.
func (m *multiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string {
if m.baseTableAlias == "" || m.fromTableName == "" || m.fromTableAlias == "" {
return "0=1"
}
if params == nil {
params = m.params
} else {
// merge by updating the parent params
for k, v := range m.params {
params[k] = v
}
}
var mergedJoins strings.Builder
for i, j := range m.joins {
if i > 0 {
mergedJoins.WriteString(" ")
}
mergedJoins.WriteString("LEFT JOIN ")
mergedJoins.WriteString(db.QuoteTableName(j.tableName))
mergedJoins.WriteString(" ")
mergedJoins.WriteString(db.QuoteTableName(j.tableAlias))
if j.on != nil {
mergedJoins.WriteString(" ON ")
mergedJoins.WriteString(j.on.Build(db, params))
}
}
return fmt.Sprintf(
`SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`,
db.QuoteColumnName(m.valueIdentifier),
db.QuoteTableName(m.fromTableName),
db.QuoteTableName(m.fromTableAlias),
mergedJoins.String(),
db.QuoteColumnName(m.fromTableAlias+".id"),
db.QuoteColumnName(m.baseTableAlias+".id"),
)
}
@@ -0,0 +1,31 @@
package core
import (
"strings"
"github.com/pocketbase/dbx"
)
var _ dbx.Expression = (*replaceWithExpression)(nil)
// replaceWithExpression defines a custom expression that will replace
// a placeholder identifier found in "old" with the result of "new".
type replaceWithExpression struct {
placeholder string
old dbx.Expression
new dbx.Expression
}
// Build converts the expression into a SQL fragment.
//
// Implements [dbx.Expression] interface.
func (e *replaceWithExpression) Build(db *dbx.DB, params dbx.Params) string {
if e.placeholder == "" || e.old == nil || e.new == nil {
return "0=1"
}
oldResult := e.old.Build(db, params)
newResult := e.new.Build(db, params)
return strings.ReplaceAll(oldResult, e.placeholder, newResult)
}
+203 -141
View File
@@ -49,14 +49,13 @@ type runner struct {
// shared processing state
// ---------------------------------------------------------------
activeProps []string // holds the active props that remains to be processed
activeCollectionName string // the last used collection name
activeTableAlias string // the last used table alias
allowHiddenFields bool // indicates whether hidden fields (eg. email) should be allowed without extra conditions
nullifyMisingField bool // indicating whether to return null on missing field or return an error
withMultiMatch bool // indicates whether to attach a multiMatchSubquery condition to the ResolverResult
multiMatchActiveTableAlias string // the last used multi-match table alias
multiMatch *multiMatchSubquery // the multi-match subquery expression generated from the fieldName
activeProps []string // holds the active props that remains to be processed
activeCollectionName string // the last used collection name
activeTableAlias string // the last used table alias
nullifyMisingField bool // indicating whether to return null on missing field or return an error
withMultiMatch bool // indicates whether to attach a MultiMatchSubquery condition to the ResolverResult
multiMatchActiveTableAlias string // the last used multi-match table alias
multiMatch *search.MultiMatchSubquery // the multi-match subquery expression generated from the fieldName
}
func (r *runner) run() (*search.ResolverResult, error) {
@@ -81,6 +80,7 @@ func (r *runner) run() (*search.ResolverResult, error) {
}
if r.activeProps[0] == "@request" {
// @todo consider returning an error instead?
if r.resolver.requestInfo == nil {
return &search.ResolverResult{Identifier: "NULL"}, nil
}
@@ -89,7 +89,9 @@ func (r *runner) run() (*search.ResolverResult, error) {
return r.processRequestAuthField()
}
if strings.HasPrefix(r.fieldName, "@request.body.") && len(r.activeProps) > 2 {
totalProps := len(r.activeProps)
if strings.HasPrefix(r.fieldName, "@request.body.") && totalProps > 2 {
name, modifier, err := splitModifier(r.activeProps[2])
if err != nil {
return nil, err
@@ -101,23 +103,21 @@ func (r *runner) run() (*search.ResolverResult, error) {
}
// check for body relation field
if bodyField.Type() == FieldTypeRelation && len(r.activeProps) > 3 {
return r.processRequestInfoRelationField(bodyField)
if bodyField.Type() == FieldTypeRelation && totalProps > 3 {
return r.processRequestBodyRelationField(bodyField)
}
// check for body arrayble fields ":each" modifier
if modifier == eachModifier && len(r.activeProps) == 3 {
return r.processRequestInfoEachModifier(bodyField)
}
// check for body arrayble fields ":length" modifier
if modifier == lengthModifier && len(r.activeProps) == 3 {
return r.processRequestInfoLengthModifier(bodyField)
}
// check for body arrayble fields ":lower" modifier
if modifier == lowerModifier && len(r.activeProps) == 3 {
return r.processRequestInfoLowerModifier(bodyField)
if totalProps == 3 { // aka. last prop
switch modifier {
case eachModifier:
return r.processRequestBodyEachModifier(bodyField)
case lengthModifier:
return r.processRequestBodyLengthModifier(bodyField)
case lowerModifier:
return r.processRequestBodyLowerModifier(bodyField)
case changedModifier:
return r.processRequestBodyChangedModifier(bodyField)
}
}
}
@@ -133,12 +133,10 @@ func (r *runner) prepare() {
r.activeProps = strings.Split(r.fieldName, ".")
r.activeCollectionName = r.resolver.baseCollection.Name
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
r.allowHiddenFields = r.resolver.allowHiddenFields
// always allow hidden fields since the @.* filter is a system one
if r.activeProps[0] == "@collection" || r.activeProps[0] == "@request" {
r.allowHiddenFields = true
if r.resolver.baseCollectionAlias == "" {
r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
} else {
r.activeTableAlias = r.resolver.baseCollectionAlias
}
// enable the ignore flag for missing @request.* fields for backward
@@ -146,13 +144,13 @@ func (r *runner) prepare() {
r.nullifyMisingField = r.activeProps[0] == "@request"
// prepare a multi-match subquery
r.multiMatch = &multiMatchSubquery{
baseTableAlias: r.activeTableAlias,
params: dbx.Params{},
r.multiMatch = &search.MultiMatchSubquery{
TargetTableAlias: r.activeTableAlias,
Params: dbx.Params{},
}
r.multiMatch.fromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.fromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.fromTableAlias
r.multiMatch.FromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.FromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.FromTableAlias
r.withMultiMatch = false
}
@@ -172,21 +170,24 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
r.activeCollectionName = collection.Name
if len(collectionParts) == 2 && collectionParts[1] != "" {
r.activeTableAlias = inflector.Columnify("__collection_alias_" + collectionParts[1])
r.activeTableAlias = inflector.Columnify("__collection_alias_"+collectionParts[1]) + r.resolver.joinAliasSuffix
} else {
r.activeTableAlias = inflector.Columnify("__collection_" + r.activeCollectionName)
r.activeTableAlias = inflector.Columnify("__collection_"+r.activeCollectionName) + r.resolver.joinAliasSuffix
}
r.withMultiMatch = true
// join the collection to the main query
r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
err = r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
if err != nil {
return nil, err
}
// join the collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm" + r.activeTableAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: inflector.Columnify(collection.Name),
tableAlias: r.multiMatchActiveTableAlias,
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: inflector.Columnify(collection.Name),
TableAlias: r.multiMatchActiveTableAlias,
})
// leave only the collection fields
@@ -212,10 +213,10 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
collection := r.resolver.requestInfo.Auth.Collection()
r.activeCollectionName = collection.Name
r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName)
r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName) + r.resolver.joinAliasSuffix
// join the auth collection to the main query
r.resolver.registerJoin(
err := r.resolver.registerJoin(
inflector.Columnify(r.activeCollectionName),
r.activeTableAlias,
dbx.HashExp{
@@ -223,15 +224,18 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
(r.activeTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
},
)
if err != nil {
return nil, err
}
// join the auth collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: inflector.Columnify(r.activeCollectionName),
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.HashExp{
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: inflector.Columnify(r.activeCollectionName),
TableAlias: r.multiMatchActiveTableAlias,
On: dbx.HashExp{
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
},
},
@@ -268,10 +272,37 @@ func toSlice(value any) []any {
return result
}
func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.ResolverResult, error) {
func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.ResolverResult, error) {
name := bodyField.GetName()
alias := search.FilterData(fmt.Sprintf("@request.body.%s:isset = true && @request.body.%s != %s", name, name, name))
aliasExpr, err := alias.BuildExpr(r.resolver)
if err != nil {
return nil, err
}
placeholder := "@changed@" + name + security.PseudorandomString(8)
result := &search.ResolverResult{
Identifier: placeholder,
NullFallback: search.NullFallbackDisabled,
AfterBuild: func(expr dbx.Expression) dbx.Expression {
return &replaceWithExpression{
placeholder: placeholder,
old: expr,
new: aliasExpr,
}
},
}
return result, nil
}
func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) {
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(8)
result := &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})",
@@ -281,7 +312,7 @@ func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.Resol
return result, nil
}
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
func (r *runner) processRequestBodyLengthModifier(bodyField Field) (*search.ResolverResult, error) {
if _, ok := bodyField.(MultiValuer); !ok {
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
}
@@ -295,7 +326,7 @@ func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.Reso
return result, nil
}
func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.ResolverResult, error) {
func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.ResolverResult, error) {
multiValuer, ok := bodyField.(MultiValuer)
if !ok {
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
@@ -307,11 +338,15 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
}
placeholder := "dataEach" + security.PseudorandomString(6)
placeholder := "dataEach" + security.PseudorandomString(8)
cleanFieldName := inflector.Columnify(bodyField.GetName())
jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
jeAlias := "__dataEach_" + cleanFieldName + "_je"
r.resolver.registerJoin(jeTable, jeAlias, nil)
jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix
err = r.resolver.registerJoin(jeTable, jeAlias, nil)
if err != nil {
return nil, err
}
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
@@ -325,14 +360,14 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
if r.withMultiMatch {
placeholder2 := "mm" + placeholder
jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
jeAlias2 := "__mm" + jeAlias
jeAlias2 := "__mm_" + jeAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: jeTable2,
tableAlias: jeAlias2,
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: jeTable2,
TableAlias: jeAlias2,
})
r.multiMatch.params[placeholder2] = bodyItemsRaw
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
r.multiMatch.Params[placeholder2] = bodyItemsRaw
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -340,7 +375,7 @@ func (r *runner) processRequestInfoEachModifier(bodyField Field) (*search.Resolv
return result, nil
}
func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.ResolverResult, error) {
func (r *runner) processRequestBodyRelationField(bodyField Field) (*search.ResolverResult, error) {
relField, ok := bodyField.(*RelationField)
if !ok {
return nil, fmt.Errorf("failed to initialize data relation field %q", bodyField.GetName())
@@ -360,10 +395,10 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
}
r.activeCollectionName = dataRelCollection.Name
r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name + "_" + relField.Name)
r.activeTableAlias = inflector.Columnify("__data_"+dataRelCollection.Name+"_"+relField.Name) + r.resolver.joinAliasSuffix
// join the data rel collection to the main collection
r.resolver.registerJoin(
err = r.resolver.registerJoin(
r.activeCollectionName,
r.activeTableAlias,
dbx.In(
@@ -371,19 +406,22 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
list.ToInterfaceSlice(dataRelIds)...,
),
)
if err != nil {
return nil, err
}
if relField.IsMultiple() {
r.withMultiMatch = true
}
// join the data rel collection to the multi-match subquery
r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name + "_" + relField.Name)
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: r.activeCollectionName,
tableAlias: r.multiMatchActiveTableAlias,
on: dbx.In(
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: r.activeCollectionName,
TableAlias: r.multiMatchActiveTableAlias,
On: dbx.In(
fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
list.ToInterfaceSlice(dataRelIds)...,
),
@@ -399,6 +437,7 @@ func (r *runner) processRequestInfoRelationField(bodyField Field) (*search.Resol
var viaRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
// @todo refactor and abstract lastProp processing with the support of field plugins
func (r *runner) processActiveProps() (*search.ResolverResult, error) {
totalProps := len(r.activeProps)
@@ -410,17 +449,18 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// last prop
if i == totalProps-1 {
return r.processLastProp(collection, prop)
return r.finalizeActivePropsProcessing(collection, prop, i)
}
field := collection.Fields.GetByName(prop)
if field != nil && field.GetHidden() && !r.allowHiddenFields {
if field != nil && field.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable field %q", prop)
}
// json field -> treat the rest of the props as json path
if field != nil && field.Type() == FieldTypeJSON {
// @todo consider moving to the finalizer and converting to "JSONExtractable" interface with optional extra validation for the remaining props?
// json or geoPoint field -> treat the rest of the props as json path
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
var jsonPath strings.Builder
for j, p := range r.activeProps[i+1:] {
if _, err := strconv.Atoi(p); err == nil {
@@ -437,12 +477,12 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
jsonPathStr := jsonPath.String()
result := &search.ResolverResult{
NoCoalesce: true,
Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
NullFallback: search.NullFallbackDisabled,
Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -487,7 +527,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
}
if backField.GetHidden() && !r.allowHiddenFields {
if backField.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
}
@@ -507,25 +547,24 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// ---
cleanProp := inflector.Columnify(prop)
cleanBackFieldName := inflector.Columnify(backRelField.Name)
newTableAlias := r.activeTableAlias + "_" + cleanProp
newTableAlias := r.activeTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
newCollectionName := inflector.Columnify(backCollection.Name)
isBackRelMultiple := backRelField.IsMultiple()
if !isBackRelMultiple {
// additionally check if the rel field has a single column unique index
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
isBackRelMultiple = !hasUniqueIndex
}
if !isBackRelMultiple {
r.resolver.registerJoin(
err := r.resolver.registerJoin(
newCollectionName,
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias, cleanBackFieldName, r.activeTableAlias)),
)
if err != nil {
return nil, err
}
} else {
jeAlias := r.activeTableAlias + "_" + cleanProp + "_je"
r.resolver.registerJoin(
jeAlias := "__je_" + newTableAlias
err := r.resolver.registerJoin(
newCollectionName,
newTableAlias,
dbx.NewExp(fmt.Sprintf(
@@ -536,6 +575,9 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
jeAlias,
)),
)
if err != nil {
return nil, err
}
}
r.activeCollectionName = newCollectionName
@@ -546,27 +588,32 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// ---
if isBackRelMultiple {
r.withMultiMatch = true // enable multimatch if not already
} else if !r.withMultiMatch {
// additionally check if the rel field has a single column unique index;
// if not - apply a multi-match check
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
r.withMultiMatch = !hasUniqueIndex
}
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp
newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
if !isBackRelMultiple {
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: newCollectionName,
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: newCollectionName,
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
},
)
} else {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + "_je"
r.multiMatch.joins = append(
r.multiMatch.joins,
&join{
tableName: newCollectionName,
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf(
jeAlias2 := "__je_" + newTableAlias2
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: newCollectionName,
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf(
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
r.multiMatchActiveTableAlias,
jeAlias2,
@@ -606,28 +653,39 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
if !relField.IsMultiple() &&
// the penultimate prop is "id"
i == totalProps-2 && r.activeProps[i+1] == FieldNameId {
return r.processLastProp(collection, relField.Name)
return r.finalizeActivePropsProcessing(collection, relField.Name, i)
}
cleanFieldName := inflector.Columnify(relField.Name)
prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
newTableAlias := r.activeTableAlias + "_" + cleanFieldName
newTableAlias := r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
newCollectionName := relCollection.Name
if !relField.IsMultiple() {
r.resolver.registerJoin(
err := r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
)
if err != nil {
return nil, err
}
} else {
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(dbutils.JSONEach(prefixedFieldName), jeAlias, nil)
r.resolver.registerJoin(
jeAlias := "__je_" + newTableAlias
err := r.resolver.registerJoin(dbutils.JSONEach(prefixedFieldName), jeAlias, nil)
if err != nil {
return nil, err
}
err = r.resolver.registerJoin(
inflector.Columnify(newCollectionName),
newTableAlias,
dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
)
if err != nil {
return nil, err
}
}
r.activeCollectionName = newCollectionName
@@ -644,26 +702,26 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
if !relField.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)),
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.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: dbutils.JSONEach(prefixedFieldName2),
tableAlias: jeAlias2,
r.multiMatch.Joins = append(
r.multiMatch.Joins,
&search.Join{
TableName: dbutils.JSONEach(prefixedFieldName2),
TableAlias: jeAlias2,
},
&join{
tableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
&search.Join{
TableName: inflector.Columnify(newCollectionName),
TableAlias: newTableAlias2,
On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
},
)
}
@@ -675,7 +733,7 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
}
func (r *runner) processLastProp(collection *Collection, prop string) (*search.ResolverResult, error) {
func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop string, propDepth int) (*search.ResolverResult, error) {
name, modifier, err := splitModifier(prop)
if err != nil {
return nil, err
@@ -689,7 +747,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
return nil, fmt.Errorf("unknown field %q", name)
}
if field.GetHidden() && !r.allowHiddenFields {
if field.GetHidden() && !r.resolver.allowHiddenFields {
return nil, fmt.Errorf("non-filterable field %q", name)
}
@@ -708,7 +766,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.valueIdentifier = dbutils.JSONArrayLength(jePair2)
r.multiMatch.ValueIdentifier = dbutils.JSONArrayLength(jePair2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -719,8 +777,12 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
// -------------------------------------------------------
if modifier == eachModifier && isMultivaluer {
jePair := r.activeTableAlias + "." + cleanFieldName
jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je"
r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil)
jeAlias := "__je_" + r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
err := r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil)
if err != nil {
return nil, err
}
result := &search.ResolverResult{
Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
@@ -732,13 +794,13 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
r.multiMatch.joins = append(r.multiMatch.joins, &join{
tableName: dbutils.JSONEach(jePair2),
tableAlias: jeAlias2,
r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
TableName: dbutils.JSONEach(jePair2),
TableAlias: jeAlias2,
})
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch
}
@@ -753,12 +815,12 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
}
if r.withMultiMatch {
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
r.multiMatch.ValueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
result.MultiMatchSubQuery = r.multiMatch
}
// allow querying only auth records with emails marked as public
if field.GetName() == FieldNameEmail && !r.allowHiddenFields && collection.IsAuth() {
if field.GetName() == FieldNameEmail && !r.resolver.allowHiddenFields && collection.IsAuth() {
result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
"[[%s.%s]] = TRUE",
@@ -772,10 +834,10 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
// stored as json work correctly when compared to their SQL equivalent
// (https://github.com/pocketbase/pocketbase/issues/4068)
if field.Type() == FieldTypeJSON {
result.NoCoalesce = true
result.NullFallback = search.NullFallbackDisabled
result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "")
if r.withMultiMatch {
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
}
}
@@ -783,7 +845,7 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
if modifier == lowerModifier {
result.Identifier = "LOWER(" + result.Identifier + ")"
if r.withMultiMatch {
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")"
r.multiMatch.ValueIdentifier = "LOWER(" + r.multiMatch.ValueIdentifier + ")"
}
}
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -15,6 +15,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/inflector"
@@ -969,13 +970,20 @@ func (m *Record) GetDateTime(key string) types.DateTime {
return d
}
// GetGeoPoint returns the data value for "key" as a GeoPoint instance.
func (m *Record) GetGeoPoint(key string) types.GeoPoint {
point := types.GeoPoint{}
_ = point.Scan(m.Get(key))
return point
}
// GetStringSlice returns the data value for "key" as a slice of non-zero unique strings.
func (m *Record) GetStringSlice(key string) []string {
return list.ToUniqueStringSlice(m.Get(key))
}
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
// (aka. the current [*filesytem.File] values) so that you can apply further
// (aka. the current [*filesystem.File] values) so that you can apply further
// validations or modifications (including changing the file name or content before persisting).
//
// Example:
@@ -1523,8 +1531,8 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
} else {
query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
`SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName,
`SELECT 1 FROM %s {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
dbutils.JSONEach(prefixedFieldName),
), dbx.Params{
"jevalue": mainRecord.Id,
})))
+44 -4
View File
@@ -879,7 +879,7 @@ func TestRecordGetInt(t *testing.T) {
{123, 123},
{2.4, 2},
{"123", 123},
{"123.5", 0},
{"123.5", 123},
{false, 0},
{true, 1},
}
@@ -1016,6 +1016,43 @@ func TestRecordGetStringSlice(t *testing.T) {
}
}
func TestRecordGetGeoPoint(t *testing.T) {
t.Parallel()
scenarios := []struct {
value any
expected string
}{
{nil, `{"lon":0,"lat":0}`},
{"", `{"lon":0,"lat":0}`},
{0, `{"lon":0,"lat":0}`},
{false, `{"lon":0,"lat":0}`},
{"{}", `{"lon":0,"lat":0}`},
{"[]", `{"lon":0,"lat":0}`},
{[]int{1, 2}, `{"lon":0,"lat":0}`},
{map[string]any{"lon": 1, "lat": 2}, `{"lon":1,"lat":2}`},
{[]byte(`{"lon":1,"lat":2}`), `{"lon":1,"lat":2}`},
{`{"lon":1,"lat":2}`, `{"lon":1,"lat":2}`},
{types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
{&types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
}
collection := core.NewBaseCollection("test")
record := core.NewRecord(collection)
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
record.Set("test", s.value)
pointStr := record.GetGeoPoint("test").String()
if pointStr != s.expected {
t.Fatalf("Expected %q, got %q", s.expected, pointStr)
}
})
}
}
func TestRecordGetUnsavedFiles(t *testing.T) {
t.Parallel()
@@ -1659,18 +1696,21 @@ func TestRecordModelEventSync(t *testing.T) {
changeRecordEventBefore := func(e *core.RecordEvent) {
e.Type = "test_b"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 123)
e.Record = testRecords[1]
}
modelEventFinalizerChange := func(e *core.ModelEvent) {
e.Type = "test_c"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 456)
e.Model = testRecords[2]
}
changeRecordEventAfter := func(e *core.RecordEvent) {
e.Type = "test_d"
//nolint:staticcheck
e.Context = context.WithValue(context.Background(), "test", 789)
e.Record = testRecords[3]
}
@@ -2214,13 +2254,13 @@ func TestRecordDelete(t *testing.T) {
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.FindRecordById("users", "oap640cot4yru2s")
@@ -2240,7 +2280,7 @@ func TestRecordDelete(t *testing.T) {
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
}
+21 -4
View File
@@ -35,7 +35,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
tableName = "@@__invalidCollectionModelOrIdentifier"
}
query := app.DB().Select(app.DB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
query := app.ConcurrentDB().Select(app.ConcurrentDB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
@@ -314,9 +314,19 @@ func (app *BaseApp) FindAllRecords(collectionModelOrIdentifier any, exprs ...dbx
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
func (app *BaseApp) FindFirstRecordByData(collectionModelOrIdentifier any, key string, value any) (*Record, error) {
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
if err != nil {
return nil, err
}
field := collection.Fields.GetByName(key)
if field == nil {
return nil, errors.New("invalid or missing field " + key)
}
record := &Record{}
err := app.RecordQuery(collectionModelOrIdentifier).
err = app.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1).
One(record)
@@ -397,7 +407,10 @@ func (app *BaseApp) FindRecordsByFilter(
}
}
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
err = resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
if err != nil {
return nil, err
}
// ---
if offset > 0 {
@@ -611,7 +624,11 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
if err != nil {
return false, err
}
resolver.UpdateQuery(query)
err = resolver.UpdateQuery(query)
if err != nil {
return false, err
}
err = query.AndWhere(expr).Limit(1).Row(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
+1 -1
View File
@@ -103,7 +103,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
// add the related id(s) as a dynamic relation field value to
// allow further expand checks at later stage in a more unified manner
prepErr := func() error {
q := app.DB().Select("id").
q := app.ConcurrentDB().Select("id").
From(indirectRel.Name).
Limit(1000) // the limit is arbitrary chosen and may change in the future
+7
View File
@@ -525,6 +525,13 @@ func TestFindFirstRecordByData(t *testing.T) {
"",
true,
},
{
"demo2",
"invalid_or_missing",
"llvuca81nly1qls",
"",
true,
},
{
"demo2",
"id",
+14 -4
View File
@@ -423,7 +423,7 @@ type BatchConfig struct {
// MaxRequests is the maximum allowed batch request to execute.
MaxRequests int `form:"maxRequests" json:"maxRequests"`
// Timeout is the the max duration in seconds to wait before cancelling the batch transaction.
// Timeout is the max duration in seconds to wait before cancelling the batch transaction.
Timeout int64 `form:"timeout" json:"timeout"`
// MaxBodySize is the maximum allowed batch request body size in bytes.
@@ -449,7 +449,7 @@ type BackupsConfig struct {
// Leave it empty to disable the auto backups functionality.
Cron string `form:"cron" json:"cron"`
// CronMaxKeep is the the max number of cron generated backups to
// CronMaxKeep is the max number of cron generated backups to
// keep before removing older entries.
//
// This field works only when the cron config has valid cron expression.
@@ -676,8 +676,8 @@ type RateLimitRule struct {
// Audience specifies the auth group the rule should apply for:
// - "" - both guests and authenticated users (default)
// - "guest" - only for guests
// - "auth" - only for authenticated users
// - "@guest" - only for guests
// - "@auth" - only for authenticated users
Audience string `form:"audience" json:"audience"`
// Duration specifies the interval (in seconds) per which to reset
@@ -704,3 +704,13 @@ func (c RateLimitRule) Validate() error {
func (c RateLimitRule) DurationTime() time.Duration {
return time.Duration(c.Duration) * time.Second
}
// String returns a string representation of the rule.
func (c RateLimitRule) String() string {
raw, err := json.Marshal(c)
if err != nil {
return c.Label // extremely rare case
}
return string(raw)
}
+39 -5
View File
@@ -687,7 +687,7 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
func TestRateLimitRuleValidate(t *testing.T) {
scenarios := []struct {
name string
config core.RateLimitRule
rule core.RateLimitRule
expectedErrors []string
}{
{
@@ -784,7 +784,7 @@ func TestRateLimitRuleValidate(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.config.Validate()
result := s.rule.Validate()
tests.TestValidationErrors(t, result, s.expectedErrors)
})
@@ -793,7 +793,7 @@ func TestRateLimitRuleValidate(t *testing.T) {
func TestRateLimitRuleDurationTime(t *testing.T) {
scenarios := []struct {
config core.RateLimitRule
rule core.RateLimitRule
expected time.Duration
}{
{core.RateLimitRule{}, 0 * time.Second},
@@ -801,8 +801,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
result := s.config.DurationTime()
t.Run(fmt.Sprintf("%d_%d", i, s.rule.Duration), func(t *testing.T) {
result := s.rule.DurationTime()
if result != s.expected {
t.Fatalf("Expected duration %d, got %d", s.expected, result)
@@ -810,3 +810,37 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
})
}
}
func TestRateLimitRuleString(t *testing.T) {
scenarios := []struct {
name string
rule core.RateLimitRule
expected string
}{
{
"empty",
core.RateLimitRule{},
`{"label":"","audience":"","duration":0,"maxRequests":0}`,
},
{
"all fields",
core.RateLimitRule{
Label: "POST /a/b/",
Duration: 1,
MaxRequests: 2,
Audience: core.RateLimitRuleAudienceAuth,
},
`{"label":"POST /a/b/","audience":"@auth","duration":1,"maxRequests":2}`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := s.rule.String()
if result != s.expected {
t.Fatalf("Expected string\n%s\ngot\n%s", s.expected, result)
}
})
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !(js && wasm)
package core
import "syscall"
// execve invokes the execve(2) system call.
func execve(argv0 string, argv []string, envv []string) error {
return syscall.Exec(argv0, argv, envv)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build js && wasm
package core
import "errors"
// https://github.com/pocketbase/pocketbase/pull/7116
func execve(argv0 string, argv []string, envv []string) error {
return errors.ErrUnsupported
}
+40 -20
View File
@@ -19,11 +19,11 @@ import (
// This method is a no-op if a view with the provided name doesn't exist.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
func (app *BaseApp) DeleteView(name string) error {
// "dangerousViewName" argument must come only from trusted input!
func (app *BaseApp) DeleteView(dangerousViewName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}",
name,
dangerousViewName,
)).Execute()
return err
@@ -31,19 +31,19 @@ func (app *BaseApp) DeleteView(name string) error {
// SaveView creates (or updates already existing) persistent SQL view.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
func (app *BaseApp) SaveView(name string, selectQuery string) error {
// NB! Be aware that this method is vulnerable to SQL injection and
// its arguments must come only from trusted input!
func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
return app.RunInTransaction(func(txApp App) error {
// delete old view (if exists)
if err := txApp.DeleteView(name); err != nil {
if err := txApp.DeleteView(dangerousViewName); err != nil {
return err
}
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
// try to loosely detect multiple inline statements
tk := tokenizer.NewFromString(selectQuery)
tk := tokenizer.NewFromString(dangerousSelectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
@@ -53,17 +53,17 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txApp.TableInfo(name); err != nil {
if _, err := txApp.TableInfo(dangerousViewName); err != nil {
// manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded
txApp.DeleteView(name)
txApp.DeleteView(dangerousViewName)
return err
}
@@ -77,18 +77,21 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, error) {
result := NewFieldsList()
suggestedFields, err := parseQueryToFields(app, selectQuery)
suggestedFields, err := parseQueryToFields(app, dangerousSelectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// note wrap in a transaction in case the dangerousSelectQuery contains
// multiple statements allowing us to rollback on any error
txErr := app.RunInTransaction(func(txApp App) error {
info, err := getQueryTableInfo(txApp, selectQuery)
info, err := getQueryTableInfo(txApp, dangerousSelectQuery)
if err != nil {
return err
}
@@ -215,7 +218,7 @@ func defaultViewField(name string) Field {
}
}
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
@@ -254,7 +257,16 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
}
// numeric aggregations
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
if strings.HasPrefix(colLower, "count(") {
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
OnlyInt: true,
},
}
continue
}
if strings.HasPrefix(colLower, "total(") {
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
@@ -265,16 +277,24 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
castMatch := castRegex.FindStringSubmatch(colLower)
// numeric casts
// casts
if len(castMatch) == 2 {
switch castMatch[1] {
case "real", "integer", "int", "decimal", "numeric":
case "real", "decimal", "numeric":
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
},
}
continue
case "int", "integer":
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
OnlyInt: true,
},
}
continue
case "text":
result[col.alias] = &queryField{
field: &TextField{

Some files were not shown because too many files have changed in this diff Show More