Compare commits

..
141 Commits
Author SHA1 Message Date
Gani Georgiev 465cfb5293 bumped app version 2026-06-08 18:54:43 +03:00
Gani Georgiev f58a65e4be apply the save shortcut only for the top open modal 2026-06-08 16:06:45 +03:00
Gani Georgiev aa7be1edd1 don't recreate the maxSelect inputs on each change 2026-06-08 14:26:58 +03:00
Gani Georgiev f868756721 fixed number settings 0 max validator 2026-06-08 12:57:44 +03:00
Gani Georgiev b66a4e32cc replace type text with number 2026-06-08 10:27:28 +03:00
Gani Georgiev 72df31e22d normalized fields setting tooltip texts 2026-06-08 09:57:50 +03:00
Gani Georgiev 77639f94ce normalized field settings argument name and multiple required tooltip 2026-06-08 09:50:49 +03:00
Gani Georgiev bff6b4c3ab [#7731] fixed file field settings variable name 2026-06-08 09:23:33 +03:00
Gani Georgiev aee115a99f fixed typo 2026-06-07 11:14:23 +03:00
Gani Georgiev b81d92dbd2 updated changelog 2026-06-07 11:13:09 +03:00
Gani Georgiev 98a0f5eb05 updated ui/dist 2026-06-07 10:49:42 +03:00
Gani Georgiev 3350ae651d return filepath.SkipDir instead of nil for excluded dir entries 2026-06-07 10:45:48 +03:00
Gani Georgiev 394a46f3a2 updated modernc.org/sqlite to v1.52.0 2026-06-06 19:33:13 +03:00
Gani Georgiev 4624e84b50 added some extra known write query keywords to the slq api and hide the Affected rows in case empty to avoid ambiguities 2026-06-06 19:25:41 +03:00
Gani Georgiev 21dd105d9a [#7726] don't clear date input on invalid value while still typing 2026-06-05 20:28:24 +03:00
Gani Georgiev 8d60b928fc updated shablon to preserve the original order of new elements 2026-06-04 16:21:21 +03:00
Gani Georgiev e7ae0bd716 [#7724] updated Shablon to fix records list sorting 2026-06-04 15:58:42 +03:00
Gani Georgiev 4752ed84c7 update example code 2026-06-04 15:57:41 +03:00
Gani Georgiev 5631d9b1c2 fixed typos 2026-06-03 16:14:28 +03:00
Gani Georgiev b36b8e35af updated ui/dist 2026-06-03 15:17:55 +03:00
Gani Georgiev 9ff3e5a895 bumped min go github action version to 1.26.4 2026-06-03 15:11:07 +03:00
Gani Georgiev e5964e739e updated code comments and fixed checkApiError legacy calls 2026-06-02 14:07:33 +03:00
Gani Georgiev 40d2849aa6 added default panic-recover handling for the cron jobs 2026-05-31 10:34:27 +03:00
Gani Georgiev f3ae7731d4 simplified and fixed flaky test 2026-05-30 15:01:58 +03:00
Gani Georgiev 1c3a912532 [#7721] return the hidden record data fields for superusers realtime subscribers 2026-05-30 14:58:34 +03:00
Gani Georgiev 2b61b2babf allow label word breaking for the responsive layout 2026-05-29 19:36:52 +03:00
Gani Georgiev d4b73af2e9 updated ui/dist 2026-05-29 19:32:02 +03:00
Gani Georgiev b11731222f fixed typo 2026-05-29 19:29:08 +03:00
Gani Georgiev f1873ad26d [#7720] fixed multiple select options wrapping 2026-05-29 19:27:56 +03:00
Gani Georgiev aeb78e51a1 reference the correct announcement 2026-05-28 18:23:05 +03:00
Gani Georgiev 541aa6b1e1 updated ui/dist and changelog 2026-05-28 18:19:11 +03:00
Gani Georgiev a9f259a1c7 updated modernc.org/sqlite to 1.51.0 2026-05-28 18:18:00 +03:00
Gani Georgiev a2d4f12071 fixed grammar 2026-05-28 18:16:38 +03:00
Gani Georgiev 846432f4c8 added the new no-llm security policy 2026-05-28 18:04:04 +03:00
Gani Georgiev 167e23e9ba added extra confirm verification guards 2026-05-27 09:56:16 +03:00
Gani Georgiev ea2ef87d08 updated btn loading animation 2026-05-26 19:20:52 +03:00
Gani Georgiev e067bf1517 fixed bulkbar wrapper clickthrough and normalized exported filename dates 2026-05-26 18:47:05 +03:00
Gani Georgiev 97b85334a9 removed legacy remember me redirect 2026-05-26 18:22:17 +03:00
Gani Georgiev d4026ce60f [#7698] send system email alerts to superusers in case of an error with the automated backups 2026-05-26 16:54:45 +03:00
Gani Georgiev 40c631db32 clarified the note regarding social engineering attacks 2026-05-26 10:32:13 +03:00
Gani Georgiev 095b0aca51 updated migratecmd template tests 2026-05-26 10:00:08 +03:00
Gani Georgiev a7dfbbb8ec updated the security policy of the project 2026-05-26 09:49:30 +03:00
Gani Georgiev b65b7c5c56 updated default email texts for consistency and set slightly smaller default token durations 2026-05-26 09:33:03 +03:00
Gani Georgiev 4ebdfe4a1e updated default email texts for consistency and set a smaller default verification token duration 2026-05-26 09:31:50 +03:00
Gani Georgiev 19db35c48c updated raw sql length limit to 5000 for consistency with the dry-run-view-query 2026-05-25 12:45:37 +03:00
Gani Georgiev 1034dc9842 registered missing oidc2/oidc3 option fields 2026-05-25 07:19:34 +03:00
Gani Georgiev 5bea84b94b updated js-sdk and replaced missing handlers 2026-05-24 16:09:11 +03:00
Gani Georgiev 7984e586a8 minor ui adjustments 2026-05-24 10:52:37 +03:00
Gani Georgiev b022e138d4 minimal sql console API and UI 2026-05-24 09:30:30 +03:00
Gani Georgiev db853850ab allow word breaking in labels 2026-05-23 22:39:30 +03:00
Gani Georgiev 3d3d3b7355 fixed logs bulk selection 2026-05-23 10:52:49 +03:00
Gani Georgiev 3616b9d667 updated backport changelog 2026-05-22 07:20:20 +03:00
Gani Georgiev efd9604aa7 updated jsvm types 2026-05-22 07:14:50 +03:00
Gani Georgiev 1ab6bba61b updated golang.org/x dependencies 2026-05-22 06:24:21 +03:00
Gani Georgiev 9e6b4eeda8 updated reload tooltip and bumped app version 2026-05-21 19:00:58 +03:00
Gani Georgiev ea1a537a60 allow the select empty and placeholder props to be reactive functions 2026-05-20 16:39:59 +03:00
Gani Georgiev 23a989118c updated shablon 2026-05-20 16:27:19 +03:00
Gani Georgiev d0b2551e78 [#7694] don't reset the records list pagination on record update 2026-05-18 19:26:17 +03:00
Gani Georgiev b9b0e5ae80 added extra IP checks for the connected realtime client 2026-05-18 19:13:25 +03:00
Gani Georgiev f7fbc6c2c3 added RealtimeConnectRequestEvent.MaxTimeout field 2026-05-18 11:40:46 +03:00
Gani Georgiev a286d28bf9 fixed changelog typo 2026-05-15 07:34:50 +03:00
Gani Georgiev 5cff808438 updated error message and regenerated jsvm types 2026-05-15 07:23:25 +03:00
Gani Georgiev 45e3ca6ea6 normalized scaffold indexes 2026-05-15 07:06:56 +03:00
Gani Georgiev 9c8557a124 updated changelog 2026-05-15 06:32:58 +03:00
Gani Georgiev 8d7e3abbd6 [#7689] fixed indexes update collection error 2026-05-14 21:32:21 +03:00
Gani Georgiev 9d50e20880 updated api preview examples 2026-05-14 10:02:42 +03:00
Gani Georgiev fbf4f3e5fb updated modernc.org/sqlite 2026-05-14 09:26:13 +03:00
Gani Georgiev 820b9afe98 added error marker for each collection tab and fixed the styles of the raw errors tooltip 2026-05-14 09:15:05 +03:00
Gani Georgiev b061673d9a updated test to trigger the entire save hook chain 2026-05-13 23:11:10 +03:00
Gani Georgiev c9c47dc75c removed duplicated entry from the backport changelog 2026-05-13 22:49:25 +03:00
Gani Georgiev cbb7c061a3 updated backport changelog 2026-05-13 22:48:35 +03:00
Gani Georgiev caad25bb61 fixed grammar 2026-05-13 22:37:28 +03:00
Gani Georgiev 3b98059a8a force unset realtime connections auth state 2026-05-13 22:34:50 +03:00
Gani Georgiev a8c236a54d fixed ui extensions test 2026-05-10 14:41:53 +03:00
Gani Georgiev 81a5672498 updated ui extensions api to allow top-level await calls 2026-05-10 14:36:09 +03:00
Gani Georgiev 1b5ea9a1fa silenced the superuser ips confirmation if there is no change 2026-05-07 22:57:45 +03:00
Gani Georgiev d438c6a96a bumped app version 2026-05-07 19:51:35 +03:00
Gani Georgiev 6828ca1d4e bumped min go github action version to 1.26.3 2026-05-07 19:49:22 +03:00
Gani Georgiev 88193b9396 adjusted noitems alignment 2026-05-07 19:39:33 +03:00
Gani Georgiev 3cedf032ca [#7681] changed settings app url input to type=text 2026-05-06 19:40:44 +03:00
Gani Georgiev 4396369bb9 removed unused css file 2026-05-06 10:30:11 +03:00
Gani Georgiev 4a4f8ad9db use input css vars and updated changelog 2026-05-06 00:56:12 +03:00
Gani Georgiev 5a144e1342 [#6410] added rate limit option to exclude IPs/CIDR subnets 2026-05-05 17:29:25 +03:00
Gani Georgiev 24d72877e9 updated default log level marker color 2026-05-05 14:12:51 +03:00
Gani Georgiev c40f519b7b bump dev version for the demo 2026-05-05 14:02:10 +03:00
Gani Georgiev b026c6844b updated changelog 2026-05-05 14:01:18 +03:00
Gani Georgiev 21a5524fed added superuser ips whitelist 2026-05-05 13:04:06 +03:00
Gani Georgiev fe2d90641c added content-type serving exception tests 2026-05-05 11:40:07 +03:00
Gani Georgiev 8091a756e8 rollback plex-sans since the feedback from the user wasn't ok (inter was still 'fuzzy') 2026-05-04 21:47:57 +03:00
Gani Georgiev 7542cd14e2 [#7677] fixed default ui csp to allow iframe/object previews 2026-05-04 13:39:21 +03:00
Gani Georgiev 7da023f345 [#7467] forced serving conten-type for xlsx, docx and pptx 2026-05-04 13:14:56 +03:00
Gani Georgiev a104de5b9f fixed minor alignment issues after the font replacement 2026-05-04 11:40:40 +03:00
Gani Georgiev 97c5f3fa02 replaced ibm plex sans with inter 2026-05-04 10:50:41 +03:00
Gani Georgiev fccfa20653 added logs list start date guard 2026-05-04 09:49:32 +03:00
Gani Georgiev 29c6dacbfc updated changelog 2026-05-04 09:35:42 +03:00
Gani Georgiev d33927718b removed unnecessery logs filter normalization breaking the pagination 2026-05-04 09:33:47 +03:00
Gani Georgiev d153553d52 added eager alg error check to minimize misuse 2026-05-02 23:50:05 +03:00
Gani Georgiev db88253aac adjusted default button css 2026-05-02 10:05:43 +03:00
Gani Georgiev 0cf34c475b updated changelog 2026-05-02 07:34:50 +03:00
Gani Georgiev 547ee715c0 slightly adjusted the dark text color 2026-05-01 20:55:17 +03:00
Gani Georgiev 4850da6f56 adjusted flaky test 2026-05-01 19:32:00 +03:00
Gani Georgiev 53ac0d29da reordered change email validations to make enumerations slightly harder 2026-05-01 19:31:50 +03:00
Gani Georgiev d90aaedc00 skip duplicated records ids from the IN expand 2026-05-01 19:16:53 +03:00
Gani Georgiev 74defc48b9 fixed editor keydown propagation outside of form 2026-05-01 18:11:06 +03:00
Gani Georgiev 9205b11dc3 bumped app version 2026-05-01 17:51:40 +03:00
Gani Georgiev 8d0881db3f reload trusted proxy info UI after settings save 2026-04-29 11:06:26 +03:00
Gani Georgiev dbcd95eb62 updated the security policy 2026-04-28 12:10:24 +03:00
Gani Georgiev 905256b0c8 added the local time zone name next to the date field label 2026-04-27 22:38:27 +03:00
Gani Georgiev 602f3a4442 added auth collection specific check in the replacer 2026-04-27 22:38:03 +03:00
Gani Georgiev 5c9bcfaf8e [#7670] fixed password fields not being detected as changed 2026-04-27 17:41:19 +03:00
Gani Georgiev 44bf55097a updated changelogs 2026-04-27 09:30:32 +03:00
Gani Georgiev 338d672bee updated ui/dist 2026-04-27 09:04:38 +03:00
Gani Georgiev 5bd9d87bad reorder editor buttons to avoid dropdowns text wrapping 2026-04-27 08:47:14 +03:00
Gani Georgiev 6ba78d5218 updated gitlab userinfo doc reference 2026-04-27 08:03:20 +03:00
Gani Georgiev 260bd59c5b updated jstypes 2026-04-27 07:57:43 +03:00
Gani Georgiev 006566478a added explicit gitlab confirmed_at check 2026-04-27 07:55:51 +03:00
Gani Georgiev 419f335f5b various minor ui fixes 2026-04-27 01:13:08 +03:00
Gani Georgiev 326f150db2 added more tests for internal record hooks 2026-04-26 20:47:47 +03:00
Gani Georgiev 1c86addc4c [#7665] added BaseURL to the ghupdate plugin configuration 2026-04-26 20:47:02 +03:00
Gani Georgiev 494f47efb8 bumped go deps 2026-04-26 16:50:24 +03:00
Gani Georgiev 555a4f1a1e lowered the default mfa duration and reorganized internal record pre/post handling 2026-04-26 16:46:16 +03:00
Gani Georgiev 37b258810a updated gitea displayName 2026-04-26 14:23:23 +03:00
Gani Georgiev ca7cf1162f added App.DeleteAllExternalAuthsByRecord 2026-04-26 11:40:09 +03:00
Gani Georgiev dddb0a029f updated bitbucket,github and gitea oauth2 providers 2026-04-25 17:51:28 +03:00
Gani Georgiev 5d55fc18ee added dummy bcrypt check 2026-04-25 16:16:23 +03:00
Gani Georgiev 449e5af590 adjust dark text color 2026-04-25 11:41:09 +03:00
Gani Georgiev 1e460d3f96 updated changelog and rebuild ui/dist 2026-04-24 22:27:26 +03:00
Gani Georgiev b5030ddfa1 [#7664] fixed codeEditor not firing the change and input events on autocomplete selection 2026-04-24 22:08:58 +03:00
Gani Georgiev fbeb09c40b [#7660] added missing type:button attribute and replaced form tag with div to minimize future regressions 2026-04-24 15:42:47 +03:00
Gani Georgiev 69cdda4bf3 [#7659] fixed SMTP IPv6 format 2026-04-23 21:25:58 +03:00
Gani Georgiev e708f39e1b updated erd styles 2026-04-23 21:18:32 +03:00
Gani Georgiev 52eccb3aac fade non-focused erd tables 2026-04-23 17:43:15 +03:00
Gani Georgiev 1d593476b0 updated view autocomplete keywords 2026-04-23 17:32:07 +03:00
Gani Georgiev 8a04904de1 fixed godoc example typo 2026-04-23 16:57:26 +03:00
Gani Georgiev a3ac674f36 removed title loader to minimize layout jumps 2026-04-23 14:12:59 +03:00
Gani Georgiev ae7041a889 preload the record preview to minimize content jumps 2026-04-23 00:37:05 +03:00
Gani Georgiev 257f03e1fa removed lazy tinymce mount since the relation are now preloaded 2026-04-23 00:01:43 +03:00
Gani Georgiev 3566ba3729 exclude expand from the record draft 2026-04-22 23:15:46 +03:00
Gani Georgiev a6002c4622 optimized record upsert panel loading to minimize layout jumps 2026-04-22 23:02:56 +03:00
Gani Georgiev 2ddf161314 renamed list-group to list-content for consistency with the others 2026-04-22 17:59:11 +03:00
Gani Georgiev b15f358fc9 [#7655] added backups list scroll container 2026-04-22 17:22:22 +03:00
209 changed files with 9414 additions and 4233 deletions

No files matched your search

+34 -7
View File
@@ -2,19 +2,26 @@
**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!**
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).
If you want to responsibly report a security issue you'll have to reach out as a human to **support at pocketbase.io**.
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.
This means:
- no overconfident and arrogant tone
- no threatening deadlines
- no requirement for me to login in your security platform just to read the report
- no inflated severity (we can discuss the CVSS score after confirming the issue)
- no LLMs usage as part of your report description or followup communication
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).
Reports that don't follow the above will NOT be reviewed no matter of their validity _(you are of course free to publish whatever you want; see also [#7718](https://github.com/pocketbase/pocketbase/discussions/7718))_.
### Please:
**Or in other words - a simple _"Hey I think I found a security issue when I do X"_ is enough.**
- 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.
I try to be as responsive as possible and usually address security issues within couple days but if you didn't receive a reply from me for more than a week 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.
- 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.
In case the vulnerability is confirmed:
- 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.)_.
- I'll start working on a local fix.
- Once the fix is implemented locally, I'll publish a pre-announcement with a scheduled release date _(and when possible an approximate release time)_.
- After the release, I'll publish a GitHub security advisory and CVE with remediation steps and **minimal** details regarding the found exploit _(you are free to publish PoC and more details in your own blog, gist, etc. but it is advised to wait at least a week after the release to allow enough time for people to patch their instances before making it more publicly known)_.
### Below is a short list of previous reports that are NOT considered security issues:
@@ -72,6 +79,26 @@ If someone is able to tamper with the OAuth2 responses then the entire OAuth2 fl
~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>Users enumeration</strong></summary>
This is a common and usually valid report but there is no easy solution without confusing and degrading the users experience.
Some endpoints, like the user create/register, can be used for usernames or emails enumeration based on various response heuristics - timing, specific error messages, etc.
In many places where applicable we've tried to minimize the impact by using constant time checks, returning non-descriptive error messages, applying an internal rate limit for some operations, etc. but it is not bulletproof and if somebody wants to find out if a user is registered they will be able to do it one way or another.
If you think that there is a place where we can improve the handling without hurting too much the user experience, feel free to open a regular public issue and it will be considered.
</details>
<details>
<summary><strong>Attack-vectors relying on social engineering</strong></summary>
Reports for attacks relying on various social engineering tactics _(e.g. tricking someone to click on a link)_ are valid concerns but usually out of the security scope of the project as there are a lot of cases where the APIs are deliberately designed for minimal friction.
If you have concerns for such attack, feel free to open a regular public issue and we can eventually try to reconsider adding extra guards when feasible _(or at least properly document the existing behavior)_.
</details>
<details>
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '>=1.26.2'
go-version: '>=1.26.4'
# 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.
+150
View File
@@ -1,3 +1,153 @@
## v0.39.3
- Fixed JS error on `file` settings `maxSelect` change ([#7731](https://github.com/pocketbase/pocketbase/issues/7731)).
- Apply the `Ctrl+S` record panel save shortcut only if it is the current top open modal.
- Fixed `number` settings validator to not ignore 0 `max` value.
- Normalized field settings validation error messages and tooltips.
## v0.39.2
- Fixed records list UI sorting ([#7724](https://github.com/pocketbase/pocketbase/issues/7724)).
- Don't clear the date input on invalid value while still typing ([#7726](https://github.com/pocketbase/pocketbase/issues/7726)).
- Return `filepath.SkipDir` in the `pb_hooks` dirs watcher to avoid unnecessary iterating over `node_modules` and `.*` prefixed hidden dirs (`.DS_Store`, `.git`, etc.).
- Show the "Affected rows" SQL console message only if non-empty to avoid ambiguity with drivers that don't support returning the affected rows count.
- Updated `modernc.org/sqlite` to v1.52.0 ([SQLite 3.53.2](https://sqlite.org/src/timeline?from=version-3.53.0&to=version-3.53.2&to2=branch-3.53&y=ci)).
## v0.39.1
- Fixed multiple select options wrapping ([#7720](https://github.com/pocketbase/pocketbase/issues/7720)).
- Return the hidden record data fields for superusers realtime subscribers ([#7721](https://github.com/pocketbase/pocketbase/issues/7721)).
- Added default panic-recover handling for the cron jobs to avoid terminating the server on panic.
- Bumped the min Go GitHub action version to 1.26.4 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.4).
## v0.39.0
- Added new "SQL console" section under _Settings > Debug_ allowing executing any raw SQL query from the UI ([#2236](https://github.com/pocketbase/pocketbase/issues/2236); [#7638](https://github.com/pocketbase/pocketbase/discussions/7638)).
_Note that this is intended for one-off analytic queries, the occasional `VACUUM`/`PRAGMA optimize` or debug purposes and not as the primary interface for interacting with your PocketBase data because it can break your application if not used with proper care!_
- Send system email alerts to superusers in case of an error with the automated backups ([#7698](https://github.com/pocketbase/pocketbase/issues/7698)).
- Various minor improvements and fixes:
- fixed logs bulk selection export error
- optimized logs and records list rendering
- allowed word breaking in labels
- text contrast improvements
- registered missing `oidc2` and `oidc3` option fields
- updated default email template texts for consistency
- updated `modernc.org/sqlite` to v1.51.0
- etc.
## v0.38.2
- Added `RealtimeConnectRequestEvent.MaxTimeout` field to specify the absolute max duration a realtime connection can remain open (default to 30mins).
_This is in addition to the `IdeTimeout` of 5mins in order to prevent misuse and to allow the GC to run more regularly._
- Added extra checks for the connected user IP in the realtime APIs to prevent bruteforce guest subscription update attempts and to serve as an extra protection for the "all-in-one" OAuth2 realtime handler.
- Don't reset the records list pagination on record update ([#7694](https://github.com/pocketbase/pocketbase/issues/7694)).
- Updated all `golang.org/x/` packages to cover the recent [security fixes](https://groups.google.com/g/golang-announce/c/PdiGK3xulk4) _(none of them should be a critical issue in PocketBase but nonetheless it is advised to update)_.
## v0.38.1
- Silenced the superuser IPs confirmation if there is no change.
- Updated the _experimental_ UI extensions APIs to allow top-level `await` in the initialization script.
- Force unset the auth state of existing realtime connections on user password, collection secret, etc. changes.
_This is not strictly necessary because the realtime connections have short-lived idle timeout by design but nonetheless it was implemented to minimize the attack vectors._
- Added error marker for each collection tab and fixed the styles of the raw errors tooltip.
- Fixed indexes collection update error ([#7689](https://github.com/pocketbase/pocketbase/issues/7689)).
_⚠️ The fix comes with a system migration that resaves all collections with indexes to ensure that all indexes are normalized and available in the `Collection.Indexes` field (it will also include indexes created manually via the sqlite3 cli or other external tool)._
_If you are using a test `pb_data` for your Go automation tests you may want to apply the migration to it too so that it runs only once and not for each execution of your tests, aka. you could run once `go run main.go migrate up --dir="/path/to/test_pb_data"`._
- Updated `modernc.org/sqlite` to v1.50.1 (SQLite 3.53.1).
- Other minor fixes (_updated API preview examples, fixed code comment typos, etc._).
## v0.38.0
- Fixed UI logs pagination when no custom range is specified.
- Fixed default CSP not allowing audio/video previews ([#7677](https://github.com/pocketbase/pocketbase/issues/7677)).
- Serve fixed `Content-Type` for `.xlsx`, `.docx` and `.pptx` files to allow previews on iOS ([#7467](https://github.com/pocketbase/pocketbase/discussions/7467)).
- Changed settings app URL input to `type="text"` for compatibility with earlier versions ([#7681](https://github.com/pocketbase/pocketbase/issues/7681)).
- Added an internal watcher to sync various runtime states between multiple PocketBase processes (e.g. memory store) using the same `pb_data`.
_This is helpful in case for example a separate PocketBase console command change the collections or application settings while the server is still running._
_The watcher is debounced and implemented by watching the special `pb_data/.notify` dir as a workaround to avoid depending on OS and SQLite driver specific APIs._
- Added new [Superuser IPs/CIDR subnets whitelist setting](https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets).
The optional setting can be changed from the UI under _Dasboard > Settings > Application > Superuser IPs_.
To avoid lockout in case your superuser IP change, the ips whitelist can be updated also via the `superuser ips` console command:
```sh
# note: --dir is optional and defaults to pb_data next to the executable
# clear whitelisted IPs
./pocketbase superuser ips --dir=/custom/path/to/pb_data
# OR change the whitelisted IPs to 127.0.0.1 and 10.0.0.0 (replace with your real IP(s))
./pocketbase superuser ips 127.0.0.1 10.0.0.0 --dir=/custom/path/to/pb_data
```
- Added rate limit option to exclude IPs/CIDR subnets ([#6410](https://github.com/pocketbase/pocketbase/issues/6410)).
- Bumped min Go GitHub action version to 1.26.3 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.3).
## v0.37.5
- Fixed password fields not being detected as changed ([#7670](https://github.com/pocketbase/pocketbase/issues/7670)).
- Added the local time zone name next to the `date` field label.
- Reload trusted proxy info UI after settings save.
- Other minor improvements (skips the duplicated record ids from the `IN` expand list, reordered confirm-email-change error checks to minimize enumeration attacks, etc.).
## v0.37.4
- Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)).
- Optimized record upsert and preview modals data loading to minimize layout jumps.
- Fixed SMTP IPv6 network address format ([#7659](https://github.com/pocketbase/pocketbase/issues/7659)).
- Fixed autocomplete selection not properly updating the underlying input value ([#7664](https://github.com/pocketbase/pocketbase/issues/7664)).
- Added `ghupdate.BaseURL` config option ([#7665](https://github.com/pocketbase/pocketbase/issues/7665)).
- Added dummy bcrypt password check for the failure auth path to minimize enumeration timing attacks when registrations are disabled.
- Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662); thanks @Alardiians for reporting it privately).
- Bumped Go and npm dependencies.
## v0.37.3
- Fixed total count load on page back/forward navigation.
+28
View File
@@ -2,6 +2,34 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
---
## v0.22.46
- (_Backported from v0.39.1_) Bumped the min Go GitHub action version to 1.26.4 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.4).
## v0.22.45
- (_Backported from v0.38.2_) Updated all `golang.org/x/` packages to cover the recent [security fixes](https://groups.google.com/g/golang-announce/c/PdiGK3xulk4) _(none of them should be a critical issue in PocketBase but nonetheless it is advised to update)_.
## v0.22.44
- (_Backported from v0.38.1_) Force unset the auth state of existing realtime connections on user tokenKey change.
## v0.22.43
- (_Backported from v0.38.0_) Bumped min Go GitHub action version to 1.26.3 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.3).
## v0.22.42
- (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
- (_Backported from v0.37.4_) ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662)).
## v0.22.41
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
+7 -2
View File
@@ -70,8 +70,10 @@ func backupDownload(e *core.RequestEvent) error {
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
}
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
@@ -79,6 +81,9 @@ func backupDownload(e *core.RequestEvent) error {
}
defer fsys.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys.SetContext(ctx)
key := e.Request.PathValue("key")
+52
View File
@@ -528,6 +528,58 @@ func TestBackupsDownload(t *testing.T) {
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid superuser file token AND whitelisted IP",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
"storage/",
"data.db",
"auxiliary.db",
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid superuser file token BUT non-whitelisted IP",
Method: http.MethodGet,
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
+2
View File
@@ -31,6 +31,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(panicRecover())
pbRouter.Bind(rateLimit())
pbRouter.Bind(loadAuthToken())
pbRouter.Bind(superuserIPsWhitelist())
pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
@@ -47,6 +48,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindBatchApi(app, apiGroup)
bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup)
bindSQLApi(app, apiGroup)
// UI routes
bindUIExtensions(app)
+65 -1
View File
@@ -224,7 +224,7 @@ func TestBatchRequest(t *testing.T) {
},
},
{
Name: "mixed create/update/delete (rules failure)",
Name: "mixed create/update/delete (non-superuser rule failure)",
Method: http.MethodPost,
URL: "/api/batch",
Body: strings.NewReader(`{
@@ -284,6 +284,70 @@ func TestBatchRequest(t *testing.T) {
}
},
},
{
Name: "mixed create/update/delete (superuser rule failure)",
Method: http.MethodPost,
URL: "/api/batch",
Headers: map[string]string{
// test@example.com, clients
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
Body: strings.NewReader(`{
"requests": [
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}},
{"method":"POST", "url":"/api/collections/_superusers/records", "body": {"email":"test_batch@example.com","password":"1234567890"}}
]
}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"requests":{`,
`"2":{"code":"batch_request_failed"`,
`403`,
},
NotExpectedContent: []string{
`"0":`,
`"1":`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnBatchRequest": 1,
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateError": 1,
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteError": 1,
"OnModelValidate": 1,
"OnRecordUpdateRequest": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateError": 1,
"OnRecordDeleteRequest": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteError": 1,
"OnRecordEnrich": 1,
"OnRecordValidate": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err = app.FindRecordById("demo2", "achvryl401bhse3")
if err != nil {
t.Fatal("Expected record to not be deleted")
}
_, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
if err == nil {
t.Fatal("Expected record to not be updated")
}
_, err = app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test_batch@example.com")
if err == nil {
t.Fatal("Expected superuser to not be created")
}
},
},
{
Name: "mixed create/update/delete (rules success)",
Method: http.MethodPost,
+2 -1
View File
@@ -81,7 +81,8 @@ func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
defer f.Close()
// wrap in a self-executing function to avoid scope and concatenation issues
_, _ = buf.WriteString("(function(){")
// (the await/async is for top-level await)
_, _ = buf.WriteString("await (async function(){")
_, err = io.Copy(buf, f)
if err != nil {
+1 -1
View File
@@ -72,7 +72,7 @@ func TestUIExtensions_Mainjs(t *testing.T) {
},
AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
ExpectedContent: []string{"await (async function(){ext1_main})();await (async function(){ext3_main})();"},
ExpectedEvents: map[string]int{"*": 0},
},
}
+10
View File
@@ -60,6 +60,7 @@ type fileApi struct {
}
func (api *fileApi) fileToken(e *core.RequestEvent) error {
// extra check for just in case the handler is called in a different context
if e.Auth == nil {
return e.UnauthorizedError("Missing auth context.", nil)
}
@@ -114,6 +115,15 @@ func (api *fileApi) download(e *core.RequestEvent) error {
token := e.Request.URL.Query().Get("token")
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
// reset the auth state if it is superuser and it is not whitelisted
// (not critical because file tokens are short-lived but checked nonetheless as an extra precaution)
if authRecord != nil && authRecord.IsSuperuser() {
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
authRecord = nil
}
}
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
requestInfo := *originalRequestInfo
requestInfo.Context = core.RequestInfoContextProtectedFile
+44
View File
@@ -353,6 +353,50 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - superuser with non-whitelisted IP",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
err := app.Save(app.Settings())
if err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "protected file - superuser with whitelisted IP",
Method: http.MethodGet,
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app.Save(app.Settings()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
"*": 0,
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,
+25
View File
@@ -42,6 +42,9 @@ const (
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
@@ -299,6 +302,28 @@ func securityHeaders() *hook.Handler[*core.RequestEvent] {
}
}
// superuserIPsWhitelist middleware checks the current authenticated superuser IP
// against the configured SuperuserIPs whitelist setting.
//
// This middleware is registered by default for all routes.
func superuserIPsWhitelist() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: DefaultSuperuserIPsWhitelistMiddlewareId,
Priority: DefaultSuperuserIPsWhitelistMiddlewarePriority,
Func: func(e *core.RequestEvent) error {
if e.HasSuperuserAuth() {
ips := e.App.Settings().SuperuserIPs
if len(ips) > 0 && !isIPInList(ips, e.RealIP()) {
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
}
}
return e.Next()
},
}
}
// SkipSuccessActivityLog is a helper middleware that instructs the global
// activity logger to log only requests that have failed/returned an error.
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
+39 -1
View File
@@ -2,6 +2,7 @@ package apis
import (
"errors"
"net/netip"
"sync"
"time"
@@ -106,6 +107,41 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
return nil
}
// isIPInList checks if the specified IP is in a list of other individual IPs or subnets.
func isIPInList(ipsOrSubnets []string, ip string) bool {
if len(ipsOrSubnets) == 0 || ip == "" {
return false
}
// normalize
searchAddr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
for _, item := range ipsOrSubnets {
// subnet?
prefix, err := netip.ParsePrefix(item)
if err == nil {
if prefix.Contains(searchAddr) {
return true
}
continue
}
// individual ip?
addr, err := netip.ParseAddr(item)
if err == nil {
if addr == searchAddr {
return true
}
continue
}
}
return false
}
// -------------------------------------------------------------------
// @todo consider exporting as helper?
@@ -153,7 +189,9 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
}
func skipRateLimit(e *core.RequestEvent) bool {
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
return !e.App.Settings().RateLimits.Enabled ||
e.HasSuperuserAuth() ||
isIPInList(e.App.Settings().RateLimits.ExcludedIPs, e.RealIP())
}
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
+163 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
)
func TestDefaultRateLimitMiddleware(t *testing.T) {
@@ -85,9 +86,8 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 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", 900, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
{"/rate/a", 900, false, 200},
{"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429},
@@ -160,3 +160,163 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
})
}
}
func TestDefaultRateLimitMiddlewareSkipChecks(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.Rules = []core.RateLimitRule{
{
Label: "/rate",
MaxRequests: 1,
Duration: 5,
},
}
pbRouter, err := apis.NewRouter(app)
if err != nil {
t.Fatal(err)
}
// just for the exclude tests - load the user IP from a query param
pbRouter.Bind(&hook.Handler[*core.RequestEvent]{
Priority: apis.DefaultRateLimitMiddlewarePriority - 1,
Func: func(e *core.RequestEvent) error {
testIp := e.Request.URL.Query().Get("testIP")
if testIp != "" {
e.Request.Header.Set("x-test-ip", testIp)
}
return e.Next()
},
})
pbRouter.GET("/rate", func(e *core.RequestEvent) error {
return e.String(200, "test")
})
mux, err := pbRouter.BuildMux()
if err != nil {
t.Fatal(err)
}
checkStatusCodes := func(t *testing.T, got []int, expected []int) {
if len(expected) != len(got) {
t.Fatalf("Expected status codes %v, got %v", expected, got)
}
for i, item := range expected {
if got[i] != item {
t.Fatalf("Expected %d status code to be %d, got %d:\n%v", i, item, got[i], got)
}
}
}
t.Run("base check", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
})
t.Run("disabled rate limiter", func(t *testing.T) {
app.Settings().RateLimits.Enabled = false
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
t.Run("authenticated as superuser", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
token, err := superuser.NewAuthToken()
if err != nil {
t.Fatal(err)
}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Add("Authorization", token)
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
t.Run("excludedIPs (different)", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.ExcludedIPs = []string{"10.0.0.0"}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Set("x-test-ip", "127.0.0.1")
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
})
t.Run("excludedIPs (match)", func(t *testing.T) {
app.Settings().RateLimits.Enabled = true
app.Settings().RateLimits.ExcludedIPs = []string{"127.0.0.1"}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
statusCodes := []int{}
for range 3 {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/rate", nil)
req.Header.Set("x-test-ip", "127.0.0.1")
mux.ServeHTTP(rec, req)
result := rec.Result()
statusCodes = append(statusCodes, result.StatusCode)
}
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
})
}
+93
View File
@@ -553,3 +553,96 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
scenario.Test(t)
}
}
func TestSuperuserIPsWhitelist(t *testing.T) {
t.Parallel()
setupWhitelist := func(superuserIPs ...string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// allow loading a mock IP from the test scenario
app.Settings().TrustedProxy = core.TrustedProxyConfig{
Headers: []string{"x-test-ip"},
}
app.Settings().SuperuserIPs = superuserIPs
err := app.Save(app.Settings())
if err != nil {
t.Fatal(err)
}
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
return e.String(200, "test123")
})
}
}
scenarios := []tests.ApiScenario{
{
Name: "guest with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "regular user with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with non-matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist("0.0.0.0"),
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with matching IP",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist("0.0.0.0", "127.0.0.1"),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser with no whitelisted IPs",
Method: http.MethodGet,
URL: "/my/test",
Headers: map[string]string{
"x-test-ip": "127.0.0.1",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: setupWhitelist(),
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+132 -11
View File
@@ -28,6 +28,9 @@ const clientsChunkSize = 150
// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
const RealtimeClientAuthKey = "auth"
// RealtimeClientIPKey is the name of the realtime client store key that holds the IP of the connected client.
const RealtimeClientIPKey = "pbRealtimeClientIP"
// bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/realtime")
@@ -63,8 +66,12 @@ func realtimeConnect(e *core.RequestEvent) error {
connectEvent := new(core.RealtimeConnectRequestEvent)
connectEvent.RequestEvent = e
connectEvent.Client = subscriptions.NewDefaultClient()
connectEvent.IdleTimeout = 5 * time.Minute
connectEvent.MaxTimeout = 30 * time.Minute
connectEvent.Client = subscriptions.NewDefaultClient()
// could be used as an optional cross-reference check in other API endpoints
connectEvent.Client.Set(RealtimeClientIPKey, e.RealIP())
return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
// register new subscription client
@@ -73,7 +80,7 @@ func realtimeConnect(e *core.RequestEvent) error {
e.App.SubscriptionsBroker().Unregister(ce.Client.Id())
}()
ce.App.Logger().Debug("Realtime connection established.", slog.String("clientId", ce.Client.Id()))
ce.App.Logger().Debug("Realtime connection established", slog.String("clientId", ce.Client.Id()))
// signalize established connection (aka. fire "connect" message)
connectMsgEvent := new(core.RealtimeMessageEvent)
@@ -99,12 +106,19 @@ func realtimeConnect(e *core.RequestEvent) error {
return nil
}
// start a max lifetime timer to prevent accumulating too much
// connection resources and to allow the GC to run more regularly
maxTimer := time.NewTimer(ce.MaxTimeout)
defer maxTimer.Stop()
// start an idle timer to keep track of inactive/forgotten connections
idleTimer := time.NewTimer(ce.IdleTimeout)
defer idleTimer.Stop()
for {
select {
case <-maxTimer.C:
cancelRequest()
case <-idleTimer.C:
cancelRequest()
case msg, ok := <-ce.Client.Channel():
@@ -186,6 +200,21 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
return e.NotFoundError("Missing or invalid client id.", err)
}
// for just in case to prevent someone changing a guest subscription
//
// note1: this is an extra precaution against clientId bruteforce attempts
// for installations allowing longer realtime connections duration
//
// note2: custom registered clients (aka. those without IP in the store)
// are excluded from the check for backward compatibility
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
if clientIP != "" && clientIP != e.RealIP() {
return e.BadRequestError(
"Invalid realtime client.",
errors.New("the subscription request IP doesn't match with the realtime client IP"),
)
}
// for now allow only guest->auth upgrades and any other auth change is forbidden
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
@@ -208,7 +237,7 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
e.Client.Subscribe(e.Subscriptions...)
e.App.Logger().Debug(
"Realtime subscriptions updated.",
"Realtime subscriptions updated",
slog.String("clientId", e.Client.Id()),
slog.Any("subscriptions", e.Subscriptions),
)
@@ -219,8 +248,13 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
})
}
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
//
// Realtime connections has short lifetime by design, but to minimize abuse
// if the new record has a different tokenKey (e.g. in case of password reset)
// the auth state of the related realtime connections is also cleared
// (aka. they remain active but unauthenticated, allowing to reauthenicate with the next subscription).
func realtimeUpdateClientsAuth(app core.App, authRecord *core.Record) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -230,9 +264,13 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil &&
clientAuth.Id == newAuthRecord.Id &&
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
client.Set(RealtimeClientAuthKey, newAuthRecord)
clientAuth.Id == authRecord.Id &&
clientAuth.Collection().Name == authRecord.Collection().Name {
if clientAuth.TokenKey() != authRecord.TokenKey() {
client.Unset(RealtimeClientAuthKey)
} else {
client.Set(RealtimeClientAuthKey, authRecord)
}
}
}
@@ -243,8 +281,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
return group.Wait()
}
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
@@ -267,7 +305,76 @@ func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
return group.Wait()
}
// realtimeUnsetClientsAuthByCollection unsets the auth state of all authenticated clients related to the collection.
func realtimeUnsetClientsAuthByCollection(app core.App, collection *core.Collection) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group)
for _, chunk := range chunks {
group.Go(func() error {
for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && clientAuth.Collection().Name == collection.Name {
client.Unset(RealtimeClientAuthKey)
}
}
return nil
})
}
return group.Wait()
}
func bindRealtimeEvents(app core.App) {
// reset the clients auth on collection secret change
// (@todo with the future tracking of old collections data consider replacing with *AfterUpdateSuccess to account for transaction rollback)
app.OnCollectionUpdate().Bind(&hook.Handler[*core.CollectionEvent]{
Func: func(e *core.CollectionEvent) error {
if !e.Collection.IsAuth() {
return e.Next()
}
cached, _ := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
if err := e.Next(); err != nil {
return err
}
if cached != nil && cached.AuthToken.Secret != e.Collection.AuthToken.Secret {
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the changed auth collection",
slog.String("collectionName", e.Collection.Name),
slog.String("error", err.Error()),
)
}
}
return nil
},
Priority: -99,
})
// unset the clients auth on auth collection delete
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
Func: func(e *core.CollectionEvent) error {
if e.Collection.IsAuth() {
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth collection",
slog.String("collectionName", e.Collection.Name),
slog.String("error", err.Error()),
)
}
}
return e.Next()
},
Priority: -99,
})
// update the clients that has auth record association
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error {
@@ -294,7 +401,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() {
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil {
app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model",
slog.Any("id", e.Model.PK()),
@@ -548,6 +655,20 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
// which exact fields the client subscription requested or has permissions to access
cleanRecord := record.Fresh()
// -------------------------------------------
// @todo consider with the refactoring whether
// the default enriching used by the regular APIs
// can be reused here too to avoid eventual future
// discrepencies in the record event data
//
// https://github.com/pocketbase/pocketbase/issues/7721
// -------------------------------------------
// enable hidden fields for superuser subscribers
if requestInfo.HasSuperuserAuth() {
cleanRecord.Unhide(collection.Fields.FieldNames()...)
}
// trigger the enrich hooks
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
// apply expand
+371 -17
View File
@@ -26,6 +26,7 @@ func TestRealtimeConnect(t *testing.T) {
Method: http.MethodGet,
URL: "/api/realtime",
Timeout: 100 * time.Millisecond,
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
ExpectedStatus: 200,
ExpectedContent: []string{
`id:`,
@@ -37,6 +38,17 @@ func TestRealtimeConnect(t *testing.T) {
"OnRealtimeConnectRequest": 1,
"OnRealtimeMessageSend": 1,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
app.OnRealtimeConnectRequest().BindFunc(func(e *core.RealtimeConnectRequestEvent) error {
if ip, _ := e.Client.Get(apis.RealtimeClientIPKey).(string); ip != "127.0.0.2" {
t.Fatalf("Expected IP %q, got %q", "127.0.0.2", ip)
}
return e.Next()
})
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
@@ -102,7 +114,8 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
client.Set(apis.RealtimeClientAuthKey, nil)
client.Unset(apis.RealtimeClientAuthKey)
client.Unset(apis.RealtimeClientIPKey)
}
validSubscriptionsLimit := make([]string, 1000)
@@ -208,6 +221,26 @@ func TestRealtimeSubscribe(t *testing.T) {
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "existing client with different IP",
Method: http.MethodPost,
URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test"]}`),
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
client.Set(apis.RealtimeClientIPKey, "127.0.0.1")
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
resetClient()
},
},
{
Name: "existing client with valid topic length",
Method: http.MethodPost,
@@ -429,7 +462,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
@@ -460,7 +496,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
e.Context = context.Background()
e.Model = authRecord1
testApp.OnModelAfterDeleteSuccess().Trigger(e)
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
@@ -484,7 +523,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
@@ -495,25 +537,331 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord and change its email
// refetch the authRecord and change its name
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord2.SetEmail("new@example.com")
// mock update event
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeUpdate
e.Context = context.Background()
e.Model = authRecord2
newName := "test_new_name"
authRecord2.Set("name", newName)
testApp.OnModelAfterUpdateSuccess().Trigger(e)
err = testApp.Save(authRecord2)
if err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
if clientAuthRecord.Get("name") != newName {
t.Fatalf("Expected authRecord with email %q, got %q", newName, clientAuthRecord.Email())
}
}
func TestRealtimeRecordHiddenFields(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
// create temp collection with hidden fields
testCollection := core.NewBaseCollection("test_realtime")
testCollection.ListRule = types.Pointer("@request.auth.id != ''")
testCollection.Fields.Add(
&core.TextField{Name: "public"},
&core.TextField{Name: "hidden", Hidden: true},
)
if err := testApp.Save(testCollection); err != nil {
t.Fatal(err)
}
testSubscription := testCollection.Name + "/*"
// register guest subscriber
guestClient := subscriptions.NewDefaultClient()
guestClient.Subscribe(testSubscription)
testApp.SubscriptionsBroker().Register(guestClient)
// register regular user subscriber
regular, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
regularClient := subscriptions.NewDefaultClient()
regularClient.Set(apis.RealtimeClientAuthKey, regular)
regularClient.Subscribe(testSubscription)
testApp.SubscriptionsBroker().Register(regularClient)
// register superuser subscriber
superuser, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
superuserClient := subscriptions.NewDefaultClient()
superuserClient.Set(apis.RealtimeClientAuthKey, superuser)
superuserClient.Subscribe(testSubscription)
testApp.SubscriptionsBroker().Register(superuserClient)
enrichCalls := map[string]int{}
testApp.OnRecordEnrich(testCollection.Name).BindFunc(func(e *core.RecordEnrichEvent) error {
var id string
if e.RequestInfo.Auth != nil {
id = e.RequestInfo.Auth.Id
}
enrichCalls[id]++
return e.Next()
})
timeout := time.After(3 * time.Second)
done := make(chan struct{})
// collect first received messages
var regularMessageData, superuserMessageData string
go func() {
regularMessageData = string((<-regularClient.Channel()).Data)
superuserMessageData = string((<-superuserClient.Channel()).Data)
done <- struct{}{}
}()
// broadcast create message
testRecord := core.NewRecord(testCollection)
testRecord.Set("public", "test1")
testRecord.Set("hidden", "test2")
if err := testApp.Save(testRecord); err != nil {
t.Fatal(err)
}
// wait for the events
select {
case <-timeout:
t.Fatal("realtime test messages timeout")
case <-done:
// ready
}
if total := len(enrichCalls); total != 2 {
t.Fatalf("Expected %d enrich hook calls, got %d", 2, total)
}
if total := enrichCalls[regular.Id]; total != 1 {
t.Fatalf("Expected exactly 1 regular user enrich hook call, got %d", total)
}
if total := enrichCalls[superuser.Id]; total != 1 {
t.Fatalf("Expected exactly 1 superuser enrich hook call, got %d", total)
}
// validate messages content
scenarios := map[string]bool{
"regular message public field should exist": strings.Contains(regularMessageData, `"public":`),
"regular message hidden field should NOT exist": !strings.Contains(regularMessageData, `"hidden":`),
"superuser message public field should exist": strings.Contains(superuserMessageData, `"public":`),
"superuser message hidden field should exist": strings.Contains(superuserMessageData, `"hidden":`),
}
for name, valid := range scenarios {
t.Run(name, func(t *testing.T) {
if !valid {
t.Fatal("Invalid realtime message expectation")
}
})
}
}
func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord and refresh its tokenKey
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
authRecord2.RefreshTokenKey()
err = testApp.Save(authRecord2)
if err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if clientAuthRecord != nil {
t.Fatalf("Expected authRecord to be unset, got %q", clientAuthRecord.Email())
}
}
func TestRealtimeAuthRecordUnsetOnCollectionSecretChange(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
usersCollection, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
clientMocks := map[*core.Record]subscriptions.Client{
authRecord1: client1,
authRecord2: client2,
authRecord3: client3,
}
for _, client := range clientMocks {
testApp.SubscriptionsBroker().Register(client)
}
// change the secret of the users collection (should trigger unset)
usersCollection.AuthToken.Secret = strings.Repeat("a", 30)
err = testApp.Save(usersCollection)
if err != nil {
t.Fatal(err)
}
// change something else of the clients collection (shouldn't trigger unset)
clientsCollection.ListRule = nil
err = testApp.Save(clientsCollection)
if err != nil {
t.Fatal(err)
}
expectations := map[*core.Record]bool{
// record -> unset
authRecord1: true,
authRecord2: true,
authRecord3: false,
}
for record, expectedUnset := range expectations {
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
unset := clientAuthRecord == nil
if unset != expectedUnset {
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
}
}
}
func TestRealtimeAuthRecordUnsetOnCollectionDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init realtime handlers
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
usersCollection, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client1 := subscriptions.NewDefaultClient()
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client2 := subscriptions.NewDefaultClient()
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
if err != nil {
t.Fatal(err)
}
client3 := subscriptions.NewDefaultClient()
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
clientMocks := map[*core.Record]subscriptions.Client{
authRecord1: client1,
authRecord2: client2,
authRecord3: client3,
}
for _, client := range clientMocks {
testApp.SubscriptionsBroker().Register(client)
}
// mock users collection delete event to avoid triggering constraints check
e := new(core.ModelEvent)
e.App = testApp
e.Type = core.ModelEventTypeDelete
e.Context = context.Background()
e.Model = usersCollection
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
expectations := map[*core.Record]bool{
// record -> unset
authRecord1: true,
authRecord2: true,
authRecord3: false,
}
for record, expectedUnset := range expectations {
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
unset := clientAuthRecord == nil
if unset != expectedUnset {
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
}
}
}
@@ -551,7 +899,10 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
@@ -608,7 +959,10 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
defer testApp.Cleanup()
// init realtime handlers
apis.NewRouter(testApp)
_, err := apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
+6 -6
View File
@@ -102,12 +102,6 @@ func (form *EmailChangeConfirmForm) parseToken() (*core.Record, string, error) {
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
}
// ensure that there aren't other users with the new email
_, err := form.app.FindAuthRecordByEmail(form.collection, newEmail)
if err == nil {
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
}
// verify that the token is not expired and its signature is valid
authRecord, err := form.app.FindAuthRecordByToken(form.Token, core.TokenTypeEmailChange)
if err != nil {
@@ -118,5 +112,11 @@ func (form *EmailChangeConfirmForm) parseToken() (*core.Record, string, error) {
return nil, "", validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
}
// check if there are other users with the new email
_, err = form.app.FindAuthRecordByEmail(form.collection, newEmail)
if err == nil {
return nil, "", validation.NewError("validation_invalid_token_email", "The new email address is invalid.")
}
return authRecord, newEmail, nil
}
+40 -1
View File
@@ -111,12 +111,51 @@ func TestRecordConfirmEmailChange(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnRecordValidate": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatalf("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByEmail("users", "change@example.com")
user, err := app.FindAuthRecordByEmail("users", "change@example.com")
if err != nil {
t.Fatalf("Expected to find user with email %q, got error: %v", "change@example.com", err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
@@ -114,11 +114,18 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
"OnModelValidate": 1,
"OnRecordValidate": 1,
// ---
"OnModelDelete": 2, // pre-existing OAuth2 links
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -151,6 +158,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
@@ -221,6 +237,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
if !user.ValidatePassword("1234567!") {
t.Fatal("Password wasn't changed")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -251,11 +276,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
t.Fatalf("Failed to fetch confirm password user: %v", err)
}
oldTokenKey := user.TokenKey()
// ensure that the user is already verified
user.SetVerified(true)
if err := app.Save(user); err != nil {
t.Fatalf("Failed to update user verified state")
}
// resave with the old token key since the verified change above
// would refresh it and will make the password token invalid
user.SetTokenKey(oldTokenKey)
if err = app.Save(user); err != nil {
t.Fatalf("Failed to restore original user tokenKey: %v", err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
_, err := app.FindAuthRecordByToken(
+7
View File
@@ -45,6 +45,13 @@ func recordConfirmVerification(e *core.RequestEvent) error {
if !wasVerified {
e.Record.SetVerified(true)
// similar to the OTP auth, we enforce an extra password reset
// guard as this way is less prone to pre-hijacking attacks
// in case the password auth is eventually enabled later
if !e.Record.Collection().PasswordAuth.Enabled {
e.Record.SetRandomPassword()
}
if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
}
@@ -105,6 +105,130 @@ func TestRecordConfirmVerification(t *testing.T) {
"OnRecordValidate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected the user to be unverified before the confirmation")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
Name: "valid token (disabled password auth)",
Method: http.MethodPost,
URL: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordConfirmVerificationRequest": 1,
"OnModelUpdate": 1,
"OnModelValidate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnRecordUpdate": 1,
"OnRecordValidate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// unverified->verified external auths removal
"OnModelDelete": 2,
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user.Collection().PasswordAuth.Enabled = false
if err = app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected the user to be unverified before the confirmation")
}
if !user.ValidatePassword("1234567890") {
t.Fatal("Expected password to be valid")
}
// ensure that there is at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auths")
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected the user to be verified after the confirmation")
}
if user.ValidatePassword("1234567890") {
t.Fatal("Expected the user password to be reset")
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+23 -6
View File
@@ -338,26 +338,43 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
e.Auth.Id == e.Record.Id &&
e.Auth.Collection().Id == e.Record.Collection().Id
// set random password for users with unverified email
// (this is in case a malicious actor has registered previously with the user email)
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
e.Record.SetRandomPassword()
// prevent pre-hijacking with password auth
//
// reset the unverified user password in case the record was precreated by a malicious actor
if !isLoggedAuthRecord && !e.Record.Verified() {
needUpdate = true
e.Record.SetRandomPassword()
}
// prevent pre-hijacking with different OAuth2 provider
//
// delete all other previous OAuth2 record links for the cases
// when the user was precreated by malicious OAuth2 auth with custom payload data
//
// while this would be also done automatically on unverified -> verified upgrade,
// doing it manually here ensures that a single unverified record could have
// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
if !e.Record.Verified() {
err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
optExternalAuth = nil // clear to allow recreate below
}
// update the existing auth record empty email if the data.OAuth2User has one
// (this is in case previously the auth record was created
// with an OAuth2 provider that didn't return an email address)
if e.Record.Email() == "" && e.OAuth2User.Email != "" {
e.Record.SetEmail(e.OAuth2User.Email)
needUpdate = true
e.Record.SetEmail(e.OAuth2User.Email)
}
// update the existing auth record verified state
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
e.Record.SetVerified(true)
needUpdate = true
e.Record.SetVerified(true)
}
if needUpdate {
+10
View File
@@ -55,6 +55,16 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
}
defer client.Unsubscribe(oauth2SubscriptionTopic)
// additional check to minimize the risk of XSRF attack vectors
//
// note: custom registered clients (aka. those without IP in the store)
// are excluded from the check for backward compatibility
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
if clientIP != "" && clientIP != e.RealIP() {
e.App.Logger().Debug("The client IP that completed the authentication is different from the one that initialized the OAuth2 realtime connection")
return failureRedirect(e)
}
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
// (see https://github.com/pocketbase/pocketbase/issues/7090)
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
+25 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/subscriptions"
@@ -16,9 +17,9 @@ import (
func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
t.Parallel()
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
clientStubs := make([]map[string]subscriptions.Client, 0, 11)
for i := 0; i < 10; i++ {
for i := 0; i < 11; i++ {
c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient()
@@ -335,6 +336,28 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
}
},
},
{
Name: "client with different IP",
Method: http.MethodGet,
URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[10]["c3"].Id(),
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
clientStubs[10]["c3"].Set(apis.RealtimeClientIPKey, "127.0.0.1")
beforeTestFunc(clientStubs[10], map[string][]string{
"c3": {`"state":"` + clientStubs[10]["c3"].Id(), `"code":"123"`},
})(t, app, e)
},
ExpectedStatus: http.StatusTemporaryRedirect,
ExpectedEvents: map[string]int{"*": 0},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
app.Store().Get("cancelFunc").(context.CancelFunc)()
checkFailureRedirect(t, app, res)
},
},
}
for _, scenario := range scenarios {
+233 -13
View File
@@ -178,6 +178,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatal(err)
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Id != ea.Id {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// test at least once that the correct request info context is properly loaded
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
info, err := e.RequestInfo()
@@ -213,12 +227,12 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnModelCreate": 2, // user + recreated external auth
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -227,8 +241,15 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelValidate": 2, // create + update
"OnRecordValidate": 2,
"OnModelDelete": 3, // pre-existing external auths
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
// ---
"OnModelValidate": 3, // user create/update + recreated external auth
"OnRecordValidate": 3,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
@@ -248,6 +269,24 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
@@ -343,7 +382,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to be valid", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -353,7 +392,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
},
},
{
Name: "link by email",
Name: "link by email (unverified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
@@ -376,6 +415,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that it was deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
@@ -432,6 +485,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -449,6 +509,145 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
// ensure that other linked external auths have been deleted
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) != 1 {
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
}
if provider := externalAuths[0].Provider(); provider != "test" {
t.Fatalf("Expected %q external auth, got %q", "test", provider)
}
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
}
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
}
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
}
},
},
{
Name: "link by email (verified user)",
Method: http.MethodPost,
URL: "/api/collections/users/auth-with-oauth2",
Body: strings.NewReader(`{
"provider": "test",
"code":"123",
"redirectURL": "https://example.com"
}`),
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatalf("Expected user %q to be verified", user.Email())
}
// ensure that the old password works
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q to be valid", "1234567890")
}
// register the test provider
auth.Providers["test"] = func() auth.Provider {
return &oauth2MockProvider{
AuthUser: &auth.AuthUser{Id: "test_id", Email: "test3@example.com"},
Token: &oauth2.Token{AccessToken: "abc"},
}
}
// ensure that there is at least one other external auth different than test
// so that later we can verify that they are not deleted
var hasAtLeastOneOtherEA = false
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
for _, rel := range externalAuths {
if rel.Provider() != "test" {
hasAtLeastOneOtherEA = true
break
}
}
if !hasAtLeastOneOtherEA {
t.Fatal("Expected at least one non-test external auth linked")
}
// add the test provider in the collection
user.Collection().MFA.Enabled = false
user.Collection().OAuth2.Enabled = true
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: "test",
ClientId: "123",
ClientSecret: "456",
}}
if err := app.Save(user.Collection()); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"record":{`,
`"token":"`,
`"meta":{`,
`"isNew":false`,
`"email":"test3@example.com"`,
`"id":"bgs820n361vj1qd"`,
`"id":"test_id"`,
`"verified":true`,
},
NotExpectedContent: []string{
// hidden fields
`"tokenKey"`,
`"password"`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnRecordAuthWithOAuth2Request": 1,
"OnRecordAuthRequest": 1,
"OnRecordEnrich": 1,
// ---
"OnModelCreate": 2, // authOrigins + externalAuths
"OnModelCreateExecute": 2,
"OnModelAfterCreateSuccess": 2,
"OnRecordCreate": 2,
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelValidate": 2, // authOrigins + externalAuths
"OnRecordValidate": 2,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
if len(devices) != 1 {
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
}
var hasTestEA = false
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if len(externalAuths) <= 1 {
t.Fatalf("Expected to have 2+ ExternalAuth records, got %d (%v)", len(externalAuths), err)
}
for _, rel := range externalAuths {
if rel.Provider() == "test" {
hasTestEA = true
break
}
}
if !hasTestEA {
t.Fatal("Expected test external auth to be linked")
}
},
},
{
@@ -531,6 +730,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordCreateExecute": 2,
"OnRecordAfterCreateSuccess": 2,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 2,
"OnRecordValidate": 2,
},
@@ -541,7 +747,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -652,6 +858,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -662,7 +875,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
@@ -758,6 +971,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
// ---
"OnModelDelete": 2, // pre-existing external auths
"OnModelDeleteExecute": 2,
"OnModelAfterDeleteSuccess": 2,
"OnRecordDelete": 2,
"OnRecordDeleteExecute": 2,
"OnRecordAfterDeleteSuccess": 2,
// ---
"OnModelValidate": 3, // record + authOrigins + externalAuths
"OnRecordValidate": 3,
},
@@ -768,7 +988,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
}
if !user.ValidatePassword("1234567890") {
t.Fatalf("Expected password %q not to be changed", "1234567890")
t.Fatalf("Expected old password %q to remain valid", "1234567890")
}
devices, err := app.FindAllAuthOriginsByRecord(user)
+20 -10
View File
@@ -65,28 +65,38 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
// ---
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
otpId := e.OTP.Id
otpSentTo := e.OTP.SentTo()
// eagerly delete the OTP to avoid unnecessary double delete model hook calls
// triggered by the password change below
err := e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
// update the user email verified state in case the OTP originate from an email address matching the current record one
//
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
e.Record.SetVerified(true)
err = e.App.Save(e.Record)
if err != nil {
// this is technically not required but we enforce password
// reset on verified upgrades in case the OTP is used on its own
// since this makes it less error prone to pre-hijacking attacks
if !e.Record.Collection().MFA.Enabled {
e.Record.SetRandomPassword()
}
if err := e.App.Save(e.Record); err != nil {
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
"error", err,
"otpId", e.OTP.Id,
"otpId", otpId,
"recordId", e.Record.Id,
)
}
}
// try to delete the used otp
err = e.App.Delete(e.OTP)
if err != nil {
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
}
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
})
}
+43 -7
View File
@@ -327,6 +327,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if user.Verified() {
t.Fatal("Expected the user to remain unverified because sentTo != email")
}
// ensure that all pre-existing OAuth2 were NOT deleted
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) != 2 {
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
}
},
},
{
@@ -364,6 +373,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
// verify that there are at least one pre-existing OAuth2 link
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) == 0 {
t.Fatal("Expected at least one external auth")
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -388,10 +406,10 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnModelCreate": 1,
"OnModelCreateExecute": 1,
"OnModelAfterCreateSuccess": 1,
// OTP delete
"OnModelDelete": 1,
"OnModelDeleteExecute": 1,
"OnModelAfterDeleteSuccess": 1,
// record OTP + 2 ExternalAuths delete
"OnModelDelete": 3,
"OnModelDeleteExecute": 3,
"OnModelAfterDeleteSuccess": 3,
// user verified update
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
@@ -401,9 +419,9 @@ func TestRecordAuthWithOTP(t *testing.T) {
"OnRecordCreate": 1,
"OnRecordCreateExecute": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordDelete": 1,
"OnRecordDeleteExecute": 1,
"OnRecordAfterDeleteSuccess": 1,
"OnRecordDelete": 3,
"OnRecordDeleteExecute": 3,
"OnRecordAfterDeleteSuccess": 3,
"OnRecordUpdate": 1,
"OnRecordUpdateExecute": 1,
"OnRecordAfterUpdateSuccess": 1,
@@ -417,6 +435,24 @@ func TestRecordAuthWithOTP(t *testing.T) {
if !user.Verified() {
t.Fatal("Expected the user to be marked as verified")
}
// ensure that all pre-existing OTPs are cleared
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) > 0 {
t.Fatalf("Expected all OTPs to be cleared, found %d", len(otps))
}
// ensure that all pre-existing OAuth2 links are cleared
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(externalAuths) > 0 {
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
}
},
},
{
+20
View File
@@ -85,6 +85,11 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
return e.App.OnRecordAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordRequestEvent) error {
if e.Record == nil || !e.Record.ValidatePassword(e.Password) {
// dummy password check to minimize enumeration side-channel attacks
if e.Record == nil {
dummyPasswordCheck(e.App, e.Collection)
}
return e.BadRequestError("Failed to authenticate.", errors.New("invalid login credentials"))
}
@@ -115,6 +120,21 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
)
}
// dummy password check to minimize side-channel attacks
// (performed with the collection configured field cost)
func dummyPasswordCheck(app core.App, collection *core.Collection) {
record := &core.Record{}
// find any random existing record
err := app.RecordQuery(collection).Limit(1).One(record)
if err != nil {
return
}
// the value and result doesn't matter, we just need a constant-time check
_ = record.ValidatePassword("")
}
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
return nil, errors.New("invalid identity field " + field)
+7
View File
@@ -43,6 +43,13 @@ func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMetho
}
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error {
if authRecord.IsSuperuser() {
allowedIPs := e.App.Settings().SuperuserIPs
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
}
}
originalRequestInfo, err := e.RequestInfo()
if err != nil {
return err
+36
View File
@@ -759,3 +759,39 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
}
})
}
func TestRecordAuthResponseSuperuserIPsWhitelistCheck(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
event := new(core.RequestEvent)
event.App = app
event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
event.Request.Header.Set("x-test-ip", "127.0.0.1")
event.Response = httptest.NewRecorder()
t.Run("non-whitelisted", func(t *testing.T) {
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
err = apis.RecordAuthResponse(event, superuser, "example", nil)
if err == nil {
t.Fatal("Expected response error, got nil")
}
})
t.Run("whitelisted", func(t *testing.T) {
app.Settings().SuperuserIPs = []string{"0.0.0.0", "127.0.0.1"}
err = apis.RecordAuthResponse(event, superuser, "example", nil)
if err != nil {
t.Fatal(err)
}
})
}
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"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'"
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-ancestors 'none'"
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
+194
View File
@@ -0,0 +1,194 @@
package apis
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
runSQLMaxRows = 1000
runSQLMaxTimeout = 3 * time.Minute
)
// bindSQLApi registers the SQL api endpoints.
func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
subGroup.POST("", runSQL)
}
func runSQL(e *core.RequestEvent) error {
// extra precaution in case manually invoked from somewhere else
if !e.HasSuperuserAuth() {
return e.ForbiddenError("", nil)
}
form := runSQLForm{}
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 := executeQuery(e.App, form.Query, runSQLMaxRows)
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
}
return e.JSON(http.StatusOK, result)
}
type runSQLForm struct {
Query string `form:"query" json:"query"`
}
func (form *runSQLForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
)
}
type runSQLResultColumn struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable"`
}
type runSQLResult struct {
ExecTime int64 `json:"execTime"`
AffectedRows int64 `json:"affectedRows"`
Columns []runSQLResultColumn `json:"columns"`
Rows [][]any `json:"rows"`
}
var knownWriteQueryPrefixes = []string{
"INSERT", "CREATE", "UPDATE", "DELETE",
"DROP", "DETACH", "ALTER", "REPLACE",
}
func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
query = strings.TrimSpace(query)
if query == "" {
// see https://github.com/mattn/go-sqlite3/issues/950
return nil, errors.New("empty query")
}
var isPossibleWriteQuery bool
// loosely check the query type
ucQuery := strings.ToUpper(query)
if !strings.HasPrefix(ucQuery, "SELECT") {
for _, prefix := range knownWriteQueryPrefixes {
if strings.HasPrefix(ucQuery, prefix) {
isPossibleWriteQuery = true
break
}
}
}
// note: don't extend the request context to minimize the risk of
// causing integrity issues with custom non-transaction mutations
ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
defer cancelFunc()
result := &runSQLResult{
// init empty slices to ensure "[]" serialization
Columns: []runSQLResultColumn{},
Rows: [][]any{},
}
now := time.Now()
defer func() {
result.ExecTime = time.Since(now).Milliseconds()
}()
// assume write/mutation query
// ---------------------------------------------------------------
if isPossibleWriteQuery {
// auto wrap in transaction in case there are multiple inline queries
txErr := app.RunInTransaction(func(txApp core.App) error {
execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
if err != nil {
return err
}
result.AffectedRows, err = execResult.RowsAffected()
if err != nil {
// non-critical error (e.g. not supported by the driver)
txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
}
return nil
})
if txErr != nil {
return nil, txErr
}
return result, nil
}
// assume query returning rows
// ---------------------------------------------------------------
rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
// populate columns info
// ---
colTypes, err := rows.ColumnTypes()
if err != nil {
return nil, err
}
for _, colType := range colTypes {
col := runSQLResultColumn{
Name: colType.Name(),
Type: colType.DatabaseTypeName(),
}
col.Nullable, _ = colType.Nullable()
result.Columns = append(result.Columns, col)
}
// populate rows
// ---
for rows.Next() {
if len(result.Rows) >= maxRows {
break
}
rowData := make([]any, len(colTypes))
for i := 0; i < len(colTypes); i++ {
var v *string
rowData[i] = &v
}
err := rows.Scan(rowData...)
if err != nil {
return nil, err
}
result.Rows = append(result.Rows, rowData)
}
err = rows.Err()
if err != nil {
return nil, err
}
return result, nil
}
+220
View File
@@ -0,0 +1,220 @@
package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tests"
)
func TestSQLRun(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "regular user",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
Headers: map[string]string{
// users, test2@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
`"columns":[{"name":"1","type":"","nullable":true}]`,
`"rows":[["1"]]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "empty query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":""}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"query":{`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "invalid query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"invalid"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
`SQL logic error`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "query with length above the limit",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"query":{`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "query with length equal to the limit",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select '` + strings.Repeat("a", 4985) + `' as id"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
`"columns":[{"name":"id","type":"","nullable":true}]`,
`"rows":[["aaa`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "single write query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key)"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if !app.HasTable("test_sql_table") {
t.Fatalf("Missing expected new %q table", "test_sql_table")
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
`"columns":[]`,
`"rows":[]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple write queries",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1)"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
var total int
err := app.DB().NewQuery("select count(*) from test_sql_table").Row(&total)
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Fatalf("Expected exactly 1 row, found: %d", total)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":1`,
`"columns":[]`,
`"rows":[]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple write queries (transaction rollback)",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1);invalid"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.HasTable("test_sql_table") {
t.Fatalf("Expected table %q to not be created", "test_sql_table")
}
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
`SQL logic error`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple read queries",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1;select 2"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
// only the result of the last query should be returned
`"columns":[{"name":"2","type":"","nullable":true}]`,
`"rows":[["2"]]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+36
View File
@@ -24,6 +24,7 @@ func NewSuperuserCommand(app core.App) *cobra.Command {
command.AddCommand(superuserUpdateCommand(app))
command.AddCommand(superuserDeleteCommand(app))
command.AddCommand(superuserOTPCommand(app))
command.AddCommand(superuserIPsCommand(app))
return command
}
@@ -209,3 +210,38 @@ func superuserOTPCommand(app core.App) *cobra.Command {
return command
}
func superuserIPsCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "ips",
Example: "superuser ips 127.0.0.1 10.0.0.0/24",
Short: "Updates the superuser IPs whitelist setting (the IPs/subnets arguments must be space separated; leave empty to clear the whitelist restriction)",
SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
settings := app.Settings()
settings.SuperuserIPs = args
if err := app.Save(settings); err != nil {
return err
}
if len(args) == 0 {
color.Green("Successfully cleared SuperuserIPs setting!")
} else {
color.New(color.BgGreen, color.FgBlack).Println("Successfully updated SuperuserIPs setting:")
superuserIPs := app.Settings().SuperuserIPs
for i, ip := range superuserIPs {
if i == len(superuserIPs)-1 {
color.Green("└─ %s", ip)
} else {
color.Green("├─ %s", ip)
}
}
}
return nil
},
}
return command
}
+61
View File
@@ -1,6 +1,7 @@
package cmd_test
import (
"slices"
"testing"
"github.com/pocketbase/pocketbase/cmd"
@@ -401,3 +402,63 @@ func TestSuperuserOTPCommand(t *testing.T) {
})
}
}
func TestSuperuserIPsCommand(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
name string
ips []string
expectError bool
}{
{
"no ips",
nil,
false,
},
{
"invalid ips",
[]string{"127.0.0.1", "invalid"},
true,
},
{
"valid ips",
[]string{"127.0.0.1", "::1", "127.0.0.1/24"},
false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
args := []string{"ips"}
args = append(args, s.ips...)
command := cmd.NewSuperuserCommand(app)
command.SetArgs(args)
err := command.Execute()
hasErr := err != nil
if s.expectError != hasErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
if hasErr {
return
}
settingIPs := app.Settings().SuperuserIPs
if len(settingIPs) != len(s.ips) {
t.Fatalf("Expected %d ips, got %d (%v)", len(s.ips), len(settingIPs), settingIPs)
}
for _, ip := range settingIPs {
if !slices.Contains(s.ips, ip) {
t.Fatalf("Missing expected ip %q (%v)", ip, settingIPs)
}
}
})
}
}
+5
View File
@@ -502,6 +502,11 @@ type App interface {
// ExternalAuth model that satisfies the non-nil expression.
FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error)
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
DeleteAllExternalAuthsByRecord(authRecord *Record) error
// ---------------------------------------------------------------
// FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
+3 -1
View File
@@ -38,8 +38,9 @@ const (
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
LocalNotifyDirName string = ".notify" // optional watched directory that is used as a cross-platform workaround for synchronizing various runtime states between multiple PocketBase instances pointing to the same pb_data
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
// @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found"
@@ -1382,6 +1383,7 @@ func (app *BaseApp) registerBaseHooks() {
app.registerMFAHooks()
app.registerOTPHooks()
app.registerAuthOriginHooks()
app.registerNotifyWatcherHooks()
}
// getLoggerMinLevel returns the logger min level based on the
+20 -1
View File
@@ -54,7 +54,13 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup generation
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
event.Exclude = []string{
LocalBackupsDirName,
LocalTempDirName,
LocalNotifyDirName,
LocalAutocertCacheDirName,
lostFoundDirName,
}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
// generate a default name if missing
@@ -316,6 +322,19 @@ func (app *BaseApp) registerAutobackupHooks() {
slog.String("name", name),
slog.String("error", err.Error()),
)
alertError := sendSystemAlertToAllSuperusers(
app,
"Autobackup failure",
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
)
if alertError != nil {
app.Logger().Warn(
"[Backup cron] Failed to send backup error alerts",
slog.String("name", name),
slog.String("error", alertError.Error()),
)
}
}
maxKeep := app.Settings().Backups.CronMaxKeep
+20
View File
@@ -348,6 +348,7 @@ func (app *BaseApp) registerCollectionHooks() {
}
// @todo experiment eventually replacing the rules *string with a struct?
// @todo consider changing the Indexes field to a "getter" for the sqlite_master table?
type baseCollection struct {
BaseModel
@@ -820,6 +821,25 @@ func onCollectionSave(e *CollectionEvent) error {
e.Collection.updateGeneratedIdIfExists(e.App)
// normalize indexes table name
for i, raw := range e.Collection.Indexes {
parsed := dbutils.ParseIndex(raw)
// no need to normalize
if parsed.TableName == e.Collection.Name {
continue
}
parsed.TableName = e.Collection.Name
normalized := parsed.Build()
if normalized == "" {
continue // leave to the model validator to decide whether to return an error
}
e.Collection.Indexes[i] = normalized
}
return e.Next()
}
+3 -3
View File
@@ -60,7 +60,7 @@ func (m *Collection) setDefaultAuthOptions() {
},
MFA: MFAConfig{
Enabled: false,
Duration: 1800, // 30min
Duration: 600, // 10min
},
OTP: OTPConfig{
Enabled: false,
@@ -70,7 +70,7 @@ func (m *Collection) setDefaultAuthOptions() {
},
AuthToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 604800, // 7 days
Duration: 432000, // 5days
},
PasswordResetToken: TokenConfig{
Secret: security.RandomString(50),
@@ -82,7 +82,7 @@ func (m *Collection) setDefaultAuthOptions() {
},
VerificationToken: TokenConfig{
Secret: security.RandomString(50),
Duration: 259200, // 3days
Duration: 86400, // 1day
},
FileToken: TokenConfig{
Secret: security.RandomString(50),
+3 -2
View File
@@ -18,6 +18,7 @@ var defaultVerificationTemplate = EmailTemplate{
<p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Verify</a>
</p>
<p><i>If you didn't recently register, please ignore this email.</i></p>
<p>
Thanks,<br/>
` + EmailPlaceholderAppName + ` team
@@ -31,7 +32,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
<p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Reset password</a>
</p>
<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>
<p>
Thanks,<br/>
` + EmailPlaceholderAppName + ` team
@@ -45,7 +46,7 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
<p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Confirm new email</a>
</p>
<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>
<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>
<p>
Thanks,<br/>
` + EmailPlaceholderAppName + ` team
+37
View File
@@ -1678,3 +1678,40 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
})
}
}
func TestCollectionSaveIndexesTableNameNormalization(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
dummyCollection := core.NewBaseCollection("new_test")
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
dummyCollection.Indexes = []string{
"create index `new_test_idx1` on `` (`test`) where 1=1",
"create index `new_test_idx2` on `test` (`test`) where 1=2",
"create index `new_test_idx3` on `someting_else` (`test`) where 1=3",
}
err := app.Save(dummyCollection)
if err != nil {
t.Fatal(err)
}
// refetch a clean state
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
if err != nil {
t.Fatal(err)
}
if len(dummyCollection.Indexes) != 3 {
t.Fatalf("Expected 3 indexes, got %v", dummyCollection.Indexes)
}
for _, raw := range dummyCollection.Indexes {
parsed := dbutils.ParseIndex(raw)
if parsed.TableName != dummyCollection.Name {
t.Fatalf("Expected all indexes to have tableName %q, found %q:\n%s", dummyCollection.Name, parsed.TableName, raw)
}
}
}
+4 -2
View File
@@ -306,8 +306,10 @@ func dropCollectionIndexes(app App, collection *Collection) error {
for _, raw := range collection.Indexes {
parsed := dbutils.ParseIndex(raw)
if !parsed.IsValid() {
continue
// note: don't check IsValid because the index table name may not be populated
// (https://github.com/pocketbase/pocketbase/issues/7689)
if parsed.IndexName == "" {
return fmt.Errorf("failed to dop index - missing index name: %s", raw)
}
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
+46
View File
@@ -294,3 +294,49 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
})
}
}
func TestDropIndexWithoutTableName(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
properIndex := "CREATE INDEX `new_test_idx2` ON `new_test` (`test`)"
indexWithoutTableName := "CREATE INDEX `new_test_idx2` ON `` (`test`)"
dummyCollection := core.NewBaseCollection("new_test")
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
dummyCollection.Indexes = []string{properIndex}
err := app.Save(dummyCollection)
if err != nil {
t.Fatal(err)
}
// resave without table name but without hooks to avoid the normalizations
dummyCollection.Indexes[0] = indexWithoutTableName
err = app.UnsafeWithoutHooks().Save(dummyCollection)
if err != nil {
t.Fatal(err)
}
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
if err != nil {
t.Fatal(err)
}
// resave should normalize the index
err = app.Save(dummyCollection)
if err != nil {
t.Fatal(err)
}
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
if err != nil {
t.Fatal(err)
}
if len(dummyCollection.Indexes) != 1 || dummyCollection.Indexes[0] != properIndex {
t.Fatalf("Expected exactly 1 index\n%s\ngot\n%v", properIndex, dummyCollection.Indexes)
}
}
+17 -1
View File
@@ -448,8 +448,24 @@ type RealtimeConnectRequestEvent struct {
Client subscriptions.Client
// note: modifying it after the connect has no effect
// IdleTimeout specifies the max duration to wait for a new message
// before closing the connection.
//
// Modifying the value after the connection has been established has no effect.
//
// Defaults to 5 minutes.
IdleTimeout time.Duration
// MaxTimeout specifies the maximum duration a realtime connection
// can remain open (including even if there are ongoing messages).
//
// Once the specified duration expires, the current connection will
// be terminated, until a client reconnect is issued (if the client is still active).
//
// Modifying the value after the connection has been established has no effect.
//
// Defaults to 30 minutes.
MaxTimeout time.Duration
}
type RealtimeMessageEvent struct {
+38
View File
@@ -137,4 +137,42 @@ func (app *BaseApp) registerExternalAuthHooks() {
},
Priority: 99,
})
// delete all pre-existing external auths on verified upgrade
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
if !e.Record.Collection().IsAuth() {
return e.Next()
}
hasUpgradedVerified := !e.Record.Original().IsNew() && !e.Record.Original().Verified() && e.Record.Verified()
if !hasUpgradedVerified {
return e.Next()
}
originalApp := e.App
return e.App.RunInTransaction(func(txApp App) error {
e.App = txApp
defer func() { e.App = originalApp }()
externalAuths, err := txApp.FindAllExternalAuthsByRecord(e.Record)
if err != nil {
return err
}
if len(externalAuths) > 0 {
// delete all pre-existing external auths
if err := txApp.DeleteAllExternalAuthsByRecord(e.Record); err != nil {
return err
}
// force refresh tokens reset (if not already)
e.Record.RefreshTokenKey()
}
return e.Next()
})
},
Priority: 99,
})
}
+101
View File
@@ -308,3 +308,104 @@ func TestExternalAuthValidateHook(t *testing.T) {
})
}
}
func TestExternalAuthClearOnVerfiedUpgrade(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
t.Run("unverified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
t.Run("unverified->verified", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
if user.Verified() {
t.Fatal("Expected user to be unverified")
}
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
user.SetVerified(true)
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey == user.TokenKey() {
t.Fatal("Expected tokenKey to be renewed")
}
externalAuths, err = app.FindAllExternalAuthsByRecord(user)
if err != nil || len(externalAuths) != 0 {
t.Fatalf("Expected all user external auths to be deleted, found %d (%v)", len(externalAuths), err)
}
})
t.Run("verified->no changes", func(t *testing.T) {
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
if err != nil {
t.Fatal(err)
}
if !user.Verified() {
t.Fatal("Expected user to be verified")
}
beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(beforeAuths) == 0 {
t.Fatalf("Expected at least one external auth (%v)", err)
}
oldTokenKey := user.TokenKey()
if err = app.Save(user); err != nil {
t.Fatal(err)
}
if oldTokenKey != user.TokenKey() {
t.Fatal("Expected tokenKey to remain unchanged")
}
afterAuths, err := app.FindAllExternalAuthsByRecord(user)
if err != nil || len(afterAuths) != len(beforeAuths) {
t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
}
})
}
+24
View File
@@ -1,6 +1,8 @@
package core
import (
"errors"
"github.com/pocketbase/dbx"
)
@@ -59,3 +61,25 @@ func (app *BaseApp) FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalA
return model, nil
}
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
//
// Returns a combined error with the failed deletes.
func (app *BaseApp) DeleteAllExternalAuthsByRecord(authRecord *Record) error {
models, err := app.FindAllExternalAuthsByRecord(authRecord)
if err != nil {
return err
}
var errs []error
for _, m := range models {
if err := app.Delete(m); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
+66
View File
@@ -2,6 +2,7 @@ package core_test
import (
"fmt"
"slices"
"testing"
"github.com/pocketbase/dbx"
@@ -174,3 +175,68 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
})
}
}
func TestDeleteAllExternalAuthsByRecord(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
if err != nil {
t.Fatal(err)
}
user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
if err != nil {
t.Fatal(err)
}
client2, err := testApp.FindAuthRecordByEmail("clients", "test2@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
record *core.Record
deletedIds []string
}{
{demo1, nil}, // non-auth record
{user1, []string{"dlmflokuq1xl342", "clmflokuq1xl341"}},
{client1, []string{"f1z5b3843pzc964"}},
{client2, nil},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
deletedIds := []string{}
app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
deletedIds = append(deletedIds, e.Record.Id)
return e.Next()
})
err := app.DeleteAllExternalAuthsByRecord(s.record)
if err != nil {
t.Fatal(err)
}
if len(deletedIds) != len(s.deletedIds) {
t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
}
for _, id := range s.deletedIds {
if !slices.Contains(deletedIds, id) {
t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
}
}
})
}
}
+18 -6
View File
@@ -2,7 +2,6 @@ package core
import (
"context"
"fmt"
"math"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -23,6 +22,12 @@ var (
_ SetterFinder = (*NumberField)(nil)
)
var (
onlyIntValidationError = validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed")
minNumberValidationError = validation.NewError("validation_min_number_constraint", "Must be greater or equal than {{.min}}")
maxNumberValidationError = validation.NewError("validation_max_number_constraint", "Must be less or equal than {{.max}}")
)
// NumberField defines "number" type field for storing numeric (float64) value.
//
// The respective zero record field value is 0.
@@ -151,15 +156,15 @@ func (f *NumberField) ValidateValue(ctx context.Context, app App, record *Record
}
if f.OnlyInt && val != float64(int64(val)) {
return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed")
return onlyIntValidationError
}
if f.Min != nil && val < *f.Min {
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *f.Min))
return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
}
if f.Max != nil && val > *f.Max {
return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *f.Max))
return maxNumberValidationError.SetParams(map[string]any{"max": *f.Max})
}
return nil
@@ -171,7 +176,14 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
validation.By(f.checkOnlyInt),
}
if f.Min != nil && f.Max != nil {
maxRules = append(maxRules, validation.Min(*f.Min))
maxRules = append(maxRules, validation.By(func(value interface{}) error {
// similar to validation.Min but doesn't ignore zero values
v, _ := value.(*float64)
if v == nil || f.Min == nil || *v >= *f.Min {
return nil
}
return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
}))
}
return validation.ValidateStruct(f,
@@ -190,7 +202,7 @@ func (f *NumberField) checkOnlyInt(value any) error {
}
if *v != float64(int64(*v)) {
return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed.")
return onlyIntValidationError
}
return nil
+31 -7
View File
@@ -237,7 +237,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"decumal min",
"decimal min",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
@@ -248,7 +248,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"decumal min (onlyInt)",
"decimal min (onlyInt)",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
@@ -272,7 +272,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"decumal max",
"decimal max",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
@@ -283,7 +283,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"decumal max (onlyInt)",
"decimal max (onlyInt)",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
@@ -307,19 +307,31 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{},
},
{
"min > max",
"min > max (0)",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
Name: "test",
Min: types.Pointer(2.0),
Max: types.Pointer(1.0),
Max: types.Pointer(0.0),
}
},
[]string{"max"},
},
{
"min <= max",
"min (0) > max",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
Name: "test",
Min: types.Pointer(0.0),
Max: types.Pointer(-1.0),
}
},
[]string{"max"},
},
{
"min == max",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
@@ -330,6 +342,18 @@ func TestNumberFieldValidateSettings(t *testing.T) {
},
[]string{},
},
{
"min < max",
func() *core.NumberField {
return &core.NumberField{
Id: "test",
Name: "test",
Min: types.Pointer(2.0),
Max: types.Pointer(3.0),
}
},
[]string{},
},
}
for _, s := range scenarios {
+6 -5
View File
@@ -3,6 +3,7 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
@@ -141,11 +142,11 @@ func (app *BaseApp) registerMFAHooks() {
if old != new {
err = e.App.DeleteAllMFAsByRecord(e.Record)
if err != nil {
e.App.Logger().Warn(
"Failed to delete all previous mfas",
"error", err,
"recordId", e.Record.Id,
"collectionId", e.Record.Collection().Id,
return fmt.Errorf(
"[%s] failed to delete all previous MFAs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
+61
View File
@@ -300,3 +300,64 @@ func TestMFAValidateHook(t *testing.T) {
})
}
}
func TestMFAClearOnPasswordChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
mfasToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range mfasToCreate {
for range total {
mfa := core.NewMFA(app)
mfa.SetCollectionRef(user.Collection().Id)
mfa.SetRecordRef(user.Id)
mfa.SetMethod(core.MFAMethodPassword)
if err := app.Save(mfa); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.SetRandomPassword()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedMFAs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedMFAs {
mfas, err := app.FindAllMFAsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(mfas) != expected {
t.Fatalf("Expected %d MFAs, got %d", expected, len(mfas))
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package core
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/security"
)
const systemHookIdNotifyWatcher = "__pbNotifyWatcherSystemHook__"
func (app *BaseApp) registerNotifyWatcherHooks() {
var notifyWatcher *fsnotify.Watcher
instanceId := "@" + security.PseudorandomString(10)
localNotifyDirPath := filepath.Join(app.DataDir(), LocalNotifyDirName)
settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
// init
app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *BootstrapEvent) error {
err := e.Next()
if err != nil {
return err
}
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
if err != nil {
e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
}
return nil
},
Priority: -998,
})
// cleanup
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
Id: systemHookIdNotifyWatcher,
Func: func(e *TerminateEvent) error {
if notifyWatcher != nil {
_ = notifyWatcher.Close()
}
_ = os.Remove(settingsFile)
_ = os.Remove(collectionsFile)
return e.Next()
},
Priority: -998,
})
// ---------------------------------------------------------------
settingsNotify := func(e *ModelEvent) error {
err := e.Next()
if err != nil || e.Model.PK() != paramsKeySettings {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(settingsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
}
_ = os.Remove(settingsFile)
}
return nil
}
app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
Id: systemHookIdNotifyWatcher,
Func: settingsNotify,
Priority: 999,
})
// ---------------------------------------------------------------
collectionsNotify := func(e *CollectionEvent) error {
if err := e.Next(); err != nil {
return err
}
if notifyWatcher != nil {
if err := os.WriteFile(collectionsFile, nil, 0644); err != nil {
e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
}
_ = os.Remove(collectionsFile)
}
return nil
}
app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*CollectionEvent]{
Id: systemHookIdNotifyWatcher,
Func: collectionsNotify,
Priority: 999,
})
}
func createNotifyDirWatcher(app App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
// create the notify dir (if not already)
err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
if err != nil {
return nil, fmt.Errorf("failed to create a notify dir: %w", err)
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
}
err = watcher.Add(localNotifyDirPath)
if err != nil {
_ = watcher.Close()
return nil, fmt.Errorf("unable to watch notify dir: %w", err)
}
var debounceTimer *time.Timer
stopDebounceTimer := func() {
if debounceTimer != nil {
debounceTimer.Stop()
debounceTimer = nil
}
}
// watch
go func() {
defer stopDebounceTimer()
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// modified from within the current app instance or cleanup event
if strings.HasSuffix(event.Name, instanceId) || event.Has(fsnotify.Remove) || !app.IsBootstrapped() {
continue
}
stopDebounceTimer()
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
filename := filepath.Base(event.Name)
// settings changed
if strings.HasPrefix(filename, "settings@") {
app.Logger().Debug("Reloading settings after notify event")
err := app.ReloadSettings()
if err != nil {
app.Logger().Warn("Failed to reload app settings after notify", "error", err)
}
return
}
// collections changed
if strings.HasPrefix(filename, "collections@") {
app.Logger().Debug("Reloading cached collections after notify event")
err := app.ReloadCachedCollections()
if err != nil {
app.Logger().Warn("Failed to reload cached collections after notify", "error", err)
}
return
}
})
case err, ok := <-watcher.Errors:
if app.IsDev() && err != nil {
color.Red("Notify dir watch error:", err)
}
if !ok {
return
}
}
}
}()
return watcher, err
}
+190
View File
@@ -0,0 +1,190 @@
package core_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/sync/semaphore"
)
func TestNotifyWatcher_SettingsUpdate(t *testing.T) {
t.Parallel()
testEvents := store.New[core.App, int](nil)
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
app1 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app1.Bootstrap(); err != nil {
t.Fatal(err)
}
app2 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app2.Bootstrap(); err != nil {
t.Fatal(err)
}
timeout := time.After(3 * time.Second)
done := make(chan struct{})
app1.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
testEvents.SetFunc(app1, func(old int) int {
return old + 1
})
return e.Next()
})
app2.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
testEvents.SetFunc(app2, func(old int) int {
defer func() {
done <- struct{}{}
}()
return old + 1
})
return e.Next()
})
// updating app1 settings should trigger a reload in app2
app1.Settings().SuperuserIPs = []string{"127.0.0.1"}
if err := app1.Save(app1.Settings()); err != nil {
t.Fatal(err)
}
// wait for the event
select {
case <-timeout:
t.Fatal("app2 reload event timeout")
case <-done:
// ready
}
if app1Total := testEvents.Get(app1); app1Total != 1 {
t.Fatalf("Expected 1 app1 event, got %d", app1Total)
}
if app2Total := testEvents.Get(app2); app2Total != 1 {
t.Fatalf("Expected 1 app2 event, got %d", app2Total)
}
app2SuperuserIPs := app2.Settings().SuperuserIPs
if len(app2SuperuserIPs) != 1 || app2SuperuserIPs[0] != "127.0.0.1" {
t.Fatalf("Expected exactly 127.0.0.1 superuser IP in app2 settings event, got %v", app2SuperuserIPs)
}
}
func TestNotifyWatcher_CollectionsUpdate(t *testing.T) {
t.Parallel()
tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
app1 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app1.Bootstrap(); err != nil {
t.Fatal(err)
}
app2 := core.NewBaseApp(core.BaseAppConfig{
DataDir: tmpDir,
})
if err := app2.Bootstrap(); err != nil {
t.Fatal(err)
}
testQueries := store.New[string, []string](nil)
app2.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
testQueries.SetFunc("concurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
testQueries.SetFunc("concurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
return append(old, sql)
})
}
app2.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
testQueries.SetFunc("nonconcurrent", func(old []string) []string {
return append(old, sql)
})
}
ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelCtx()
sem := semaphore.NewWeighted(1)
sem.Acquire(ctx, 1)
// currently there is no hook for the collections cache reload so we pool instead
done := make(chan bool, 1)
ticker := time.NewTicker(100 * time.Millisecond)
go func() {
for {
select {
case <-ticker.C:
if len(testQueries.Get("concurrent")) == 1 {
sem.Release(1)
return
}
case <-done:
return
}
}
}()
// create/update/delete app1 collections should trigger a reload in app2
dummyCollection := core.NewBaseCollection("test")
if err := app1.Save(dummyCollection); err != nil {
t.Fatal(err)
}
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
if err := app1.Save(dummyCollection); err != nil {
t.Fatal(err)
}
if err := app1.Delete(dummyCollection); err != nil {
t.Fatal(err)
}
// block until released or timeouted
sem.Acquire(ctx, 1)
ticker.Stop()
done <- true
nonconcurrentQueries := testQueries.Get("nonconcurrent")
concurrentQueries := testQueries.Get("concurrent")
if len(nonconcurrentQueries) != 0 {
t.Fatalf("Expected 0 concurrent queries, got %d (%v)", len(nonconcurrentQueries), nonconcurrentQueries)
}
if len(concurrentQueries) != 1 {
t.Fatalf("Expected 1 concurrent query, got %d (%v)", len(concurrentQueries), concurrentQueries)
}
expectedQuery := "SELECT {{_collections}}.* FROM `_collections` ORDER BY `rowid` ASC"
if concurrentQueries[0] != expectedQuery {
t.Fatalf("Expected query\n%s\ngot\n%s", expectedQuery, concurrentQueries[0])
}
}
+27
View File
@@ -3,8 +3,10 @@ package core
import (
"context"
"errors"
"fmt"
"time"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -124,4 +126,29 @@ func (app *BaseApp) registerOTPHooks() {
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
}
})
// delete all record OTPs on tokenKey change to minimize the risk of hijacking attacks
app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
Func: func(e *RecordEvent) error {
err := e.Next()
if err != nil || !e.Record.Collection().IsAuth() {
return err
}
if !e.Record.Original().IsNew() && e.Record.Original().TokenKey() != e.Record.TokenKey() {
err := e.App.DeleteAllOTPsByRecord(e.Record)
if err != nil {
return fmt.Errorf(
"[%s] failed to delete all previous OTPs for record %q: %w",
e.Record.Collection().Name,
e.Record.Id,
err,
)
}
}
return nil
},
Priority: 99,
})
}
+61
View File
@@ -300,3 +300,64 @@ func TestOTPValidateHook(t *testing.T) {
})
}
}
func TestOTPClearOnTokenKeyChange(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
if err != nil {
t.Fatal(err)
}
otpsToCreate := map[*core.Record]int{
user1: 3,
user2: 2,
}
for user, total := range otpsToCreate {
for range total {
otp := core.NewOTP(app)
otp.SetCollectionRef(user.Collection().Id)
otp.SetRecordRef(user.Id)
otp.SetPassword("123456")
if err := app.Save(otp); err != nil {
t.Fatal(err)
}
}
}
// update both users
err = app.Save(user1)
if err != nil {
t.Fatal(err)
}
user2.RefreshTokenKey()
err = app.Save(user2)
if err != nil {
t.Fatal(err)
}
expectedOTPs := map[*core.Record]int{
user1: 3,
user2: 0,
}
for user, expected := range expectedOTPs {
otps, err := app.FindAllOTPsByRecord(user)
if err != nil {
t.Fatal(err)
}
if len(otps) != expected {
t.Fatalf("Expected %d OTPs, got %d", expected, len(otps))
}
}
}
+3 -2
View File
@@ -1428,13 +1428,13 @@ func onRecordValidate(e *RecordEvent) error {
func onRecordSaveExecute(e *RecordEvent) error {
if e.Record.Collection().IsAuth() {
// ensure that the token key is regenerated on password change or email change
if !e.Record.IsNew() {
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
if err != nil {
return err
}
// ensure that the token key is regenerated on password change or email change
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
lastSavedRecord.Email() != e.Record.Email()) {
@@ -1442,7 +1442,8 @@ func onRecordSaveExecute(e *RecordEvent) error {
}
}
// cross-check that the auth record id is unique across all auth collections.
// loosely cross-check that the auth record id is unique across all auth collections
// to minimize impact of mistakes in API rules when multiple auth collections are used
authCollections, err := e.App.FindAllCollections(CollectionTypeAuth)
if err != nil {
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
+11 -2
View File
@@ -162,11 +162,20 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
// ---------------------------------------------------------------
// extract the id of the relations to expand
// extract the unique ids of the relations to expand
// (the initial size assumes that most of the relations are single and unique)
existsSet := make(map[string]struct{}, len(records))
relIds := make([]string, 0, len(records))
for _, record := range records {
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
ids := record.GetStringSlice(relField.Name)
for _, id := range ids {
if _, ok := existsSet[id]; !ok {
existsSet[id] = struct{}{}
relIds = append(relIds, id)
}
}
}
existsSet = nil
// fetch rels
rels, relsErr := fetchFunc(relCollection, relIds)
+42
View File
@@ -1,11 +1,15 @@
package core_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
@@ -484,3 +488,41 @@ func TestBackRelationExpandSingeVsArrayResult(t *testing.T) {
}
}
}
func TestExpandRecordsQuerySkipDuplicatedIds(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
// fetch records that are known to have at least 1 common relation between them
records, err := app.FindRecordsByIds("demo1", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"})
if err != nil {
t.Fatal(err)
}
// log selects
concurrentQueries := []string{}
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
concurrentQueries = append(concurrentQueries, sql)
}
// expand
failed := app.ExpandRecords(records, []string{"rel_many"}, nil)
if len(failed) > 0 {
t.Fatalf("Expected no expand errors, got %v", failed)
}
if len(concurrentQueries) != 1 {
t.Fatalf("Expected exactly 1 expand query, got %d:\n%v", len(concurrentQueries), concurrentQueries)
}
// "oap640cot4yru2s" is used in both relations but must exists only once
expected := "SELECT `users`.* FROM `users` WHERE `users`.`id` IN ('oap640cot4yru2s', 'bgs820n361vj1qd', '4q1xlclmfloku33')"
if concurrentQueries[0] != expected {
t.Fatalf("Expected query\n%v\ngot\n%v", expected, concurrentQueries[0])
}
}
+28 -2
View File
@@ -120,6 +120,10 @@ var (
)
type settings struct {
// SuperuserIPs defines an optional list of the superuser allowed
// individual IPs and subnets (in CIDR notation).
SuperuserIPs []string `form:"superuserIPs" json:"superuserIPs"`
SMTP SMTPConfig `form:"smtp" json:"smtp"`
Backups BackupsConfig `form:"backups" json:"backups"`
S3 S3Config `form:"s3" json:"s3"`
@@ -253,6 +257,12 @@ func (s *Settings) DBExport(app App) (map[string]any, error) {
}
result["updated"] = now
// @todo remove with encoding/json/2
// serialize as empty array
if s.settings.SuperuserIPs == nil {
s.settings.SuperuserIPs = []string{}
}
encoded, err := json.Marshal(s.settings)
if err != nil {
return nil, err
@@ -280,6 +290,7 @@ func (s *Settings) PostValidate(ctx context.Context, app App) error {
defer s.mu.RUnlock()
return validation.ValidateStructWithContext(ctx, s,
validation.Field(&s.SuperuserIPs, validation.Each(validation.Required, validation.By(validators.IPOrSubnet))),
validation.Field(&s.Meta),
validation.Field(&s.Logs),
validation.Field(&s.SMTP),
@@ -343,6 +354,12 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
}
}
// @todo remove with encoding/json/2
// serialize as empty array
if copy.SuperuserIPs == nil {
copy.SuperuserIPs = []string{}
}
return json.Marshal(copy)
}
@@ -531,6 +548,7 @@ func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
// @todo when replacing the URL validator we may need a system migration to normalize values without protocol
validation.Field(&c.AppURL, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required),
@@ -587,8 +605,9 @@ func (c TrustedProxyConfig) Validate() error {
// -------------------------------------------------------------------
type RateLimitsConfig struct {
Rules []RateLimitRule `form:"rules" json:"rules"`
Enabled bool `form:"enabled" json:"enabled"`
Rules []RateLimitRule `form:"rules" json:"rules"`
ExcludedIPs []string `form:"excludedIPs" json:"excludedIPs"`
Enabled bool `form:"enabled" json:"enabled"`
}
// FindRateLimitRule returns the first matching rule based on the provided labels.
@@ -633,6 +652,9 @@ func (c RateLimitsConfig) MarshalJSON() ([]byte, error) {
if c.Rules == nil {
c.Rules = []RateLimitRule{}
}
if c.ExcludedIPs == nil {
c.ExcludedIPs = []string{}
}
return json.Marshal(alias(c))
}
@@ -645,6 +667,10 @@ func (c RateLimitsConfig) Validate() error {
validation.When(c.Enabled, validation.Required),
validation.By(checkUniqueRuleLabel),
),
validation.Field(
&c.ExcludedIPs,
validation.Each(validation.Required, validation.By(validators.IPOrSubnet)),
),
)
}
+9 -5
View File
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
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}}`
expected := `{"superuserIPs":[],"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":[],"excludedIPs":[],"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)
}
@@ -180,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
}
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":{"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}}`
expected := `{"superuserIPs":[],"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":[],"excludedIPs":[],"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 {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -196,6 +196,7 @@ func TestSettingsValidate(t *testing.T) {
s := app.Settings()
// set invalid settings data
s.SuperuserIPs = []string{"127.0.0.1", ""}
s.Meta.AppName = ""
s.Logs.MaxDays = -10
s.SMTP.Enabled = true
@@ -217,6 +218,7 @@ func TestSettingsValidate(t *testing.T) {
}
expectations := []string{
`"superuserIPs":{`,
`"meta":{`,
`"logs":{`,
`"smtp":{`,
@@ -595,7 +597,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
{
"invalid data",
core.RateLimitsConfig{
Enabled: true,
Enabled: true,
ExcludedIPs: []string{"", "127.0.0.1"},
Rules: []core.RateLimitRule{
{
Label: "/123abc/",
@@ -609,12 +612,13 @@ func TestRateLimitsConfigValidate(t *testing.T) {
},
},
},
[]string{"rules"},
[]string{"rules", "excludedIPs"},
},
{
"valid data",
core.RateLimitsConfig{
Enabled: true,
Enabled: true,
ExcludedIPs: []string{"127.0.0.1", "10.0.0.1/20"},
Rules: []core.RateLimitRule{
{
Label: "123_abc",
+131
View File
@@ -0,0 +1,131 @@
package core
import (
"bytes"
"errors"
"html"
"html/template"
"net/mail"
"github.com/pocketbase/pocketbase/tools/mailer"
)
const systemAlertHTML = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body, html {
padding: 0;
margin: 0;
border: 0;
color: #16161a;
background: #fff;
font-size: 14px;
line-height: 20px;
font-weight: normal;
font-family: Source Sans Pro, sans-serif, emoji;
}
body {
padding: 20px 30px;
}
p {
display: block;
margin: 10px 0;
font-family: inherit;
}
small {
font-size: 12px;
line-height: 16px;
}
strong {
font-weight: bold;
}
em, i {
font-style: italic;
}
a {
color: inherit;
}
.alert {
padding: 15px;
background: #e4e8ec;
border-radius: 5px;
white-space: pre-wrap;
}
</style>
</head>
<body>
<p>{{.AppName}} system alert occurred:</p>
<p class="alert"><strong>{{.AlertDetails}}</strong></p>
<p>For more information you could explore the logs in the dashboard of your application.</p>
</body>
</html>`
// sendSystemAlertToAllSuperusers sends a system error alert to all superusers.
//
// note: unexported for now until there is clarity around the planned log level alerts.
func sendSystemAlertToAllSuperusers(app App, subject string, details string) error {
superusers, err := app.FindAllRecords(CollectionNameSuperusers)
if err != nil {
return err
}
var alertErrors []error
for _, superuser := range superusers {
err := sendSystemAlert(app, superuser, subject, details)
if err != nil {
alertErrors = append(alertErrors, err)
}
}
return errors.Join(alertErrors...)
}
// sendSystemAlert sends a system error alert to a single superuser.
//
// note: unexported for now until there is clarity around the planned log level alerts.
func sendSystemAlert(app App, superuser *Record, subject string, details string) error {
if !superuser.IsSuperuser() {
return errors.New("system alerts can be sent only to superusers")
}
if subject == "" || details == "" {
return errors.New("system alerts subject and details are required")
}
data := struct {
AppName string
AlertDetails string
}{
AppName: app.Settings().Meta.AppName,
AlertDetails: details,
}
tpl := template.New("system_alert")
var parseErr error
tpl, parseErr = tpl.Parse(systemAlertHTML)
if parseErr != nil {
return parseErr
}
var buff bytes.Buffer
executeErr := tpl.Execute(&buff, data)
if executeErr != nil {
return executeErr
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: []mail.Address{{Address: superuser.Email()}},
Subject: "[" + app.Settings().Meta.AppName + " system alert] " + html.EscapeString(subject),
HTML: buff.String(),
}
return app.NewMailClient().Send(message)
}
+124
View File
@@ -0,0 +1,124 @@
package core
import (
"os"
"strconv"
"strings"
"testing"
)
func TestSendSystemAlert(t *testing.T) {
t.Parallel()
testDataDir, err := os.MkdirTemp("", "sendSystemAlert_pb_data")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
}
if err := createTestSuperusers(testApp, 3); err != nil {
t.Fatal(err)
}
superuser, err := testApp.FindAuthRecordByEmail(CollectionNameSuperusers, "test1@example.com")
if err != nil {
t.Fatal(err)
}
var sendCalls int
testApp.OnMailerSend().BindFunc(func(e *MailerEvent) error {
sendCalls++
if !strings.Contains(e.Message.Subject, "test_subject") {
t.Fatalf("Missing %q in Message.Subject:\n%s", "test_subject", e.Message.Subject)
}
if !strings.Contains(e.Message.HTML, "test_details") {
t.Fatalf("Missing %q in Message.HTML:\n%s", "test_details", e.Message.HTML)
}
if len(e.Message.To) != 1 || e.Message.To[0].Address != "test1@example.com" {
t.Fatalf("Expected To address %q, got %v", "test1@example.com", e.Message.To)
}
return nil
})
sendSystemAlert(testApp, superuser, "test_subject", "test_details")
if sendCalls != 1 {
t.Fatalf("Expected 1 mail send call, got %d", sendCalls)
}
}
func TestSendSystemAlertToAllSuperusers(t *testing.T) {
t.Parallel()
testDataDir, err := os.MkdirTemp("", "sendSystemAlertToAllSuperusers_pb_data")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDataDir)
testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir,
})
defer testApp.ResetBootstrapState()
if err := testApp.Bootstrap(); err != nil {
t.Fatal(err)
}
if err := createTestSuperusers(testApp, 3); err != nil {
t.Fatal(err)
}
var sendCalls int
testApp.OnMailerSend().BindFunc(func(e *MailerEvent) error {
sendCalls++
if !strings.Contains(e.Message.Subject, "test_subject") {
t.Fatalf("Missing %q in Message.Subject:\n%s", "test_subject", e.Message.Subject)
}
if !strings.Contains(e.Message.HTML, "test_details") {
t.Fatalf("Missing %q in Message.HTML:\n%s", "test_details", e.Message.HTML)
}
return nil
})
sendSystemAlertToAllSuperusers(testApp, "test_subject", "test_details")
if sendCalls != 3 {
t.Fatalf("Expected 3 mail send calls, got %d", sendCalls)
}
}
func createTestSuperusers(app App, total int) error {
superusersCollection, err := app.FindCollectionByNameOrId(CollectionNameSuperusers)
if err != nil {
return err
}
for i := range total {
superuser := NewRecord(superusersCollection)
superuser.SetEmail("test" + strconv.Itoa(i+1) + "@example.com")
superuser.SetRandomPassword()
if err := app.Save(superuser); err != nil {
return err
}
}
return nil
}
+2 -2
View File
@@ -31,7 +31,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
"validation_file_size_limit",
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
).SetParams(map[string]any{
"file": v.OriginalName,
"file": cutStr(v.OriginalName, 300),
"maxSize": maxBytes,
})
}
@@ -60,7 +60,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
baseErr := validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
fmt.Sprintf("Failed to upload %q due to unsupported file type.", cutStr(v.OriginalName, 300)),
)
if len(validTypes) == 0 {
+28
View File
@@ -1,6 +1,7 @@
package validators
import (
"net/netip"
"regexp"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -27,3 +28,30 @@ func IsRegex(value any) error {
return nil
}
// IPOrSubnet checks whether the validated value is an individual
// IPv4/IPv6 or CIDR subnet.
func IPOrSubnet(value any) error {
v, ok := value.(string)
if !ok {
return ErrUnsupportedValueType
}
if v == "" {
return nil // nothing to check
}
// subnet
_, err := netip.ParsePrefix(v)
if err == nil {
return nil
}
// individual IP
_, err = netip.ParseAddr(v)
if err == nil {
return nil
}
return validation.NewError("validation_invlaid_ip_or_subnet", "invalid IP or CIDR subnet")
}
+29
View File
@@ -31,3 +31,32 @@ func TestIsRegex(t *testing.T) {
})
}
}
func TestIPOrSubnet(t *testing.T) {
t.Parallel()
scenarios := []struct {
val string
expectError bool
}{
{"", false},
{`invalid`, true},
{`127.0`, true}, // incomplete
{`127.0.0.1`, false},
{`::1`, false},
{`0000:0000:0000:0000:0000:0000:0000:0001`, false},
{`127.0.0.1/24`, false},
{`::/128`, false},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) {
err := validators.IPOrSubnet(s.val)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
+7
View File
@@ -38,3 +38,10 @@ func JoinValidationErrors(errA, errB error) error {
return errors.Join(errA, errB)
}
func cutStr(str string, max int) string {
if len(str) > max {
return str[:max] + "..."
}
return str
}
+10 -10
View File
@@ -17,12 +17,12 @@ require (
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
modernc.org/sqlite v1.49.1
modernc.org/sqlite v1.52.0
)
require (
@@ -35,15 +35,15 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+26 -26
View File
@@ -55,8 +55,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -82,40 +82,40 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
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/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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -124,18 +124,18 @@ 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/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
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/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+120
View File
@@ -0,0 +1,120 @@
package migrations
import (
"fmt"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/dbutils"
)
// see https://github.com/pocketbase/pocketbase/issues/7689
func init() {
core.SystemMigrations.Register(func(txApp core.App) error {
collections, err := txApp.FindAllCollections()
if err != nil {
return err
}
for _, collection := range collections {
// existing system collection indexes can't be modified and view don't have indexes
if collection.System || collection.IsView() {
continue
}
masterIndexes := []struct {
Name string `db:"name"`
SQL string `db:"sql"`
}{}
err := txApp.DB().Select("name", "sql").
From("sqlite_master").
AndWhere(dbx.HashExp{
"type": "index",
"tbl_name": collection.Name,
}).
AndWhere(dbx.NewExp("sql IS NOT NULL AND name NOT LIKE 'sqlite_autoindex_%'")).
All(&masterIndexes)
if err != nil {
return err
}
// no indexes
if len(masterIndexes) == 0 && len(collection.Indexes) == 0 {
continue
}
missingParsedIndexes := map[string]dbutils.Index{}
// find missing master indexes
masterLoop:
for _, masterIndex := range masterIndexes {
mParsed := dbutils.ParseIndex(masterIndex.SQL)
mParsed.SchemaName = ""
mParsed.TableName = collection.Name
for _, raw := range collection.Indexes {
cParsed := dbutils.ParseIndex(raw)
// index already exists (if needed it will be normalized on resave)
if cParsed.IndexName != "" && strings.EqualFold(cParsed.IndexName, mParsed.IndexName) {
continue masterLoop
}
}
missingParsedIndexes[masterIndex.Name] = mParsed
}
missingIndexesLoop:
for _, missing := range missingParsedIndexes {
missingSQL := missing.Build()
// it shouldn't be possible but for just in case if there is an edge case the regex doesn't cover
if missingSQL == "" {
return fmt.Errorf("failed to build sqlite_master index: %v", missing)
}
// drop the missing index to recreate later
_, err := txApp.DB().DropIndex(missing.TableName, missing.IndexName).Execute()
if err != nil {
return fmt.Errorf("failed to drop index %s: %w", missing.IndexName, err)
}
// no recreate: duplicated single unique tokenKey or email
// (auth collections are guaranteed to have them)
if collection.IsAuth() && missing.Unique && len(missing.Columns) == 1 &&
(strings.EqualFold(missing.Columns[0].Name, core.FieldNameTokenKey) || strings.EqualFold(missing.Columns[0].Name, core.FieldNameEmail)) {
continue missingIndexesLoop
}
// no recreate: the same index definition alreay exists
// in the collection but with different name
for _, raw := range collection.Indexes {
cParsed := dbutils.ParseIndex(raw)
cParsed.IndexName = missing.IndexName
cParsed.SchemaName = missing.SchemaName
cParsed.TableName = missing.TableName
cSQL := cParsed.Build()
if missingSQL == cSQL {
continue missingIndexesLoop
}
}
// recreate: add the missing index to the collection list and
// leave the user to decide whether they want to keep it or not
// (the index could have been previously created externally, e.g. via the sqlite3 cli)
collection.Indexes = append(collection.Indexes, missingSQL)
}
// resave to trigger indexes normalization
err = txApp.Save(collection)
if err != nil {
return err
}
}
return nil
}, nil)
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
const (
expectedDriverVersion = "v1.49.1"
expectedLibcVersion = "v1.72.0"
expectedDriverVersion = "v1.52.0"
expectedLibcVersion = "v1.72.3"
// 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.
+16 -10
View File
@@ -46,6 +46,12 @@ type Config struct {
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
// BaseURL is the base URL of the GitHub API (or similar compatible)
// used to fetch the latest releases information.
//
// Defaults to "https://api.github.com".
BaseURL string
// Optional context to use when fetching and downloading the latest release.
Context context.Context
@@ -82,6 +88,12 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p.config.ArchiveExecutable = "pocketbase"
}
if p.config.BaseURL == "" {
p.config.BaseURL = "https://api.github.com"
} else {
p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/")
}
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
@@ -145,12 +157,9 @@ func (p *plugin) updateCmd() *cobra.Command {
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
latest, err := fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
p.config.Owner,
p.config.Repo,
)
url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", p.config.BaseURL, p.config.Owner, p.config.Repo)
latest, err := fetchLatestRelease(p.config.Context, p.config.HttpClient, url)
if err != nil {
return err
}
@@ -260,11 +269,8 @@ func (p *plugin) update(withBackup bool) error {
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
owner string,
repo string,
url string,
) (*release, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -443,11 +443,16 @@ func (p *plugin) watchHooks() error {
//
// @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 {
// skip access failures, hidden directories, node_modules, etc.
if err != nil || !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
// ignore access failures and non-dir entries
if err != nil || !entry.IsDir() {
return nil
}
// skip traversing hidden directories and node_modules
if strings.HasPrefix(entry.Name(), ".") || entry.Name() == "node_modules" {
return filepath.SkipDir
}
return watcher.Add(path)
})
if dirsErr != nil {
+24 -24
View File
@@ -36,10 +36,10 @@ migrate((app) => {
},
"authRule": "",
"authToken": {
"duration": 604800
"duration": 432000
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
@@ -137,7 +137,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -170,18 +170,18 @@ migrate((app) => {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p><i>If you didn't recently register, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
"duration": 86400
},
"viewRule": "id = \"1\""
});
@@ -218,10 +218,10 @@ func init() {
},
"authRule": "",
"authToken": {
"duration": 604800
"duration": 432000
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
@@ -319,7 +319,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -352,18 +352,18 @@ func init() {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p><i>If you didn't recently register, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
"duration": 86400
},
"viewRule": "id = \"1\""
}` + "`" + `
@@ -489,10 +489,10 @@ migrate((app) => {
},
"authRule": "",
"authToken": {
"duration": 604800
"duration": 432000
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
@@ -590,7 +590,7 @@ migrate((app) => {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -623,18 +623,18 @@ migrate((app) => {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p><i>If you didn't recently register, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
"duration": 86400
},
"viewRule": "id = \"1\""
});
@@ -674,10 +674,10 @@ func init() {
},
"authRule": "",
"authToken": {
"duration": 604800
"duration": 432000
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
@@ -775,7 +775,7 @@ func init() {
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"duration": 600,
"enabled": false,
"rule": ""
},
@@ -808,18 +808,18 @@ func init() {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p><i>If you didn't recently register, please ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
"duration": 86400
},
"viewRule": "id = \"1\""
}` + "`" + `
Binary file not shown.
Binary file not shown.
+4 -3
View File
@@ -120,8 +120,9 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
expected := struct {
Values []struct {
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
Email string `json:"email"`
IsPrimary bool `json:"is_primary"`
IsConfirmed bool `json:"is_confirmed"`
} `json:"values"`
}{}
if err := json.Unmarshal(data, &expected); err != nil {
@@ -129,7 +130,7 @@ func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
}
for _, v := range expected.Values {
if v.IsPrimary {
if v.IsPrimary && v.IsConfirmed {
return v.Email, nil
}
}
+65 -7
View File
@@ -3,6 +3,9 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"github.com/pocketbase/pocketbase/tools/types"
@@ -15,10 +18,10 @@ func init() {
var _ Provider = (*Gitea)(nil)
// NameGitea is the unique name of the Gitea provider.
// NameGitea is the unique name of the Gitea/Forgejo provider.
const NameGitea string = "gitea"
// Gitea allows authentication via Gitea OAuth2.
// Gitea allows authentication via Gitea/Forgejo OAuth2.
type Gitea struct {
BaseProvider
}
@@ -29,7 +32,7 @@ func NewGiteaProvider() *Gitea {
ctx: context.Background(),
order: 11,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m396 484-127-61c-12-6-18-21-12-34l61-127c6-12 21-17 34-11l27 13V154h17v118s57 24 83 40q7 3 13 14 3 10-1 19l-61 127c-6 13-22 18-34 12" style="fill:#fff"/><path d="M623 150c-4-4-10-4-10-4l-178 8-39 1v117l-17-8V155l-89-3-157-8q-15-2-39 1c-9 2-34 8-54 27C-5 212 7 276 8 286c2 12 7 44 32 72 46 56 144 55 144 55s12 29 31 56c25 33 50 59 75 62h189s12 0 29-11c14-8 26-23 26-23s13-14 31-45l14-28s55-118 55-232c-1-34-9-40-11-42M126 354c-26-9-37-19-37-19s-19-13-29-40c-16-44-1-71-1-71s8-22 38-30c14-4 31-3 31-3s7 59 16 94c7 30 25 78 25 78s-26-3-43-9m300 108s-6 14-20 15l-10-1-5-2-113-55s-11-6-13-16c-2-8 3-18 3-18l54-112s5-10 12-13l5-1c8-3 18 2 18 2l110 54s13 6 16 16q1 12-2 17c-6 16-55 114-55 114" style="fill:#609926"/><path d="M327 380q-14 1-17 14-2 13 9 20c7 4 17 2 22-5q8-13-1-24l24-49h6l7-4 29 16 5 5c2 6-2 15-2 15-2 7-18 40-18 40q-13 1-18 13-4 14 9 21a18 18 0 0 0 21-28l6-11 13-30c1-2 6-11 3-22-2-11-13-16-13-16-12-8-29-16-29-16l-1-7-4-6 14-29-12-6-14 29q-11 0-16 10t1 20z" style="fill:#609926"/></svg>`,
displayName: "Gitea",
displayName: "Gitea/Forgejo",
pkce: true,
scopes: []string{"read:user", "user:email"},
authURL: "https://gitea.com/login/oauth/authorize",
@@ -38,9 +41,9 @@ func NewGiteaProvider() *Gitea {
}}
}
// FetchAuthUser returns an AuthUser instance based on Gitea's user api.
// FetchAuthUser returns an AuthUser instance based on Gitea/Forgejo's user api.
//
// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent
// API reference: https://codeberg.org/api/swagger#/user/userGetCurrent
func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -55,26 +58,81 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
extracted := struct {
Name string `json:"full_name"`
Username string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Active bool `json:"active"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if !extracted.Active {
return nil, errors.New("user account is not active")
}
user := &AuthUser{
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, fmt.Errorf("failed to fetch primary email: %w", err)
}
user.Email = email
user.Expiry, _ = types.ParseDateTime(token.Expiry)
return user, nil
}
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://codeberg.org/api/swagger#/user/userListEmails
func (p *Gitea) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
if err != nil {
return "", err
}
defer response.Body.Close()
// ignore common http errors caused by insufficient scope permissions
// (the email field is optional, aka. return the auth user without it)
if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 {
return "", nil
}
content, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
emails := []struct {
Email string
Verified bool
Primary bool
}{}
if err := json.Unmarshal(content, &emails); err != nil {
return "", err
}
// extract the verified primary email
for _, email := range emails {
if email.Verified && email.Primary {
return email.Email, nil
}
}
return "", nil
}
+12 -15
View File
@@ -42,7 +42,7 @@ func NewGithubProvider() *Github {
// FetchAuthUser returns an AuthUser instance based the Github's user api.
//
// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
// API reference: https://docs.github.com/en/rest/users/users?apiVersion=2026-03-10#get-the-authenticated-user
func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -55,9 +55,8 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
Login string `json:"login"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
}{}
@@ -69,7 +68,6 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -78,27 +76,26 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
// in case user has set "Keep my email address private", send an
// **optional** API request to retrieve the verified primary email
if user.Email == "" {
email, err := p.fetchPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
// always send a primary email request even though the email is
// returned in the userinfo endpoint since the API may change and
// enterprise setups may have configuration that could allow unverified emails
email, err := p.fetchVerifiedPrimaryEmail(token)
if err != nil {
return nil, err
}
user.Email = email
return user, nil
}
// fetchPrimaryEmail sends an API request to retrieve the verified
// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified
// primary email, in case "Keep my email address private" was set.
//
// NB! This method can succeed and still return an empty email.
// Error responses that are result of insufficient scopes permissions are ignored.
//
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28
func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) {
// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
func (p *Github) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) {
client := p.Client(token)
response, err := client.Get(p.userInfoURL + "/emails")
+13 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/oauth2"
@@ -40,7 +41,7 @@ func NewGitlabProvider() *Gitlab {
// FetchAuthUser returns an AuthUser instance based the Gitlab's user api.
//
// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin
// API reference: https://docs.gitlab.com/api/users/#retrieve-the-current-user
func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
data, err := p.FetchRawUserInfo(token)
if err != nil {
@@ -53,11 +54,12 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
}
extracted := struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
Id int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
ConfirmedAt string `json:"confirmed_at"`
Id int64 `json:"id"`
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
@@ -67,7 +69,6 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Id: strconv.FormatInt(extracted.Id, 10),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarURL: extracted.AvatarURL,
RawUser: rawUser,
AccessToken: token.AccessToken,
@@ -76,5 +77,10 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
user.Expiry, _ = types.ParseDateTime(token.Expiry)
confirmedAt, err := time.Parse(time.RFC3339, extracted.ConfirmedAt)
if err == nil && !confirmedAt.IsZero() {
user.Email = extracted.Email
}
return user, nil
}
+2 -2
View File
@@ -114,12 +114,12 @@ func Fetch(ctx context.Context, jwksURL string, kid string) (*JWK, error) {
}
for _, key := range jwks.Keys {
if key.Kid == kid {
if key.Kid == kid && key.Alg != "" {
return key, nil
}
}
return nil, fmt.Errorf("JWK with kid %q was not found", kid)
return nil, fmt.Errorf("missing JWK with kid %q and non-empty alg", kid)
}
// ValidateTokenSignature validates the signature of a token with the
+6
View File
@@ -168,6 +168,12 @@ func TestFetch(t *testing.T) {
true,
nil,
},
{
"matching kid (no alg)",
"abc",
true,
nil,
},
{
"matching kid",
"def",
+3 -1
View File
@@ -14,6 +14,8 @@ import (
"slices"
"sync"
"time"
"github.com/pocketbase/pocketbase/tools/routine"
)
// Cron is a crontab-like struct for tasks/jobs scheduling.
@@ -222,7 +224,7 @@ func (c *Cron) runDue(t time.Time) {
for _, j := range c.jobs {
if j.schedule.IsDue(moment) {
go j.Run()
routine.FireAndForget(j.Run)
}
}
}
+14 -4
View File
@@ -411,10 +411,20 @@ var inlineServeContentTypes = []string{
// manualExtensionContentTypes is a map of file extensions to content types.
var manualExtensionContentTypes = map[string]string{
".svg": "image/svg+xml", // (see https://github.com/whatwg/mimesniff/issues/7)
".css": "text/css", // (see https://github.com/gabriel-vasile/mimetype/pull/113)
".js": "text/javascript", // (see https://github.com/pocketbase/pocketbase/issues/6597)
// https://github.com/whatwg/mimesniff/issues/7
".svg": "image/svg+xml",
// https://github.com/gabriel-vasile/mimetype/pull/113
".css": "text/css",
// https://github.com/pocketbase/pocketbase/issues/6597
".js": "text/javascript",
".mjs": "text/javascript",
// https://github.com/pocketbase/pocketbase/discussions/7467
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
// forceAttachmentParam is the name of the request query parameter to
@@ -449,7 +459,7 @@ func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey strin
// make an exception for specific content types and force a custom
// content type to send in the response so that it can be loaded properly
extContentType := realContentType
if ct, found := manualExtensionContentTypes[filepath.Ext(name)]; found {
if ct, found := manualExtensionContentTypes[filepath.Ext(fileKey)]; found {
extContentType = ct
}
+83 -8
View File
@@ -405,12 +405,12 @@ func TestFileSystemServe(t *testing.T) {
{
// svg exception
"image.svg",
"test_name.svg",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.svg",
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "image/svg+xml",
"Content-Length": "0",
"Content-Security-Policy": csp,
@@ -420,12 +420,12 @@ func TestFileSystemServe(t *testing.T) {
{
// css exception
"style.css",
"test_name.css",
"test_name",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.css",
"Content-Disposition": "attachment; filename=test_name",
"Content-Type": "text/css",
"Content-Length": "0",
"Content-Security-Policy": csp,
@@ -435,12 +435,12 @@ func TestFileSystemServe(t *testing.T) {
{
// js exception
"main.js",
"test_name.js",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.js",
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "text/javascript",
"Content-Length": "0",
"Content-Security-Policy": csp,
@@ -450,18 +450,63 @@ func TestFileSystemServe(t *testing.T) {
{
// mjs exception
"main.mjs",
"test_name.mjs",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.mjs",
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "text/javascript",
"Content-Length": "0",
"Content-Security-Policy": csp,
"Cache-Control": cacheControl,
},
},
{
// xlsx exception
"dummy.xlsx",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Length": "0",
"Content-Security-Policy": csp,
"Cache-Control": cacheControl,
},
},
{
// docx exception
"dummy.docx",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Length": "0",
"Content-Security-Policy": csp,
"Cache-Control": cacheControl,
},
},
{
// pptx exception
"dummy.pptx",
"test_name.abc",
nil,
nil,
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.abc",
"Content-Type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"Content-Length": "0",
"Content-Security-Policy": csp,
"Cache-Control": cacheControl,
},
},
{
// custom header
"test/sub2.txt",
@@ -716,6 +761,9 @@ func TestFileSystemList(t *testing.T) {
"style.css",
"main.js",
"main.mjs",
"dummy.xlsx",
"dummy.docx",
"dummy.pptx",
"test/sub1.txt",
"test/sub2.txt",
},
@@ -1041,5 +1089,32 @@ func createTestDir(t *testing.T) string {
file.Close()
}
// "docx" (we are interested only in the extension)
{
file, err := os.OpenFile(filepath.Join(dir, "dummy.docx"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file.Close()
}
// "xlsx" (we are interested only in the extension)
{
file, err := os.OpenFile(filepath.Join(dir, "dummy.xlsx"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file.Close()
}
// "pptx" (we are interested only in the extension)
{
file, err := os.OpenFile(filepath.Join(dir, "dummy.pptx"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file.Close()
}
return dir
}
+12 -7
View File
@@ -3,7 +3,9 @@ package mailer
import (
"errors"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"github.com/domodwyer/mailyak/v3"
@@ -70,16 +72,18 @@ func (c *SMTPClient) send(m *Message) error {
}
}
hostWithPort := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
// create mail instance
var yak *mailyak.MailYak
if c.TLS {
var tlsErr error
yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil)
yak, tlsErr = mailyak.NewWithTLS(hostWithPort, smtpAuth, nil)
if tlsErr != nil {
return tlsErr
}
} else {
yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth)
yak = mailyak.New(hostWithPort, smtpAuth)
}
if c.LocalName != "" {
@@ -133,15 +137,16 @@ func (c *SMTPClient) send(m *Message) error {
}
// add custom headers (if any)
var hasMessageId bool
var hasMessageIdHeader bool
for k, v := range m.Headers {
if strings.EqualFold(k, "Message-ID") {
hasMessageId = true
if !hasMessageIdHeader && strings.EqualFold(k, "Message-ID") {
hasMessageIdHeader = true
}
yak.AddHeader(k, v)
}
if !hasMessageId {
// add a default message id if missing
// add a default message id if missing
if !hasMessageIdHeader {
fromParts := strings.Split(m.From.Address, "@")
if len(fromParts) == 2 {
yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>",
+1 -1
View File
@@ -349,7 +349,7 @@ const DefaultMaxMemory = 16 << 20 // 16mb
//
// Title string `json:"title" form:"title"`
// Total int `json:"total" form:"total"`
// }
// }{}
// err := e.BindBody(&data)
func (e *Event) BindBody(dst any) error {
if e.Request.ContentLength == 0 {
+1 -1
View File
@@ -22,7 +22,7 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) {
defer func() {
if err := recover(); err != nil {
log.Println("RECOVERED FROM PANIC (safe to ignore):", err)
log.Println("[FireAndForget] RECOVERED FROM PANIC:", err)
stack := make([]byte, 2<<10) // 2 KB
length := runtime.Stack(stack, false)
+14 -13
View File
@@ -1,14 +1,15 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.37.3"
PB_BACKEND_URL = "../"
PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication"
PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters"
PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling"
PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files"
PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/"
PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields"
PB_SUPERUSER_IPS_RESET_DOCS = "https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets"
PB_DOCS_URL = "https://pocketbase.io/docs"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.39.3"
@@ -1,4 +1,4 @@
import{t as e}from"./expandInfo-DGS0CLSa.js";import{t as n}from"./fieldsInfo-Bz62125-.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r))},null,2)},{title:401,value:`
import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r))},null,2)},{title:401,value:`
{
"status": 401,
"message": "The request requires valid record authorization token to be set.",
@@ -1,4 +1,4 @@
import{t as e}from"./expandInfo-DGS0CLSa.js";import{t as n}from"./fieldsInfo-Bz62125-.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r)),meta:{id:`abc123`,name:`John Doe`,username:`john.doe`,email:`test@example.com`,avatarURL:`https://example.com/avatar.png`,accessToken:`...`,refreshToken:`...`,expiry:`2022-01-01 10:00:00.123Z`,isNew:!1,rawUser:{}}},null,2)},{title:400,value:`
import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r)),meta:{id:`abc123`,name:`John Doe`,username:`john.doe`,email:`test@example.com`,avatarURL:`https://example.com/avatar.png`,accessToken:`...`,refreshToken:`...`,expiry:`2022-01-01 10:00:00.123Z`,isNew:!1,rawUser:{}}},null,2)},{title:400,value:`
{
"status": 400,
"message": "An error occurred while submitting the form.",
@@ -1,4 +1,4 @@
import{t as e}from"./expandInfo-DGS0CLSa.js";import{t as n}from"./fieldsInfo-Bz62125-.js";function r(e){let n=app.utils.getApiExampleURL(),r=[{title:`OTP request`,content:i},{title:`OTP auth`,content:a}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewAuthWithOTP`,className:`content`},t.p(null,`Authenticate with an one-time/short-lived password (OTP).`),t.p(null,`Note that when requesting an OTP we return an `,t.code(null,`otpId`),` even if a user with the provided email doesn't exist as a very basic enumeration protection.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:`
import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(e){let n=app.utils.getApiExampleURL(),r=[{title:`OTP request`,content:i},{title:`OTP auth`,content:a}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewAuthWithOTP`,className:`content`},t.p(null,`Authenticate with an one-time/short-lived password (OTP).`),t.p(null,`On successful authentication the user will be also marked as verified (if the OTP source is email and the user is not verified already).`),t.p(null,`Note that when requesting an OTP we return an `,t.code(null,`otpId`),` even if a user with the provided email doesn't exist as a very basic enumeration protection.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${n}');
@@ -1,4 +1,4 @@
import{t as e}from"./expandInfo-DGS0CLSa.js";import{t as n}from"./fieldsInfo-Bz62125-.js";function r(r){let i=app.utils.getApiExampleURL(),a=r.passwordAuth?.identityFields||[],o=a.length==0?`NONE`:`YOUR_`+a.join(`_OR_`).toUpperCase(),s={collectionId:r.id,collectionName:r.name},c=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(s,app.utils.getDummyFieldsData(r))},null,2)},{title:400,value:`
import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a=r.passwordAuth?.identityFields||[],o=a.length==0?`NONE`:`YOUR_`+a.join(`_OR_`).toUpperCase(),s={collectionId:r.id,collectionName:r.name},c=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(s,app.utils.getDummyFieldsData(r))},null,2)},{title:400,value:`
{
"status": 400,
"message": "Failed to authenticate.",
File renamed without changes.
@@ -1,4 +1,4 @@
import{t as e}from"./expandInfo-DGS0CLSa.js";import{t as n}from"./fieldsInfo-Bz62125-.js";function r(r){let s=app.utils.getApiExampleURL(),c=r.createRule===null,l=r.type===`auth`,u=l?[`password`,`verified`,`email`,`emailVisibility`]:[],d=r.fields?.filter(e=>!e.hidden&&e.type!=`autodate`&&!u.includes(e.name))||[],f={collectionId:r.id,collectionName:r.name},p=[{title:200,value:JSON.stringify(Object.assign(f,app.utils.getDummyFieldsData(r)),null,2)},{title:400,value:`
import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let s=app.utils.getApiExampleURL(),c=r.createRule===null,l=r.type===`auth`,u=l?[`password`,`verified`,`email`,`emailVisibility`]:[],d=r.fields?.filter(e=>!e.hidden&&e.type!=`autodate`&&!u.includes(e.name))||[],f={collectionId:r.id,collectionName:r.name},p=[{title:200,value:JSON.stringify(Object.assign(f,app.utils.getDummyFieldsData(r)),null,2)},{title:400,value:`
{
"status": 400,
"message": "Failed to create record.",
File renamed without changes.
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff. Show more