Compare commits

..

2 Commits

Author SHA1 Message Date
T. R. Bernstein 173bc3967d adapt readme to new repository
basebuild / goreleaser (push) Has been cancelled
2026-05-03 23:22:44 +02:00
T. R. Bernstein 795cd8335f adjust code to new repository 2026-05-03 23:22:40 +02:00
271 changed files with 5404 additions and 10594 deletions
+7 -34
View File
@@ -2,26 +2,19 @@
**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!** **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 want to responsibly report a security issue you'll have to reach out as a human to **support at pocketbase.io**. 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).
This means: 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.
- 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
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))_. 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).
**Or in other words - a simple _"Hey I think I found a security issue when I do X"_ is enough.** ### Please:
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 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.
In case the vulnerability is confirmed: - 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.
- I'll start working on a local fix. - 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.)_.
- 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: ### Below is a short list of previous reports that are NOT considered security issues:
@@ -91,14 +84,6 @@ In many places where applicable we've tried to minimize the impact by using cons
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. 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>
<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> <details>
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary> <summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
@@ -114,15 +99,3 @@ Third, even if that issue is still available, with PocketBase it would have been
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive. In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
</details> </details>
<details>
<summary><strong>JSVM "sandboxing"</strong></summary>
This is another very common report but **there is no such thing as JSVM "sandboxing" in PocketBase**.
The JS `pb_hooks` (or JSVM for short) are NOT supposed to run untrusted or client provided JavaScript code _(the same way you are not supposed to run untrusted code in your Node.js server)_.
Once interpreted the `pb_hooks` run as part of the same application process together with the rest of the Go code. There are no additional filesystem, network, memory, etc. restrictions. This means that it is OK for developers to be able to access environment variables, perform network calls to any URLs they want, invoke shell commands or even sleep/block the script execution.
So if you are security researcher and not sure if something is a security fault in the JS hooks, ask yourself - "Can I do the same when using PocketBase as Go framework?" and if the answer is "Yes" then it is not a security issue with the JSVM.
</details>
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: '>=1.26.5' go-version: '>=1.26.2'
# This step usually is not needed because the /ui/dist is pregenerated locally # This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts. # but its here to ensure that each release embeds the latest admin ui artifacts.
-194
View File
@@ -1,197 +1,3 @@
## v0.39.10
- Reverted the auto panic recover handling for the cli commands to preserve the old behavior and allow panic to force exit with non-zero code ([#7781](https://github.com/pocketbase/pocketbase/issues/7781)).
_Proper command non-zero exit support will be available with the next v0.40/v0.41 release._
- Minor UI improvements (added placeholder loader for the logs chart, npm dev deps update, etc.).
- Updated `modernc.org/sqlite` to v1.55.0 (doc changes).
## v0.39.9
- Fixed `Shift + Click` range bulk selection not working in Firefox ([#7771](https://github.com/pocketbase/pocketbase/issues/7771))
- Updated goja and its related dependencies _(fixes for TypedArray and regexp2 dep regression for the reported empty string match with lookahead patterns)_.
- Minor filter (fexpr) improvements _(optimization for large string literals and fix for control characters handling)_.
## v0.39.8
- Properly reset JSVM global `$app` overwrite so that pooled executors always get a clean state.
- Minor UI improvements:
- prevent resetting number inputs with leading 0 while still typing (normalized in `onchange`)
- added support for `Shift + Click` range bulk selection ([#7759](https://github.com/pocketbase/pocketbase/issues/7759))
- Bumped `golang.org/x/*` indirect dependencies as there are some minor security fixes.
- Updated `modernc.org/sqlite` to v1.54.0 ([SQLite 3.53.3](https://sqlite.org/src/timeline?from=version-3.53.2&to=version-3.53.3&to2=branch-3.53)).
## v0.39.7
- Replaced `github.com/go-ozzo/ozzo-validation` with the fork `github.com/pocketbase/ozzo-validation` since the original library has recently changed ownership and the new maintainer cannot be trusted.
_There are plans to create eventually a new validation library from scratch more suited for our needs in PocketBase because ozzo-validation is known to have some minor performance and obscure regex issues, but until then we'll stick with the fork (and if you use `ozzo-validation` in your own Go code, I'd suggest to swap the imports with the fork)_.
- Fixed missing import collection `fields` property access ([#7760](https://github.com/pocketbase/pocketbase/issues/7760)).
- Fixed View collection `*` validator and added more friendly error messages ([#7761](https://github.com/pocketbase/pocketbase/issues/7761)).
- ⚠️ Security fix for unhandled panic in internal worker goroutines ([#7762](https://github.com/pocketbase/pocketbase/discussions/7762)).
_To prevent this from showing again, all existing internal worker functions were wrapped with [`routine.SafeWrap(f)`](https://pkg.go.dev/github.com/pocketbase/pocketbase/tools/routine#SafeWrap) (auto recovers and returns any eventual panic as regular error)._
## v0.39.6
- Added `Cc` and `Bcc` recipients to the dev `sendmail` command for consistency with the SMTP mailer.
- Added extra hardening options to the Microsoft OAuth2 provider allowing developers to specify the preferred safe email extraction method.
- Updated goja and the related `golang.org/x/*` dependencies _(`WeakMap` regression fixes)_.
- Bumped the min Go GitHub action version to 1.26.5 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.5).
## v0.39.5
- Limit with ellipsis long `url` field values.
- Readded the "fullscreen" `editor` field option and preloaded the TinyMCE component for slightly faster initial rendering ([#7746](https://github.com/pocketbase/pocketbase/issues/7746)).
- Updated goja (`TypedArray` fixes).
## v0.39.4
- Removed `redirectURL` required validator from the code->token exchange endpoint (aka. `authWithOAuth2Code()`) ([#7734](https://github.com/pocketbase/pocketbase/issues/7734)).
_Note that OAuth2 providers have their own validations and whether it is allowed to be empty or not could depend on the configured OAuth2 app (in most cases it is required and the redirect address must match with the initial value submitted with the authorization request)._
- Enabled sorting by the first _implicit_ presentable relation field ([#7735](https://github.com/pocketbase/pocketbase/discussions/7735)).
- Other minor UI fixes (tooltip clear on hovered element removal, optional before element sortable fix, etc.).
- Updated goja and the related `golang.org/x/*` dependencies (regex support improvements).
## 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 ## v0.37.5
- Fixed password fields not being detected as changed ([#7670](https://github.com/pocketbase/pocketbase/issues/7670)). - Fixed password fields not being detected as changed ([#7670](https://github.com/pocketbase/pocketbase/issues/7670)).
-51
View File
@@ -2,57 +2,6 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md) > For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
--- ---
## v0.22.51
- (_Backported from v0.39.10_) Reverted the auto panic recover handling for the cli commands to preserve the old behavior and allow panic to force exit with non-zero code ([#7781](https://github.com/pocketbase/pocketbase/issues/7781)).
## v0.22.50
- (_Backported from v0.39.9_) Bumped goja, fexpr and their related deps.
## v0.22.49
- (_Backported from v0.39.8_) Bumped `golang.org/x/*` indirect dependencies as there are some minor security fixes.
- (_Backported from v0.39.8_) Updated `modernc.org/sqlite` to v1.54.0 ([SQLite 3.53.3](https://sqlite.org/src/timeline?from=version-3.53.2&to=version-3.53.3&to2=branch-3.53)).
## v0.22.48
- (_Backported from v0.39.7_) Replaced `github.com/go-ozzo/ozzo-validation` with the fork `github.com/pocketbase/ozzo-validation` since the original library has recently changed ownership and the new maintainer cannot be trusted.
- (_Backported from v0.39.7_) Fixed View collection `*` validator and added more friendly error messages ([#7761](https://github.com/pocketbase/pocketbase/issues/7761)).
- (_Backported from v0.39.7_) ⚠️ Security fix for unhandled panic in internal worker goroutines ([#7762](https://github.com/pocketbase/pocketbase/discussions/7762)).
## v0.22.47
- (_Backported from v0.39.6_) Bumped the min Go GitHub action version to 1.26.5 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.5).
## 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 ## 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. - (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
+2 -7
View File
@@ -70,10 +70,8 @@ func backupDownload(e *core.RequestEvent) error {
return e.ForbiddenError("Insufficient permissions to access the resource.", err) return e.ForbiddenError("Insufficient permissions to access the resource.", err)
} }
allowedIPs := e.App.Settings().SuperuserIPs ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) { defer cancel()
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
}
fsys, err := e.App.NewBackupsFilesystem() fsys, err := e.App.NewBackupsFilesystem()
if err != nil { if err != nil {
@@ -81,9 +79,6 @@ func backupDownload(e *core.RequestEvent) error {
} }
defer fsys.Close() defer fsys.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
fsys.SetContext(ctx) fsys.SetContext(ctx)
key := e.Request.PathValue("key") key := e.Request.PathValue("key")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"regexp" "regexp"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
) )
-52
View File
@@ -528,58 +528,6 @@ func TestBackupsDownload(t *testing.T) {
}, },
ExpectedEvents: map[string]int{"*": 0}, 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 { for _, scenario := range scenarios {
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/filesystem" "github.com/tabshift-gh/pocketbase/tools/filesystem"
-2
View File
@@ -31,7 +31,6 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
pbRouter.Bind(panicRecover()) pbRouter.Bind(panicRecover())
pbRouter.Bind(rateLimit()) pbRouter.Bind(rateLimit())
pbRouter.Bind(loadAuthToken()) pbRouter.Bind(loadAuthToken())
pbRouter.Bind(superuserIPsWhitelist())
pbRouter.Bind(securityHeaders()) pbRouter.Bind(securityHeaders())
pbRouter.Bind(BodyLimit(DefaultMaxBodySize)) pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
@@ -48,7 +47,6 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindBatchApi(app, apiGroup) bindBatchApi(app, apiGroup)
bindRealtimeApi(app, apiGroup) bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup) bindHealthApi(app, apiGroup)
bindSQLApi(app, apiGroup)
// UI routes // UI routes
bindUIExtensions(app) bindUIExtensions(app)
+3 -4
View File
@@ -14,11 +14,10 @@ import (
"strings" "strings"
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/filesystem" "github.com/tabshift-gh/pocketbase/tools/filesystem"
"github.com/tabshift-gh/pocketbase/tools/router" "github.com/tabshift-gh/pocketbase/tools/router"
"github.com/tabshift-gh/pocketbase/tools/routine"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@@ -196,7 +195,7 @@ func (p *batchProcessor) Process(batch []*core.InternalRequest, timeout time.Dur
p.stopCh <- struct{}{} p.stopCh <- struct{}{}
}() }()
routine.FireAndForget(func() { go func() {
err := p.process(txApp, batch, 0) err := p.process(txApp, batch, 0)
if err != nil { if err != nil {
@@ -217,7 +216,7 @@ func (p *batchProcessor) Process(batch []*core.InternalRequest, timeout time.Dur
} }
p.errCh <- err p.errCh <- err
}) }()
select { select {
case responseErr := <-p.errCh: case responseErr := <-p.errCh:
+1 -65
View File
@@ -224,7 +224,7 @@ func TestBatchRequest(t *testing.T) {
}, },
}, },
{ {
Name: "mixed create/update/delete (non-superuser rule failure)", Name: "mixed create/update/delete (rules failure)",
Method: http.MethodPost, Method: http.MethodPost,
URL: "/api/batch", URL: "/api/batch",
Body: strings.NewReader(`{ Body: strings.NewReader(`{
@@ -284,70 +284,6 @@ 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)", Name: "mixed create/update/delete (rules success)",
Method: http.MethodPost, Method: http.MethodPost,
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/auth" "github.com/tabshift-gh/pocketbase/tools/auth"
"github.com/tabshift-gh/pocketbase/tools/router" "github.com/tabshift-gh/pocketbase/tools/router"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
) )
+4 -5
View File
@@ -8,9 +8,9 @@ import (
"log/slog" "log/slog"
"os" "os"
"github.com/tabshift-gh/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"github.com/tabshift-gh/pocketbase/ui" "github.com/pocketbase/pocketbase/ui"
) )
// bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router. // bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router.
@@ -81,8 +81,7 @@ func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
defer f.Close() defer f.Close()
// wrap in a self-executing function to avoid scope and concatenation issues // wrap in a self-executing function to avoid scope and concatenation issues
// (the await/async is for top-level await) _, _ = buf.WriteString("(function(){")
_, _ = buf.WriteString("await (async function(){")
_, err = io.Copy(buf, f) _, err = io.Copy(buf, f)
if err != nil { if err != nil {
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"github.com/tabshift-gh/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/tabshift-gh/pocketbase/ui" "github.com/pocketbase/pocketbase/ui"
) )
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test // note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
@@ -72,7 +72,7 @@ func TestUIExtensions_Mainjs(t *testing.T) {
}, },
AfterTestFunc: successAfterTestFunc, AfterTestFunc: successAfterTestFunc,
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"await (async function(){ext1_main})();await (async function(){ext3_main})();"}, ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
ExpectedEvents: map[string]int{"*": 0}, ExpectedEvents: map[string]int{"*": 0},
}, },
} }
-10
View File
@@ -60,7 +60,6 @@ type fileApi struct {
} }
func (api *fileApi) fileToken(e *core.RequestEvent) error { 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 { if e.Auth == nil {
return e.UnauthorizedError("Missing auth context.", nil) return e.UnauthorizedError("Missing auth context.", nil)
} }
@@ -115,15 +114,6 @@ func (api *fileApi) download(e *core.RequestEvent) error {
token := e.Request.URL.Query().Get("token") token := e.Request.URL.Query().Get("token")
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile) 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) // create a shallow copy of the cached request data and adjust it to the current auth record (if any)
requestInfo := *originalRequestInfo requestInfo := *originalRequestInfo
requestInfo.Context = core.RequestInfoContextProtectedFile requestInfo.Context = core.RequestInfoContextProtectedFile
-44
View File
@@ -353,50 +353,6 @@ func TestFileDownload(t *testing.T) {
"OnFileDownloadRequest": 1, "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", Name: "protected file - guest without view access",
Method: http.MethodGet, Method: http.MethodGet,
-25
View File
@@ -42,9 +42,6 @@ const (
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20 DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken" DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10 DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders" DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
@@ -302,28 +299,6 @@ 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 // SkipSuccessActivityLog is a helper middleware that instructs the global
// activity logger to log only requests that have failed/returned an error. // activity logger to log only requests that have failed/returned an error.
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] { func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
+1 -39
View File
@@ -2,7 +2,6 @@ package apis
import ( import (
"errors" "errors"
"net/netip"
"sync" "sync"
"time" "time"
@@ -107,41 +106,6 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
return nil 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? // @todo consider exporting as helper?
@@ -189,9 +153,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
} }
func skipRateLimit(e *core.RequestEvent) bool { func skipRateLimit(e *core.RequestEvent) bool {
return !e.App.Settings().RateLimits.Enabled || return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
e.HasSuperuserAuth() ||
isIPInList(e.App.Settings().RateLimits.ExcludedIPs, e.RealIP())
} }
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth} var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
+3 -163
View File
@@ -8,7 +8,6 @@ import (
"github.com/tabshift-gh/pocketbase/apis" "github.com/tabshift-gh/pocketbase/apis"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tests" "github.com/tabshift-gh/pocketbase/tests"
"github.com/tabshift-gh/pocketbase/tools/hook"
) )
func TestDefaultRateLimitMiddleware(t *testing.T) { func TestDefaultRateLimitMiddleware(t *testing.T) {
@@ -86,8 +85,9 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/norate", 0, false, 200}, {"/norate", 0, false, 200},
{"/rate/a", 0, false, 200}, {"/rate/a", 0, false, 200},
{"/rate/a", 900, 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}, // (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", 600, false, 200},
{"/rate/a", 850, false, 200},
{"/rate/a", 0, false, 200}, {"/rate/a", 0, false, 200},
{"/rate/a", 0, false, 429}, {"/rate/a", 0, false, 429},
{"/rate/a", 0, false, 429}, {"/rate/a", 0, false, 429},
@@ -160,163 +160,3 @@ 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,96 +553,3 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
scenario.Test(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)
}
}
+22 -143
View File
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/tabshift-gh/pocketbase/tools/hook"
"github.com/tabshift-gh/pocketbase/tools/picker" "github.com/tabshift-gh/pocketbase/tools/picker"
@@ -28,9 +28,6 @@ const clientsChunkSize = 150
// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state. // RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
const RealtimeClientAuthKey = "auth" 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. // bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) { func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/realtime") sub := rg.Group("/realtime")
@@ -66,12 +63,8 @@ func realtimeConnect(e *core.RequestEvent) error {
connectEvent := new(core.RealtimeConnectRequestEvent) connectEvent := new(core.RealtimeConnectRequestEvent)
connectEvent.RequestEvent = e connectEvent.RequestEvent = e
connectEvent.IdleTimeout = 5 * time.Minute
connectEvent.MaxTimeout = 30 * time.Minute
connectEvent.Client = subscriptions.NewDefaultClient() connectEvent.Client = subscriptions.NewDefaultClient()
connectEvent.IdleTimeout = 5 * time.Minute
// 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 { return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
// register new subscription client // register new subscription client
@@ -80,7 +73,7 @@ func realtimeConnect(e *core.RequestEvent) error {
e.App.SubscriptionsBroker().Unregister(ce.Client.Id()) 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) // signalize established connection (aka. fire "connect" message)
connectMsgEvent := new(core.RealtimeMessageEvent) connectMsgEvent := new(core.RealtimeMessageEvent)
@@ -106,19 +99,12 @@ func realtimeConnect(e *core.RequestEvent) error {
return nil 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 // start an idle timer to keep track of inactive/forgotten connections
idleTimer := time.NewTimer(ce.IdleTimeout) idleTimer := time.NewTimer(ce.IdleTimeout)
defer idleTimer.Stop() defer idleTimer.Stop()
for { for {
select { select {
case <-maxTimer.C:
cancelRequest()
case <-idleTimer.C: case <-idleTimer.C:
cancelRequest() cancelRequest()
case msg, ok := <-ce.Client.Channel(): case msg, ok := <-ce.Client.Channel():
@@ -200,21 +186,6 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
return e.NotFoundError("Missing or invalid client id.", err) 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 // for now allow only guest->auth upgrades and any other auth change is forbidden
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record) clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) { if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
@@ -237,7 +208,7 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
e.Client.Subscribe(e.Subscriptions...) e.Client.Subscribe(e.Subscriptions...)
e.App.Logger().Debug( e.App.Logger().Debug(
"Realtime subscriptions updated", "Realtime subscriptions updated.",
slog.String("clientId", e.Client.Id()), slog.String("clientId", e.Client.Id()),
slog.Any("subscriptions", e.Subscriptions), slog.Any("subscriptions", e.Subscriptions),
) )
@@ -248,47 +219,38 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
}) })
} }
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord. // updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
// func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
// 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) chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group) group := new(errgroup.Group)
for _, chunk := range chunks { for _, chunk := range chunks {
group.Go(routine.SafeWrap(func() error { group.Go(func() error {
for _, client := range chunk { for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record) clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && if clientAuth != nil &&
clientAuth.Id == authRecord.Id && clientAuth.Id == newAuthRecord.Id &&
clientAuth.Collection().Name == authRecord.Collection().Name { clientAuth.Collection().Name == newAuthRecord.Collection().Name {
if clientAuth.TokenKey() != authRecord.TokenKey() { client.Set(RealtimeClientAuthKey, newAuthRecord)
client.Unset(RealtimeClientAuthKey)
} else {
client.Set(RealtimeClientAuthKey, authRecord)
}
} }
} }
return nil return nil
})) })
} }
return group.Wait() return group.Wait()
} }
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model. // realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error { func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize) chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
group := new(errgroup.Group) group := new(errgroup.Group)
for _, chunk := range chunks { for _, chunk := range chunks {
group.Go(routine.SafeWrap(func() error { group.Go(func() error {
for _, client := range chunk { for _, client := range chunk {
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record) clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
if clientAuth != nil && if clientAuth != nil &&
@@ -299,82 +261,13 @@ func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.M
} }
return nil return nil
})) })
}
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(routine.SafeWrap(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() return group.Wait()
} }
func bindRealtimeEvents(app core.App) { 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 // update the clients that has auth record association
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{ app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
Func: func(e *core.ModelEvent) error { Func: func(e *core.ModelEvent) error {
@@ -401,7 +294,7 @@ func bindRealtimeEvents(app core.App) {
Func: func(e *core.ModelEvent) error { Func: func(e *core.ModelEvent) error {
collection := realtimeResolveRecordCollection(e.App, e.Model) collection := realtimeResolveRecordCollection(e.App, e.Model)
if collection != nil && collection.IsAuth() { if collection != nil && collection.IsAuth() {
if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil { if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
app.Logger().Warn( app.Logger().Warn(
"Failed to remove client(s) associated to the deleted auth model", "Failed to remove client(s) associated to the deleted auth model",
slog.Any("id", e.Model.PK()), slog.Any("id", e.Model.PK()),
@@ -623,7 +516,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
} }
for _, chunk := range chunks { for _, chunk := range chunks {
group.Go(routine.SafeWrap(func() error { group.Go(func() error {
var clientAuth *core.Record var clientAuth *core.Record
for _, client := range chunk { for _, client := range chunk {
@@ -655,20 +548,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
// which exact fields the client subscription requested or has permissions to access // which exact fields the client subscription requested or has permissions to access
cleanRecord := record.Fresh() 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 // trigger the enrich hooks
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error { enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
// apply expand // apply expand
@@ -766,7 +645,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
} }
return nil return nil
})) })
} }
return group.Wait() return group.Wait()
@@ -782,7 +661,7 @@ func realtimeBroadcastDryCacheKey(app core.App, key string) error {
group := new(errgroup.Group) group := new(errgroup.Group)
for _, chunk := range chunks { for _, chunk := range chunks {
group.Go(routine.SafeWrap(func() error { group.Go(func() error {
for _, client := range chunk { for _, client := range chunk {
messages, ok := client.Get(key).([]subscriptions.Message) messages, ok := client.Get(key).([]subscriptions.Message)
if !ok { if !ok {
@@ -801,7 +680,7 @@ func realtimeBroadcastDryCacheKey(app core.App, key string) error {
} }
return nil return nil
})) })
} }
return group.Wait() return group.Wait()
@@ -817,7 +696,7 @@ func realtimeUnsetDryCacheKey(app core.App, key string) error {
group := new(errgroup.Group) group := new(errgroup.Group)
for _, chunk := range chunks { for _, chunk := range chunks {
group.Go(routine.SafeWrap(func() error { group.Go(func() error {
for _, client := range chunk { for _, client := range chunk {
if client.Get(key) != nil { if client.Get(key) != nil {
client.Unset(key) client.Unset(key)
@@ -825,7 +704,7 @@ func realtimeUnsetDryCacheKey(app core.App, key string) error {
} }
return nil return nil
})) })
} }
return group.Wait() return group.Wait()
+15 -369
View File
@@ -26,7 +26,6 @@ func TestRealtimeConnect(t *testing.T) {
Method: http.MethodGet, Method: http.MethodGet,
URL: "/api/realtime", URL: "/api/realtime",
Timeout: 100 * time.Millisecond, Timeout: 100 * time.Millisecond,
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`id:`, `id:`,
@@ -38,17 +37,6 @@ func TestRealtimeConnect(t *testing.T) {
"OnRealtimeConnectRequest": 1, "OnRealtimeConnectRequest": 1,
"OnRealtimeMessageSend": 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) { AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 { if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients())) t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
@@ -114,8 +102,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() { resetClient := func() {
client.Unsubscribe() client.Unsubscribe()
client.Unset(apis.RealtimeClientAuthKey) client.Set(apis.RealtimeClientAuthKey, nil)
client.Unset(apis.RealtimeClientIPKey)
} }
validSubscriptionsLimit := make([]string, 1000) validSubscriptionsLimit := make([]string, 1000)
@@ -221,26 +208,6 @@ func TestRealtimeSubscribe(t *testing.T) {
}, },
ExpectedEvents: map[string]int{"*": 0}, 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", Name: "existing client with valid topic length",
Method: http.MethodPost, Method: http.MethodPost,
@@ -462,10 +429,7 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
defer testApp.Cleanup() defer testApp.Cleanup()
// init realtime handlers // init realtime handlers
_, err := apis.NewRouter(testApp) apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com") authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
@@ -496,10 +460,7 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
e.Context = context.Background() e.Context = context.Background()
e.Model = authRecord1 e.Model = authRecord1
err = testApp.OnModelAfterDeleteSuccess().Trigger(e) testApp.OnModelAfterDeleteSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 { if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
t.Fatalf("Expected %d subscription clients, found %d", 3, total) t.Fatalf("Expected %d subscription clients, found %d", 3, total)
@@ -523,10 +484,7 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
defer testApp.Cleanup() defer testApp.Cleanup()
// init realtime handlers // init realtime handlers
_, err := apis.NewRouter(testApp) apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com") authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
@@ -537,331 +495,25 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
client.Set(apis.RealtimeClientAuthKey, authRecord1) client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client) testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord and change its name // refetch the authRecord and change its email
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com") authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
authRecord2.SetEmail("new@example.com")
newName := "test_new_name" // mock update event
authRecord2.Set("name", newName)
err = testApp.Save(authRecord2)
if err != nil {
t.Fatal(err)
}
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
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 := new(core.ModelEvent)
e.App = testApp e.App = testApp
e.Type = core.ModelEventTypeDelete e.Type = core.ModelEventTypeUpdate
e.Context = context.Background() e.Context = context.Background()
e.Model = usersCollection e.Model = authRecord2
err = testApp.OnModelAfterDeleteSuccess().Trigger(e) testApp.OnModelAfterUpdateSuccess().Trigger(e)
if err != nil {
t.Fatal(err)
}
expectations := map[*core.Record]bool{ clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
// record -> unset if clientAuthRecord.Email() != authRecord2.Email() {
authRecord1: true, t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
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)
}
} }
} }
@@ -899,10 +551,7 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
defer testApp.Cleanup() defer testApp.Cleanup()
// init realtime handlers // init realtime handlers
_, err := apis.NewRouter(testApp) apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com") authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
@@ -959,10 +608,7 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
defer testApp.Cleanup() defer testApp.Cleanup()
// init realtime handlers // init realtime handlers
_, err := apis.NewRouter(testApp) apis.NewRouter(testApp)
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com") authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
) )
+2 -2
View File
@@ -3,8 +3,8 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/mails" "github.com/tabshift-gh/pocketbase/mails"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
) )
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/mails" "github.com/tabshift-gh/pocketbase/mails"
"github.com/tabshift-gh/pocketbase/tools/routine" "github.com/tabshift-gh/pocketbase/tools/routine"
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/mails" "github.com/tabshift-gh/pocketbase/mails"
"github.com/tabshift-gh/pocketbase/tools/routine" "github.com/tabshift-gh/pocketbase/tools/routine"
+1 -8
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
"github.com/spf13/cast" "github.com/spf13/cast"
@@ -45,13 +45,6 @@ func recordConfirmVerification(e *core.RequestEvent) error {
if !wasVerified { if !wasVerified {
e.Record.SetVerified(true) 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 { if err := e.App.Save(e.Record); err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err)) return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
} }
+1 -80
View File
@@ -120,7 +120,7 @@ func TestRecordConfirmVerification(t *testing.T) {
} }
if user.Verified() { if user.Verified() {
t.Fatal("Expected the user to be unverified before the confirmation") t.Fatalf("Expected the user to be unverified before the confirmation")
} }
// ensure that there is at least one pre-existing OAuth2 link // ensure that there is at least one pre-existing OAuth2 link
@@ -152,85 +152,6 @@ func TestRecordConfirmVerification(t *testing.T) {
} }
}, },
}, },
{
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))
}
},
},
{ {
Name: "valid token (already verified)", Name: "valid token (already verified)",
Method: http.MethodPost, Method: http.MethodPost,
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/mails" "github.com/tabshift-gh/pocketbase/mails"
"github.com/tabshift-gh/pocketbase/tools/routine" "github.com/tabshift-gh/pocketbase/tools/routine"
+2 -1
View File
@@ -17,8 +17,8 @@ import (
"syscall" "syscall"
"time" "time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/auth" "github.com/tabshift-gh/pocketbase/tools/auth"
"github.com/tabshift-gh/pocketbase/tools/dbutils" "github.com/tabshift-gh/pocketbase/tools/dbutils"
@@ -203,6 +203,7 @@ func (form *recordOAuth2LoginForm) validate() error {
return validation.ValidateStruct(form, return validation.ValidateStruct(form,
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)), validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
validation.Field(&form.Code, validation.Required), validation.Field(&form.Code, validation.Required),
validation.Field(&form.RedirectURL, validation.Required),
) )
} }
-10
View File
@@ -55,16 +55,6 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
} }
defer client.Unsubscribe(oauth2SubscriptionTopic) 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 // temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
// (see https://github.com/tabshift-gh/pocketbase/issues/7090) // (see https://github.com/tabshift-gh/pocketbase/issues/7090)
if data.AppleUser != "" && data.Error == "" && data.Code != "" { if data.AppleUser != "" && data.Error == "" && data.Code != "" {
+2 -25
View File
@@ -8,7 +8,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/tabshift-gh/pocketbase/apis"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tests" "github.com/tabshift-gh/pocketbase/tests"
"github.com/tabshift-gh/pocketbase/tools/subscriptions" "github.com/tabshift-gh/pocketbase/tools/subscriptions"
@@ -17,9 +16,9 @@ import (
func TestRecordAuthWithOAuth2Redirect(t *testing.T) { func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
t.Parallel() t.Parallel()
clientStubs := make([]map[string]subscriptions.Client, 0, 11) clientStubs := make([]map[string]subscriptions.Client, 0, 10)
for i := 0; i < 11; i++ { for i := 0; i < 10; i++ {
c1 := subscriptions.NewDefaultClient() c1 := subscriptions.NewDefaultClient()
c2 := subscriptions.NewDefaultClient() c2 := subscriptions.NewDefaultClient()
@@ -336,28 +335,6 @@ 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 { for _, scenario := range scenarios {
+4 -6
View File
@@ -90,11 +90,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"data":{`, `"data":{`,
`"provider":`, `"provider":`,
`"code":`, `"code":`,
`"redirectURL":`,
}, },
NotExpectedContent: []string{ NotExpectedContent: []string{
// should be optional `"codeVerifier":`, // should be optional
`"codeVerifier":`,
`"redirectURL":`,
}, },
ExpectedEvents: map[string]int{"*": 0}, ExpectedEvents: map[string]int{"*": 0},
}, },
@@ -110,11 +109,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
`"data":{`, `"data":{`,
`"provider":`, `"provider":`,
`"code":`, `"code":`,
`"redirectURL":`,
}, },
NotExpectedContent: []string{ NotExpectedContent: []string{
// should be optional `"codeVerifier":`, // should be optional
`"codeVerifier":`,
`"redirectURL":`,
}, },
ExpectedEvents: map[string]int{"*": 0}, ExpectedEvents: map[string]int{"*": 0},
}, },
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
) )
@@ -68,7 +68,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
otpId := e.OTP.Id otpId := e.OTP.Id
otpSentTo := e.OTP.SentTo() otpSentTo := e.OTP.SentTo()
// eagerly delete the OTP to avoid unnecessary double delete model hook calls // eagerly delete the OTP to avoid unnecessery double delete model hook calls
// triggered by the password change below // triggered by the password change below
err := e.App.Delete(e.OTP) err := e.App.Delete(e.OTP)
if err != nil { if err != nil {
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"slices" "slices"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/dbutils" "github.com/tabshift-gh/pocketbase/tools/dbutils"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
-7
View File
@@ -43,13 +43,6 @@ func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMetho
} }
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error { 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() originalRequestInfo, err := e.RequestInfo()
if err != nil { if err != nil {
return err return err
-36
View File
@@ -759,39 +759,3 @@ 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" "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-ancestors '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-src 'none'"
// ServeConfig defines a configuration struct for apis.Serve(). // ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct { type ServeConfig struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package apis
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/forms" "github.com/tabshift-gh/pocketbase/forms"
"github.com/tabshift-gh/pocketbase/tools/router" "github.com/tabshift-gh/pocketbase/tools/router"
-194
View File
@@ -1,194 +0,0 @@
package apis
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/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
@@ -1,220 +0,0 @@
package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/tabshift-gh/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)
}
}
+1 -37
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -24,7 +24,6 @@ func NewSuperuserCommand(app core.App) *cobra.Command {
command.AddCommand(superuserUpdateCommand(app)) command.AddCommand(superuserUpdateCommand(app))
command.AddCommand(superuserDeleteCommand(app)) command.AddCommand(superuserDeleteCommand(app))
command.AddCommand(superuserOTPCommand(app)) command.AddCommand(superuserOTPCommand(app))
command.AddCommand(superuserIPsCommand(app))
return command return command
} }
@@ -210,38 +209,3 @@ func superuserOTPCommand(app core.App) *cobra.Command {
return 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,7 +1,6 @@
package cmd_test package cmd_test
import ( import (
"slices"
"testing" "testing"
"github.com/tabshift-gh/pocketbase/cmd" "github.com/tabshift-gh/pocketbase/cmd"
@@ -402,63 +401,3 @@ 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)
}
}
})
}
}
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"errors" "errors"
"slices" "slices"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/tabshift-gh/pocketbase/tools/hook"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+3 -5
View File
@@ -38,9 +38,8 @@ const (
LocalStorageDirName string = "storage" LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups" LocalBackupsDirName string = "backups"
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() LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
LocalAutocertCacheDirName string = ".autocert_cache"
// @todo consider removing after backups refactoring // @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found" lostFoundDirName string = "lost+found"
@@ -1383,7 +1382,6 @@ func (app *BaseApp) registerBaseHooks() {
app.registerMFAHooks() app.registerMFAHooks()
app.registerOTPHooks() app.registerOTPHooks()
app.registerAuthOriginHooks() app.registerAuthOriginHooks()
app.registerNotifyWatcherHooks()
} }
// getLoggerMinLevel returns the logger min level based on the // getLoggerMinLevel returns the logger min level based on the
@@ -1458,7 +1456,7 @@ func (app *BaseApp) initLogger() error {
}, },
}) })
routine.FireAndForget(func() { go func() {
ctx := context.Background() ctx := context.Background()
for { for {
@@ -1469,7 +1467,7 @@ func (app *BaseApp) initLogger() error {
handler.WriteAll(ctx) handler.WriteAll(ctx)
} }
} }
}) }()
app.logger = slog.New(handler) app.logger = slog.New(handler)
+1 -20
View File
@@ -54,13 +54,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
event.Context = ctx event.Context = ctx
event.Name = name event.Name = name
// default root dir entries to exclude from the backup generation // default root dir entries to exclude from the backup generation
event.Exclude = []string{ event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
LocalBackupsDirName,
LocalTempDirName,
LocalNotifyDirName,
LocalAutocertCacheDirName,
lostFoundDirName,
}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error { return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
// generate a default name if missing // generate a default name if missing
@@ -322,19 +316,6 @@ func (app *BaseApp) registerAutobackupHooks() {
slog.String("name", name), slog.String("name", name),
slog.String("error", err.Error()), 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 maxKeep := app.Settings().Backups.CronMaxKeep
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"fmt" "fmt"
"slices" "slices"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+2 -28
View File
@@ -348,7 +348,6 @@ func (app *BaseApp) registerCollectionHooks() {
} }
// @todo experiment eventually replacing the rules *string with a struct? // @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 { type baseCollection struct {
BaseModel BaseModel
@@ -821,25 +820,6 @@ func onCollectionSave(e *CollectionEvent) error {
e.Collection.updateGeneratedIdIfExists(e.App) 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() return e.Next()
} }
@@ -925,14 +905,8 @@ func onCollectionSaveExecute(e *CollectionEvent) error {
} }
// trigger an update for all views with changed fields as a result of the current collection save // trigger an update for all views with changed fields as a result of the current collection save
// (only log the error to allow users to adjust the problematic view queries from the UI) // (ignoring view errors to allow users to update the query from the UI)
depViewsErr := resaveViewsWithChangedFields(e.App, e.Collection.Id) resaveViewsWithChangedFields(e.App, e.Collection.Id)
if depViewsErr != nil {
e.App.Logger().Warn(
"Dependent view collection(s) may need to be updated after "+e.Collection.Name+" collection change",
"error", depViewsErr,
)
}
return nil return nil
} }
+4 -4
View File
@@ -5,8 +5,8 @@ import (
"strings" "strings"
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/tools/auth" "github.com/tabshift-gh/pocketbase/tools/auth"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
@@ -70,7 +70,7 @@ func (m *Collection) setDefaultAuthOptions() {
}, },
AuthToken: TokenConfig{ AuthToken: TokenConfig{
Secret: security.RandomString(50), Secret: security.RandomString(50),
Duration: 432000, // 5days Duration: 604800, // 7 days
}, },
PasswordResetToken: TokenConfig{ PasswordResetToken: TokenConfig{
Secret: security.RandomString(50), Secret: security.RandomString(50),
@@ -82,7 +82,7 @@ func (m *Collection) setDefaultAuthOptions() {
}, },
VerificationToken: TokenConfig{ VerificationToken: TokenConfig{
Secret: security.RandomString(50), Secret: security.RandomString(50),
Duration: 86400, // 1day Duration: 259200, // 3days
}, },
FileToken: TokenConfig{ FileToken: TokenConfig{
Secret: security.RandomString(50), Secret: security.RandomString(50),
+2 -3
View File
@@ -18,7 +18,6 @@ var defaultVerificationTemplate = EmailTemplate{
<p> <p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Verify</a> <a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Verify</a>
</p> </p>
<p><i>If you didn't recently register, please ignore this email.</i></p>
<p> <p>
Thanks,<br/> Thanks,<br/>
` + EmailPlaceholderAppName + ` team ` + EmailPlaceholderAppName + ` team
@@ -32,7 +31,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
<p> <p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Reset password</a> <a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Reset password</a>
</p> </p>
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p> <p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>
<p> <p>
Thanks,<br/> Thanks,<br/>
` + EmailPlaceholderAppName + ` team ` + EmailPlaceholderAppName + ` team
@@ -46,7 +45,7 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
<p> <p>
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Confirm new email</a> <a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Confirm new email</a>
</p> </p>
<p><i>If you didn't ask to change your email address, please ignore this email.</i></p> <p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>
<p> <p>
Thanks,<br/> Thanks,<br/>
` + EmailPlaceholderAppName + ` team ` + EmailPlaceholderAppName + ` team
-37
View File
@@ -1678,40 +1678,3 @@ 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)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
package core package core
import ( import (
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
) )
var _ optionsValidator = (*collectionViewOptions)(nil) var _ optionsValidator = (*collectionViewOptions)(nil)
+81 -44
View File
@@ -250,6 +250,52 @@ func (app *BaseApp) TruncateCollection(collection *Collection) error {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// saveViewCollection persists the provided View collection changes:
// - deletes the old related SQL view (if any)
// - creates a new SQL view with the latest newCollection.Options.Query
// - generates new feilds list based on newCollection.Options.Query
// - updates newCollection.Fields based on the generated view table info and query
// - saves the newCollection
//
// This method returns an error if newCollection is not a "view".
func saveViewCollection(app App, newCollection, oldCollection *Collection) error {
if !newCollection.IsView() {
return errors.New("not a view collection")
}
return app.RunInTransaction(func(txApp App) error {
query := newCollection.ViewQuery
// generate collection fields from the query
viewFields, err := txApp.CreateViewFields(query)
if err != nil {
return err
}
// delete old renamed view
if oldCollection != nil {
if err := txApp.DeleteView(oldCollection.Name); err != nil {
return err
}
}
// wrap view query if necessary
query, err = normalizeViewQueryId(txApp, query)
if err != nil {
return fmt.Errorf("failed to normalize view query id: %w", err)
}
// (re)create the view
if err := txApp.SaveView(newCollection.Name, query); err != nil {
return err
}
newCollection.Fields = viewFields
return txApp.Save(newCollection)
})
}
// normalizeViewQueryId wraps (if necessary) the provided view query // normalizeViewQueryId wraps (if necessary) the provided view query
// with a subselect to ensure that the id column is a text since // with a subselect to ensure that the id column is a text since
// currently we don't support non-string model ids // currently we don't support non-string model ids
@@ -296,59 +342,50 @@ func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
} }
return app.RunInTransaction(func(txApp App) error { return app.RunInTransaction(func(txApp App) error {
var collectionErrors []error
for _, collection := range collections { for _, collection := range collections {
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) { if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
continue continue
} }
check := func() error { // clone the existing fields for temp modifications
// clone the existing fields for temp modifications oldFields, err := collection.Fields.Clone()
oldFields, err := collection.Fields.Clone() if err != nil {
if err != nil { return err
return err
}
// generate new fields from the query
newFields, err := txApp.CreateViewFields(collection.ViewQuery)
if err != nil {
return err
}
// unset the fields' ids to exclude from the comparison
for _, f := range oldFields {
f.SetId("")
}
for _, f := range newFields {
f.SetId("")
}
encodedNewFields, err := json.Marshal(newFields)
if err != nil {
return err
}
encodedOldFields, err := json.Marshal(oldFields)
if err != nil {
return err
}
if bytes.EqualFold(encodedNewFields, encodedOldFields) {
return nil // no changes
}
return txApp.Save(collection)
} }
if err := check(); err != nil { // generate new fields from the query
collectionErrors = append( newFields, err := txApp.CreateViewFields(collection.ViewQuery)
collectionErrors, if err != nil {
fmt.Errorf("[%s] %w", collection.Name, err), return err
) }
// unset the fields' ids to exclude from the comparison
for _, f := range oldFields {
f.SetId("")
}
for _, f := range newFields {
f.SetId("")
}
encodedNewFields, err := json.Marshal(newFields)
if err != nil {
return err
}
encodedOldFields, err := json.Marshal(oldFields)
if err != nil {
return err
}
if bytes.EqualFold(encodedNewFields, encodedOldFields) {
continue // no changes
}
if err := saveViewCollection(txApp, collection, nil); err != nil {
return err
} }
} }
return errors.Join(collectionErrors...) return nil
}) })
} }
+3 -5
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/dbutils" "github.com/tabshift-gh/pocketbase/tools/dbutils"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
) )
@@ -306,10 +306,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
for _, raw := range collection.Indexes { for _, raw := range collection.Indexes {
parsed := dbutils.ParseIndex(raw) parsed := dbutils.ParseIndex(raw)
// note: don't check IsValid because the index table name may not be populated if !parsed.IsValid() {
// (https://github.com/pocketbase/pocketbase/issues/7689) continue
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() _, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
-46
View File
@@ -294,49 +294,3 @@ 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)
}
}
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"strconv" "strconv"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/dbutils" "github.com/tabshift-gh/pocketbase/tools/dbutils"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"strconv" "strconv"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"net/http" "net/http"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/tabshift-gh/pocketbase/tools/hook"
) )
+1 -17
View File
@@ -448,24 +448,8 @@ type RealtimeConnectRequestEvent struct {
Client subscriptions.Client Client subscriptions.Client
// IdleTimeout specifies the max duration to wait for a new message // note: modifying it after the connect has no effect
// before closing the connection.
//
// Modifying the value after the connection has been established has no effect.
//
// Defaults to 5 minutes.
IdleTimeout time.Duration 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 { type RealtimeMessageEvent struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/auth" "github.com/tabshift-gh/pocketbase/tools/auth"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/tabshift-gh/pocketbase/tools/hook"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp" "regexp"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"slices" "slices"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+3 -7
View File
@@ -9,7 +9,7 @@ import (
"regexp" "regexp"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/filesystem" "github.com/tabshift-gh/pocketbase/tools/filesystem"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
@@ -732,16 +732,12 @@ func (f *FileField) toSliceValue(raw any) []any {
case nil: case nil:
// nothing to cast // nothing to cast
case *filesystem.File: case *filesystem.File:
if value != nil { result = append(result, value)
result = append(result, value)
}
case filesystem.File: case filesystem.File:
result = append(result, &value) result = append(result, &value)
case []*filesystem.File: case []*filesystem.File:
for _, v := range value { for _, v := range value {
if v != nil { result = append(result, v)
result = append(result, v)
}
} }
case []filesystem.File: case []filesystem.File:
for _, v := range value { for _, v := range value {
+2 -6
View File
@@ -103,8 +103,6 @@ func TestFileFieldPrepareValue(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var nilFile *filesystem.File
scenarios := []struct { scenarios := []struct {
raw any raw any
field *core.FileField field *core.FileField
@@ -116,9 +114,8 @@ func TestFileFieldPrepareValue(t *testing.T) {
{123, &core.FileField{MaxSelect: 1}, `"123"`}, {123, &core.FileField{MaxSelect: 1}, `"123"`},
{"a", &core.FileField{MaxSelect: 1}, `"a"`}, {"a", &core.FileField{MaxSelect: 1}, `"a"`},
{`["a"]`, &core.FileField{MaxSelect: 1}, `"a"`}, {`["a"]`, &core.FileField{MaxSelect: 1}, `"a"`},
{f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
{*f1, &core.FileField{MaxSelect: 1}, string(f1Raw)}, {*f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
{nilFile, &core.FileField{MaxSelect: 1}, `""`}, {f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
{[]string{}, &core.FileField{MaxSelect: 1}, `""`}, {[]string{}, &core.FileField{MaxSelect: 1}, `""`},
{[]string{"a", "b"}, &core.FileField{MaxSelect: 1}, `"b"`}, {[]string{"a", "b"}, &core.FileField{MaxSelect: 1}, `"b"`},
@@ -129,9 +126,8 @@ func TestFileFieldPrepareValue(t *testing.T) {
{"a", &core.FileField{MaxSelect: 2}, `["a"]`}, {"a", &core.FileField{MaxSelect: 2}, `["a"]`},
{`["a"]`, &core.FileField{MaxSelect: 2}, `["a"]`}, {`["a"]`, &core.FileField{MaxSelect: 2}, `["a"]`},
{[]any{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`}, {[]any{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
{[]filesystem.File{*f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
{[]*filesystem.File{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`}, {[]*filesystem.File{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
{[]any{nilFile, f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`}, {[]filesystem.File{*f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
{[]string{}, &core.FileField{MaxSelect: 2}, `[]`}, {[]string{}, &core.FileField{MaxSelect: 2}, `[]`},
{[]string{"a", "b", "c"}, &core.FileField{MaxSelect: 2}, `["a","b","c"]`}, {[]string{"a", "b", "c"}, &core.FileField{MaxSelect: 2}, `["a","b","c"]`},
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+7 -19
View File
@@ -2,9 +2,10 @@ package core
import ( import (
"context" "context"
"fmt"
"math" "math"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@@ -22,12 +23,6 @@ var (
_ SetterFinder = (*NumberField)(nil) _ 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. // NumberField defines "number" type field for storing numeric (float64) value.
// //
// The respective zero record field value is 0. // The respective zero record field value is 0.
@@ -156,15 +151,15 @@ func (f *NumberField) ValidateValue(ctx context.Context, app App, record *Record
} }
if f.OnlyInt && val != float64(int64(val)) { if f.OnlyInt && val != float64(int64(val)) {
return onlyIntValidationError return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed")
} }
if f.Min != nil && val < *f.Min { if f.Min != nil && val < *f.Min {
return minNumberValidationError.SetParams(map[string]any{"min": *f.Min}) return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *f.Min))
} }
if f.Max != nil && val > *f.Max { if f.Max != nil && val > *f.Max {
return maxNumberValidationError.SetParams(map[string]any{"max": *f.Max}) return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *f.Max))
} }
return nil return nil
@@ -176,14 +171,7 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
validation.By(f.checkOnlyInt), validation.By(f.checkOnlyInt),
} }
if f.Min != nil && f.Max != nil { if f.Min != nil && f.Max != nil {
maxRules = append(maxRules, validation.By(func(value interface{}) error { maxRules = append(maxRules, validation.Min(*f.Min))
// 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, return validation.ValidateStruct(f,
@@ -202,7 +190,7 @@ func (f *NumberField) checkOnlyInt(value any) error {
} }
if *v != float64(int64(*v)) { if *v != float64(int64(*v)) {
return onlyIntValidationError return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed.")
} }
return nil return nil
+7 -31
View File
@@ -237,7 +237,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{}, []string{},
}, },
{ {
"decimal min", "decumal min",
func() *core.NumberField { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
@@ -248,7 +248,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{}, []string{},
}, },
{ {
"decimal min (onlyInt)", "decumal min (onlyInt)",
func() *core.NumberField { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
@@ -272,7 +272,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{}, []string{},
}, },
{ {
"decimal max", "decumal max",
func() *core.NumberField { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
@@ -283,7 +283,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{}, []string{},
}, },
{ {
"decimal max (onlyInt)", "decumal max (onlyInt)",
func() *core.NumberField { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
@@ -307,31 +307,19 @@ func TestNumberFieldValidateSettings(t *testing.T) {
[]string{}, []string{},
}, },
{ {
"min > max (0)", "min > max",
func() *core.NumberField { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
Name: "test", Name: "test",
Min: types.Pointer(2.0), Min: types.Pointer(2.0),
Max: types.Pointer(0.0), Max: types.Pointer(1.0),
} }
}, },
[]string{"max"}, []string{"max"},
}, },
{ {
"min (0) > max", "min <= 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 { func() *core.NumberField {
return &core.NumberField{ return &core.NumberField{
Id: "test", Id: "test",
@@ -342,18 +330,6 @@ func TestNumberFieldValidateSettings(t *testing.T) {
}, },
[]string{}, []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 { for _, s := range scenarios {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"regexp" "regexp"
"strings" "strings"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"database/sql/driver" "database/sql/driver"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"database/sql/driver" "database/sql/driver"
"slices" "slices"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/list" "github.com/tabshift-gh/pocketbase/tools/list"
"github.com/tabshift-gh/pocketbase/tools/types" "github.com/tabshift-gh/pocketbase/tools/types"
) )
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core" "github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/pocketbase/tests" "github.com/tabshift-gh/pocketbase/tests"
) )
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"regexp" "regexp"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/security" "github.com/tabshift-gh/pocketbase/tools/security"
"github.com/spf13/cast" "github.com/spf13/cast"
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/url" "net/url"
"slices" "slices"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
+1 -1
View File
@@ -143,7 +143,7 @@ func (app *BaseApp) registerMFAHooks() {
err = e.App.DeleteAllMFAsByRecord(e.Record) err = e.App.DeleteAllMFAsByRecord(e.Record)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"[%s] failed to delete all previous MFAs for record %q: %w", "[%s] failed to delete all previos MFAs for record %q: %w",
e.Record.Collection().Name, e.Record.Collection().Name,
e.Record.Id, e.Record.Id,
err, err,
-211
View File
@@ -1,211 +0,0 @@
package core
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/fsnotify/fsnotify"
"github.com/tabshift-gh/pocketbase/tools/hook"
"github.com/tabshift-gh/pocketbase/tools/routine"
"github.com/tabshift-gh/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
routine.FireAndForget(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
@@ -1,190 +0,0 @@
package core_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/tabshift-gh/pocketbase/core"
"github.com/tabshift-gh/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])
}
}
+1 -1
View File
@@ -139,7 +139,7 @@ func (app *BaseApp) registerOTPHooks() {
err := e.App.DeleteAllOTPsByRecord(e.Record) err := e.App.DeleteAllOTPsByRecord(e.Record)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"[%s] failed to delete all previous OTPs for record %q: %w", "[%s] failed to delete all previos OTPs for record %q: %w",
e.Record.Collection().Name, e.Record.Collection().Name,
e.Record.Id, e.Record.Id,
err, err,
-1
View File
@@ -241,7 +241,6 @@ func (r *RecordFieldResolver) updateQueryWithDeduplicateConstraint(query *dbx.Se
// } // }
} }
//nolint:unused
func preferGroupBy(info *dbx.QueryInfo, fullUnquotedGroupByCol string) bool { func preferGroupBy(info *dbx.QueryInfo, fullUnquotedGroupByCol string) bool {
if len(info.GroupBy) != 0 { if len(info.GroupBy) != 0 {
return false return false
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"sort" "sort"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/dbutils" "github.com/tabshift-gh/pocketbase/tools/dbutils"
"github.com/tabshift-gh/pocketbase/tools/filesystem" "github.com/tabshift-gh/pocketbase/tools/filesystem"
+1 -1
View File
@@ -175,7 +175,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
} }
} }
} }
existsSet = nil //nolint:ineffassign existsSet = nil
// fetch rels // fetch rels
rels, relsErr := fetchFunc(relCollection, relIds) rels, relsErr := fetchFunc(relCollection, relIds)
+4 -31
View File
@@ -13,8 +13,8 @@ import (
"sync" "sync"
"time" "time"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is" "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tools/cron" "github.com/tabshift-gh/pocketbase/tools/cron"
"github.com/tabshift-gh/pocketbase/tools/hook" "github.com/tabshift-gh/pocketbase/tools/hook"
@@ -120,10 +120,6 @@ var (
) )
type settings struct { 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"` SMTP SMTPConfig `form:"smtp" json:"smtp"`
Backups BackupsConfig `form:"backups" json:"backups"` Backups BackupsConfig `form:"backups" json:"backups"`
S3 S3Config `form:"s3" json:"s3"` S3 S3Config `form:"s3" json:"s3"`
@@ -257,13 +253,6 @@ func (s *Settings) DBExport(app App) (map[string]any, error) {
} }
result["updated"] = now result["updated"] = now
// @todo remove with encoding/json/2
// serialize as empty array
//nolint:staticcheck
if s.settings.SuperuserIPs == nil {
s.settings.SuperuserIPs = []string{}
}
encoded, err := json.Marshal(s.settings) encoded, err := json.Marshal(s.settings)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -291,7 +280,6 @@ func (s *Settings) PostValidate(ctx context.Context, app App) error {
defer s.mu.RUnlock() defer s.mu.RUnlock()
return validation.ValidateStructWithContext(ctx, s, return validation.ValidateStructWithContext(ctx, s,
validation.Field(&s.SuperuserIPs, validation.Each(validation.Required, validation.By(validators.IPOrSubnet))),
validation.Field(&s.Meta), validation.Field(&s.Meta),
validation.Field(&s.Logs), validation.Field(&s.Logs),
validation.Field(&s.SMTP), validation.Field(&s.SMTP),
@@ -355,12 +343,6 @@ 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) return json.Marshal(copy)
} }
@@ -549,7 +531,6 @@ func (c MetaConfig) Validate() error {
return validation.ValidateStruct(&c, return validation.ValidateStruct(&c,
validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor), validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)), validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
// @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.AppURL, validation.Required, is.URL),
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)), validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required), validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required),
@@ -606,9 +587,8 @@ func (c TrustedProxyConfig) Validate() error {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type RateLimitsConfig struct { type RateLimitsConfig struct {
Rules []RateLimitRule `form:"rules" json:"rules"` Rules []RateLimitRule `form:"rules" json:"rules"`
ExcludedIPs []string `form:"excludedIPs" json:"excludedIPs"` Enabled bool `form:"enabled" json:"enabled"`
Enabled bool `form:"enabled" json:"enabled"`
} }
// FindRateLimitRule returns the first matching rule based on the provided labels. // FindRateLimitRule returns the first matching rule based on the provided labels.
@@ -653,9 +633,6 @@ func (c RateLimitsConfig) MarshalJSON() ([]byte, error) {
if c.Rules == nil { if c.Rules == nil {
c.Rules = []RateLimitRule{} c.Rules = []RateLimitRule{}
} }
if c.ExcludedIPs == nil {
c.ExcludedIPs = []string{}
}
return json.Marshal(alias(c)) return json.Marshal(alias(c))
} }
@@ -668,10 +645,6 @@ func (c RateLimitsConfig) Validate() error {
validation.When(c.Enabled, validation.Required), validation.When(c.Enabled, validation.Required),
validation.By(checkUniqueRuleLabel), validation.By(checkUniqueRuleLabel),
), ),
validation.Field(
&c.ExcludedIPs,
validation.Each(validation.Required, validation.By(validators.IPOrSubnet)),
),
) )
} }
+5 -9
View File
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
valueStr = string(export["value"].([]byte)) valueStr = string(export["value"].([]byte))
} }
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}}` expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected { if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr) t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
} }
@@ -180,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
} }
rawStr := string(raw) rawStr := string(raw)
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}}` expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected { if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr) t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -196,7 +196,6 @@ func TestSettingsValidate(t *testing.T) {
s := app.Settings() s := app.Settings()
// set invalid settings data // set invalid settings data
s.SuperuserIPs = []string{"127.0.0.1", ""}
s.Meta.AppName = "" s.Meta.AppName = ""
s.Logs.MaxDays = -10 s.Logs.MaxDays = -10
s.SMTP.Enabled = true s.SMTP.Enabled = true
@@ -218,7 +217,6 @@ func TestSettingsValidate(t *testing.T) {
} }
expectations := []string{ expectations := []string{
`"superuserIPs":{`,
`"meta":{`, `"meta":{`,
`"logs":{`, `"logs":{`,
`"smtp":{`, `"smtp":{`,
@@ -597,8 +595,7 @@ func TestRateLimitsConfigValidate(t *testing.T) {
{ {
"invalid data", "invalid data",
core.RateLimitsConfig{ core.RateLimitsConfig{
Enabled: true, Enabled: true,
ExcludedIPs: []string{"", "127.0.0.1"},
Rules: []core.RateLimitRule{ Rules: []core.RateLimitRule{
{ {
Label: "/123abc/", Label: "/123abc/",
@@ -612,13 +609,12 @@ func TestRateLimitsConfigValidate(t *testing.T) {
}, },
}, },
}, },
[]string{"rules", "excludedIPs"}, []string{"rules"},
}, },
{ {
"valid data", "valid data",
core.RateLimitsConfig{ core.RateLimitsConfig{
Enabled: true, Enabled: true,
ExcludedIPs: []string{"127.0.0.1", "10.0.0.1/20"},
Rules: []core.RateLimitRule{ Rules: []core.RateLimitRule{
{ {
Label: "123_abc", Label: "123_abc",
-131
View File
@@ -1,131 +0,0 @@
package core
import (
"bytes"
"errors"
"html"
"html/template"
"net/mail"
"github.com/tabshift-gh/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
@@ -1,124 +0,0 @@
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
}
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"errors" "errors"
"strings" "strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
validation "github.com/pocketbase/ozzo-validation/v4"
) )
// UniqueId checks whether a field string id already exists in the specified table. // UniqueId checks whether a field string id already exists in the specified table.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"testing" "testing"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
"github.com/tabshift-gh/pocketbase/tests" "github.com/tabshift-gh/pocketbase/tests"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package validators
import ( import (
"reflect" "reflect"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
) )
// Equal checks whether the validated value matches another one from the same type. // Equal checks whether the validated value matches another one from the same type.
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/tools/filesystem" "github.com/tabshift-gh/pocketbase/tools/filesystem"
) )
@@ -31,7 +31,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
"validation_file_size_limit", "validation_file_size_limit",
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.", "Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
).SetParams(map[string]any{ ).SetParams(map[string]any{
"file": cutStr(v.OriginalName, 300), "file": v.OriginalName,
"maxSize": maxBytes, "maxSize": maxBytes,
}) })
} }
@@ -60,7 +60,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
baseErr := validation.NewError( baseErr := validation.NewError(
"validation_invalid_mime_type", "validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", cutStr(v.OriginalName, 300)), fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
) )
if len(validTypes) == 0 { if len(validTypes) == 0 {
+1 -29
View File
@@ -1,10 +1,9 @@
package validators package validators
import ( import (
"net/netip"
"regexp" "regexp"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
) )
// IsRegex checks whether the validated value is a valid regular expression pattern. // IsRegex checks whether the validated value is a valid regular expression pattern.
@@ -28,30 +27,3 @@ func IsRegex(value any) error {
return nil 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,32 +31,3 @@ 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)
}
})
}
}
+1 -8
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"maps" "maps"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
) )
var ErrUnsupportedValueType = validation.NewError("validation_unsupported_value_type", "Invalid or unsupported value type.") var ErrUnsupportedValueType = validation.NewError("validation_unsupported_value_type", "Invalid or unsupported value type.")
@@ -38,10 +38,3 @@ func JoinValidationErrors(errA, errB error) error {
return errors.Join(errA, errB) return errors.Join(errA, errB)
} }
func cutStr(str string, max int) string {
if len(str) > max {
return str[:max] + "..."
}
return str
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"testing" "testing"
validation "github.com/pocketbase/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/tabshift-gh/pocketbase/core/validators" "github.com/tabshift-gh/pocketbase/core/validators"
) )

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