Compare commits

...
161 Commits
Author SHA1 Message Date
Gani Georgiev bf1745fa13 fixed changelog typo 2026-04-20 17:40:11 +03:00
Gani Georgiev efc095f7d0 updated changelog and ui/dist 2026-04-20 17:26:00 +03:00
Gani Georgiev 14e7286840 updated color vars and hide Safari negative margin horizontal scroll 2026-04-20 16:51:20 +03:00
Gani Georgiev 4ace75b3d5 [#7650] workarounded Safari position-try-fallbacks freeze 2026-04-20 16:49:55 +03:00
Gani Georgiev d23963aaca updated dark complementary colors 2026-04-20 12:25:04 +03:00
Gani Georgiev d35a0d841c [#7649] renamed the stringify util and removed its unnecessery tags stripping 2026-04-20 11:48:44 +03:00
Gani Georgiev 1b18ab9bec [#7648] darken the surface colors a little bit more 2026-04-20 10:32:23 +03:00
Gani Georgiev 93e6ebfe49 [#7648] fixed firefox autoexpandable input and updated dark theme colors 2026-04-19 23:44:04 +03:00
Gani Georgiev c3a53cb183 fallback to 0 2026-04-19 15:36:38 +03:00
Gani Georgiev ba554b8470 [#7646] fixed number field min/max input value normalization 2026-04-19 15:31:33 +03:00
Gani Georgiev 61ce760e0f show collection name in the page title on initial load 2026-04-19 12:29:45 +03:00
Gani Georgiev 7b92b7c857 updated ui/dist 2026-04-19 12:20:37 +03:00
Gani Georgiev 8c127b2849 updated changelog 2026-04-19 11:54:57 +03:00
Gani Georgiev e6b8841421 allow to open collections page in new tab on middle click and minor flickering improvements 2026-04-19 11:53:24 +03:00
Gani Georgiev 862064e061 enable back npm build check 2026-04-19 09:45:41 +03:00
Gani Georgiev 90594cc331 temp disable npm build check 2026-04-19 09:19:52 +03:00
Gani Georgiev 6012ba701d fixed relation and file custom change event trigger 2026-04-19 08:52:40 +03:00
Gani Georgiev 5cc95a2e63 reset responsive table padding and min-height 2026-04-19 08:33:32 +03:00
Gani Georgiev e41f43241b reorder records list watchers and cancel prev count requests 2026-04-19 08:28:03 +03:00
Gani Georgiev 3ad737e606 sync superusers mfa and otp toggles 2026-04-19 01:22:42 +03:00
Gani Georgiev 3b49e8489e added otp superusers help tooltip 2026-04-19 01:19:38 +03:00
Gani Georgiev 07679dd5ba fixed collection getter 2026-04-19 00:57:24 +03:00
Gani Georgiev 3b8bb4cba9 updated changelog 2026-04-19 00:02:25 +03:00
Gani Georgiev e63fdf4dd0 updated changelog and /ui/dist 2026-04-18 23:55:24 +03:00
Gani Georgiev c96415caae responsive adjustments 2026-04-18 22:47:10 +03:00
Gani Georgiev 075e20efae minor screen reader improvements 2026-04-18 22:11:58 +03:00
Gani Georgiev 624c3357be sync forgotten field tooltip 2026-04-18 18:34:18 +03:00
Gani Georgiev 7673798fa3 normalized the names of the exposed jsvm bind funcs 2026-04-18 17:48:41 +03:00
Gani Georgiev d4987a153e updated modernc.org/sqlite to v1.49.1 2026-04-18 17:39:10 +03:00
Gani Georgiev b02d9b3662 updated node action 2026-04-18 17:33:21 +03:00
Gani Georgiev 4c44044c0c merge newui branch 2026-04-18 16:50:39 +03:00
Gani Georgiev 58f605e90c bumped modernc.org/sqlite to 1.48.2 2026-04-09 17:34:00 +03:00
Gani Georgiev 6ae3d47eeb updated changelog 2026-04-09 13:06:34 +03:00
Gani Georgiev cb185ad6bf ratelimit test flakiness adjustments 2026-04-09 10:31:03 +03:00
Gani Georgiev f89858f1ec [#7632] added missing error check in the jsvm watcher 2026-04-09 10:12:58 +03:00
Gani Georgiev 0695ca254d [#7630] added missing file close after seek error 2026-04-09 09:50:36 +03:00
Gani Georgiev e91694154f bumped ui/dist and go action version 2026-04-08 14:27:53 +03:00
Gani Georgiev b251a4cf65 updated backport changelog 2026-04-07 08:35:24 +03:00
Gani Georgiev 01949b059b removed unnecessery struct wrapping 2026-04-05 16:28:11 +03:00
Gani Georgiev 89f3668da2 updated settings update test 2026-04-05 14:15:12 +03:00
Gani Georgiev 7865ca7b95 updated jsvm types 2026-04-05 13:51:57 +03:00
Gani Georgiev d92a98b100 fixed settings smtp password clear persistence 2026-04-05 13:47:06 +03:00
Gani Georgiev e9118fa6b6 removed unnecessery error return 2026-04-02 21:00:16 +03:00
Gani Georgiev e49b64b114 attempt to reduce ratelimit test flakiness 2026-04-02 20:22:16 +03:00
Gani Georgiev 1204362e9c use the raw address in the error message 2026-04-02 20:12:01 +03:00
Gani Georgiev cc535cde3b updated ui/dist 2026-04-02 20:03:56 +03:00
Gani Georgiev cb44d9e716 added extra OAuth2 avatar url download checks 2026-04-02 19:55:05 +03:00
Gani Georgiev 5cb66bd52f updated .static jsvm docs 2026-04-02 08:06:51 +03:00
Gani Georgiev 3a893d15ad avoid explicit fs.FS wrapping 2026-04-02 08:06:11 +03:00
Gani Georgiev 64854ef08d fixed changelog typos 2026-03-30 21:38:11 +03:00
Gani Georgiev 9f3cdf4ad5 use the explicitly mapped username column name for the unique legacy checks 2026-03-30 08:51:11 +03:00
Gani Georgiev 2dbc70d60d assign discord avatar only if exist 2026-03-30 08:15:26 +03:00
Gani GeorgievandHans a2b14bcb93 [#7603] updated the Discord AuthUser.Name field to use global_name
Co-authored-by: Hans <hi@hans0805.me>
2026-03-30 07:42:22 +03:00
Gani Georgiev 864bac6dc4 fixed grammar 2026-03-28 10:22:37 +02:00
Gani Georgiev 78dc12dc29 regenerated jsvm types 2026-03-28 01:14:43 +02:00
Gani Georgiev 4b4c2ec7c3 updated ui/dist 2026-03-28 00:22:41 +02:00
Gani Georgiev d87fa3bd80 bumped go deps 2026-03-28 00:16:34 +02:00
Gani Georgiev 45d353ffdb fixed OAuth2 client secret reset when marshalizing a cached collection model 2026-03-27 23:56:17 +02:00
Gani Georgiev e5390c3d86 added missing error return and fixed comment typo 2026-03-19 08:48:30 +02:00
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
873 changed files with 60567 additions and 55670 deletions

No files matched your search

+85 -3
View File
@@ -1,7 +1,89 @@
# Security # 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!**
**This is non-commercial personal open source project, so there are no bounties!** 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).
All reports will be promptly addressed and you'll be credited in the fix release notes. 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 then to 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 short list of previous reports that are NOT considered security issues:
<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.~ _Done._
</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>
+5 -5
View File
@@ -16,19 +16,19 @@ jobs:
run: echo "flags=--snapshot" >> $GITHUB_ENV run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 20.17.0 node-version: '>=25.2.1'
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: '>=1.24.8' go-version: '>=1.26.2'
# This step usually is not needed because the /ui/dist is pregenerated locally # 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. # but its here to ensure that each release embeds the latest admin ui artifacts.
+229 -3
View File
@@ -1,3 +1,229 @@
## v0.37.2
- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Slightly adjusted the dark theme colors for better readability ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Removed unnecessary tags stripping from the displayed log attributes ([#7649](https://github.com/pocketbase/pocketbase/issues/7649)).
- Workarounded Safari freeze caused by a buggy CSS popover property ([#7650](https://github.com/pocketbase/pocketbase/issues/7650)).
## v0.37.1
- Minor UI bugfixes:
- Fixed `number` field input values normalization ([#7646](https://github.com/pocketbase/pocketbase/issues/7646)).
- Allow opening collections in new tab with middle click.
- Show collection name in the page title on initial load.
## v0.37.0
- New UI rewritten from scratch and with support for external customization in mind.
> Note that as explained in [#7612](https://github.com/pocketbase/pocketbase/discussions/7612) the new UI kit and extensions APIs will intentionally remain undocumented until "Stage 2 completion" _(there no ETAs)_.
The new UI also introduced several other small improvements:
- ~2MB smaller bundle size.
- Dark mode and theming support.
- Basic responsive/mobile support _(it is far from perfect but certainly more usable than before)_.
- Help text option for the collection fields.
- Lifted the max nested level restriction of presentable relations _(children are lazy loaded)_.
- Lighter rules autocomplete.
- Live view query preview.
- Insert of an audio/video embed tag in the richtext editor from a collection file.
- Option to bulk export records as JSON.
- Local search history for all searchbars.
- API rules overview across all collections.
- Very basic ERD-like visualization for the collections structure and relations.
- New stepped logs chart visualization with panning support.
- `listAuthMethods()` (aka. `/api/collection/{col}/auth-methods`) now returns the OAuth2 provider logo for each provider as inlined SVG string in its response data.
_⚠️ Note that if your app for whatever reason rely on the dashboard OAuth2 logos available under `/_/images/oauth2/*` they are still available for now but will be removed in future versions and it is recommended to use the new inline SVGs!_
- Added optional `no_ui` build tag to exclude the UI from bundling with the executable ([#7548](https://github.com/pocketbase/pocketbase/issues/7548)).
```sh
go build -tags no_ui
```
- Exported the internal JSVM bind functions ([#7600](https://github.com/pocketbase/pocketbase/discussions/7600)).
```go
jsvm.BindCore(vm)
jsvm.BindDbx(vm)
jsvm.BindSecurity(vm)
jsvm.BindOS(vm)
jsvm.BindFilepath(vm)
jsvm.BindHTTP(vm)
jsvm.BindFilesystem(vm)
jsvm.BindForms(vm)
jsvm.BindMails(vm)
jsvm.BindApis(vm)
```
- Updated `modernc.org/sqlite` to v1.49.1 (SQLite 3.53.0).
## v0.36.9
- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
- Fixed settings SMTP password clear persistence.
- Added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor.
- Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
- Updated min Go GitHub action version to 1.26.2 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
- Other small improvements _(updated `$apis.static` JSVM documentation, fixed comment typos, added missing file close on seek error, etc.)_.
## v0.36.8
- Fixed OAuth2 client secret reset when serializing a cached collection model.
- Bumped all Go and npm deps.
_This should also silence recent spam reports and security scanners regarding `golang.org/x/image` [CVE-2026-33809](https://www.cve.org/CVERecord?id=CVE-2026-33809) (it is not an issue in PocketBase because we don't support TIFF thumbs)._
## 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 ## v0.34.0
- Added `@request.body.someField:changed` modifier. - Added `@request.body.someField:changed` modifier.
@@ -523,7 +749,7 @@
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s. - Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule. - Normalized the hidden fields filter checks and allow targeting hidden fields in the List API rule.
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)). - Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
@@ -615,7 +841,7 @@
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router. - Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
- Added collection rules change list in the confirmation popup - Added collection rules change list in the confirmation popup
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_). (_to avoid getting annoying during development, the rules confirmation currently is enabled only when using https_).
## v0.23.1 ## v0.23.1
@@ -658,7 +884,7 @@ There are a lot of changes but to highlight some of the most notable ones:
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds. - Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._ _Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_). - New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._). - Various `Record` model improvements (_support for get/set modifiers, simplified file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically. - Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_). - Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.). - Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
+39 -3
View File
@@ -2,6 +2,42 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md) > For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
--- ---
## v0.22.41
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
- (_Backported from v0.36.9_) Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
- (_Backported from v0.36.9_) Bumped min Go GitHub action version to 1.26.2 because it comes with several [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
## 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 ## 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). - (_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).
@@ -67,7 +103,7 @@
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)). - Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks. - Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibility of eventual side-channel attacks.
## v0.22.24 ## v0.22.24
@@ -469,7 +505,7 @@
A negative or zero value means no tests timeout. A negative or zero value means no tests timeout.
If a single API test takes more than 3s to complete it will have a log message visible when the test fails or when `go test -v` flag is used. If a single API test takes more than 3s to complete it will have a log message visible when the test fails or when `go test -v` flag is used.
- Added timestamp at the beginning of the generated JSVM types file to avoid creating it everytime with the app startup. - Added timestamp at the beginning of the generated JSVM types file to avoid creating it every time with the app startup.
## v0.20.0 ## v0.20.0
@@ -849,7 +885,7 @@
- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections. - ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)). - ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successful email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)). - ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
+2 -2
View File
@@ -326,7 +326,7 @@
- Added "tags" support for all Record and Model related event hooks. - Added "tags" support for all Record and Model related event hooks.
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s). The "tags" allow registering event handlers that will be called only on matching table name(s) or collection id(s)/name(s).
For example: For example:
```go ```go
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error { app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
@@ -840,7 +840,7 @@ Please check the individual SDK package changelog and apply the necessary change
</tr> </tr>
</table> </table>
- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part: - All datetime strings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
<table class="d-table" width="100%"> <table class="d-table" width="100%">
<tr> <tr>
<th>Old</th> <th>Old</th>
+16 -13
View File
@@ -1,17 +1,20 @@
# Contributing to PocketBase # Contributing to PocketBase
Thanks for taking the time to improve PocketBase! > [!IMPORTANT]
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
This document describes how to prepare a PR for a change in the main repository. This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code) - [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui) - [Making changes in the Superuser UI](#making-changes-in-the-admin-ui)
## Prerequisites ## 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) - Node 24+ (for making changes in the Superuser UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally: If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
@@ -34,7 +37,7 @@ So, let's assume that you already done some changes in the PocketBase Go code an
1. Navigate to `examples/base` 1. Navigate to `examples/base`
2. Run `go run main.go serve` 2. Run `go run main.go serve`
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it! This will start a web server on `http://localhost:8090` with the embedded prebuilt Superuser UI from `ui/dist`. And that's it!
**Before making a PR to the main repository, it is a good idea to:** **Before making a PR to the main repository, it is a good idea to:**
@@ -57,11 +60,11 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
make lint make lint
``` ```
## Making changes in the Admin UI ## Making changes in the Superuser UI
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite. PocketBase Superuser UI is a single-page application (SPA) built with Svelte and Vite.
To start the Admin UI: To start the Superuser UI:
1. Navigate to the `ui` project directory 1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies 2. Run `npm install` to install the node dependencies
@@ -70,13 +73,13 @@ To start the Admin UI:
npm run dev npm run dev
``` ```
You could open the browser and access the running Admin UI at `http://localhost:3000`. You could open the browser and access the running Superuser UI at `http://localhost:5173`.
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable). Since the Superuser UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
> [!NOTE] > [!NOTE]
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it. > By default, the Superuser UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page. Every change you make in the Superuser UI should be automatically reflected in the browser at `http://localhost:5173` without reloading the page.
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository. Once you are done with your changes, you have to build the Superuser UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
+9 -6
View File
@@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener"> <a href="https://pocketbase.io" target="_blank" rel="noopener">
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" /> <img src="https://i.imgur.com/aCBbjKx.png" alt="PocketBase - open source backend in 1 file" />
</a> </a>
</p> </p>
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
Here is a minimal example: 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: 1. Create a new project directory with the following `main.go` file inside it:
```go ```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: 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 1. Clone/download the repo
2. Navigate to `examples/base` 2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build` 3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
@@ -146,10 +146,13 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md) - [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues) - [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome. Please refrain creating PRs for _new features_ without previously discussing the implementation details.
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication. PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged. Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes). Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
> [!IMPORTANT]
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
+7 -3
View File
@@ -15,7 +15,7 @@ import (
// StaticWildcardParam is the name of Static handler wildcard parameter. // StaticWildcardParam is the name of Static handler wildcard parameter.
const StaticWildcardParam = "path" const StaticWildcardParam = "path"
// NewRouter returns a new router instance loaded with the default app middlewares and api routes. // NewRouter returns a new router instance loaded with the default app middlewares and routes.
func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) { func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) { pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
event := new(core.RequestEvent) event := new(core.RequestEvent)
@@ -34,6 +34,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(securityHeaders()) pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize)) pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
// API routes
apiGroup := pbRouter.Group("/api") apiGroup := pbRouter.Group("/api")
bindSettingsApi(app, apiGroup) bindSettingsApi(app, apiGroup)
bindCollectionApi(app, apiGroup) bindCollectionApi(app, apiGroup)
@@ -47,6 +48,9 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindRealtimeApi(app, apiGroup) bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup) bindHealthApi(app, apiGroup)
// UI routes
bindUIExtensions(app)
return pbRouter, nil return pbRouter, nil
} }
@@ -86,7 +90,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
// Static is a handler function to serve static directory content from fsys. // Static is a handler function to serve static directory content from fsys.
// //
// If a file resource is missing and indexFallback is set, the request // If a file resource is missing and indexFallback is true, the request
// will be forwarded to the base index.html (useful for SPA with pretty urls). // will be forwarded to the base index.html (useful for SPA with pretty urls).
// //
// NB! Expects the route to have a "{path...}" wildcard parameter. // NB! Expects the route to have a "{path...}" wildcard parameter.
@@ -94,7 +98,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
// Special redirects: // Special redirects:
// - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/) // - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
// - if "path" is a directory that has index.html, the index.html file is rendered, // - if "path" is a directory that has index.html, the index.html file is rendered,
// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is set // otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
// //
// Example: // Example:
// //
+2 -1
View File
@@ -88,7 +88,7 @@ func (brs batchRequestsForm) validate() error {
} }
// NB! When the request is submitted as multipart/form-data, // 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 // json under the @jsonPayload field and file keys need to follow the
// pattern "requests.N.fileField" or requests[N].fileField. // pattern "requests.N.fileField" or requests[N].fileField.
func batchTransaction(e *core.RequestEvent) error { func batchTransaction(e *core.RequestEvent) error {
@@ -364,6 +364,7 @@ func processInternalRequest(
// assign request // assign request
event.Request = r event.Request = r
event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads
defer event.Request.Body.Close()
// assign response // assign response
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+87
View File
@@ -3,10 +3,12 @@ package apis
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
@@ -23,6 +25,10 @@ func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
subGroup.DELETE("/{collection}/truncate", collectionTruncate) subGroup.DELETE("/{collection}/truncate", collectionTruncate)
subGroup.PUT("/import", collectionsImport) subGroup.PUT("/import", collectionsImport)
subGroup.GET("/meta/scaffolds", collectionScaffolds) subGroup.GET("/meta/scaffolds", collectionScaffolds)
// @todo experimental
subGroup.GET("/meta/oauth2-providers", collectionListOAuth2Providers)
subGroup.POST("/meta/dry-run-view", collectionDryRunView)
} }
func collectionsList(e *core.RequestEvent) error { func collectionsList(e *core.RequestEvent) error {
@@ -207,3 +213,84 @@ func collectionScaffolds(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, collections) return e.JSON(http.StatusOK, collections)
} }
type providerListItem struct {
order int
Name string `json:"name"`
DisplayName string `json:"displayName"`
Logo string `json:"logo"`
}
func collectionListOAuth2Providers(e *core.RequestEvent) error {
providers := make([]*providerListItem, 0, len(auth.Providers))
for name, factory := range auth.Providers {
p := factory()
providers = append(providers, &providerListItem{
order: p.Order(),
Name: name,
DisplayName: p.DisplayName(),
Logo: p.Logo(),
})
}
slices.SortStableFunc(providers, func(a, b *providerListItem) int {
// sort by order
if a.order < b.order {
return -1
}
if a.order > b.order {
return 1
}
// fallback sort by name
if a.Name < b.Name {
return -1
}
if a.Name > b.Name {
return 1
}
return 0
})
return e.JSON(http.StatusOK, providers)
}
func collectionDryRunView(e *core.RequestEvent) error {
// extra precaution in case reused in custom route group
if !e.HasSuperuserAuth() {
return e.ForbiddenError("", nil)
}
form := dryRunViewForm{}
err := e.BindBody(&form)
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
}
err = form.validate()
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
result, err := e.App.DryRunView(form.Query, 10)
if err != nil {
return firstApiError(err, e.BadRequestError("Invalid view query. Raw error: \n"+err.Error(), nil))
}
return e.JSON(http.StatusOK, result)
}
type dryRunViewForm struct {
Query string `form:"query" json:"query"`
}
func (form *dryRunViewForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
)
}
+190 -6
View File
@@ -536,7 +536,7 @@ func TestCollectionCreate(t *testing.T) {
`"type":"base"`, `"type":"base"`,
`"system":false`, `"system":false`,
// ensures that id field was prepended // ensures that id field was prepended
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`, `"fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"*": 0, "*": 0,
@@ -585,7 +585,7 @@ func TestCollectionCreate(t *testing.T) {
`"name":"verified"`, `"name":"verified"`,
`"duration":123`, `"duration":123`,
// should overwrite the user required option but keep the min value // should overwrite the user required option but keep the min value
`{"autogeneratePattern":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`, `{"autogeneratePattern":"","help":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
}, },
NotExpectedContent: []string{ NotExpectedContent: []string{
`"secret":"`, `"secret":"`,
@@ -751,7 +751,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new", "name":"new",
"type":"view", "type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery":"invalid" "viewQuery":"select '123' as abc"
}`), }`),
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -780,7 +780,7 @@ func TestCollectionCreate(t *testing.T) {
"name":"new", "name":"new",
"type":"view", "type":"view",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + `" "viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + ` limit 1"
}`), }`),
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -789,7 +789,7 @@ func TestCollectionCreate(t *testing.T) {
ExpectedContent: []string{ ExpectedContent: []string{
`"name":"new"`, `"name":"new"`,
`"type":"view"`, `"type":"view"`,
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`, `"fields":[{"autogeneratePattern":"","help":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
}, },
ExpectedEvents: map[string]int{ ExpectedEvents: map[string]int{
"*": 0, "*": 0,
@@ -1262,7 +1262,7 @@ func TestCollectionUpdate(t *testing.T) {
Body: strings.NewReader(`{ Body: strings.NewReader(`{
"name":"view2_update", "name":"view2_update",
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}], "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + `" "viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + ` limit 1"
}`), }`),
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
@@ -1584,3 +1584,187 @@ func TestCollectionTruncate(t *testing.T) {
scenario.Test(t) scenario.Test(t)
} }
} }
func TestCollectionOAuth2Providers(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodGet,
URL: "/api/collections/meta/oauth2-providers",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`,
},
NotExpectedContent: []string{
`"order":`,
`"pkce":`,
`"scopes":`,
`"authURL":`,
`"tokenURL":`,
`"userInfoURL":`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestCollectionTestView(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"sample":[{`,
`"id":"1"`,
},
},
{
Name: "empty query",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":""}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"query":`,
},
},
{
Name: "query length beyond validator limit",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"query":`,
},
},
{
Name: "query with length equal to the validator limit",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"select 1 as id` + strings.Repeat(" ", 4986) + `"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"fields":[{`,
`"name":"id"`,
`"type":"text"`,
`"sample":[`,
`"id":"1"`,
},
},
{
Name: "missing ids sample",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"(select 1 as id union select '' as id)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
{
Name: "duplicated ids sample",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"(select 1 as id union all select 1 as id)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
{
Name: "write query",
Method: http.MethodPost,
URL: "/api/collections/meta/dry-run-view",
Body: strings.NewReader(`{"query":"CREATE TABLE t1(x INT)"}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+94
View File
@@ -0,0 +1,94 @@
package apis
import (
"bytes"
"errors"
"fmt"
"io"
"log/slog"
"os"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/ui"
)
// bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router.
//
// This method does nothing if the superuser UI is not bundled (aka. build with "no_ui" tag),
func bindUIExtensions(app core.App) {
if ui.DistDirFS == nil {
return
}
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Priority: 9999, // execute as latest as possible
Func: func(se *core.ServeEvent) error {
uiGroup := se.Router.Group("/_").
BindFunc(func(e *core.RequestEvent) error {
if !e.App.IsDev() && e.Response.Header().Get("Cache-Control") == "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
if e.Response.Header().Get("Content-Security-Policy") == "" {
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
}
return e.Next()
}).
Bind(Gzip())
// register static extension routes
for _, ext := range se.UIExtensions {
if ext.Name == "" || ext.FS == nil {
se.App.Logger().Debug("Invalid UI extension configuration", slog.Any("extension", ext))
continue
}
uiGroup.GET("/extensions/"+ext.Name+"/{path...}", Static(ext.FS, false))
}
// combine all extensions main.js in one file
//
// note: don't cache in memory to allow previewing changes without restart
uiGroup.GET("/extensions.js", func(re *core.RequestEvent) error {
buf := new(bytes.Buffer)
for _, ext := range se.UIExtensions {
err := copyExtensionMainjs(buf, ext)
if err != nil {
return re.InternalServerError("An error occurred while generating the main.js extension file", err)
}
}
return re.Stream(200, "text/javascript", buf)
}).Bind(SkipSuccessActivityLog())
return se.Next()
},
})
}
func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
f, err := ext.FS.Open("main.js")
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // nothing to copy
}
return fmt.Errorf("[UI extension %q] main.js open error: %w", ext.Name, err)
}
defer f.Close()
// wrap in a self-executing function to avoid scope and concatenation issues
_, _ = buf.WriteString("(function(){")
_, err = io.Copy(buf, f)
if err != nil {
return fmt.Errorf("[UI extension %q] main.js copy error: %w", ext.Name, err)
}
_, _ = buf.WriteString("})();")
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package apis_test
import (
"net/http"
"testing"
"testing/fstest"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/ui"
)
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
func TestUIExtensions_Mainjs(t *testing.T) {
successAfterTestFunc := func(t testing.TB, app *tests.TestApp, res *http.Response) {
expected := "text/javascript"
if ct := res.Header.Get("content-type"); ct != expected {
t.Fatalf("Expected response Content-Type %q, got %q", expected, ct)
}
}
oldDistDirFS := ui.DistDirFS
scenarios := []tests.ApiScenario{
{
Name: "disabled UI",
Method: http.MethodGet,
URL: "/_/extensions.js",
TestAppFactory: func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
// simulate no_ui tag (needs to be cleared before the router is initialized)
ui.DistDirFS = nil
return app
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ui.DistDirFS = oldDistDirFS
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "no extensions",
Method: http.MethodGet,
URL: "/_/extensions.js",
AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with extensions",
Method: http.MethodGet,
URL: "/_/extensions.js",
TestAppFactory: func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
e.UIExtensions = createTestExtensions()
return e.Next()
})
return app
},
AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
func TestUIExtensions_Files(t *testing.T) {
testAppFactory := func(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
e.UIExtensions = createTestExtensions()
return e.Next()
})
return app
}
scenarios := []tests.ApiScenario{
{
Name: "no extensions",
Method: http.MethodGet,
URL: "/_/extensions/ext1/test.txt",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with missing extension file",
Method: http.MethodGet,
URL: "/_/extensions/ext1/missing",
TestAppFactory: testAppFactory,
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with existing extension file (ext1)",
Method: http.MethodGet,
URL: "/_/extensions/ext1/test.txt",
TestAppFactory: testAppFactory,
ExpectedStatus: 200,
ExpectedContent: []string{"ext1_txt"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with existing extension file (extension name escape)",
Method: http.MethodGet,
URL: "/_/extensions/ext3%20with%20spaces/test.txt",
TestAppFactory: testAppFactory,
ExpectedStatus: 200,
ExpectedContent: []string{"ext3_txt"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func createTestExtensions() []core.UIExtension {
return []core.UIExtension{
{
Name: "ext1",
FS: fstest.MapFS{
"main.js": &fstest.MapFile{
Data: []byte("ext1_main"),
},
"test.txt": &fstest.MapFile{
Data: []byte("ext1_txt"),
},
},
},
{
Name: "ext2",
FS: fstest.MapFS{
"test.txt": &fstest.MapFile{
Data: []byte("ext2_txt"),
},
},
},
{
Name: "ext3 with spaces",
FS: fstest.MapFS{
"main.js": &fstest.MapFile{
Data: []byte("ext3_main"),
},
"test.txt": &fstest.MapFile{
Data: []byte("ext3_txt"),
},
},
},
}
}
+1
View File
@@ -25,6 +25,7 @@ func healthCheck(e *core.RequestEvent) error {
Message: "API is healthy.", Message: "API is healthy.",
} }
// @todo evaluate whether it is worth removing the extra info from the health endpoint
if e.HasSuperuserAuth() { if e.HasSuperuserAuth() {
resp.Data = make(map[string]any, 3) resp.Data = make(map[string]any, 3)
resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup) resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup)
+7 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils" "github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/ui"
) )
// DefaultInstallerFunc is the default PocketBase installer function. // DefaultInstallerFunc is the default PocketBase installer function.
@@ -22,13 +23,18 @@ import (
// //
// See https://github.com/pocketbase/pocketbase/discussions/5814. // See https://github.com/pocketbase/pocketbase/discussions/5814.
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error { func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
if ui.DistDirFS == nil {
color.Magenta("You can create your first superuser by running: %s superuser upsert EMAIL PASS", executablePath())
return nil
}
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute) token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
if err != nil { if err != nil {
return err return err
} }
// launch url (ignore errors and always print a help text as fallback) // launch url (ignore errors and always print a help text as fallback)
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token) url := fmt.Sprintf("%s/_/#/pbinstall/%s", strings.TrimRight(baseURL, "/"), token)
_ = osutils.LaunchURL(url) _ = 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.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.Bold).Add(color.FgCyan).Println(url)
+6 -4
View File
@@ -207,11 +207,13 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
func getAuthTokenFromRequest(e *core.RequestEvent) string { func getAuthTokenFromRequest(e *core.RequestEvent) string {
token := e.Request.Header.Get("Authorization") token := e.Request.Header.Get("Authorization")
if token != "" {
// the schema prefix is not required and it is only for // the "Bearer" schema prefix is not required by PocketBase and it is
// compatibility with the defaults of some HTTP clients // supported only for compatibility with the defaults of some HTTP clients
token = strings.TrimPrefix(token, "Bearer ") if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
return token[7:]
} }
return token return token
} }
+15 -3
View File
@@ -11,7 +11,7 @@ import (
var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil) var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil)
const DefaultMaxBodySize int64 = 32 << 20 const DefaultMaxBodySize int64 = 32 << 20 // @todo consider replacing with router.DefaultMaxMemory
const ( const (
DefaultBodyLimitMiddlewareId = "pbBodyLimit" DefaultBodyLimitMiddlewareId = "pbBodyLimit"
@@ -112,9 +112,21 @@ func (r *limitedReader) Read(b []byte) (int, error) {
return n, nil 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() { func (r *limitedReader) Reread() {
rr, ok := r.ReadCloser.(router.Rereader) rereader, ok := r.ReadCloser.(router.Rereader)
if ok { if ok {
rr.Reread() rereader.Reread()
} }
} }
func (r *limitedReader) Close() error {
closer, ok := r.ReadCloser.(io.Closer)
if ok {
return closer.Close()
}
return nil
}
+12 -38
View File
@@ -108,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? // @todo consider exporting as helper?
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error { func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
switch rule.Audience { switch rule.Audience {
@@ -154,7 +132,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
} }
rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter { 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 { if rt == nil {
e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId) e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
@@ -311,30 +289,27 @@ func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
} }
} }
// @todo evaluate swiching to a more traditional fixed window or sliding window counter // @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
// implementations since some users complained that it is not intuitive (see #7329).
// //
// rateClient is a mixture of token bucket and fixed window rate limit strategies // rateClient implements fixed window rate limit strategy.
// that refills the allowance only after at least "interval" seconds
// has elapsed since the last request.
type rateClient struct { type rateClient struct {
// use plain Mutex instead of RWMutex since the operations are expected // use plain Mutex instead of RWMutex since the operations are expected
// to be mostly writes (e.g. consume()) and it should perform better // to be mostly writes (e.g. consume()) and it should perform better
sync.Mutex sync.Mutex
maxAllowed int // the max allowed tokens per interval maxAllowed int // the max allowed tokens per interval
available int // the total available tokens available int // the total available tokens
interval int64 // in seconds start int64 // the start time of the current window
lastConsume int64 // the time of the last consume 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). // (usually used to perform periodic cleanup of staled instances).
func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool { func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
l.Lock() l.Lock()
defer l.Unlock() defer l.Unlock()
return relativeNow-l.lastConsume > minElapsed return relativeNow-(l.start+l.interval) > minElapsed
} }
// consume decreases the current allowance with 1 (if not exhausted already). // consume decreases the current allowance with 1 (if not exhausted already).
@@ -347,15 +322,14 @@ func (l *rateClient) consume() bool {
nowUnix := time.Now().Unix() nowUnix := time.Now().Unix()
// reset consumed counter // reset
if nowUnix-l.lastConsume >= l.interval { if nowUnix-l.start >= l.interval {
l.available = l.maxAllowed l.available = l.maxAllowed
l.start = nowUnix
} }
if l.available > 0 { if l.available > 0 {
l.available-- l.available--
l.lastConsume = nowUnix
return true return true
} }
+11 -8
View File
@@ -74,7 +74,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
scenarios := []struct { scenarios := []struct {
url string url string
wait float64 wait float64 // ms
authenticated bool authenticated bool
expectedStatus int expectedStatus int
}{ }{
@@ -85,10 +85,13 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 0, false, 200}, {"/norate", 0, false, 200},
{"/rate/a", 0, false, 200}, {"/rate/a", 0, false, 200},
{"/rate/a", 700, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 800, false, 200},
{"/rate/a", 800, false, 200},
{"/rate/a", 0, false, 200}, {"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429}, {"/rate/a", 0, false, 429},
{"/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, 200},
{"/rate/a", 0, false, 429}, {"/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, 200}, {"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429}, {"/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, 200}, {"/rate/b", 0, false, 200},
{"/rate/b", 0, false, 429}, {"/rate/b", 0, false, 429},
@@ -118,7 +121,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/rate/guest", 0, false, 429}, {"/rate/guest", 0, false, 429},
// "guest" rule with regular user (should fallback to the /rate/ rule) // "guest" rule with regular user (should fallback to the /rate/ rule)
{"/rate/guest", 1.1, true, 200}, {"/rate/guest", 1000, true, 200},
{"/rate/guest", 0, true, 200}, {"/rate/guest", 0, true, 200},
{"/rate/guest", 0, true, 429}, {"/rate/guest", 0, true, 429},
{"/rate/guest", 0, true, 429}, {"/rate/guest", 0, true, 429},
@@ -126,10 +129,6 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
for _, s := range scenarios { for _, s := range scenarios {
t.Run(s.url, func(t *testing.T) { t.Run(s.url, func(t *testing.T) {
if s.wait > 0 {
time.Sleep(time.Duration(s.wait) * time.Second)
}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", s.url, nil) req := httptest.NewRequest("GET", s.url, nil)
@@ -147,6 +146,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
req.Header.Add("Authorization", token) req.Header.Add("Authorization", token)
} }
if s.wait > 0 {
time.Sleep(time.Duration(s.wait) * time.Millisecond)
}
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
result := rec.Result() result := rec.Result()
+16
View File
@@ -224,6 +224,22 @@ func TestRequireAuth(t *testing.T) {
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"test123"}, 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 { for _, scenario := range scenarios {
+1 -1
View File
@@ -34,7 +34,7 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second) token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
if err != nil { if err != nil {
e.InternalServerError("Failed to generate static auth token", err) return e.InternalServerError("Failed to generate static auth token", err)
} }
return recordAuthResponse(e, record, token, "", nil) return recordAuthResponse(e, record, token, "", nil)
+10 -1
View File
@@ -34,6 +34,7 @@ type oauth2Response struct {
type providerInfo struct { type providerInfo struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Logo string `json:"logo"`
State string `json:"state"` State string `json:"state"`
AuthURL string `json:"authURL"` AuthURL string `json:"authURL"`
@@ -68,7 +69,14 @@ func (amr *authMethodsResponse) fillLegacyFields() {
amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username") amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
if amr.OAuth2.Enabled { if amr.OAuth2.Enabled {
amr.AuthProviders = amr.OAuth2.Providers // clone without the logo
legacyProviders := make([]providerInfo, len(amr.OAuth2.Providers))
for i, p := range amr.OAuth2.Providers {
legacyProviders[i] = p
legacyProviders[i].Logo = ""
}
amr.AuthProviders = legacyProviders
} }
} }
@@ -128,6 +136,7 @@ func recordAuthMethods(e *core.RequestEvent) error {
info := providerInfo{ info := providerInfo{
Name: config.Name, Name: config.Name,
DisplayName: provider.DisplayName(), DisplayName: provider.DisplayName(),
Logo: provider.Logo(),
State: security.RandomString(30), State: security.RandomString(30),
} }
+2
View File
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
`"providers":[{`, `"providers":[{`,
`"name":"google"`, `"name":"google"`,
`"name":"gitlab"`, `"name":"gitlab"`,
`"logo":"\u003csvg`,
`"logo":""`, // for the legacy fields
`"state":`, `"state":`,
`"displayName":`, `"displayName":`,
`"codeVerifier":`, `"codeVerifier":`,
+115 -9
View File
@@ -1,15 +1,20 @@
package apis package apis
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"maps" "maps"
"net"
"net/http" "net/http"
"path"
"strings" "strings"
"syscall"
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -18,6 +23,7 @@ import (
"github.com/pocketbase/pocketbase/tools/auth" "github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/dbutils" "github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@@ -213,16 +219,31 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
return nil return nil
} }
// @todo evaluate if it is still worth keeping as this exists only for backward-compatibility with pre v0.23 versions
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool { func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
field := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username)
if field == nil {
return false
}
// ensure that the value matches the pattern of the username field (if text)
if txtField, ok := field.(*core.TextField); ok && txtField.ValidatePlainValue(username) != nil {
return false
}
// ensure that username is unique // ensure that username is unique
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username) index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field.GetName())
if hasUniqueue { if hasUniqueue {
// it is not required because collection fields are already sanitized
// but normalize as an extra precaution in case of a custom validator
colName := inflector.Columnify(field.GetName())
var expr dbx.Expression var expr dbx.Expression
if strings.EqualFold(index.Columns[0].Collate, "nocase") { if strings.EqualFold(index.Columns[0].Collate, "nocase") {
// case-insensitive search // case-insensitive search
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username}) expr = dbx.NewExp("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
} else { } else {
expr = dbx.HashExp{"username": username} expr = dbx.HashExp{colName: username}
} }
var exists int var exists int
@@ -232,10 +253,7 @@ func oldCanAssignUsername(txApp core.App, collection *core.Collection, username
} }
} }
// ensure that the value matches the pattern of the username field (if text) return true
txtField, _ := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username).(*core.TextField)
return txtField != nil && txtField.ValidatePlainValue(username) == nil
} }
func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error { func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
@@ -281,9 +299,12 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
if mappedField != nil && mappedField.Type() == core.FieldTypeFile { if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
// download the avatar if the mapped field is a file // download the avatar if the mapped field is a file
avatarFile, err := func() (*filesystem.File, error) { avatarFile, err := func() (*filesystem.File, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
return filesystem.NewFileFromURL(ctx, e.OAuth2User.AvatarURL)
// the extra checks are not required because the OAuth2 APIs are trusted vendor
// but are here to minimize the impact in case the provider is vulnerable
return safeFileFromURL(ctx, e.OAuth2User.AvatarURL)
}() }()
if err != nil { if err != nil {
txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error())) txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
@@ -386,3 +407,88 @@ func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2R
return createdRecord, nil return createdRecord, nil
} }
// -------------------------------------------------------------------
// safeHTTPClient initializes a custom http.Client with extra host checks
// to prevent internal network probing requests
// (aka. disallow loopback, private, multicast, etc. requests).
//
// NB! The host checks are not perfect and there are probably edge cases that are not covered,
// so if you plan using with untrusted user URL, consider performing additional whitelist checks.
//
// @todo Evaluate with the refactoring if worth exporting(+tests) and moving under the security package.
func safeHTTPClient() *http.Client {
dialer := &net.Dialer{
// the same options as in http.DefaultTransport.DialContext
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
// check the address right after estrablishing the connection to prevent dns rebinding
Control: func(network, address string, c syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() {
return fmt.Errorf("address %q is invalid or resolve to disallowed IP", address)
}
return nil
},
}
return &http.Client{
Timeout: 180 * time.Second, // can be still cancelled with the request context
Transport: &http.Transport{
DialContext: dialer.DialContext,
// the same options as in http.DefaultTransport
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
}
// safeFileFromURL downloads the file from the specified url (using safeHTTPClient)
// and creates a new filesystem.File value from its content (limited to DefaultMaxBodySize).
//
// @todo Evaluate with the refactoring if worth exporting/replacing filesystem.NewFileFromURL (or redefine as NewUnsafeFileFromURL).
func safeFileFromURL(ctx context.Context, url string) (*filesystem.File, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
client := safeHTTPClient()
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 399 {
return nil, fmt.Errorf("failed to download url %s (%d)", url, res.StatusCode)
}
body := io.LimitReader(res.Body, DefaultMaxBodySize)
var buf bytes.Buffer
if _, err = io.Copy(&buf, body); err != nil {
return nil, err
}
return filesystem.NewFileFromBytes(buf.Bytes(), path.Base(url))
}
+33 -11
View File
@@ -9,6 +9,7 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/ui"
) )
const ( const (
@@ -28,17 +29,12 @@ type oauth2RedirectData struct {
} }
func oauth2SubscriptionRedirect(e *core.RequestEvent) error { func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
redirectStatusCode := http.StatusTemporaryRedirect
if e.Request.Method != http.MethodGet {
redirectStatusCode = http.StatusSeeOther
}
data := oauth2RedirectData{} data := oauth2RedirectData{}
if e.Request.Method == http.MethodPost { if e.Request.Method == http.MethodPost {
if err := e.BindBody(&data); err != nil { if err := e.BindBody(&data); err != nil {
e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err) e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
} else { } else {
query := e.Request.URL.Query() query := e.Request.URL.Query()
@@ -49,13 +45,13 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.State == "" { if data.State == "" {
e.App.Logger().Debug("Missing OAuth2 state parameter") e.App.Logger().Debug("Missing OAuth2 state parameter")
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
client, err := e.App.SubscriptionsBroker().ClientById(data.State) client, err := e.App.SubscriptionsBroker().ClientById(data.State)
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) { if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State) e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
defer client.Unsubscribe(oauth2SubscriptionTopic) defer client.Unsubscribe(oauth2SubscriptionTopic)
@@ -76,7 +72,7 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
encodedData, err := json.Marshal(data) encodedData, err := json.Marshal(data)
if err != nil { if err != nil {
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err) e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
msg := subscriptions.Message{ msg := subscriptions.Message{
@@ -88,10 +84,36 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
if data.Error != "" || data.Code == "" { if data.Error != "" || data.Code == "" {
e.App.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State) e.App.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath) return failureRedirect(e)
} }
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath) return successRedirect(e)
}
func redirectStatusCode(e *core.RequestEvent) int {
if e.Request.Method != http.MethodGet {
return http.StatusSeeOther
}
return http.StatusTemporaryRedirect
}
func failureRedirect(e *core.RequestEvent) error {
// fallback if UI is not bundled
if ui.DistDirFS == nil {
return e.String(http.StatusOK, "Failed to authenticate. You can close this window and go back to the app to try again.")
}
return e.Redirect(redirectStatusCode(e), oauth2RedirectFailurePath)
}
func successRedirect(e *core.RequestEvent) error {
// fallback if UI is not bundled
if ui.DistDirFS == nil {
return e.HTML(http.StatusOK, "Auth completed. You can close this window and go back to the app.")
}
return e.Redirect(redirectStatusCode(e), oauth2RedirectSuccessPath)
} }
// parseAndStoreAppleRedirectName extracts the first and last name // parseAndStoreAppleRedirectName extracts the first and last name
+98 -5
View File
@@ -45,12 +45,14 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Parallel() t.Parallel()
// start a test server // start a test server
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { localServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
png.Encode(buf, image.Rect(0, 0, 1, 1)) // tiny 1x1 png png.Encode(buf, image.Rect(0, 0, 1, 1)) // tiny 1x1 png
http.ServeContent(res, req, "test_avatar.png", time.Now(), bytes.NewReader(buf.Bytes())) http.ServeContent(res, req, "test_avatar.png", time.Now(), bytes.NewReader(buf.Bytes()))
})) }))
defer server.Close() defer localServer.Close()
externalImageURL := "https://pocketbase.io/images/logo.svg"
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
{ {
@@ -1176,7 +1178,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
Id: "oauth2_id", Id: "oauth2_id",
Email: "oauth2@example.com", Email: "oauth2@example.com",
Username: "oauth2_username", Username: "oauth2_username",
AvatarURL: server.URL + "/oauth2_avatar.png", AvatarURL: externalImageURL,
}, },
Token: &oauth2.Token{AccessToken: "abc"}, Token: &oauth2.Token{AccessToken: "abc"},
} }
@@ -1208,7 +1210,98 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"username":"oauth2_username"`, `"username":"oauth2_username"`,
`"verified":true`, `"verified":true`,
`"rel":"0yxhwia2amd8gec"`, `"rel":"0yxhwia2amd8gec"`,
`"avatar":"oauth2_avatar_`, `"avatar":"logo_`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordCreateRequest": 1,
"OnRecordEnrich": 2, // the auth response and from the create request
// ---
"OnModelCreate": 3, // record + authOrigins + externalAuths
"OnModelCreateExecute": 3,
"OnModelAfterCreateSuccess": 3,
"OnRecordCreate": 3,
"OnRecordCreateExecute": 3,
"OnRecordAfterCreateSuccess": 3,
// ---
"OnModelUpdate": 1, // created record verified state change
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelValidate": 4,
"OnRecordValidate": 4,
},
},
{
Name: "creating user (with mapped OAuth2 fields and local avatarURL->file field; ensures that safeHTTPClient is being used)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com",
"createData": {
"name": "test_name",
"emailVisibility": true,
"rel": "0yxhwia2amd8gec"
}
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
usersCol, 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: "oauth2_id",
Email: "oauth2@example.com",
Username: "oauth2_username",
AvatarURL: localServer.URL + "/oauth2_avatar.png", // local/private file download is not allowed
},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// add the test provider in the collection
usersCol.MFA.Enabled = false
usersCol.OAuth2.Enabled = true
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
Username: "name", // should be ignored because of the explicit submitted value
Id: "username",
AvatarURL: "avatar",
}
if err := app.Save(usersCol); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"isNew":true`,
`"email":"oauth2@example.com"`,
`"emailVisibility":true`,
`"name":"test_name"`,
`"username":"oauth2_username"`,
`"verified":true`,
`"rel":"0yxhwia2amd8gec"`,
`"avatar":"`,
}, },
NotExpectedContent: []string{ NotExpectedContent: []string{
// hidden fields // hidden fields
@@ -1343,7 +1436,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
Email: "oauth2@example.com", Email: "oauth2@example.com",
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
Name: "oauth2_name", Name: "oauth2_name",
AvatarURL: server.URL + "/oauth2_avatar.png", AvatarURL: localServer.URL + "/oauth2_avatar.png", // allowed because it is not being downloaded
}, },
Token: &oauth2.Token{AccessToken: "abc"}, Token: &oauth2.Token{AccessToken: "abc"},
} }
+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)) 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}) err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
if err != nil { if err != nil {
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil) return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
+1 -1
View File
@@ -554,7 +554,7 @@ func firstApiError(errs ...error) *router.ApiError {
return router.NewInternalServerError("", errors.Join(errs...)) return router.NewInternalServerError("", errors.Join(errs...))
} }
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction. // execAfterSuccessTx ensures that fn is executed only after a successful transaction.
// //
// If the current app instance is not a transactional or checkTx is false, // If the current app instance is not a transactional or checkTx is false,
// then fn is directly executed. // then fn is directly executed.
+25 -15
View File
@@ -22,6 +22,8 @@ import (
"golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/acme/autocert"
) )
const defaultCSP = "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' http://127.0.0.1:*; frame-src 'none'"
// ServeConfig defines a configuration struct for apis.Serve(). // ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct { type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message. // ShowStartBanner indicates whether to show or hide the server start console message.
@@ -77,21 +79,25 @@ func Serve(app core.App, config ServeConfig) error {
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
})) }))
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)). // @todo consider moving in base
BindFunc(func(e *core.RequestEvent) error { if ui.DistDirFS != nil {
// ignore root path pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
if e.Request.PathValue(StaticWildcardParam) != "" { BindFunc(func(e *core.RequestEvent) error {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400") if !e.App.IsDev() &&
} // exclude root path
e.Request.PathValue(StaticWildcardParam) != "" &&
e.Response.Header().Get("Cache-Control") == "" {
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
}
// add a default CSP if e.Response.Header().Get("Content-Security-Policy") == "" {
if e.Response.Header().Get("Content-Security-Policy") == "" { e.Response.Header().Set("Content-Security-Policy", defaultCSP)
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() return e.Next()
}). }).
Bind(Gzip()) Bind(Gzip())
}
// start http server // start http server
// --- // ---
@@ -279,8 +285,12 @@ func Serve(app core.App, config ServeConfig) error {
) )
regular := color.New() regular := color.New()
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL)) if ui.DistDirFS == nil {
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL)) regular.Printf("└─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
} else {
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
}
} }
var serveErr error var serveErr error
+7 -5
View File
@@ -16,6 +16,8 @@ func bindSettingsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup.PATCH("", settingsSet) subGroup.PATCH("", settingsSet)
subGroup.POST("/test/s3", settingsTestS3) subGroup.POST("/test/s3", settingsTestS3)
subGroup.POST("/test/email", settingsTestEmail) subGroup.POST("/test/email", settingsTestEmail)
// @todo move to collections
subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret) subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret)
} }
@@ -62,12 +64,12 @@ func settingsSet(e *core.RequestEvent) error {
return e.BadRequestError("An error occurred while saving the new settings.", err) return e.BadRequestError("An error occurred while saving the new settings.", err)
} }
appSettings, err := e.App.Settings().Clone()
if err != nil {
return e.InternalServerError("Failed to clone app settings.", err)
}
return execAfterSuccessTx(true, e.App, func() error { return execAfterSuccessTx(true, e.App, func() error {
appSettings, err := e.App.Settings().Clone()
if err != nil {
return e.InternalServerError("Failed to clone app settings.", err)
}
return e.JSON(http.StatusOK, appSettings) return e.JSON(http.StatusOK, appSettings)
}) })
}) })
+22 -2
View File
@@ -97,8 +97,9 @@ func TestSettingsSet(t *testing.T) {
validData := `{ validData := `{
"meta":{"appName":"update_test"}, "meta":{"appName":"update_test"},
"s3":{"secret": "s3_secret"}, "smtp":{"password": "new_smtp_password"},
"backups":{"s3":{"secret":"backups_s3_secret"}} "s3":{"secret": "new_s3_secret"},
"backups":{"s3":{"secret":"new_backups_s3_secret"}}
}` }`
scenarios := []tests.ApiScenario{ scenarios := []tests.ApiScenario{
@@ -179,6 +180,25 @@ func TestSettingsSet(t *testing.T) {
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
}, },
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
settings := app.Settings()
// verify that the secret values are persisted
secrets := map[string]struct {
current string
expected string
}{
"smtp.password": {settings.SMTP.Password, "new_smtp_password"},
"s3.secret": {settings.S3.Secret, "new_s3_secret"},
"backups.s3.secret": {settings.Backups.S3.Secret, "new_backups_s3_secret"},
}
for name, secret := range secrets {
if secret.current != secret.expected {
t.Errorf("[%s] expected secret %q, got %q", name, secret.expected, secret.current)
}
}
},
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`"meta":{`, `"meta":{`,
+20 -8
View File
@@ -240,29 +240,41 @@ type App interface {
// This method is a no-op if a table with the provided name doesn't exist. // 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 // NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input! // "dangerousTableName" argument must come only from trusted input!
DeleteTable(tableName string) error DeleteTable(dangerousTableName string) error
// DeleteView drops the specified view name. // DeleteView drops the specified view name.
// //
// This method is a no-op if a view with the provided name doesn't exist. // 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 // NB! Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input! // "dangerousViewName" argument must come only from trusted input!
DeleteView(name string) error DeleteView(dangerousViewName string) error
// SaveView creates (or updates already existing) persistent SQL view. // SaveView creates (or updates already existing) persistent SQL view.
// //
// NB! Be aware that this method is vulnerable to SQL injection and the // NB! Be aware that this method is vulnerable to SQL injection and
// "selectQuery" argument must come only from trusted input! // its arguments must come only from trusted input!
SaveView(name string, selectQuery string) error SaveView(dangerousViewName string, dangerousSelectQuery string) error
// CreateViewFields creates a new FieldsList from the provided select query. // CreateViewFields creates a new FieldsList from the provided select query.
// //
// There are some caveats: // There are some caveats:
// - The select query must have an "id" column. // - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data. // - 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)
// DryRunView executes the provided query by creating a temporary view
// collection and returning a sample of the resulting query records (if valid).
//
// The same caveats from CreateViewFields apply here too.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
// FindRecordByViewFile returns the original Record of the provided view collection file. // FindRecordByViewFile returns the original Record of the provided view collection file.
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
+1 -1
View File
@@ -1220,7 +1220,7 @@ var sqlLogReplacements = []struct {
{regexp.MustCompile(`<nil>`), "NULL"}, {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 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). // (the normalization is done here to avoid breaking changes in dbx).
func normalizeSQLLog(sql string) string { func normalizeSQLLog(sql string) string {
+3 -3
View File
@@ -69,7 +69,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
return fmt.Errorf("failed to create a temp dir: %w", err) 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)
// --- // ---
@@ -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". // 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. // If for whatever reason the revert is not possible, it panics.
// //
// Note that if your pb_data has custom network mounts as subdirectories, then // Note that if your pb_data has custom network mounts as subdirectories, then
+15 -8
View File
@@ -545,7 +545,7 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
// MarshalJSON implements the [json.Marshaler] interface. // MarshalJSON implements the [json.Marshaler] interface.
// //
// Note that non-type related fields are ignored from the serialization // 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) { func (m Collection) MarshalJSON() ([]byte, error) {
switch m.Type { switch m.Type {
case CollectionTypeView: case CollectionTypeView:
@@ -559,19 +559,26 @@ func (m Collection) MarshalJSON() ([]byte, error) {
collectionAuthOptions collectionAuthOptions
}{m.baseCollection, m.collectionAuthOptions} }{m.baseCollection, m.collectionAuthOptions}
// ensure that it is always returned as array // @todo to avoid the below changes consider omitting the field values from the individual structs json tags
if alias.OAuth2.Providers == nil { //
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
}
// hide secret keys from the serialization // hide secret keys from the serialization
alias.AuthToken.Secret = "" alias.AuthToken.Secret = ""
alias.FileToken.Secret = "" alias.FileToken.Secret = ""
alias.PasswordResetToken.Secret = "" alias.PasswordResetToken.Secret = ""
alias.EmailChangeToken.Secret = "" alias.EmailChangeToken.Secret = ""
alias.VerificationToken.Secret = "" alias.VerificationToken.Secret = ""
for i := range alias.OAuth2.Providers {
alias.OAuth2.Providers[i].ClientSecret = "" if alias.OAuth2.Providers == nil {
// ensure that it is always returned as array
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
} else {
// create a deep copy of the slice to avoid modifying the cached model state
redactedProviders := make([]OAuth2ProviderConfig, len(alias.OAuth2.Providers))
copy(redactedProviders, alias.OAuth2.Providers)
for i := range redactedProviders {
redactedProviders[i].ClientSecret = ""
}
alias.OAuth2.Providers = redactedProviders
} }
return json.Marshal(alias) return json.Marshal(alias)
+68 -26
View File
@@ -760,6 +760,46 @@ func TestCollectionSerialize(t *testing.T) {
} }
} }
func TestCollectionSerializeNotModifyingCache(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
c, err := app.FindCachedCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
_, err = json.Marshal(c)
if err != nil {
t.Fatal(err)
}
redactedFields := map[string]string{
"AuthToken.Secret": c.AuthToken.Secret,
"FileToken.Secret": c.FileToken.Secret,
"PasswordResetToken.Secret": c.PasswordResetToken.Secret,
"EmailChangeToken.Secret": c.EmailChangeToken.Secret,
"VerificationToken.Secret": c.VerificationToken.Secret,
}
if len(c.OAuth2.Providers) == 0 {
t.Fatal("Expected at least one users OAuth2 provider, got 0")
}
for _, p := range c.OAuth2.Providers {
redactedFields[p.Name+".ClientSecret"] = p.ClientSecret
}
for k, v := range redactedFields {
t.Run(k, func(t *testing.T) {
if v == "" {
t.Fatalf("Expected the redacted field %q to remain unmodified after serialization, got empty value", k)
}
})
}
}
func TestCollectionDBExport(t *testing.T) { func TestCollectionDBExport(t *testing.T) {
t.Parallel() t.Parallel()
@@ -777,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
}{ }{
{ {
"unknown", "unknown",
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`, `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
}, },
{ {
core.CollectionTypeBase, core.CollectionTypeBase,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`, `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
}, },
{ {
core.CollectionTypeView, core.CollectionTypeView,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`, `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
}, },
{ {
core.CollectionTypeAuth, core.CollectionTypeAuth,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`, `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
}, },
} }
@@ -1536,60 +1576,62 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
viewName := "test_wrapping" viewName := "test_wrapping"
// note: some of the queries use "limit 0" because the tested field value could be empty
// which will trigger the extra sample records validation that are not important for this test
scenarios := []struct { scenarios := []struct {
name string name string
query string query string
expected string expected string
}{ }{
{ {
"no wrapping - text field", "no wrapping - id field",
"select text as id, bool from demo1", "select id, bool from demo1",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select id, bool from demo1)",
}, },
{ {
"no wrapping - id field", "no wrapping - text field",
"select text as id, bool from demo1", "select text as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - relation field", "no wrapping - relation field",
"select rel_one as id, bool from demo1", "select rel_one as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - select field", "no wrapping - select field",
"select select_many as id, bool from demo1", "select select_many as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - email field", "no wrapping - email field",
"select email as id, bool from demo1", "select email as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - datetime field", "no wrapping - datetime field",
"select datetime as id, bool from demo1", "select datetime as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
}, },
{ {
"no wrapping - url field", "no wrapping - url field",
"select url as id, bool from demo1", "select url as id, bool from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)", "CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
}, },
{ {
"wrapping - bool field", "wrapping - bool field",
"select bool as id, text as txt, url from demo1", "select bool as id, text as txt, url from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))", "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1 limit 0))",
}, },
{ {
"wrapping - bool field (different order)", "wrapping - bool field (different order)",
"select text as txt, url, bool as id from demo1", "select text as txt, url, bool as id from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1))", "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1 limit 0))",
}, },
{ {
"wrapping - json field", "wrapping - json field",
"select json as id, text, url from demo1", "select json as id, text, url from demo1 limit 0",
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1))", "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
}, },
{ {
"wrapping - numeric id", "wrapping - numeric id",
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
}, },
expectedErrors: []string{"fields", "viewQuery"}, expectedErrors: []string{"fields", "viewQuery"},
}, },
{
name: "view with valid query but empty sample id",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewViewCollection("new_auth")
c.ViewQuery = "select '' as id"
return c, nil
},
expectedErrors: []string{"viewQuery"},
},
{
name: "view with valid query but duplicated sample id",
collection: func(app core.App) (*core.Collection, error) {
c := core.NewViewCollection("new_auth")
c.ViewQuery = "(select 'a' as id union all select 'a' as id union all select 'c' as id)"
return c, nil
},
expectedErrors: []string{"viewQuery"},
},
{ {
name: "view with valid query", name: "view with valid query",
collection: func(app core.App) (*core.Collection, error) { collection: func(app core.App) (*core.Collection, error) {
+9 -5
View File
@@ -314,11 +314,15 @@ func (cv *collectionValidator) checkViewQuery(value any) error {
return nil // nothing to check return nil // nothing to check
} }
if _, err := cv.app.CreateViewFields(v); err != nil { _, err := cv.app.DryRunView(v, 10)
return validation.NewError( if err != nil {
"validation_invalid_view_query", rawErr := err.Error()
fmt.Sprintf("Invalid query - %s", err.Error()), if len(rawErr) > 500 {
) // restrict just as an extra precaution
rawErr = rawErr[:500]
}
return validation.NewError("validation_invalid_view_query", "Invalid query - "+rawErr)
} }
return nil return nil
+3 -3
View File
@@ -85,11 +85,11 @@ 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. // 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 // NB! Be aware that this method is vulnerable to SQL injection and the
// "tableName" argument must come only from trusted input! // "dangerousTableName" argument must come only from trusted input!
func (app *BaseApp) DeleteTable(tableName string) error { func (app *BaseApp) DeleteTable(dangerousTableName string) error {
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf( _, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
"DROP TABLE IF EXISTS {{%s}}", "DROP TABLE IF EXISTS {{%s}}",
tableName, dangerousTableName,
)).Execute() )).Execute()
return err return err
+19
View File
@@ -2,6 +2,7 @@ package core
import ( import (
"context" "context"
"io/fs"
"net" "net"
"net/http" "net/http"
"time" "time"
@@ -57,6 +58,9 @@ type baseCollectionEventData struct {
Collection *Collection Collection *Collection
} }
// @todo consider storing the original collection name and use that as a tag
// to avoid the ambiguity when the collection is being modified (#7613);
// for new collection also maybe return empty tags?
func (e *baseCollectionEventData) Tags() []string { func (e *baseCollectionEventData) Tags() []string {
if e.Collection == nil { if e.Collection == nil {
return nil return nil
@@ -125,6 +129,21 @@ type ServeEvent struct {
// //
// Set it to nil if you want to skip the installer. // Set it to nil if you want to skip the installer.
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
// @todo experimental
//
// UIExtensions is a list with the superuser UI extensions.
UIExtensions []UIExtension
}
type UIExtension struct {
// Name is the name of the extension.
// It is also used as path segment for the registered public extension endpoint
// (e.g. /_/extensions/{name}/*)
Name string
// FS is the extension file system.
FS fs.FS
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+20
View File
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
) error ) error
} }
// DefaultFieldHelpValidationRule performs base validation on a field's "help" value.
func DefaultFieldHelpValidationRule(value any) error {
v, ok := value.(string)
if !ok {
return validators.ErrUnsupportedValueType
}
rules := []validation.Rule{
validation.Length(1, 300),
}
for _, r := range rules {
if err := r.Validate(v); err != nil {
return err
}
}
return nil
}
// DefaultFieldIdValidationRule performs base validation on a field id value. // DefaultFieldIdValidationRule performs base validation on a field id value.
func DefaultFieldIdValidationRule(value any) error { func DefaultFieldIdValidationRule(value any) error {
v, ok := value.(string) v, ok := value.(string)
+2 -2
View File
@@ -46,12 +46,12 @@ type AutodateField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// ---
// OnCreate auto sets the current datetime as field value on record create. // OnCreate auto sets the current datetime as field value on record create.
OnCreate bool `form:"onCreate" json:"onCreate"` OnCreate bool `form:"onCreate" json:"onCreate"`
+6 -1
View File
@@ -36,11 +36,15 @@ type BoolField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Required will require the field value to be always "true". // Required will require the field value to be always "true".
Required bool `form:"required" json:"required"` Required bool `form:"required" json:"required"`
@@ -120,5 +124,6 @@ func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
) )
} }
+1
View File
@@ -147,4 +147,5 @@ func TestBoolFieldValidateValue(t *testing.T) {
func TestBoolFieldValidateSettings(t *testing.T) { func TestBoolFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeBool) testDefaultFieldIdValidation(t, core.FieldTypeBool)
testDefaultFieldNameValidation(t, core.FieldTypeBool) testDefaultFieldNameValidation(t, core.FieldTypeBool)
testDefaultFieldHelpValidation[core.BoolField](t)
} }
+6 -1
View File
@@ -36,11 +36,15 @@ type DateField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the min allowed field value. // Min specifies the min allowed field value.
// //
@@ -148,6 +152,7 @@ func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))), validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))),
) )
} }
+1
View File
@@ -133,6 +133,7 @@ func TestDateFieldValidateValue(t *testing.T) {
func TestDateFieldValidateSettings(t *testing.T) { func TestDateFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeDate) testDefaultFieldIdValidation(t, core.FieldTypeDate)
testDefaultFieldNameValidation(t, core.FieldTypeDate) testDefaultFieldNameValidation(t, core.FieldTypeDate)
testDefaultFieldHelpValidation[core.DateField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -41,11 +41,15 @@ type EditorField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1). // MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
// //
@@ -148,6 +152,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
) )
} }
+1
View File
@@ -163,6 +163,7 @@ func TestEditorFieldValidateValue(t *testing.T) {
func TestEditorFieldValidateSettings(t *testing.T) { func TestEditorFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEditor) testDefaultFieldIdValidation(t, core.FieldTypeEditor)
testDefaultFieldNameValidation(t, core.FieldTypeEditor) testDefaultFieldNameValidation(t, core.FieldTypeEditor)
testDefaultFieldHelpValidation[core.EditorField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type EmailField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// ExceptDomains will require the email domain to NOT be included in the listed ones. // ExceptDomains will require the email domain to NOT be included in the listed ones.
// //
@@ -155,6 +159,7 @@ func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field( validation.Field(
&f.ExceptDomains, &f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+1
View File
@@ -182,6 +182,7 @@ func TestEmailFieldValidateValue(t *testing.T) {
func TestEmailFieldValidateSettings(t *testing.T) { func TestEmailFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeEmail) testDefaultFieldIdValidation(t, core.FieldTypeEmail)
testDefaultFieldNameValidation(t, core.FieldTypeEmail) testDefaultFieldNameValidation(t, core.FieldTypeEmail)
testDefaultFieldHelpValidation[core.EmailField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+7 -2
View File
@@ -47,7 +47,7 @@ var (
// FileField defines "file" type field for managing record file(s). // FileField defines "file" type field for managing record file(s).
// //
// Only the file name is stored as part of the record value. // 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. // If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
// //
@@ -88,11 +88,15 @@ type FileField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1). // MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
// //
@@ -223,6 +227,7 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Thumbs, validation.Each( validation.Field(&f.Thumbs, validation.Each(
+1
View File
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
func TestFileFieldValidateSettings(t *testing.T) { func TestFileFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeFile) testDefaultFieldIdValidation(t, core.FieldTypeFile)
testDefaultFieldNameValidation(t, core.FieldTypeFile) testDefaultFieldNameValidation(t, core.FieldTypeFile)
testDefaultFieldHelpValidation[core.FileField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -46,11 +46,15 @@ type GeoPointField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Required will require the field coordinates to be non-zero (aka. not "Null Island"). // Required will require the field coordinates to be non-zero (aka. not "Null Island").
Required bool `form:"required" json:"required"` Required bool `form:"required" json:"required"`
@@ -144,5 +148,6 @@ func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
) )
} }
+1
View File
@@ -199,4 +199,5 @@ func TestGeoPointFieldValidateValue(t *testing.T) {
func TestGeoPointFieldValidateSettings(t *testing.T) { func TestGeoPointFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint) testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint) testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
testDefaultFieldHelpValidation[core.GeoPointField](t)
} }
+6 -1
View File
@@ -45,11 +45,15 @@ type JSONField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1). // MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
// //
@@ -181,6 +185,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
) )
} }
+1
View File
@@ -188,6 +188,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
func TestJSONFieldValidateSettings(t *testing.T) { func TestJSONFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeJSON) testDefaultFieldIdValidation(t, core.FieldTypeJSON)
testDefaultFieldNameValidation(t, core.FieldTypeJSON) testDefaultFieldNameValidation(t, core.FieldTypeJSON)
testDefaultFieldHelpValidation[core.JSONField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -48,11 +48,15 @@ type NumberField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the min allowed field value. // Min specifies the min allowed field value.
// //
@@ -173,6 +177,7 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Min, validation.By(f.checkOnlyInt)), validation.Field(&f.Min, validation.By(f.checkOnlyInt)),
validation.Field(&f.Max, maxRules...), validation.Field(&f.Max, maxRules...),
) )
+1
View File
@@ -214,6 +214,7 @@ func TestNumberFieldValidateValue(t *testing.T) {
func TestNumberFieldValidateSettings(t *testing.T) { func TestNumberFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeNumber) testDefaultFieldIdValidation(t, core.FieldTypeNumber)
testDefaultFieldNameValidation(t, core.FieldTypeNumber) testDefaultFieldNameValidation(t, core.FieldTypeNumber)
testDefaultFieldHelpValidation[core.NumberField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+8 -1
View File
@@ -61,11 +61,17 @@ type PasswordField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// @todo remove
//
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Pattern specifies an optional regex pattern to match against the field value. // Pattern specifies an optional regex pattern to match against the field value.
// //
@@ -209,6 +215,7 @@ func (f *PasswordField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Min, validation.Min(1), validation.Max(71)), validation.Field(&f.Min, validation.Min(1), validation.Max(71)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)), validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)),
validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)), validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)),
+1
View File
@@ -287,6 +287,7 @@ func TestPasswordFieldValidateValue(t *testing.T) {
func TestPasswordFieldValidateSettings(t *testing.T) { func TestPasswordFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypePassword) testDefaultFieldIdValidation(t, core.FieldTypePassword)
testDefaultFieldNameValidation(t, core.FieldTypePassword) testDefaultFieldNameValidation(t, core.FieldTypePassword)
testDefaultFieldHelpValidation[core.PasswordField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type RelationField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// CollectionId is the id of the related collection. // CollectionId is the id of the related collection.
CollectionId string `form:"collectionId" json:"collectionId"` CollectionId string `form:"collectionId" json:"collectionId"`
@@ -237,6 +241,7 @@ func (f *RelationField) ValidateSettings(ctx context.Context, app App, collectio
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))), validation.Field(&f.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))),
validation.Field(&f.MinSelect, validation.Min(0)), validation.Field(&f.MinSelect, validation.Min(0)),
validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)), validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)),
+1
View File
@@ -348,6 +348,7 @@ func TestRelationFieldValidateValue(t *testing.T) {
func TestRelationFieldValidateSettings(t *testing.T) { func TestRelationFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeRelation) testDefaultFieldIdValidation(t, core.FieldTypeRelation)
testDefaultFieldNameValidation(t, core.FieldTypeRelation) testDefaultFieldNameValidation(t, core.FieldTypeRelation)
testDefaultFieldHelpValidation[core.RelationField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -66,11 +66,15 @@ type SelectField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Values specifies the list of accepted values. // Values specifies the list of accepted values.
Values []string `form:"values" json:"values"` Values []string `form:"values" json:"values"`
@@ -216,6 +220,7 @@ func (f *SelectField) ValidateSettings(ctx context.Context, app App, collection
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.Values, validation.Required), validation.Field(&f.Values, validation.Required),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)), validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)),
) )
+1
View File
@@ -337,6 +337,7 @@ func TestSelectFieldValidateValue(t *testing.T) {
func TestSelectFieldValidateSettings(t *testing.T) { func TestSelectFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeSelect) testDefaultFieldIdValidation(t, core.FieldTypeSelect)
testDefaultFieldNameValidation(t, core.FieldTypeSelect) testDefaultFieldNameValidation(t, core.FieldTypeSelect)
testDefaultFieldHelpValidation[core.SelectField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+61 -2
View File
@@ -2,6 +2,8 @@ package core_test
import ( import (
"context" "context"
"encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -113,7 +115,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
hasErr := errs["id"] != nil hasErr := errs["id"] != nil
if hasErr != s.expectError { if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
} }
}) })
} }
@@ -254,7 +256,64 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
hasErr := errs["name"] != nil hasErr := errs["name"] != nil
if hasErr != s.expectError { if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
}
})
}
}
func testDefaultFieldHelpValidation[T any](t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection := core.NewBaseCollection("test_collection")
scenarios := []struct {
name string
json string
expectError bool
}{
{
"empty value",
`{}`,
false,
},
{
"< max limit",
`{"help":"abc"}`,
false,
},
{
"= max limit",
`{"help":"` + strings.Repeat("a", 300) + `"}`,
false,
},
{
"> max limit",
`{"help":"` + strings.Repeat("a", 301) + `"}`,
true,
},
}
for _, s := range scenarios {
t.Run("[help] "+s.name, func(t *testing.T) {
var zeroField T
field, ok := reflect.New(reflect.TypeOf(zeroField)).Interface().(core.Field)
if !ok {
t.Fatalf("Expected core.Field instance, got %T", zeroField)
}
err := json.Unmarshal([]byte(s.json), &field)
if err != nil {
t.Fatal(err)
}
errs, _ := field.ValidateSettings(context.Background(), app, collection).(validation.Errors)
hasErr := errs["help"] != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
} }
}) })
} }
+6 -1
View File
@@ -72,11 +72,15 @@ type TextField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// Min specifies the minimum required string characters. // Min specifies the minimum required string characters.
// //
@@ -283,6 +287,7 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
validation.By(DefaultFieldNameValidationRule), validation.By(DefaultFieldNameValidationRule),
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)), validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
), ),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))), validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)), validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)), validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
+1
View File
@@ -381,6 +381,7 @@ func TestTextFieldValidateValue(t *testing.T) {
func TestTextFieldValidateSettings(t *testing.T) { func TestTextFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeText) testDefaultFieldIdValidation(t, core.FieldTypeText)
testDefaultFieldNameValidation(t, core.FieldTypeText) testDefaultFieldNameValidation(t, core.FieldTypeText)
testDefaultFieldHelpValidation[core.TextField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+6 -1
View File
@@ -39,11 +39,15 @@ type URLField struct {
// Hidden hides the field from the API response. // Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"` Hidden bool `form:"hidden" json:"hidden"`
// ---
// Presentable hints the Dashboard UI to use the underlying // Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label. // field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"` Presentable bool `form:"presentable" json:"presentable"`
// --- // Help is an extra text explaining what the field is about.
// It is usually shown in Dashboard UI under the field input.
Help string `form:"help" json:"help"`
// ExceptDomains will require the URL domain to NOT be included in the listed ones. // ExceptDomains will require the URL domain to NOT be included in the listed ones.
// //
@@ -156,6 +160,7 @@ func (f *URLField) ValidateSettings(ctx context.Context, app App, collection *Co
return validation.ValidateStruct(f, return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)), validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)), validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
validation.Field( validation.Field(
&f.ExceptDomains, &f.ExceptDomains,
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+1
View File
@@ -182,6 +182,7 @@ func TestURLFieldValidateValue(t *testing.T) {
func TestURLFieldValidateSettings(t *testing.T) { func TestURLFieldValidateSettings(t *testing.T) {
testDefaultFieldIdValidation(t, core.FieldTypeURL) testDefaultFieldIdValidation(t, core.FieldTypeURL)
testDefaultFieldNameValidation(t, core.FieldTypeURL) testDefaultFieldNameValidation(t, core.FieldTypeURL)
testDefaultFieldHelpValidation[core.URLField](t)
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
+1 -1
View File
@@ -186,7 +186,7 @@ func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
return extractedFields, nil 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) // (assuming that rawJSON is array of objects)
err := json.Unmarshal(rawJSON, &extractedFields) err := json.Unmarshal(rawJSON, &extractedFields)
if err != nil { if err != nil {
+6 -6
View File
@@ -473,13 +473,13 @@ func TestFieldsListScan(t *testing.T) {
"only the minimum field options", "only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`, `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false, false,
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
}, },
{ {
"all field options", "all field options",
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
false, false,
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
}, },
} }
@@ -523,13 +523,13 @@ func TestFieldsListJSON(t *testing.T) {
"only the minimum field options", "only the minimum field options",
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`, `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
false, false,
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
}, },
{ {
"all field options", "all field options",
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
false, false,
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`, `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
}, },
} }
+133 -27
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
@@ -24,6 +25,11 @@ const (
changedModifier string = "changed" changedModifier string = "changed"
) )
type ruleJoin struct {
collection *Collection
tableAlias string
}
// ensure that `search.FieldResolver` interface is implemented // ensure that `search.FieldResolver` interface is implemented
var _ search.FieldResolver = (*RecordFieldResolver)(nil) var _ search.FieldResolver = (*RecordFieldResolver)(nil)
@@ -47,11 +53,11 @@ type RecordFieldResolver struct {
requestInfo *RequestInfo requestInfo *RequestInfo
staticRequestInfo map[string]any staticRequestInfo map[string]any
allowedFields []string allowedFields []string
joins []*join joins []*search.Join
allowHiddenFields bool allowHiddenFields bool
// --- // ---
listRuleJoins map[string]*Collection // tableAlias->collection listRuleJoins []ruleJoin
joinAliasSuffix string // used for uniqueness in the flatten collection list rule join joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
baseCollectionAlias string baseCollectionAlias string
} }
@@ -87,7 +93,7 @@ func NewRecordFieldResolver(
baseCollection: baseCollection, baseCollection: baseCollection,
requestInfo: requestInfo, 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 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{ allowedFields: []string{
`^\w+[\w\.\:]*$`, `^\w+[\w\.\:]*$`,
`^\@request\.context$`, `^\@request\.context$`,
@@ -128,20 +134,20 @@ func NewRecordFieldResolver(
// resolved fields (eg. dynamically joining relations). // resolved fields (eg. dynamically joining relations).
func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error { func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
if len(r.joins) > 0 { if len(r.joins) > 0 {
query.Distinct(true) r.updateQueryWithDeduplicateConstraint(query)
for _, join := range r.joins { for _, join := range r.joins {
query.LeftJoin( query.LeftJoin(
(join.tableName + " " + join.tableAlias), (join.TableName + " " + join.TableAlias),
join.on, join.On,
) )
} }
} }
// note: for now the joins are not applied for multi-match conditions to avoid excessive checks // note: for now the joins are not applied for multi-match conditions to avoid excessive checks
if len(r.listRuleJoins) > 0 { if len(r.listRuleJoins) > 0 {
for alias, c := range r.listRuleJoins { for _, join := range r.listRuleJoins {
err := r.updateQueryWithCollectionListRule(c, alias, query) err := r.updateQueryWithCollectionListRule(join.collection, join.tableAlias, query)
if err != nil { if err != nil {
return err return err
} }
@@ -157,26 +163,43 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
} }
cloneR := *r cloneR := *r
cloneR.joins = []*join{} cloneR.joins = []*search.Join{}
cloneR.baseCollection = c cloneR.baseCollection = c
cloneR.baseCollectionAlias = tableAlias cloneR.baseCollectionAlias = tableAlias
cloneR.allowHiddenFields = true cloneR.allowHiddenFields = true
cloneR.joinAliasSuffix = security.PseudorandomString(6) cloneR.joinAliasSuffix = security.PseudorandomString(8)
expr, err := search.FilterData(*c.ListRule).BuildExpr(&cloneR) // 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 { if err != nil {
return fmt.Errorf("to buld %q list rule join subquery filter expression: %w", c.Name, err) 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) query.AndWhere(expr)
if len(cloneR.joins) > 0 { if len(cloneR.joins) > 0 {
query.Distinct(true) r.updateQueryWithDeduplicateConstraint(query)
for _, j := range cloneR.joins { for _, j := range cloneR.joins {
query.LeftJoin( query.LeftJoin(
(j.tableName + " " + j.tableAlias), (j.TableName + " " + j.TableAlias),
j.on, j.On,
) )
} }
} }
@@ -184,6 +207,79 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
return nil 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. // Resolve implements `search.FieldResolver` interface.
// //
// Example of some resolvable fieldName formats: // Example of some resolvable fieldName formats:
@@ -252,7 +348,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
// no further processing is needed... // no further processing is needed...
default: default:
// non-plain value // 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) val, castErr := cast.ToStringE(v)
// if that doesn't work, try encoding it // if that doesn't work, try encoding it
@@ -277,7 +373,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
return &search.ResolverResult{Identifier: "NULL"}, nil return &search.ResolverResult{Identifier: "NULL"}, nil
} }
placeholder := "f" + security.PseudorandomString(8) placeholder := "f" + security.PseudorandomString(10)
// @todo consider deprecating with the introduction of filter functions // @todo consider deprecating with the introduction of filter functions
if modifier == lowerModifier { if modifier == lowerModifier {
@@ -302,10 +398,10 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collec
} }
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error { func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error {
newJoin := &join{ newJoin := &search.Join{
tableName: tableName, TableName: tableName,
tableAlias: tableAlias, TableAlias: tableAlias,
on: on, On: on,
} }
// (see updateQueryWithCollectionListRule) // (see updateQueryWithCollectionListRule)
@@ -319,16 +415,13 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name) return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name)
} }
if r.listRuleJoins == nil { r.registerRuleJoin(c, newJoin.TableAlias)
r.listRuleJoins = map[string]*Collection{}
}
r.listRuleJoins[newJoin.tableAlias] = c
} }
} }
// replace existing join // replace existing join
for i, j := range r.joins { for i, j := range r.joins {
if j.tableAlias == newJoin.tableAlias { if j.TableAlias == newJoin.TableAlias {
r.joins[i] = newJoin r.joins[i] = newJoin
return nil return nil
} }
@@ -339,6 +432,19 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
return nil 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 { type mapExtractor interface {
AsMap() map[string]any AsMap() map[string]any
} }
-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"),
)
}
+83 -82
View File
@@ -49,13 +49,13 @@ type runner struct {
// shared processing state // shared processing state
// --------------------------------------------------------------- // ---------------------------------------------------------------
activeProps []string // holds the active props that remains to be processed activeProps []string // holds the active props that remains to be processed
activeCollectionName string // the last used collection name activeCollectionName string // the last used collection name
activeTableAlias string // the last used table alias activeTableAlias string // the last used table alias
nullifyMisingField bool // indicating whether to return null on missing field or return an error 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 withMultiMatch bool // indicates whether to attach a MultiMatchSubquery condition to the ResolverResult
multiMatchActiveTableAlias string // the last used multi-match table alias multiMatchActiveTableAlias string // the last used multi-match table alias
multiMatch *multiMatchSubquery // the multi-match subquery expression generated from the fieldName multiMatch *search.MultiMatchSubquery // the multi-match subquery expression generated from the fieldName
} }
func (r *runner) run() (*search.ResolverResult, error) { func (r *runner) run() (*search.ResolverResult, error) {
@@ -144,13 +144,13 @@ func (r *runner) prepare() {
r.nullifyMisingField = r.activeProps[0] == "@request" r.nullifyMisingField = r.activeProps[0] == "@request"
// prepare a multi-match subquery // prepare a multi-match subquery
r.multiMatch = &multiMatchSubquery{ r.multiMatch = &search.MultiMatchSubquery{
baseTableAlias: r.activeTableAlias, TargetTableAlias: r.activeTableAlias,
params: dbx.Params{}, Params: dbx.Params{},
} }
r.multiMatch.fromTableName = inflector.Columnify(r.activeCollectionName) r.multiMatch.FromTableName = inflector.Columnify(r.activeCollectionName)
r.multiMatch.fromTableAlias = "__mm_" + r.activeTableAlias r.multiMatch.FromTableAlias = "__mm_" + r.activeTableAlias
r.multiMatchActiveTableAlias = r.multiMatch.fromTableAlias r.multiMatchActiveTableAlias = r.multiMatch.FromTableAlias
r.withMultiMatch = false r.withMultiMatch = false
} }
@@ -185,9 +185,9 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
// join the collection to the multi-match subquery // join the collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
tableName: inflector.Columnify(collection.Name), TableName: inflector.Columnify(collection.Name),
tableAlias: r.multiMatchActiveTableAlias, TableAlias: r.multiMatchActiveTableAlias,
}) })
// leave only the collection fields // leave only the collection fields
@@ -230,12 +230,12 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
// join the auth collection to the multi-match subquery // join the auth collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: inflector.Columnify(r.activeCollectionName), TableName: inflector.Columnify(r.activeCollectionName),
tableAlias: r.multiMatchActiveTableAlias, TableAlias: r.multiMatchActiveTableAlias,
on: dbx.HashExp{ On: dbx.HashExp{
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id, (r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
}, },
}, },
@@ -282,11 +282,11 @@ func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.Res
return nil, err return nil, err
} }
placeholder := "@changed@" + name + security.PseudorandomString(6) placeholder := "@changed@" + name + security.PseudorandomString(8)
result := &search.ResolverResult{ result := &search.ResolverResult{
Identifier: placeholder, Identifier: placeholder,
NoCoalesce: true, NullFallback: search.NullFallbackDisabled,
AfterBuild: func(expr dbx.Expression) dbx.Expression { AfterBuild: func(expr dbx.Expression) dbx.Expression {
return &replaceWithExpression{ return &replaceWithExpression{
placeholder: placeholder, placeholder: placeholder,
@@ -302,7 +302,7 @@ func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.Res
func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) { func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) {
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()]) 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{ result := &search.ResolverResult{
Identifier: "LOWER({:" + placeholder + "})", Identifier: "LOWER({:" + placeholder + "})",
@@ -338,7 +338,7 @@ func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.Resolv
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2]) 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()) cleanFieldName := inflector.Columnify(bodyField.GetName())
jeTable := fmt.Sprintf("json_each({:%s})", placeholder) jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix
@@ -362,12 +362,12 @@ func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.Resolv
jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2) jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
jeAlias2 := "__mm_" + jeAlias jeAlias2 := "__mm_" + jeAlias
r.multiMatch.joins = append(r.multiMatch.joins, &join{ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
tableName: jeTable2, TableName: jeTable2,
tableAlias: jeAlias2, TableAlias: jeAlias2,
}) })
r.multiMatch.params[placeholder2] = bodyItemsRaw r.multiMatch.Params[placeholder2] = bodyItemsRaw
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2) r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch result.MultiMatchSubQuery = r.multiMatch
} }
@@ -416,12 +416,12 @@ func (r *runner) processRequestBodyRelationField(bodyField Field) (*search.Resol
// join the data rel collection to the multi-match subquery // join the data rel collection to the multi-match subquery
r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: r.activeCollectionName, TableName: r.activeCollectionName,
tableAlias: r.multiMatchActiveTableAlias, TableAlias: r.multiMatchActiveTableAlias,
on: dbx.In( On: dbx.In(
fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias), fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
list.ToInterfaceSlice(dataRelIds)..., list.ToInterfaceSlice(dataRelIds)...,
), ),
@@ -477,12 +477,12 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
jsonPathStr := jsonPath.String() jsonPathStr := jsonPath.String()
result := &search.ResolverResult{ result := &search.ResolverResult{
NoCoalesce: true, NullFallback: search.NullFallbackDisabled,
Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr), Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
} }
if r.withMultiMatch { 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 result.MultiMatchSubQuery = r.multiMatch
} }
@@ -547,15 +547,11 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// --- // ---
cleanProp := inflector.Columnify(prop) cleanProp := inflector.Columnify(prop)
cleanBackFieldName := inflector.Columnify(backRelField.Name) cleanBackFieldName := inflector.Columnify(backRelField.Name)
newTableAlias := r.activeTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix newTableAlias := r.activeTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
newCollectionName := inflector.Columnify(backCollection.Name) newCollectionName := inflector.Columnify(backCollection.Name)
isBackRelMultiple := backRelField.IsMultiple() 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 { if !isBackRelMultiple {
err := r.resolver.registerJoin( err := r.resolver.registerJoin(
@@ -592,27 +588,32 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
// --- // ---
if isBackRelMultiple { if isBackRelMultiple {
r.withMultiMatch = true // enable multimatch if not already 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 + r.resolver.joinAliasSuffix newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
if !isBackRelMultiple { if !isBackRelMultiple {
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: newCollectionName, TableName: newCollectionName,
tableAlias: newTableAlias2, TableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)), On: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
}, },
) )
} else { } else {
jeAlias2 := "__je_" + newTableAlias2 jeAlias2 := "__je_" + newTableAlias2
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: newCollectionName, TableName: newCollectionName,
tableAlias: newTableAlias2, TableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf( On: dbx.NewExp(fmt.Sprintf(
"[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})", "[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
r.multiMatchActiveTableAlias, r.multiMatchActiveTableAlias,
jeAlias2, jeAlias2,
@@ -701,26 +702,26 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
if !relField.IsMultiple() { if !relField.IsMultiple() {
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: inflector.Columnify(newCollectionName), TableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2, TableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)), On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
}, },
) )
} else { } else {
jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je" jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
r.multiMatch.joins = append( r.multiMatch.Joins = append(
r.multiMatch.joins, r.multiMatch.Joins,
&join{ &search.Join{
tableName: dbutils.JSONEach(prefixedFieldName2), TableName: dbutils.JSONEach(prefixedFieldName2),
tableAlias: jeAlias2, TableAlias: jeAlias2,
}, },
&join{ &search.Join{
tableName: inflector.Columnify(newCollectionName), TableName: inflector.Columnify(newCollectionName),
tableAlias: newTableAlias2, TableAlias: newTableAlias2,
on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)), On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
}, },
) )
} }
@@ -765,7 +766,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
if r.withMultiMatch { if r.withMultiMatch {
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
r.multiMatch.valueIdentifier = dbutils.JSONArrayLength(jePair2) r.multiMatch.ValueIdentifier = dbutils.JSONArrayLength(jePair2)
result.MultiMatchSubQuery = r.multiMatch result.MultiMatchSubQuery = r.multiMatch
} }
@@ -795,11 +796,11 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
r.multiMatch.joins = append(r.multiMatch.joins, &join{ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
tableName: dbutils.JSONEach(jePair2), TableName: dbutils.JSONEach(jePair2),
tableAlias: jeAlias2, TableAlias: jeAlias2,
}) })
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2) r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
result.MultiMatchSubQuery = r.multiMatch result.MultiMatchSubQuery = r.multiMatch
} }
@@ -814,7 +815,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
} }
if r.withMultiMatch { if r.withMultiMatch {
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]" r.multiMatch.ValueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
result.MultiMatchSubQuery = r.multiMatch result.MultiMatchSubQuery = r.multiMatch
} }
@@ -833,10 +834,10 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
// stored as json work correctly when compared to their SQL equivalent // stored as json work correctly when compared to their SQL equivalent
// (https://github.com/pocketbase/pocketbase/issues/4068) // (https://github.com/pocketbase/pocketbase/issues/4068)
if field.Type() == FieldTypeJSON { if field.Type() == FieldTypeJSON {
result.NoCoalesce = true result.NullFallback = search.NullFallbackDisabled
result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "") result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "")
if r.withMultiMatch { if r.withMultiMatch {
r.multiMatch.valueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "") r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
} }
} }
@@ -844,7 +845,7 @@ func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop stri
if modifier == lowerModifier { if modifier == lowerModifier {
result.Identifier = "LOWER(" + result.Identifier + ")" result.Identifier = "LOWER(" + result.Identifier + ")"
if r.withMultiMatch { if r.withMultiMatch {
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")" r.multiMatch.ValueIdentifier = "LOWER(" + r.multiMatch.ValueIdentifier + ")"
} }
} }
+32 -11
View File
@@ -127,14 +127,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
expectQuery string expectQuery string
}{ }{
{ {
"non relation field (with all default operators)", "none relation field (with all default operators)",
"demo4", "demo4",
"title = true || title != 'test' || title ~ 'test1' || title !~ '%test2' || title > 1 || title >= 2 || title < 3 || title <= 4", "title = true || title != 'test' || title ~ 'test1' || title !~ '%test2' || title > 1 || title >= 2 || title < 3 || title <= 4",
false, false,
"SELECT `demo4`.* FROM `demo4` WHERE ([[demo4.title]] = 1 OR [[demo4.title]] IS NOT {:TEST} OR [[demo4.title]] LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] NOT LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] > {:TEST} OR [[demo4.title]] >= {:TEST} OR [[demo4.title]] < {:TEST} OR [[demo4.title]] <= {:TEST})", "SELECT `demo4`.* FROM `demo4` WHERE ([[demo4.title]] = 1 OR [[demo4.title]] IS NOT {:TEST} OR [[demo4.title]] LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] NOT LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] > {:TEST} OR [[demo4.title]] >= {:TEST} OR [[demo4.title]] < {:TEST} OR [[demo4.title]] <= {:TEST})",
}, },
{ {
"non relation field (with all opt/any operators)", "none relation field (with all opt/any operators)",
"demo4", "demo4",
"title ?= true || title ?!= 'test' || title ?~ 'test1' || title ?!~ '%test2' || title ?> 1 || title ?>= 2 || title ?< 3 || title ?<= 4", "title ?= true || title ?!= 'test' || title ?~ 'test1' || title ?!~ '%test2' || title ?> 1 || title ?>= 2 || title ?< 3 || title ?<= 4",
false, false,
@@ -173,7 +173,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4", "demo4",
"rel_one_cascade.created > true", "rel_one_cascade.created > true",
false, false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4.rel_one_cascade]] WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo4_rel_one_cascade.created]] > 1)", "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4.rel_one_cascade]] WHERE ((([[demo4_rel_one_cascade.id]] = '' OR [[demo4_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_rel_one_cascade.created]] > 1)",
}, },
{ {
"rel to collection with non-empty list rule (with allowHiddenFields)", "rel to collection with non-empty list rule (with allowHiddenFields)",
@@ -208,14 +208,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4", "demo4",
"self_rel_one.rel_one_cascade.created > true", "self_rel_one.rel_one_cascade.created > true",
false, false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo4_self_rel_one_rel_one_cascade.created]] > 1)", "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_self_rel_one_rel_one_cascade.created]] > 1)",
}, },
{ {
"nested rels with non-empty list rule (joins reuse test)", "nested rels with non-empty list rule (joins reuse test)",
"demo4", "demo4",
"self_rel_one.rel_one_cascade.created > true && self_rel_one.rel_one_cascade.updated > true", "self_rel_one.rel_one_cascade.created > true && self_rel_one.rel_one_cascade.updated > true",
false, false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (([[demo4_self_rel_one_rel_one_cascade.created]] > 1 AND [[demo4_self_rel_one_rel_one_cascade.updated]] > 1))", "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (([[demo4_self_rel_one_rel_one_cascade.created]] > 1 AND [[demo4_self_rel_one_rel_one_cascade.updated]] > 1))",
}, },
{ {
"nested rels with non-empty list rule (with allowHiddenFields)", "nested rels with non-empty list rule (with allowHiddenFields)",
@@ -292,7 +292,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo3", "demo3",
"demo4_via_rel_one_cascade.id = true", "demo4_via_rel_one_cascade.id = true",
false, false,
"SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_one_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_one_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_one_cascade.rel_one_cascade]]), json_type([[demo3_demo4_via_rel_one_cascade.rel_one_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_one_cascade.rel_one_cascade]] ELSE json_array([[demo3_demo4_via_rel_one_cascade.rel_one_cascade]]) END) {{__je_demo3_demo4_via_rel_one_cascade}}) WHERE ((([[demo3_demo4_via_rel_one_cascade.id]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo3_demo4_via_rel_one_cascade.id]] as [[multiMatchValue]] FROM `demo3` `__mm_demo3` LEFT JOIN `demo4` `__mm_demo3_demo4_via_rel_one_cascade` ON [[__mm_demo3.id]] IN (SELECT [[__je___mm_demo3_demo4_via_rel_one_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]]), json_type([[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]])='array', FALSE) THEN [[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]] ELSE json_array([[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]]) END) {{__je___mm_demo3_demo4_via_rel_one_cascade}}) WHERE `__mm_demo3`.`id` = `demo3`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))", "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_one_cascade` ON [[demo3_demo4_via_rel_one_cascade.rel_one_cascade]] = [[demo3.id]] WHERE ((([[demo3_demo4_via_rel_one_cascade.id]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo3_demo4_via_rel_one_cascade.id]] as [[multiMatchValue]] FROM `demo3` `__mm_demo3` LEFT JOIN `demo4` `__mm_demo3_demo4_via_rel_one_cascade` ON [[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]] = [[__mm_demo3.id]] WHERE `__mm_demo3`.`id` = `demo3`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
}, },
{ {
"back relations via single relation field (with unique index)", "back relations via single relation field (with unique index)",
@@ -334,14 +334,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo1", "demo1",
"view1_via_rel_one.rel_many.created ?> true", "view1_via_rel_one.rel_many.created ?> true",
true, true,
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `view1` `demo1_view1_via_rel_one` ON [[demo1.id]] IN (SELECT [[__je_demo1_view1_via_rel_one.value]] FROM json_each(CASE WHEN iif(json_valid([[demo1_view1_via_rel_one.rel_one]]), json_type([[demo1_view1_via_rel_one.rel_one]])='array', FALSE) THEN [[demo1_view1_via_rel_one.rel_one]] ELSE json_array([[demo1_view1_via_rel_one.rel_one]]) END) {{__je_demo1_view1_via_rel_one}}) LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1_view1_via_rel_one.rel_many]]), json_type([[demo1_view1_via_rel_one.rel_many]])='array', FALSE) THEN [[demo1_view1_via_rel_one.rel_many]] ELSE json_array([[demo1_view1_via_rel_one.rel_many]]) END) `__je_demo1_view1_via_rel_one_rel_many` LEFT JOIN `users` `demo1_view1_via_rel_one_rel_many` ON [[demo1_view1_via_rel_one_rel_many.id]] = [[__je_demo1_view1_via_rel_one_rel_many.value]] WHERE [[demo1_view1_via_rel_one_rel_many.created]] > 1", "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `view1` `demo1_view1_via_rel_one` ON [[demo1_view1_via_rel_one.rel_one]] = [[demo1.id]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1_view1_via_rel_one.rel_many]]), json_type([[demo1_view1_via_rel_one.rel_many]])='array', FALSE) THEN [[demo1_view1_via_rel_one.rel_many]] ELSE json_array([[demo1_view1_via_rel_one.rel_many]]) END) `__je_demo1_view1_via_rel_one_rel_many` LEFT JOIN `users` `demo1_view1_via_rel_one_rel_many` ON [[demo1_view1_via_rel_one_rel_many.id]] = [[__je_demo1_view1_via_rel_one_rel_many.value]] WHERE [[demo1_view1_via_rel_one_rel_many.created]] > 1",
}, },
{ {
"recursive back relations with non-empty list rule", "recursive back relations with non-empty list rule",
"demo3", "demo3",
"demo4_via_rel_many_cascade.rel_one_cascade.demo4_via_rel_many_cascade.id ?= true", "demo4_via_rel_many_cascade.rel_one_cascade.demo4_via_rel_many_cascade.id ?= true",
false, false,
"SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) LEFT JOIN `demo3` `demo3_demo4_via_rel_many_cascade_rel_one_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = [[demo3_demo4_via_rel_many_cascade.rel_one_cascade]] LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade}}) WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1)", "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) LEFT JOIN `demo3` `demo3_demo4_via_rel_many_cascade_rel_one_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = [[demo3_demo4_via_rel_many_cascade.rel_one_cascade]] LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade}}) WHERE ((([[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = '' OR [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1)",
}, },
{ {
"recursive back relations with non-empty list rule (with allowHiddenFields)", "recursive back relations with non-empty list rule (with allowHiddenFields)",
@@ -425,7 +425,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
"demo4", "demo4",
"@collection.demo3.title > true", "@collection.demo3.title > true",
false, false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))))", "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE ((([[__collection_demo3.id]] = '' OR [[__collection_demo3.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))))",
}, },
{ {
"collection filter in a non-empty list rule collection (with allowHiddenFields)", "collection filter in a non-empty list rule collection (with allowHiddenFields)",
@@ -495,7 +495,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
// different collection // different collection
"@request.body.self_rel_many.title = true", "@request.body.self_rel_many.title = true",
false, false,
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_one_no_cascade` ON [[__data_demo3_rel_one_no_cascade.id]]={:p1} LEFT JOIN `demo4` `__data_demo4_self_rel_many` ON [[__data_demo4_self_rel_many.id]]={:p2} WHERE ((({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST}))) AND (([[__data_demo3_rel_one_cascade.title]] > 1 AND [[__data_demo3_rel_one_no_cascade.title]] < 1 AND (([[__data_demo4_self_rel_many.title]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___data_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm___data_demo4_self_rel_many` ON [[__mm___data_demo4_self_rel_many.id]]={:p11} WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))))", "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_one_no_cascade` ON [[__data_demo3_rel_one_no_cascade.id]]={:p1} LEFT JOIN `demo4` `__data_demo4_self_rel_many` ON [[__data_demo4_self_rel_many.id]]={:p2} WHERE (((([[__data_demo3_rel_one_cascade.id]] = '' OR [[__data_demo3_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ((([[__data_demo3_rel_one_no_cascade.id]] = '' OR [[__data_demo3_rel_one_no_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST})))) AND (([[__data_demo3_rel_one_cascade.title]] > 1 AND [[__data_demo3_rel_one_no_cascade.title]] < 1 AND (([[__data_demo4_self_rel_many.title]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___data_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm___data_demo4_self_rel_many` ON [[__mm___data_demo4_self_rel_many.id]]={:p13} WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))))",
}, },
{ {
"@request.body.arrayble:each fields", "@request.body.arrayble:each fields",
@@ -644,6 +644,27 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
false, false,
"SELECT `view1`.* FROM `view1` WHERE (([[view1.point]] = '' OR [[view1.point]] IS NULL) OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lat') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lat') END) > {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lon') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lon') END) < {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.something') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.something') END) > {:TEST})", "SELECT `view1`.* FROM `view1` WHERE (([[view1.point]] = '' OR [[view1.point]] IS NULL) OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lat') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lat') END) > {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lon') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lon') END) < {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.something') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.something') END) > {:TEST})",
}, },
{
"strftime with fixed string as time-value against known empty value (null normalizations)",
"demo5",
"strftime('%Y-%m', '2026-01-01') = ''",
false,
"SELECT `demo5`.* FROM `demo5` WHERE ((strftime({:TEST},{:TEST}) = '' OR strftime({:TEST},{:TEST}) IS NULL))",
},
{
"strftime without multi-match",
"demo5",
"strftime('%Y-%m', rel_one.created) = true",
false,
"SELECT DISTINCT `demo5`.* FROM `demo5` LEFT JOIN `demo4` `demo5_rel_one` ON [[demo5_rel_one.id]] = [[demo5.rel_one]] WHERE strftime({:TEST},[[demo5_rel_one.created]]) = 1",
},
{
"strftime with multi-match",
"demo5",
"strftime('%Y-%m', rel_many.created) = true",
false,
"SELECT DISTINCT `demo5`.* FROM `demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo5.rel_many]]), json_type([[demo5.rel_many]])='array', FALSE) THEN [[demo5.rel_many]] ELSE json_array([[demo5.rel_many]]) END) `__je_demo5_rel_many` LEFT JOIN `demo4` `demo5_rel_many` ON [[demo5_rel_many.id]] = [[__je_demo5_rel_many.value]] WHERE (((strftime({:TEST},[[demo5_rel_many.created]]) = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT strftime({:TEST},[[__mm_demo5_rel_many.created]]) as [[multiMatchValue]] FROM `demo5` `__mm_demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo5.rel_many]]), json_type([[__mm_demo5.rel_many]])='array', FALSE) THEN [[__mm_demo5.rel_many]] ELSE json_array([[__mm_demo5.rel_many]]) END) `__mm_demo5_rel_many_je` LEFT JOIN `demo4` `__mm_demo5_rel_many` ON [[__mm_demo5_rel_many.id]] = [[__mm_demo5_rel_many_je.value]] WHERE `__mm_demo5`.`id` = `demo5`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
},
} }
for _, s := range scenarios { for _, s := range scenarios {
@@ -757,7 +778,7 @@ func TestRecordFieldResolverResolveCollectionFields(t *testing.T) {
{"@request.auth.demo1_via_file_one.id", false, "NULL"}, // not a relation field {"@request.auth.demo1_via_file_one.id", false, "NULL"}, // not a relation field
{"@request.auth.demo1_via_rel_one.id", false, "NULL"}, // relation field but to a different collection {"@request.auth.demo1_via_rel_one.id", false, "NULL"}, // relation field but to a different collection
// @collection fieds // @collection fields
{"@collect", true, ""}, {"@collect", true, ""},
{"collection.demo4.title", true, ""}, {"collection.demo4.title", true, ""},
{"@collection", true, ""}, {"@collection", true, ""},
+1 -1
View File
@@ -983,7 +983,7 @@ func (m *Record) GetStringSlice(key string) []string {
} }
// GetUnsavedFiles returns the uploaded files for the provided "file" field 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). // validations or modifications (including changing the file name or content before persisting).
// //
// Example: // Example:
+11 -1
View File
@@ -314,9 +314,19 @@ func (app *BaseApp) FindAllRecords(collectionModelOrIdentifier any, exprs ...dbx
// FindFirstRecordByData returns the first found record matching // FindFirstRecordByData returns the first found record matching
// the provided key-value pair. // the provided key-value pair.
func (app *BaseApp) FindFirstRecordByData(collectionModelOrIdentifier any, key string, value any) (*Record, error) { 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{} record := &Record{}
err := app.RecordQuery(collectionModelOrIdentifier). err = app.RecordQuery(collection).
AndWhere(dbx.HashExp{inflector.Columnify(key): value}). AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
Limit(1). Limit(1).
One(record) One(record)
+7
View File
@@ -525,6 +525,13 @@ func TestFindFirstRecordByData(t *testing.T) {
"", "",
true, true,
}, },
{
"demo2",
"invalid_or_missing",
"llvuca81nly1qls",
"",
true,
},
{ {
"demo2", "demo2",
"id", "id",
+34 -3
View File
@@ -143,6 +143,7 @@ func newDefaultSettings() *Settings {
isNew: true, isNew: true,
settings: settings{ settings: settings{
Meta: MetaConfig{ Meta: MetaConfig{
AccentColor: "#1055c9",
AppName: "Acme", AppName: "Acme",
AppURL: "http://localhost:8090", AppURL: "http://localhost:8090",
HideControls: false, HideControls: false,
@@ -327,6 +328,8 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
copy := s.settings copy := s.settings
s.mu.RUnlock() s.mu.RUnlock()
copy.SMTP.hidePassword = true
sensitiveFields := []*string{ sensitiveFields := []*string{
&copy.SMTP.Password, &copy.SMTP.Password,
&copy.S3.Secret, &copy.S3.Secret,
@@ -346,11 +349,17 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type SMTPConfig struct { type SMTPConfig struct {
// @todo temp workaround to avoid introducing breaking changes;
// consider refactoring and/or normalizing with the other Settings sensitive fields
//
// hidePassword specifies whether to hide the password field from the struct JSON serialization.
hidePassword bool
Enabled bool `form:"enabled" json:"enabled"` Enabled bool `form:"enabled" json:"enabled"`
Port int `form:"port" json:"port"` Port int `form:"port" json:"port"`
Host string `form:"host" json:"host"` Host string `form:"host" json:"host"`
Username string `form:"username" json:"username"` Username string `form:"username" json:"username"`
Password string `form:"password" json:"password,omitempty"` Password string `form:"password" json:"password"`
// SMTP AUTH - PLAIN (default) or LOGIN // SMTP AUTH - PLAIN (default) or LOGIN
AuthMethod string `form:"authMethod" json:"authMethod"` AuthMethod string `form:"authMethod" json:"authMethod"`
@@ -392,6 +401,22 @@ func (c SMTPConfig) Validate() error {
) )
} }
// MarshalJSON implements the [json.Marshaler] interface.
func (c SMTPConfig) MarshalJSON() ([]byte, error) {
type alias SMTPConfig
if c.hidePassword {
v := struct {
alias
Password string `json:"password,omitempty"`
}{alias(c), ""}
return json.Marshal(v)
}
return json.Marshal(alias(c))
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type S3Config struct { type S3Config struct {
@@ -423,7 +448,7 @@ type BatchConfig struct {
// MaxRequests is the maximum allowed batch request to execute. // MaxRequests is the maximum allowed batch request to execute.
MaxRequests int `form:"maxRequests" json:"maxRequests"` 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"` Timeout int64 `form:"timeout" json:"timeout"`
// MaxBodySize is the maximum allowed batch request body size in bytes. // MaxBodySize is the maximum allowed batch request body size in bytes.
@@ -449,7 +474,7 @@ type BackupsConfig struct {
// Leave it empty to disable the auto backups functionality. // Leave it empty to disable the auto backups functionality.
Cron string `form:"cron" json:"cron"` 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. // keep before removing older entries.
// //
// This field works only when the cron config has valid cron expression. // This field works only when the cron config has valid cron expression.
@@ -489,6 +514,11 @@ func checkCronExpression(value any) error {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type MetaConfig struct { type MetaConfig struct {
// @todo experimental
//
// AccentColor specify the UI "accent" color (HEX).
AccentColor string `form:"accentColor" json:"accentColor"`
AppName string `form:"appName" json:"appName"` AppName string `form:"appName" json:"appName"`
AppURL string `form:"appURL" json:"appURL"` AppURL string `form:"appURL" json:"appURL"`
SenderName string `form:"senderName" json:"senderName"` SenderName string `form:"senderName" json:"senderName"`
@@ -499,6 +529,7 @@ type MetaConfig struct {
// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface. // Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
func (c MetaConfig) Validate() error { func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c, return validation.ValidateStruct(&c,
validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)), validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.AppURL, validation.Required, is.URL), validation.Field(&c.AppURL, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)), validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
+100 -1
View File
@@ -3,6 +3,7 @@ package core_test
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -10,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/security"
) )
func TestSettingsDelete(t *testing.T) { func TestSettingsDelete(t *testing.T) {
@@ -24,7 +26,75 @@ func TestSettingsDelete(t *testing.T) {
} }
} }
func TestSettings_DBExport(t *testing.T) {
scenarios := []struct {
name string
encryption bool
}{
{"no encryption", false},
{"with encryption", true},
}
encryptionKey := strings.Repeat("a", 32)
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
originalEnv := os.Getenv(app.EncryptionEnv())
defer func() {
os.Setenv(app.EncryptionEnv(), originalEnv)
}()
settings := &core.Settings{}
settings.Meta.AppName = "test_app_name"
settings.Logs.MaxDays = 123
settings.SMTP.Host = "smtp_host"
settings.SMTP.Username = "smtp_username"
settings.SMTP.Password = "" // ensures that empty password is exported
settings.S3.Endpoint = "s3_endpoint"
settings.S3.Secret = "s3_secret"
settings.Backups.Cron = "* * * * *"
settings.Backups.S3.Enabled = true
settings.Backups.S3.Secret = ""
settings.Batch.Timeout = 15
settings.RateLimits.Enabled = true
settings.TrustedProxy.UseLeftmostIP = true
if s.encryption {
os.Setenv(app.EncryptionEnv(), encryptionKey)
}
export, err := settings.DBExport(app)
if err != nil {
t.Fatal(err)
}
var valueStr string
if s.encryption {
decrypted, err := security.Decrypt(export["value"].(string), encryptionKey)
if err != nil {
t.Fatalf("failed to decrypt test value: %v", err)
}
valueStr = string(decrypted)
} else {
valueStr = string(export["value"].([]byte))
}
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
}
})
}
}
func TestSettingsMerge(t *testing.T) { func TestSettingsMerge(t *testing.T) {
t.Parallel()
s1 := &core.Settings{} s1 := &core.Settings{}
s1.Meta.AppURL = "app_url" // should be unset s1.Meta.AppURL = "app_url" // should be unset
@@ -58,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
} }
func TestSettingsClone(t *testing.T) { func TestSettingsClone(t *testing.T) {
t.Parallel()
s1 := &core.Settings{} s1 := &core.Settings{}
s1.Meta.AppName = "test_name" s1.Meta.AppName = "test_name"
@@ -88,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
} }
func TestSettingsMarshalJSON(t *testing.T) { func TestSettingsMarshalJSON(t *testing.T) {
t.Parallel()
settings := &core.Settings{} settings := &core.Settings{}
// control fields // control fields
@@ -106,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
} }
rawStr := string(raw) rawStr := string(raw)
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}` expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected { if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr) t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -162,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
} }
func TestMetaConfigValidate(t *testing.T) { func TestMetaConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.MetaConfig config core.MetaConfig
@@ -180,12 +256,14 @@ func TestMetaConfigValidate(t *testing.T) {
{ {
"invalid data", "invalid data",
core.MetaConfig{ core.MetaConfig{
AccentColor: "#fff",
AppName: strings.Repeat("a", 300), AppName: strings.Repeat("a", 300),
AppURL: "test", AppURL: "test",
SenderName: strings.Repeat("a", 300), SenderName: strings.Repeat("a", 300),
SenderAddress: "invalid_email", SenderAddress: "invalid_email",
}, },
[]string{ []string{
"accentColor",
"appName", "appName",
"appURL", "appURL",
"senderName", "senderName",
@@ -195,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
{ {
"valid data", "valid data",
core.MetaConfig{ core.MetaConfig{
AccentColor: "#ffffff",
AppName: "test", AppName: "test",
AppURL: "https://example.com", AppURL: "https://example.com",
SenderName: "test", SenderName: "test",
@@ -214,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
} }
func TestLogsConfigValidate(t *testing.T) { func TestLogsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.LogsConfig config core.LogsConfig
@@ -246,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
} }
func TestSMTPConfigValidate(t *testing.T) { func TestSMTPConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.SMTPConfig config core.SMTPConfig
@@ -305,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
} }
func TestS3ConfigValidate(t *testing.T) { func TestS3ConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.S3Config config core.S3Config
@@ -376,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
} }
func TestBackupsConfigValidate(t *testing.T) { func TestBackupsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.BackupsConfig config core.BackupsConfig
@@ -431,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
} }
func TestBatchConfigValidate(t *testing.T) { func TestBatchConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.BatchConfig config core.BatchConfig
@@ -486,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
} }
func TestRateLimitsConfigValidate(t *testing.T) { func TestRateLimitsConfigValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
config core.RateLimitsConfig config core.RateLimitsConfig
@@ -631,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
} }
func TestRateLimitsFindRateLimitRule(t *testing.T) { func TestRateLimitsFindRateLimitRule(t *testing.T) {
t.Parallel()
limits := core.RateLimitsConfig{ limits := core.RateLimitsConfig{
Rules: []core.RateLimitRule{ Rules: []core.RateLimitRule{
{Label: "abc"}, {Label: "abc"},
@@ -685,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
} }
func TestRateLimitRuleValidate(t *testing.T) { func TestRateLimitRuleValidate(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
rule core.RateLimitRule rule core.RateLimitRule
@@ -792,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
} }
func TestRateLimitRuleDurationTime(t *testing.T) { func TestRateLimitRuleDurationTime(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
rule core.RateLimitRule rule core.RateLimitRule
expected time.Duration expected time.Duration
@@ -812,6 +909,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
} }
func TestRateLimitRuleString(t *testing.T) { func TestRateLimitRuleString(t *testing.T) {
t.Parallel()
scenarios := []struct { scenarios := []struct {
name string name string
rule core.RateLimitRule rule core.RateLimitRule
+143 -32
View File
@@ -1,6 +1,7 @@
package core package core
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -19,11 +20,11 @@ import (
// This method is a no-op if a view with the provided name doesn't exist. // 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 // NB! Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input! // "dangerousViewName" argument must come only from trusted input!
func (app *BaseApp) DeleteView(name string) error { func (app *BaseApp) DeleteView(dangerousViewName string) error {
_, err := app.DB().NewQuery(fmt.Sprintf( _, err := app.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}", "DROP VIEW IF EXISTS {{%s}}",
name, dangerousViewName,
)).Execute() )).Execute()
return err return err
@@ -31,39 +32,37 @@ func (app *BaseApp) DeleteView(name string) error {
// SaveView creates (or updates already existing) persistent SQL view. // SaveView creates (or updates already existing) persistent SQL view.
// //
// NB! Be aware that this method is vulnerable to SQL injection and the // NB! Be aware that this method is vulnerable to SQL injection and
// "selectQuery" argument must come only from trusted input! // its arguments must come only from trusted input!
func (app *BaseApp) SaveView(name string, selectQuery string) error { func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
return app.RunInTransaction(func(txApp App) error { return app.RunInTransaction(func(txApp App) error {
// delete old view (if exists) // delete old view (if exists)
if err := txApp.DeleteView(name); err != nil { err := txApp.DeleteView(dangerousViewName)
if err != nil {
return err return err
} }
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";") dangerousSelectQuery, err = normalizeViewSelectQuery(dangerousSelectQuery)
if err != nil {
// try to loosely detect multiple inline statements return err
tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
} }
// (re)create the view // (re)create the view
// //
// note: the query is wrapped in a secondary SELECT as a rudimentary // note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution // 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 { _, err = txApp.DB().NewQuery(viewQuery).Execute()
if err != nil {
return err return err
} }
// fetch the view table info to ensure that the view was created // fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error // 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 // manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded // is called in a nested transaction and the error is discarded
txApp.DeleteView(name) txApp.DeleteView(dangerousViewName)
return err return err
} }
@@ -77,18 +76,21 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
// There are some caveats: // There are some caveats:
// - The select query must have an "id" column. // - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data. // - 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() result := NewFieldsList()
suggestedFields, err := parseQueryToFields(app, selectQuery) suggestedFields, err := parseQueryToFields(app, dangerousSelectQuery)
if err != nil { if err != nil {
return result, err 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 // multiple statements allowing us to rollback on any error
txErr := app.RunInTransaction(func(txApp App) error { txErr := app.RunInTransaction(func(txApp App) error {
info, err := getQueryTableInfo(txApp, selectQuery) info, err := getQueryTableInfo(txApp, dangerousSelectQuery)
if err != nil { if err != nil {
return err return err
} }
@@ -121,6 +123,76 @@ func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
return result, txErr return result, txErr
} }
type DryRunViewResult struct {
Fields FieldsList `json:"fields"`
Sample []*Record `json:"sample"`
}
// DryRunView executes the provided query by creating a temporary view
// collection and returning a sample of the resulting query records (if valid).
//
// The same caveats from CreateViewFields apply here too.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "dangerousSelectQuery" argument must come only from trusted input!
func (app *BaseApp) DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error) {
dangerousSelectQuery, err := normalizeViewSelectQuery(dangerousSelectQuery)
if err != nil {
return nil, err
}
fields, err := app.CreateViewFields(dangerousSelectQuery)
if err != nil {
return nil, err
}
tempName := "temp_view_" + security.RandomString(5)
tempCollection := NewViewCollection(tempName)
tempCollection.Fields = fields
// validate generated view fields
ctx := context.Background()
for i, f := range fields {
err = f.ValidateSettings(ctx, app, tempCollection)
if err != nil {
return nil, fmt.Errorf("invalid field %q (%d): %w", f.GetName(), i, err)
}
}
records := []*Record{}
err = app.RecordQuery(tempCollection).
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
From("(SELECT * FROM (" + dangerousSelectQuery + ")) as " + tempName).
Limit(int64(sampleSize)).
All(&records)
if err != nil {
return nil, fmt.Errorf("failed to retrieve query records: %w", err)
}
// warn for possible empty or duplicated record ids found in the sample
// (it is not intended for security and it is here to quickly provide a
// helpful error message without doing multiple query executions)
ids := make(map[string]struct{}, len(records))
for _, r := range records {
if r.Id == "" {
return nil, errors.New("the query could return records with empty or invalid ids")
}
if _, ok := ids[r.Id]; ok {
return nil, errors.New("the query could return records with non-unique ids")
}
ids[r.Id] = struct{}{}
}
return &DryRunViewResult{
Fields: fields,
Sample: records,
}, nil
}
// FindRecordByViewFile returns the original Record of the provided view collection file. // FindRecordByViewFile returns the original Record of the provided view collection file.
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) { func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier) view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
@@ -195,6 +267,20 @@ func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fi
// Raw query to schema helpers // Raw query to schema helpers
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// loosely normalizes the specified view query and warn against multiple inline statements
// (the check is not perfect and it is NOT intended as a security measure; it is done primarily to provide a helpful error message)
func normalizeViewSelectQuery(dangerousSelectQuery string) (string, error) {
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
tk := tokenizer.NewFromString(dangerousSelectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return "", errors.New("multiple statements are not supported")
}
return dangerousSelectQuery, nil
}
type queryField struct { type queryField struct {
// field is the final resolved field. // field is the final resolved field.
field Field field Field
@@ -209,12 +295,26 @@ type queryField struct {
} }
func defaultViewField(name string) Field { func defaultViewField(name string) Field {
if name == FieldNameId {
return defaultViewIdField()
}
return &JSONField{ return &JSONField{
Name: name, Name: name,
MaxSize: 1, // unused for views MaxSize: 1, // unused for views
} }
} }
func defaultViewIdField() Field {
return &TextField{
Name: FieldNameId,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
}
}
var castRegex = regexp.MustCompile(`(?is)^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) { func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
@@ -242,19 +342,22 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
// pk (always assume text field for now) // pk (always assume text field for now)
if col.alias == FieldNameId { if col.alias == FieldNameId {
result[col.alias] = &queryField{ result[col.alias] = &queryField{
field: &TextField{ field: defaultViewIdField(),
Name: col.alias,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
},
} }
continue continue
} }
// numeric aggregations // 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{ result[col.alias] = &queryField{
field: &NumberField{ field: &NumberField{
Name: col.alias, Name: col.alias,
@@ -265,16 +368,24 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
castMatch := castRegex.FindStringSubmatch(colLower) castMatch := castRegex.FindStringSubmatch(colLower)
// numeric casts // casts
if len(castMatch) == 2 { if len(castMatch) == 2 {
switch castMatch[1] { switch castMatch[1] {
case "real", "integer", "int", "decimal", "numeric": case "real", "decimal", "numeric":
result[col.alias] = &queryField{ result[col.alias] = &queryField{
field: &NumberField{ field: &NumberField{
Name: col.alias, Name: col.alias,
}, },
} }
continue continue
case "int", "integer":
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
OnlyInt: true,
},
}
continue
case "text": case "text":
result[col.alias] = &queryField{ result[col.alias] = &queryField{
field: &TextField{ field: &TextField{
+182 -1
View File
@@ -326,7 +326,7 @@ func TestCreateViewFields(t *testing.T) {
}, },
}, },
{ {
"query with multiple froms, joins and style of aliasses", "query with multiple froms, joins and style of aliases",
` `
select select
a.id as id, a.id as id,
@@ -545,6 +545,61 @@ func TestCreateViewFields(t *testing.T) {
ensureNoTempViews(app, t) ensureNoTempViews(app, t)
} }
func TestCreateViewFieldsWithNumberOnlyInt(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
sql := `select
a.id,
count(a.id) count,
total(a.id) total,
cast(a.id as int) cast_int,
cast(a.id as integer) cast_integer,
cast(a.id as real) cast_real,
cast(a.id as decimal) cast_decimal,
cast(a.id as numeric) cast_numeric
from demo1 a`
result, err := app.CreateViewFields(sql)
if err != nil {
t.Fatal(err)
}
onlyInts := map[string]bool{
"count": true,
"total": false,
"cast_int": true,
"cast_integer": true,
"cast_real": false,
"cast_decimal": false,
"cast_numeric": false,
}
totalExpected := len(onlyInts) + 1
if total := len(result); total != totalExpected {
t.Fatalf("Expected %d, got %d", totalExpected, total)
}
for _, f := range result {
if f.GetName() == "id" {
continue
}
t.Run(f.GetName(), func(t *testing.T) {
nf, ok := f.(*core.NumberField)
if !ok {
t.Fatalf("Expected *core.NumberField, got %v", f)
}
if nf.OnlyInt != onlyInts[nf.Name] {
t.Fatalf("Expected OnlyInt %v, got %v", onlyInts[nf.Name], nf.OnlyInt)
}
})
}
}
func TestFindRecordByViewFile(t *testing.T) { func TestFindRecordByViewFile(t *testing.T) {
t.Parallel() t.Parallel()
@@ -677,3 +732,129 @@ func TestFindRecordByViewFile(t *testing.T) {
}) })
} }
} }
func TestDryRunView(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
query string
sampleSize int
expectError bool
expectFields map[string]string // name-type pairs
expectSampleIds []string // record ids of the resulting sample
}{
{
"empty query",
"",
10,
true,
nil,
nil,
},
{
"non-select query",
"CREATE TABLE t1(x INT)",
10,
true,
nil,
nil,
},
{
"multiple inline select statements",
"select 'a' as id; select 'b' as id",
10,
true,
nil,
nil,
},
{
"select with invalid formatted field name",
"select 'a' as id, count(*)", // missing field alias
10,
true,
nil,
nil,
},
{
"select resolving to records with missing id",
"(select 'a' as id UNION ALL select null as id UNION ALL select 'c' as id)",
10,
true,
nil,
nil,
},
{
"select resolving to records with duplicated ids",
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
10,
true,
nil,
nil,
},
{
"no sample size and valid select query but with invalid records result",
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
0,
false, // still "valid" because there is no sample to check
map[string]string{"id": "text"},
nil,
},
{
"sample size < total select records",
"(select 'a' as id UNION ALL select 'b' as id UNION ALL select 'c' as id UNION ALL select 'd' as id)",
3,
false,
map[string]string{"id": "text"},
[]string{"a", "b", "c"},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, err := app.DryRunView(s.query, s.sampleSize)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
// check fields
// ---
if len(s.expectFields) != len(result.Fields) {
serialized, _ := json.Marshal(result.Fields)
t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized)
}
for name, typ := range s.expectFields {
field := result.Fields.GetByName(name)
if field == nil {
t.Fatalf("Expected to find field %s, got nil", name)
}
if field.Type() != typ {
t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type())
}
}
// check sample ids
// ---
if len(s.expectSampleIds) != len(result.Sample) {
t.Fatalf("Expected %d sample records, got %d", len(s.expectSampleIds), len(result.Sample))
}
for i, r := range result.Sample {
if s.expectSampleIds[i] != r.Id {
t.Fatalf("Expected sample record id %q, got %q at %d", s.expectSampleIds[i], r.Id, i)
}
}
})
}
ensureNoTempViews(app, t)
}
+4 -4
View File
@@ -534,7 +534,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
expectedErrors: []string{"password", "passwordConfirm"}, expectedErrors: []string{"password", "passwordConfirm"},
}, },
{ {
name: "new auth collection record with invalid record and invalid form data (without manager acess)", name: "new auth collection record with invalid record and invalid form data (without manager access)",
record: core.NewRecord(usersCol), record: core.NewRecord(usersCol),
data: map[string]any{ data: map[string]any{
"verified": true, "verified": true,
@@ -552,7 +552,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
expectedErrors: []string{"verified", "passwordConfirm"}, expectedErrors: []string{"verified", "passwordConfirm"},
}, },
{ {
name: "new auth collection record with invalid record and valid form data (without manager acess)", name: "new auth collection record with invalid record and valid form data (without manager access)",
record: core.NewRecord(usersCol), record: core.NewRecord(usersCol),
data: map[string]any{ data: map[string]any{
"verified": false, "verified": false,
@@ -570,7 +570,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
expectedErrors: []string{"password", "username"}, expectedErrors: []string{"password", "username"},
}, },
{ {
name: "new auth collection record with invalid record and invalid form data (with manager acess)", name: "new auth collection record with invalid record and invalid form data (with manager access)",
record: core.NewRecord(usersCol), record: core.NewRecord(usersCol),
managerAccess: true, managerAccess: true,
data: map[string]any{ data: map[string]any{
@@ -589,7 +589,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
expectedErrors: []string{"passwordConfirm"}, expectedErrors: []string{"passwordConfirm"},
}, },
{ {
name: "new auth collection record with invalid record and valid form data (with manager acess)", name: "new auth collection record with invalid record and valid form data (with manager access)",
record: core.NewRecord(usersCol), record: core.NewRecord(usersCol),
managerAccess: true, managerAccess: true,
data: map[string]any{ data: map[string]any{
+21 -22
View File
@@ -1,28 +1,28 @@
module github.com/pocketbase/pocketbase module github.com/pocketbase/pocketbase
go 1.24.0 go 1.25.0
require ( require (
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2 github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7 github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
github.com/fatih/color v1.18.0 github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.7.0 github.com/fsnotify/fsnotify v1.7.0
github.com/gabriel-vasile/mimetype v1.4.11 github.com/gabriel-vasile/mimetype v1.4.13
github.com/ganigeorgiev/fexpr v0.5.0 github.com/ganigeorgiev/fexpr v0.5.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/pocketbase/dbx v1.11.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.50.0
golang.org/x/image v0.33.0 golang.org/x/image v0.39.0
golang.org/x/net v0.47.0 golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.33.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.20.0
modernc.org/sqlite v1.40.1 modernc.org/sqlite v1.49.1
) )
require ( require (
@@ -31,20 +31,19 @@ require (
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/mod v0.30.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools v0.39.0 // indirect modernc.org/libc v1.72.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
+51 -50
View File
@@ -14,20 +14,20 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY= github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM= github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7 h1:jxmXU5V9tXxJnydU5v/m9SG8TRUa/Z7IXODBpMs/P+U= github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw= github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
@@ -36,15 +36,17 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -53,14 +55,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f h1:ahrn66FNJYsFkO0EOTStYs+jdBKBop/anp9hoQSzZjI= github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f h1:ahrn66FNJYsFkO0EOTStYs+jdBKBop/anp9hoQSzZjI=
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE= github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -70,61 +72,60 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -133,8 +134,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+3 -3
View File
@@ -10,15 +10,15 @@ import (
) )
const ( const (
expectedDriverVersion = "v1.40.1" expectedDriverVersion = "v1.49.1"
expectedLibcVersion = "v1.66.10" expectedLibcVersion = "v1.72.0"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks. // ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
// It could be used for removing/unbinding the hook if you don't want the checks. // It could be used for removing/unbinding the hook if you don't want the checks.
ModerncDepsCheckHookId = "pbModerncDepsCheck" ModerncDepsCheckHookId = "pbModerncDepsCheck"
) )
// checkModerncDeps checks whether the current binary was buit with the // checkModerncDeps checks whether the current binary was built with the
// expected and tested modernc driver dependencies. // expected and tested modernc driver dependencies.
// //
// This is needed because modernc.org/libc doesn't follow semantic versioning // This is needed because modernc.org/libc doesn't follow semantic versioning
+102 -18
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -288,9 +289,13 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
return wrappedMiddlewares, nil return wrappedMiddlewares, nil
} }
// -------------------------------------------------------------------
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil) var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
func baseBinds(vm *goja.Runtime) { // BindCore registers common core objects and functions such as sleep,
// toString, DynamicModel, etc. into the provided runtime.
func BindCore(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{}) vm.SetFieldNameMapper(FieldMapper{})
// deprecated: use toString // deprecated: use toString
@@ -431,6 +436,32 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue return instanceValue
}) })
// nullable helpers usually used as DynamicModel shape values
vm.Set("nullString", func() *string {
var v string
return &v
})
vm.Set("nullFloat", func() *float64 {
var v float64
return &v
})
vm.Set("nullInt", func() *int64 {
var v int64
return &v
})
vm.Set("nullBool", func() *bool {
var v bool
return &v
})
vm.Set("nullArray", func() *types.JSONArray[any] {
var v types.JSONArray[any]
return &v
})
vm.Set("nullObject", func() *types.JSONMap[any] {
var v types.JSONMap[any]
return &v
})
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object { vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
var instance *core.Record var instance *core.Record
@@ -632,7 +663,10 @@ func baseBinds(vm *goja.Runtime) {
}) })
} }
func dbxBinds(vm *goja.Runtime) { // BindDbx registers $dbx.* namespaced object with dbx database builder related methods.
//
// See https://pocketbase.io/jsvm/modules/_dbx.html.
func BindDbx(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$dbx", obj) vm.Set("$dbx", obj)
@@ -655,7 +689,10 @@ func dbxBinds(vm *goja.Runtime) {
obj.Set("notBetween", dbx.NotBetween) obj.Set("notBetween", dbx.NotBetween)
} }
func mailsBinds(vm *goja.Runtime) { // BindMails registers $mail.* namespaced object with common mail related helpers.
//
// See https://pocketbase.io/jsvm/modules/_mails.html.
func BindMails(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$mails", obj) vm.Set("$mails", obj)
@@ -666,7 +703,10 @@ func mailsBinds(vm *goja.Runtime) {
obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert) obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert)
} }
func securityBinds(vm *goja.Runtime) { // BindSecurity registers $security.* namespaced object with common security related helpers.
//
// See https://pocketbase.io/jsvm/modules/_security.html.
func BindSecurity(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$security", obj) vm.Set("$security", obj)
@@ -709,10 +749,16 @@ func securityBinds(vm *goja.Runtime) {
}) })
} }
func filesystemBinds(vm *goja.Runtime) { // BindFilesystem registers $filesystem.* namespaced object with
// common filesystem package related helpers.
//
// See https://pocketbase.io/jsvm/modules/_filesystem.html.
func BindFilesystem(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$filesystem", obj) vm.Set("$filesystem", obj)
obj.Set("s3", filesystem.NewS3)
obj.Set("local", filesystem.NewLocal)
obj.Set("fileFromPath", filesystem.NewFileFromPath) obj.Set("fileFromPath", filesystem.NewFileFromPath)
obj.Set("fileFromBytes", filesystem.NewFileFromBytes) obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart) obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
@@ -728,7 +774,11 @@ func filesystemBinds(vm *goja.Runtime) {
}) })
} }
func filepathBinds(vm *goja.Runtime) { // BindFilepath registers $filepath.* namespaced object with
// common std Go filepath package related exports.
//
// See https://pocketbase.io/jsvm/modules/_filepath.html.
func BindFilepath(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$filepath", obj) vm.Set("$filepath", obj)
@@ -749,7 +799,11 @@ func filepathBinds(vm *goja.Runtime) {
obj.Set("walkDir", filepath.WalkDir) obj.Set("walkDir", filepath.WalkDir)
} }
func osBinds(vm *goja.Runtime) { // BindOS registers $os.* namespaced object with
// common std Go os package related exports.
//
// See https://pocketbase.io/jsvm/modules/_os.html.
func BindOS(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$os", obj) vm.Set("$os", obj)
@@ -775,19 +829,32 @@ func osBinds(vm *goja.Runtime) {
obj.Set("openInRoot", os.OpenInRoot) obj.Set("openInRoot", os.OpenInRoot)
} }
func formsBinds(vm *goja.Runtime) { // BindForms registers various application form constructors.
// These bindings are mostly used internally and/or preserved for backward compatibility with earlier versions.
func BindForms(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate) registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert) registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend) registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend)
registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem) registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem)
} }
func apisBinds(vm *goja.Runtime) { // BindApis registers $apis.* namespaced object with reusable Web API
// handlers, middlewares and other related helpers.
//
// See https://pocketbase.io/jsvm/modules/_apis.html.
func BindApis(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$apis", obj) vm.Set("$apis", obj)
obj.Set("static", func(dir string, indexFallback bool) func(*core.RequestEvent) error { obj.Set("static", func(dirOrFS any, indexFallback bool) func(*core.RequestEvent) error {
return apis.Static(os.DirFS(dir), indexFallback) switch v := dirOrFS.(type) {
case fs.FS:
return apis.Static(v, indexFallback)
case string:
return apis.Static(os.DirFS(v), indexFallback)
default:
panic("$apis.static expects the first argument to be either a plain string path or fs.FS value")
}
}) })
// middlewares // middlewares
@@ -814,7 +881,11 @@ func apisBinds(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError) registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError)
} }
func httpClientBinds(vm *goja.Runtime) { // BindHTTP registers $http.* namespaced object with common utils
// for sending HTTP requests.
//
// See https://pocketbase.io/jsvm/modules/_http.html.
func BindHTTP(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$http", obj) vm.Set("$http", obj)
@@ -1100,12 +1171,19 @@ var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
// on the specified "shape". // on the specified "shape".
// //
// The "shape" values are used as defaults and could be of type: // The "shape" values are used as defaults and could be of type:
// - int (ex. 0) //
// - float (ex. -0) // - int64 (ex.: 0)
// - string (ex. "") // - *int64 (ex.: nullInt())
// - bool (ex. false) // - float64 (ex.: -0)
// - slice (ex. []) // - *float64 (ex.: nullFloat())
// - map (ex. map[string]any{}) // - string (ex.: "")
// - *string (ex.: nullString())
// - bool (ex.: false)
// - *bool (ex.: nullBool())
// - slice/arr (ex.: [])
// - *slice/arr (ex.: nullArray())
// - map (ex.: {})
// - *map (ex.: nullObject())
// //
// Example: // Example:
// //
@@ -1141,6 +1219,9 @@ func newDynamicModel(shape map[string]any) any {
newV.Scan(raw) newV.Scan(raw)
v = newV v = newV
vt = reflect.TypeOf(newV) vt = reflect.TypeOf(newV)
case reflect.Pointer:
// for pointers always fallback to nil as their default value
v = nil
} }
hash.WriteString(k) hash.WriteString(k)
@@ -1169,6 +1250,9 @@ func newDynamicModel(shape map[string]any) any {
// load default values into the new model // load default values into the new model
for i, item := range info { for i, item := range info {
if item.value == nil {
continue
}
elem.Field(i).Set(reflect.ValueOf(item.value)) elem.Field(i).Set(reflect.ValueOf(item.value))
} }
+200 -121
View File
@@ -9,6 +9,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -42,16 +43,16 @@ func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T)
// note: this test is useful as a reminder to update the tests in case // note: this test is useful as a reminder to update the tests in case
// a new base binding is added. // a new base binding is added.
func TestBaseBindsCount(t *testing.T) { func TestBindCoreCount(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
testBindsCount(vm, "this", 35, t) testBindsCount(vm, "this", 41, t)
} }
func TestBaseBindsSleep(t *testing.T) { func TestBindCoreSleep(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("reader", strings.NewReader("test")) vm.Set("reader", strings.NewReader("test"))
start := time.Now() start := time.Now()
@@ -68,9 +69,9 @@ func TestBaseBindsSleep(t *testing.T) {
} }
} }
func TestBaseBindsReaderToString(t *testing.T) { func TestBindCoreReaderToString(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("reader", strings.NewReader("test")) vm.Set("reader", strings.NewReader("test"))
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -85,9 +86,9 @@ func TestBaseBindsReaderToString(t *testing.T) {
} }
} }
func TestBaseBindsToString(t *testing.T) { func TestBindCoreToString(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("scenarios", []struct { vm.Set("scenarios", []struct {
Name string Name string
Value any Value any
@@ -119,9 +120,9 @@ func TestBaseBindsToString(t *testing.T) {
} }
} }
func TestBaseBindsToBytes(t *testing.T) { func TestBindCoreToBytes(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("bytesEqual", bytes.Equal) vm.Set("bytesEqual", bytes.Equal)
vm.Set("scenarios", []struct { vm.Set("scenarios", []struct {
Name string Name string
@@ -159,9 +160,9 @@ func TestBaseBindsToBytes(t *testing.T) {
} }
} }
func TestBaseBindsUnmarshal(t *testing.T) { func TestBindCoreUnmarshal(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("data", &map[string]any{"a": 123}) vm.Set("data", &map[string]any{"a": 123})
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -180,9 +181,9 @@ func TestBaseBindsUnmarshal(t *testing.T) {
} }
} }
func TestBaseBindsContext(t *testing.T) { func TestBindCoreContext(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const base = new Context(null, "a", 123); const base = new Context(null, "a", 123);
@@ -204,9 +205,9 @@ func TestBaseBindsContext(t *testing.T) {
} }
} }
func TestBaseBindsCookie(t *testing.T) { func TestBindCoreCookie(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const cookie = new Cookie({ const cookie = new Cookie({
@@ -233,9 +234,9 @@ func TestBaseBindsCookie(t *testing.T) {
} }
} }
func TestBaseBindsSubscriptionMessage(t *testing.T) { func TestBindCoreSubscriptionMessage(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("bytesToString", func(b []byte) string { vm.Set("bytesToString", func(b []byte) string {
return string(b) return string(b)
}) })
@@ -261,7 +262,7 @@ func TestBaseBindsSubscriptionMessage(t *testing.T) {
} }
} }
func TestBaseBindsRecord(t *testing.T) { func TestBindCoreRecord(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -271,7 +272,7 @@ func TestBaseBindsRecord(t *testing.T) {
} }
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("collection", collection) vm.Set("collection", collection)
// without record data // without record data
@@ -307,9 +308,9 @@ func TestBaseBindsRecord(t *testing.T) {
} }
} }
func TestBaseBindsCollection(t *testing.T) { func TestBindCoreCollection(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`) v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`)
if err != nil { if err != nil {
@@ -335,9 +336,9 @@ func TestBaseBindsCollection(t *testing.T) {
} }
} }
func TestBaseBindsFieldsList(t *testing.T) { func TestBindCoreFieldsList(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`) v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`)
if err != nil { if err != nil {
@@ -354,9 +355,9 @@ func TestBaseBindsFieldsList(t *testing.T) {
} }
} }
func TestBaseBindsField(t *testing.T) { func TestBindCoreField(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`) v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`)
if err != nil { if err != nil {
@@ -378,11 +379,11 @@ func isType[T any](v any) bool {
return ok return ok
} }
func TestBaseBindsNamedFields(t *testing.T) { func TestBindCoreNamedFields(t *testing.T) {
t.Parallel() t.Parallel()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -469,9 +470,9 @@ func TestBaseBindsNamedFields(t *testing.T) {
} }
} }
func TestBaseBindsMailerMessage(t *testing.T) { func TestBindCoreMailerMessage(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
v, err := vm.RunString(`new MailerMessage({ v, err := vm.RunString(`new MailerMessage({
from: {name: "test_from", address: "test_from@example.com"}, from: {name: "test_from", address: "test_from@example.com"},
@@ -516,9 +517,9 @@ func TestBaseBindsMailerMessage(t *testing.T) {
} }
} }
func TestBaseBindsCommand(t *testing.T) { func TestBindCoreCommand(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
let runCalls = 0; let runCalls = 0;
@@ -545,9 +546,9 @@ func TestBaseBindsCommand(t *testing.T) {
} }
} }
func TestBaseBindsRequestInfo(t *testing.T) { func TestBindCoreRequestInfo(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const info = new RequestInfo({ const info = new RequestInfo({
@@ -563,9 +564,9 @@ func TestBaseBindsRequestInfo(t *testing.T) {
} }
} }
func TestBaseBindsMiddleware(t *testing.T) { func TestBindCoreMiddleware(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const m = new Middleware( const m = new Middleware(
@@ -583,9 +584,9 @@ func TestBaseBindsMiddleware(t *testing.T) {
} }
} }
func TestBaseBindsTimezone(t *testing.T) { func TestBindCoreTimezone(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const v0 = (new Timezone()).string(); const v0 = (new Timezone()).string();
@@ -608,9 +609,9 @@ func TestBaseBindsTimezone(t *testing.T) {
} }
} }
func TestBaseBindsDateTime(t *testing.T) { func TestBindCoreDateTime(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const now = new DateTime(); const now = new DateTime();
@@ -649,9 +650,9 @@ func TestBaseBindsDateTime(t *testing.T) {
} }
} }
func TestBaseBindsValidationError(t *testing.T) { func TestBindCoreValidationError(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -696,14 +697,14 @@ func TestBaseBindsValidationError(t *testing.T) {
} }
} }
func TestDbxBinds(t *testing.T) { func TestBindDbx(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
vm.Set("db", app.DB()) vm.Set("db", app.DB())
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
testBindsCount(vm, "$dbx", 15, t) testBindsCount(vm, "$dbx", 15, t)
@@ -791,14 +792,14 @@ func TestDbxBinds(t *testing.T) {
} }
} }
func TestMailsBindsCount(t *testing.T) { func TestBindMailsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
mailsBinds(vm) BindMails(vm)
testBindsCount(vm, "$mails", 5, t) testBindsCount(vm, "$mails", 5, t)
} }
func TestMailsBinds(t *testing.T) { func TestBindMails(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -808,8 +809,8 @@ func TestMailsBinds(t *testing.T) {
} }
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
mailsBinds(vm) BindMails(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("record", record) vm.Set("record", record)
@@ -844,17 +845,17 @@ func TestMailsBinds(t *testing.T) {
} }
} }
func TestSecurityBindsCount(t *testing.T) { func TestBindSecurityCount(t *testing.T) {
vm := goja.New() vm := goja.New()
securityBinds(vm) BindSecurity(vm)
testBindsCount(vm, "$security", 16, t) testBindsCount(vm, "$security", 16, t)
} }
func TestSecurityCryptoBinds(t *testing.T) { func TestSecurityCryptoBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
sceneraios := []struct { sceneraios := []struct {
js string js string
@@ -887,8 +888,8 @@ func TestSecurityCryptoBinds(t *testing.T) {
func TestSecurityRandomStringBinds(t *testing.T) { func TestSecurityRandomStringBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
sceneraios := []struct { sceneraios := []struct {
js string js string
@@ -963,8 +964,8 @@ func TestSecurityJWTBinds(t *testing.T) {
for _, s := range sceneraios { for _, s := range sceneraios {
t.Run(s.name, func(t *testing.T) { t.Run(s.name, func(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
_, err := vm.RunString(s.js) _, err := vm.RunString(s.js)
if err != nil { if err != nil {
@@ -976,8 +977,8 @@ func TestSecurityJWTBinds(t *testing.T) {
func TestSecurityEncryptAndDecryptBinds(t *testing.T) { func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
securityBinds(vm) BindSecurity(vm)
_, err := vm.RunString(` _, err := vm.RunString(`
const key = "abcdabcdabcdabcdabcdabcdabcdabcd" const key = "abcdabcdabcdabcdabcdabcdabcdabcd"
@@ -995,7 +996,7 @@ func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
} }
} }
func TestFilesystemBinds(t *testing.T) { func TestBindFilesystem(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@@ -1008,14 +1009,47 @@ func TestFilesystemBinds(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
tmpDir, err := os.MkdirTemp("", "jsvm")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
vm := goja.New() vm := goja.New()
vm.Set("mh", &multipart.FileHeader{Filename: "test"}) vm.Set("mh", &multipart.FileHeader{Filename: "test"})
vm.Set("tmpDir", tmpDir)
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db")) vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
vm.Set("baseURL", srv.URL) vm.Set("baseURL", srv.URL)
baseBinds(vm) BindCore(vm)
filesystemBinds(vm) BindFilesystem(vm)
testBindsCount(vm, "$filesystem", 4, t) testBindsCount(vm, "$filesystem", 6, t)
// s3
{
v, err := vm.RunString(`$filesystem.s3("bucketName", "region", "endpoint", "accessKey", "secretKey", true)`)
if err != nil {
t.Fatal(err)
}
fsys, ok := v.Export().(*filesystem.System)
if !ok {
t.Fatalf("[s3] Expected System instance got %v", fsys)
}
}
// local
{
v, err := vm.RunString(`$filesystem.local(tmpDir)`)
if err != nil {
t.Fatal(err)
}
fsys, ok := v.Export().(*filesystem.System)
if !ok {
t.Fatalf("[s3] Expected System instance got %v", fsys)
}
}
// fileFromPath // fileFromPath
{ {
@@ -1082,24 +1116,24 @@ func TestFilesystemBinds(t *testing.T) {
} }
} }
func TestFormsBinds(t *testing.T) { func TestBindForms(t *testing.T) {
vm := goja.New() vm := goja.New()
formsBinds(vm) BindForms(vm)
testBindsCount(vm, "this", 4, t) testBindsCount(vm, "this", 4, t)
} }
func TestApisBindsCount(t *testing.T) { func TestBindApisCount(t *testing.T) {
vm := goja.New() vm := goja.New()
apisBinds(vm) BindApis(vm)
testBindsCount(vm, "this", 8, t) testBindsCount(vm, "this", 8, t)
testBindsCount(vm, "$apis", 11, t) testBindsCount(vm, "$apis", 11, t)
} }
func TestApisBindsApiError(t *testing.T) { func TestBindApisErrors(t *testing.T) {
vm := goja.New() vm := goja.New()
apisBinds(vm) BindApis(vm)
scenarios := []struct { scenarios := []struct {
js string js string
@@ -1156,50 +1190,95 @@ func TestLoadingDynamicModel(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
let result = new DynamicModel({ let result = new DynamicModel({
text: "", string: "",
bool: false, nullString: nullString(),
number: 0, nullStringEmpty: nullString(),
select_many: [],
json: [], bool: false,
// custom map-like field nullBool: nullBool(),
obj: {}, nullBoolEmpty: nullBool(),
int: 0,
nullInt: nullInt(),
nullIntEmpty: nullInt(),
float: -0,
nullFloat: nullFloat(),
nullFloatEmpty: nullFloat(),
array: [],
nullArray: nullArray(),
nullArrayEmpty: nullArray(),
object: {},
nullObject: nullObject(),
nullObjectEmpty: nullObject(),
}) })
const expectations = {
"string": "a",
"nullString": "b",
"nullStringEmpty": null,
"bool": false,
"nullBool": true,
"nullBoolEmpty": null,
"int": 1,
"nullInt": 2,
"nullIntEmpty": null,
"float": 1.1,
"nullFloat": 1.2,
"nullFloatEmpty": null,
"array": [1,2],
"nullArray": [3,4],
"nullArrayEmpty": null,
"object": {a:1},
"nullObject": {a:2},
"nullObjectEmpty": null,
};
// construct dummy SELECT column value literals based on the expectations
const selectColumns = [];
for (const col in expectations) {
const val = expectations[col]
if (val === null) {
selectColumns.push("null as [[" + col + "]]")
} else if (typeof val === "string") {
selectColumns.push("'" + val + "' as [[" + col + "]]")
} else if (typeof val === "object") {
selectColumns.push("'" + JSON.stringify(val) + "' as [[" + col + "]]")
} else {
selectColumns.push(val + " as [[" + col + "]]")
}
}
$app.db() $app.db()
.select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj") .newQuery("SELECT " + selectColumns.join(", "))
.from("demo1")
.where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
.limit(1)
.one(result) .one(result)
if (result.text != "test") { for (const col in expectations) {
throw new Error('Expected text "test", got ' + result.text); let expVal = expectations[col];
} let resVal = result[col];
if (result.bool != true) { if (expVal !== null && typeof expVal === "object") {
throw new Error('Expected bool true, got ' + result.bool); expVal = JSON.stringify(expVal)
} resVal = JSON.stringify(resVal)
}
if (result.number != 123456) { if (expVal != resVal) {
throw new Error('Expected number 123456, got ' + result.number); throw new Error("Expected '" + col + "' value " + expVal + ", got " + resVal);
} }
if (result.select_many.length != 2 || result.select_many[0] != "optionB" || result.select_many[1] != "optionC") {
throw new Error('Expected select_many ["optionB", "optionC"], got ' + result.select_many);
}
if (result.json.length != 3 || result.json[0] != 1 || result.json[1] != 2 || result.json[2] != 3) {
throw new Error('Expected json [1, 2, 3], got ' + result.json);
}
if (result.obj.get("test") != 1) {
throw new Error('Expected obj.get("test") 1, got ' + JSON.stringify(result.obj));
} }
`) `)
if err != nil { if err != nil {
@@ -1212,8 +1291,8 @@ func TestDynamicModelMapFieldCaching(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1271,8 +1350,8 @@ func TestLoadingArrayOf(t *testing.T) {
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1312,18 +1391,18 @@ func TestLoadingArrayOf(t *testing.T) {
} }
} }
func TestHttpClientBindsCount(t *testing.T) { func TestBindHTTPCount(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
vm := goja.New() vm := goja.New()
httpClientBinds(vm) BindHTTP(vm)
testBindsCount(vm, "this", 2, t) // + FormData testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t) testBindsCount(vm, "$http", 1, t)
} }
func TestHttpClientBindsSend(t *testing.T) { func TestBindHTTPSend(t *testing.T) {
t.Parallel() t.Parallel()
// start a test server // start a test server
@@ -1368,8 +1447,8 @@ func TestHttpClientBindsSend(t *testing.T) {
defer server.Close() defer server.Close()
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
httpClientBinds(vm) BindHTTP(vm)
vm.Set("testURL", server.URL) vm.Set("testURL", server.URL)
_, err := vm.RunString(` _, err := vm.RunString(`
@@ -1547,7 +1626,7 @@ func TestHooksBinds(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("result", result) vm.Set("result", result)
return vm return vm
@@ -1633,7 +1712,7 @@ func TestHooksExceptionUnwrapping(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("goErr", goErr) vm.Set("goErr", goErr)
return vm return vm
@@ -1687,8 +1766,8 @@ func TestRouterBinds(t *testing.T) {
vmFactory := func() *goja.Runtime { vmFactory := func() *goja.Runtime {
vm := goja.New() vm := goja.New()
baseBinds(vm) BindCore(vm)
apisBinds(vm) BindApis(vm)
vm.Set("$app", app) vm.Set("$app", app)
vm.Set("result", result) vm.Set("result", result)
return vm return vm
@@ -1776,16 +1855,16 @@ func TestRouterBinds(t *testing.T) {
} }
} }
func TestFilepathBindsCount(t *testing.T) { func TestBindFilepathCount(t *testing.T) {
vm := goja.New() vm := goja.New()
filepathBinds(vm) BindFilepath(vm)
testBindsCount(vm, "$filepath", 15, t) testBindsCount(vm, "$filepath", 15, t)
} }
func TestOsBindsCount(t *testing.T) { func TestBindOSCount(t *testing.T) {
vm := goja.New() vm := goja.New()
osBinds(vm) BindOS(vm)
testBindsCount(vm, "$os", 20, t) testBindsCount(vm, "$os", 20, t)
} }
File diff suppressed because it is too large. Load diff
+160 -50
View File
@@ -254,32 +254,106 @@ declare function sleep(milliseconds: number): void;
*/ */
declare function arrayOf<T>(model: T): Array<T>; declare function arrayOf<T>(model: T): Array<T>;
/**
* unmarshal clones and merges the data argument on top of dst.
*
* This method is rarely used directly by the users and it is most
* commonly used in the autogenerated migrations.
*
* To an extent it is similar to the JS native ` + "`" + `Object.assign` + "`" + `
* but the arguments are reversed and it invokes the Go standard
* ` + "`" + `json.Marshal/Unmarshal` + "`" + ` methods under the hood.
*
* The data argument could be anything serializable, usually a plain object (map).
* The dst argument could be any pointer value, usually a model instance.
*
* Example:
*
* ` + "```" + `js
* unmarshal({ authAlert: { enabled: true } }, collection)
* ` + "```" + `
*
* @group PocketBase
*/
declare function unmarshal(data: any, dst: any): void;
/** /**
* DynamicModel creates a new dynamic model with fields from the provided data shape. * DynamicModel creates a new dynamic model with fields from the provided data shape.
* *
* Caveats: * Caveats:
* - In order to use 0 as double/float initialization number you have to negate it (` + "`-0`" + `). * - In order to use 0 as double/float initialization number you have to negate it (` + "`-0`" + `).
* - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + `). * - You need to use lowerCamelCase when accessing the model fields (e.g. ` + "`model.roles`" + ` and not ` + "`model.Roles`" + ` even if in the model shape and in the DB table the column is capitalized).
* - Objects are loaded into types.JSONMap, meaning that they need to be accessed with ` + "`get(key)`" + ` (e.g. ` + "`model.meta.get('something')`" + `).
* - For describing nullable types you can use the ` + "`null*()`" + ` helpers - ` + "`nullString()`" + `, ` + "`nullInt()`" + `, ` + "`nullFloat()`" + `, ` + "`nullBool()`" + `, ` + "`nullArray()`" + `, ` + "`nullObject()`" + `.
* *
* Example: * Example:
* *
* ` + "```" + `js * ` + "```" + `js
* const model = new DynamicModel({ * const model = new DynamicModel({
* name: "" * name: "" // or nullString() if nullable
* age: 0, // int64 * age: 0, // or nullInt() if nullable
* totalSpent: -0, // float64 * totalSpent: -0, // or nullFloat() if nullable
* active: false, * active: false, // or nullBool() if nullable
* Roles: [], // maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles" * Roles: [], // or nullArray() if nullable; maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
* meta: {} * meta: {}, // or nullObject() if nullable
* }) * })
* ` + "```" + ` * ` + "```" + `
* *
* @group PocketBase * @group PocketBase
*/ */
declare class DynamicModel { declare class DynamicModel {
[key: string]: any;
constructor(shape?: { [key:string]: any }) constructor(shape?: { [key:string]: any })
} }
/**
* nullString creates an empty Go string pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` string value.
*
* @group PocketBase
*/
declare function nullString(): string;
/**
* nullInt creates an empty Go int64 pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` int value.
*
* @group PocketBase
*/
declare function nullInt(): number;
/**
* nullFloat creates an empty Go float64 pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` float value.
*
* @group PocketBase
*/
declare function nullFloat(): number;
/**
* nullBool creates an empty Go bool pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` bool value.
*
* @group PocketBase
*/
declare function nullBool(): boolean;
/**
* nullArray creates an empty Go types.JSONArray pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` JSON array value.
*
* @group PocketBase
*/
declare function nullArray(): Array<any>;
/**
* nullObject creates an empty Go types.JSONMap pointer usually used for
* describing a **nullable** ` + "`DynamicModel`" + ` JSON object value.
*
* @group PocketBase
*/
declare function nullObject(): { get(key:string):any; set(key:string,value:any):void };
interface Context extends context.Context{} // merge interface Context extends context.Context{} // merge
/** /**
* Context creates a new empty Go context.Context. * Context creates a new empty Go context.Context.
@@ -819,17 +893,17 @@ declare namespace $security {
/** /**
* {@inheritDoc security.newJWT} * {@inheritDoc security.newJWT}
*/ */
export function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
/** /**
* {@inheritDoc security.parseUnverifiedJWT} * {@inheritDoc security.parseUnverifiedJWT}
*/ */
export function parseUnverifiedJWT(token: string): _TygojaDict function parseUnverifiedJWT(token: string): _TygojaDict
/** /**
* {@inheritDoc security.parseJWT} * {@inheritDoc security.parseJWT}
*/ */
export function parseJWT(token: string, verificationKey: string): _TygojaDict function parseJWT(token: string, verificationKey: string): _TygojaDict
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -847,6 +921,24 @@ declare namespace $filesystem {
let fileFromBytes: filesystem.newFileFromBytes let fileFromBytes: filesystem.newFileFromBytes
let fileFromMultipart: filesystem.newFileFromMultipart let fileFromMultipart: filesystem.newFileFromMultipart
/**
* Initializes a new S3-only filesystem instance
* (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it).
*
* Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will
* construct a local or S3 filesystem based on the configured application settings.
*/
let s3: filesystem.newS3
/**
* Initializes a new local-only filesystem instance
* (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it).
*
* Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will
* construct a local or S3 filesystem based on the configured application settings.
*/
let local: filesystem.newLocal
/** /**
* fileFromURL creates a new File from the provided url by * fileFromURL creates a new File from the provided url by
* downloading the resource and creating a BytesReader. * downloading the resource and creating a BytesReader.
@@ -861,7 +953,7 @@ declare namespace $filesystem {
* const file2 = $filesystem.fileFromURL("https://...", 15) * const file2 = $filesystem.fileFromURL("https://...", 15)
* ` + "```" + ` * ` + "```" + `
*/ */
export function fileFromURL(url: string, secTimeout?: number): filesystem.File function fileFromURL(url: string, secTimeout?: number): filesystem.File
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -875,21 +967,21 @@ declare namespace $filesystem {
* @group PocketBase * @group PocketBase
*/ */
declare namespace $filepath { declare namespace $filepath {
export let base: filepath.base let base: filepath.base
export let clean: filepath.clean let clean: filepath.clean
export let dir: filepath.dir let dir: filepath.dir
export let ext: filepath.ext let ext: filepath.ext
export let fromSlash: filepath.fromSlash let fromSlash: filepath.fromSlash
export let glob: filepath.glob let glob: filepath.glob
export let isAbs: filepath.isAbs let isAbs: filepath.isAbs
export let join: filepath.join let join: filepath.join
export let match: filepath.match let match: filepath.match
export let rel: filepath.rel let rel: filepath.rel
export let split: filepath.split let split: filepath.split
export let splitList: filepath.splitList let splitList: filepath.splitList
export let toSlash: filepath.toSlash let toSlash: filepath.toSlash
export let walk: filepath.walk let walk: filepath.walk
export let walkDir: filepath.walkDir let walkDir: filepath.walkDir
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -906,7 +998,7 @@ declare namespace $os {
/** /**
* Legacy alias for $os.cmd(). * Legacy alias for $os.cmd().
*/ */
export let exec: exec.command let exec: exec.command
/** /**
* Prepares an external OS command. * Prepares an external OS command.
@@ -921,30 +1013,30 @@ declare namespace $os {
* const output = toString(cmd.output()); * const output = toString(cmd.output());
* ` + "```" + ` * ` + "```" + `
*/ */
export let cmd: exec.command let cmd: exec.command
/** /**
* Args hold the command-line arguments, starting with the program name. * Args hold the command-line arguments, starting with the program name.
*/ */
export let args: Array<string> let args: Array<string>
export let exit: os.exit let exit: os.exit
export let getenv: os.getenv let getenv: os.getenv
export let dirFS: os.dirFS let dirFS: os.dirFS
export let readFile: os.readFile let readFile: os.readFile
export let writeFile: os.writeFile let writeFile: os.writeFile
export let stat: os.stat let stat: os.stat
export let readDir: os.readDir let readDir: os.readDir
export let tempDir: os.tempDir let tempDir: os.tempDir
export let truncate: os.truncate let truncate: os.truncate
export let getwd: os.getwd let getwd: os.getwd
export let mkdir: os.mkdir let mkdir: os.mkdir
export let mkdirAll: os.mkdirAll let mkdirAll: os.mkdirAll
export let rename: os.rename let rename: os.rename
export let remove: os.remove let remove: os.remove
export let removeAll: os.removeAll let removeAll: os.removeAll
export let openRoot: os.openRoot let openRoot: os.openRoot
export let openInRoot: os.openInRoot let openInRoot: os.openInRoot
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -1070,10 +1162,28 @@ declare namespace $apis {
/** /**
* Route handler to serve static directory content (html, js, css, etc.). * Route handler to serve static directory content (html, js, css, etc.).
* *
* If a file resource is missing and indexFallback is set, the request * If a file resource is missing and indexFallback is true, the request
* will be forwarded to the base index.html (useful for SPA). * will be forwarded to the base index.html (useful for SPA with pretty urls).
*
* NB! Expects the route to have a "{path...}" wildcard parameter.
*
* Special redirects:
*
* - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
* - if "path" is a directory that has index.html, the index.html file is rendered,
* otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
*
* Example:
*
* ` + "```" + `js
* // serves static files from the provided dir string path (it will be wrapped in $os.dirFS())
* routerAdd("GET", "/{path...}", $apis.static("/path/to/public", false))
*
* // serves static files from the explicit fs.FS value ($os.dirFS(), $os.openRoot().fs(), etc.)
* routerAdd("GET", "/{path...}", $apis.static($os.dirFS("/path/to/public"), false))
* ` + "```" + `
*/ */
export function static(dir: string, indexFallback: boolean): (e: core.RequestEvent) => void function static(dirOrFS: string|fs.FS, indexFallback: boolean): (e: core.RequestEvent) => void
let requireGuestOnly: apis.requireGuestOnly let requireGuestOnly: apis.requireGuestOnly
let requireAuth: apis.requireAuth let requireAuth: apis.requireAuth
@@ -1095,7 +1205,7 @@ declare namespace $apis {
* Set authMethod to empty string if you want to ignore the MFA checks and the login alerts * Set authMethod to empty string if you want to ignore the MFA checks and the login alerts
* (can be also adjusted additionally via the onRecordAuthRequest hook). * (can be also adjusted additionally via the onRecordAuthRequest hook).
*/ */
export function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+25 -22
View File
@@ -1,6 +1,9 @@
// Package jsvm implements pluggable utilities for binding a JS goja runtime // Package jsvm implements pluggable utilities for binding a JS goja runtime
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.). // to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
// //
// The package also exports several reusable bindings so that users
// can utilize them as part of their own custom goja runtime setup.
//
// Example: // Example:
// //
// jsvm.MustRegister(app, jsvm.Config{ // jsvm.MustRegister(app, jsvm.Config{
@@ -200,15 +203,15 @@ func (p *plugin) registerMigrations() error {
process.Enable(vm) process.Enable(vm)
buffer.Enable(vm) buffer.Enable(vm)
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
securityBinds(vm) BindSecurity(vm)
osBinds(vm) BindOS(vm)
filepathBinds(vm) BindFilepath(vm)
httpClientBinds(vm) BindHTTP(vm)
filesystemBinds(vm) BindFilesystem(vm)
formsBinds(vm) BindForms(vm)
mailsBinds(vm) BindMails(vm)
vm.Set("$template", templateRegistry) vm.Set("$template", templateRegistry)
vm.Set("__hooks", absHooksDir) vm.Set("__hooks", absHooksDir)
@@ -288,16 +291,16 @@ func (p *plugin) registerHooks() error {
process.Enable(vm) process.Enable(vm)
buffer.Enable(vm) buffer.Enable(vm)
baseBinds(vm) BindCore(vm)
dbxBinds(vm) BindDbx(vm)
filesystemBinds(vm) BindSecurity(vm)
securityBinds(vm) BindOS(vm)
osBinds(vm) BindFilepath(vm)
filepathBinds(vm) BindHTTP(vm)
httpClientBinds(vm) BindFilesystem(vm)
formsBinds(vm) BindForms(vm)
apisBinds(vm) BindMails(vm)
mailsBinds(vm) BindApis(vm)
vm.Set("$app", p.app) vm.Set("$app", p.app)
vm.Set("$template", templateRegistry) vm.Set("$template", templateRegistry)
@@ -377,7 +380,7 @@ func (p *plugin) watchHooks() error {
if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink { if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
watchDir, err = filepath.EvalSymlinks(p.config.HooksDir) watchDir, err = filepath.EvalSymlinks(p.config.HooksDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to resolve hooksDir symink: %w", err) return fmt.Errorf("failed to resolve hooksDir symlink: %w", err)
} }
} }
@@ -440,8 +443,8 @@ func (p *plugin) watchHooks() error {
// //
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18) // @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error { dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error {
// ignore hidden directories, node_modules, symlinks, sockets, etc. // skip access failures, hidden directories, node_modules, etc.
if !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") { if err != nil || !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
return nil return nil
} }
+32
View File
@@ -50,6 +50,7 @@ migrate((app) => {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -64,6 +65,7 @@ migrate((app) => {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -77,6 +79,7 @@ migrate((app) => {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -91,6 +94,7 @@ migrate((app) => {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
@@ -101,6 +105,7 @@ migrate((app) => {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -110,6 +115,7 @@ migrate((app) => {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "verified", "name": "verified",
@@ -226,6 +232,7 @@ func init() {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -240,6 +247,7 @@ func init() {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -253,6 +261,7 @@ func init() {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -267,6 +276,7 @@ func init() {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
@@ -277,6 +287,7 @@ func init() {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -286,6 +297,7 @@ func init() {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "verified", "name": "verified",
@@ -491,6 +503,7 @@ migrate((app) => {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -505,6 +518,7 @@ migrate((app) => {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -518,6 +532,7 @@ migrate((app) => {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -532,6 +547,7 @@ migrate((app) => {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
@@ -542,6 +558,7 @@ migrate((app) => {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -551,6 +568,7 @@ migrate((app) => {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool256245529", "id": "bool256245529",
"name": "verified", "name": "verified",
@@ -670,6 +688,7 @@ func init() {
"fields": [ "fields": [
{ {
"autogeneratePattern": "[a-z0-9]{15}", "autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false, "hidden": false,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 15, "max": 15,
@@ -684,6 +703,7 @@ func init() {
}, },
{ {
"cost": 0, "cost": 0,
"help": "",
"hidden": true, "hidden": true,
"id": "password@TEST_RANDOM", "id": "password@TEST_RANDOM",
"max": 0, "max": 0,
@@ -697,6 +717,7 @@ func init() {
}, },
{ {
"autogeneratePattern": "[a-zA-Z0-9]{50}", "autogeneratePattern": "[a-zA-Z0-9]{50}",
"help": "",
"hidden": true, "hidden": true,
"id": "text@TEST_RANDOM", "id": "text@TEST_RANDOM",
"max": 60, "max": 60,
@@ -711,6 +732,7 @@ func init() {
}, },
{ {
"exceptDomains": null, "exceptDomains": null,
"help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
@@ -721,6 +743,7 @@ func init() {
"type": "email" "type": "email"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool@TEST_RANDOM", "id": "bool@TEST_RANDOM",
"name": "emailVisibility", "name": "emailVisibility",
@@ -730,6 +753,7 @@ func init() {
"type": "bool" "type": "bool"
}, },
{ {
"help": "",
"hidden": false, "hidden": false,
"id": "bool256245529", "id": "bool256245529",
"name": "verified", "name": "verified",
@@ -923,6 +947,7 @@ migrate((app) => {
// add field // add field
collection.fields.addAt(8, new Field({ collection.fields.addAt(8, new Field({
"autogeneratePattern": "", "autogeneratePattern": "",
"help": "",
"hidden": false, "hidden": false,
"id": "f4_id", "id": "f4_id",
"max": 0, "max": 0,
@@ -938,6 +963,7 @@ migrate((app) => {
// update field // update field
collection.fields.addAt(7, new Field({ collection.fields.addAt(7, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -976,6 +1002,7 @@ migrate((app) => {
// add field // add field
collection.fields.addAt(8, new Field({ collection.fields.addAt(8, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f3_id", "id": "f3_id",
"name": "f3_name", "name": "f3_name",
@@ -990,6 +1017,7 @@ migrate((app) => {
// update field // update field
collection.fields.addAt(7, new Field({ collection.fields.addAt(7, new Field({
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -1054,6 +1082,7 @@ func init() {
// add field // add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"autogeneratePattern": "", "autogeneratePattern": "",
"help": "",
"hidden": false, "hidden": false,
"id": "f4_id", "id": "f4_id",
"max": 0, "max": 0,
@@ -1071,6 +1100,7 @@ func init() {
// update field // update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
@@ -1116,6 +1146,7 @@ func init() {
// add field // add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f3_id", "id": "f3_id",
"name": "f3_name", "name": "f3_name",
@@ -1132,6 +1163,7 @@ func init() {
// update field // update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"help": "",
"hidden": false, "hidden": false,
"id": "f2_id", "id": "f2_id",
"max": null, "max": null,
+2 -1
View File
@@ -232,7 +232,8 @@ func (scenario *ApiScenario) test(t testing.TB) {
// set scenario headers // set scenario headers
for k, v := range scenario.Headers { for k, v := range scenario.Headers {
req.Header.Set(k, v) // trim whitespaces for consistency with the net/http request parsing
req.Header.Set(k, strings.TrimSpace(v))
} }
// execute request // execute request
Binary file not shown.
+2
View File
@@ -36,6 +36,8 @@ func NewAppleProvider() *Apple {
return &Apple{ return &Apple{
BaseProvider: BaseProvider{ BaseProvider: BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 1,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="315" preserveAspectRatio="xMidYMid"><path d="M213.8 167c.4 47.6 41.7 63.4 42.2 63.6-.3 1.2-6.6 22.6-21.8 44.8-13 19.1-26.7 38.2-48 38.6-21.1.4-28-12.5-52-12.5s-31.6 12.1-51.5 12.9c-20.7.8-36.4-20.7-49.6-39.8-27-39-47.7-110.3-20-158.4a77 77 0 0 1 65.1-39.4c20.3-.4 39.5 13.6 51.9 13.6s35.7-16.9 60.2-14.4c10.2.4 39 4.2 57.5 31.2-1.5 1-34.4 20-34 59.8M174.2 50.2A69 69 0 0 0 190.6 0c-15.8.6-35 10.5-46.3 23.8-10.2 11.8-19.1 30.6-16.7 48.7 17.6 1.3 35.7-9 46.6-22.3"/></svg>`,
displayName: "Apple", displayName: "Apple",
pkce: true, pkce: true,
scopes: []string{"name", "email"}, scopes: []string{"name", "email"},
+21 -2
View File
@@ -28,8 +28,20 @@ func NewProviderByName(name string) (Provider, error) {
return factory(), nil return factory(), nil
} }
// @todo refactor and consider replace with a plain struct
//
// Provider defines a common interface for an OAuth2 client. // Provider defines a common interface for an OAuth2 client.
type Provider interface { type Provider interface {
// @todo temp backport
//
// Order returns the sorting order of the provider usually used in the auth methods list response.
Logo() string
// @todo temp backport
//
// Order returns the sorting order of the provider usually used in the auth methods list response.
Order() int
// Context returns the context associated with the provider (if any). // Context returns the context associated with the provider (if any).
Context() context.Context Context() context.Context
@@ -93,7 +105,7 @@ type Provider interface {
SetUserInfoURL(url string) SetUserInfoURL(url string)
// Extra returns a shallow copy of any custom config data // Extra returns a shallow copy of any custom config data
// that the provider may be need. // that the provider may need.
Extra() map[string]any Extra() map[string]any
// SetExtra updates the provider's custom config data. // SetExtra updates the provider's custom config data.
@@ -133,11 +145,18 @@ type AuthUser struct {
Id string `json:"id"` Id string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatarURL"` AvatarURL string `json:"avatarURL"`
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"` RefreshToken string `json:"refreshToken"`
// @todo consider assigning the non-verified email and combining
// with an extra Verified bool flag.
// The VERIFIED OAuth2 account email.
//
// It must be empty if the provider is not able to verify the email ownership.
Email string `json:"email"`
// @todo // @todo
// deprecated: use AvatarURL instead // deprecated: use AvatarURL instead
// AvatarUrl will be removed after dropping v0.22 support // AvatarUrl will be removed after dropping v0.22 support
+14 -2
View File
@@ -13,16 +13,28 @@ import (
// BaseProvider defines common fields and methods used by OAuth2 client providers. // BaseProvider defines common fields and methods used by OAuth2 client providers.
type BaseProvider struct { type BaseProvider struct {
ctx context.Context ctx context.Context
extra map[string]any
redirectURL string
clientId string clientId string
clientSecret string clientSecret string
displayName string displayName string
redirectURL string logo string
authURL string authURL string
tokenURL string tokenURL string
userInfoURL string userInfoURL string
scopes []string scopes []string
order int
pkce bool pkce bool
extra map[string]any }
// Order implements Provider.Order() interface method.
func (p *BaseProvider) Order() int {
return p.order
}
// Logo implements Provider.Logo() interface method.
func (p *BaseProvider) Logo() string {
return p.logo
} }
// Context implements Provider.Context() interface method. // Context implements Provider.Context() interface method.
+46 -14
View File
@@ -30,14 +30,46 @@ func TestDisplayName(t *testing.T) {
before := b.DisplayName() before := b.DisplayName()
if before != "" { if before != "" {
t.Fatalf("Expected displayName to be empty, got %v", before) t.Fatalf("Expected displayName to be empty, got %q", before)
} }
b.SetDisplayName("test") b.SetDisplayName("test")
after := b.DisplayName() after := b.DisplayName()
if after != "test" { if after != "test" {
t.Fatalf("Expected displayName to be 'test', got %v", after) t.Fatalf("Expected displayName to be %q, got %q", "test", after)
}
}
func TestOrder(t *testing.T) {
b := BaseProvider{}
before := b.Order()
if before != 0 {
t.Fatalf("Expected order to be empty, got %d", before)
}
b.order = 123
after := b.Order()
if after != 123 {
t.Fatalf("Expected order to be %d, got %d", 123, after)
}
}
func TestLogo(t *testing.T) {
b := BaseProvider{}
before := b.Logo()
if before != "" {
t.Fatalf("Expected logo to be empty, got %q", before)
}
b.logo = "test"
after := b.Logo()
if after != "test" {
t.Fatalf("Expected logo to be %q, got %q", "test", after)
} }
} }
@@ -78,14 +110,14 @@ func TestClientId(t *testing.T) {
before := b.ClientId() before := b.ClientId()
if before != "" { if before != "" {
t.Fatalf("Expected clientId to be empty, got %v", before) t.Fatalf("Expected clientId to be empty, got %q", before)
} }
b.SetClientId("test") b.SetClientId("test")
after := b.ClientId() after := b.ClientId()
if after != "test" { if after != "test" {
t.Fatalf("Expected clientId to be 'test', got %v", after) t.Fatalf("Expected clientId to be %q, got %q", "test", after)
} }
} }
@@ -94,14 +126,14 @@ func TestClientSecret(t *testing.T) {
before := b.ClientSecret() before := b.ClientSecret()
if before != "" { if before != "" {
t.Fatalf("Expected clientSecret to be empty, got %v", before) t.Fatalf("Expected clientSecret to be empty, got %q", before)
} }
b.SetClientSecret("test") b.SetClientSecret("test")
after := b.ClientSecret() after := b.ClientSecret()
if after != "test" { if after != "test" {
t.Fatalf("Expected clientSecret to be 'test', got %v", after) t.Fatalf("Expected clientSecret to be %q, got %q", "test", after)
} }
} }
@@ -110,14 +142,14 @@ func TestRedirectURL(t *testing.T) {
before := b.RedirectURL() before := b.RedirectURL()
if before != "" { if before != "" {
t.Fatalf("Expected RedirectURL to be empty, got %v", before) t.Fatalf("Expected RedirectURL to be empty, got %q", before)
} }
b.SetRedirectURL("test") b.SetRedirectURL("test")
after := b.RedirectURL() after := b.RedirectURL()
if after != "test" { if after != "test" {
t.Fatalf("Expected RedirectURL to be 'test', got %v", after) t.Fatalf("Expected RedirectURL to be %q, got %q", "test", after)
} }
} }
@@ -126,14 +158,14 @@ func TestAuthURL(t *testing.T) {
before := b.AuthURL() before := b.AuthURL()
if before != "" { if before != "" {
t.Fatalf("Expected authURL to be empty, got %v", before) t.Fatalf("Expected authURL to be empty, got %q", before)
} }
b.SetAuthURL("test") b.SetAuthURL("test")
after := b.AuthURL() after := b.AuthURL()
if after != "test" { if after != "test" {
t.Fatalf("Expected authURL to be 'test', got %v", after) t.Fatalf("Expected authURL to be %q, got %q", "test", after)
} }
} }
@@ -142,14 +174,14 @@ func TestTokenURL(t *testing.T) {
before := b.TokenURL() before := b.TokenURL()
if before != "" { if before != "" {
t.Fatalf("Expected tokenURL to be empty, got %v", before) t.Fatalf("Expected tokenURL to be empty, got %q", before)
} }
b.SetTokenURL("test") b.SetTokenURL("test")
after := b.TokenURL() after := b.TokenURL()
if after != "test" { if after != "test" {
t.Fatalf("Expected tokenURL to be 'test', got %v", after) t.Fatalf("Expected tokenURL to be %q, got %q", "test", after)
} }
} }
@@ -158,14 +190,14 @@ func TestUserInfoURL(t *testing.T) {
before := b.UserInfoURL() before := b.UserInfoURL()
if before != "" { if before != "" {
t.Fatalf("Expected userInfoURL to be empty, got %v", before) t.Fatalf("Expected userInfoURL to be empty, got %q", before)
} }
b.SetUserInfoURL("test") b.SetUserInfoURL("test")
after := b.UserInfoURL() after := b.UserInfoURL()
if after != "test" { if after != "test" {
t.Fatalf("Expected userInfoURL to be 'test', got %v", after) t.Fatalf("Expected userInfoURL to be %q, got %q", "test", after)
} }
} }
+2
View File
@@ -28,6 +28,8 @@ type Bitbucket struct {
func NewBitbucketProvider() *Bitbucket { func NewBitbucketProvider() *Bitbucket {
return &Bitbucket{BaseProvider{ return &Bitbucket{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 9,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2256" preserveAspectRatio="xMidYMid" viewBox="-1 -0.6 257.9 230.8"><linearGradient id="a" x1="108.6%" x2="46.9%" y1="13.8%" y2="78.8%"><stop offset=".2" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><g fill="none"><path d="M101 153h54l13-76H87z"/><path fill="#2684ff" d="M8 0a8 8 0 0 0-8 10l35 211a11 11 0 0 0 11 9h167a8 8 0 0 0 8-7l35-213a8 8 0 0 0-8-10zm147 153h-53L87 77h81z"/><path fill="url(#a)" d="M245 77h-77l-13 76h-53l-63 74 7 3h167a8 8 0 0 0 8-7z"/></g></svg>`,
displayName: "Bitbucket", displayName: "Bitbucket",
pkce: false, pkce: false,
scopes: []string{"account"}, scopes: []string{"account"},
+2
View File
@@ -27,6 +27,8 @@ type Box struct {
func NewBoxProvider() *Box { func NewBoxProvider() *Box {
return &Box{BaseProvider{ return &Box{BaseProvider{
ctx: context.Background(), ctx: context.Background(),
order: 20,
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 21.6"><path fill="#0061d5" d="M39.7 19.2q.7 1.2-.2 2.1-1.2.7-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2q-1-.9-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2s1.7-.3 2.2.3l3.4 4.5L37.3 7q.9-1 2.2-.3 1 1 .2 2.2L35.8 14zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6a4.7 4.7 0 0 1-4.7 4.6m-13.8 0c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6M21.5 6.4a8 8 0 0 0-6.8 4 8 8 0 0 0-6.9-4q-2.7 0-4.7 1.5V1.5Q3 .2 1.6 0 .1.1 0 1.5v12.6a7.7 7.7 0 0 0 7.7 7.5c3 0 5.6-1.7 6.9-4.1a8 8 0 0 0 6.8 4.1c4.3 0 7.8-3.4 7.8-7.7a7.5 7.5 0 0 0-7.7-7.5"/></svg>`,
displayName: "Box", displayName: "Box",
pkce: true, pkce: true,
scopes: []string{"root_readonly"}, scopes: []string{"root_readonly"},
Loaded 100 of 873 files, more files were not shown because too many files have changed in this diff. Show more