diff --git a/.github/FUNDING.yaml b/.github/FUNDING.yaml
deleted file mode 100644
index 5de7fa7993..0000000000
--- a/.github/FUNDING.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-# These are supported funding model platforms
-
-github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
-patreon: # Replace with a single Patreon username
-open_collective: pocketbase
-ko_fi: # Replace with a single Ko-fi username
-tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
-community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
-liberapay: # Replace with a single Liberapay username
-issuehunt: # Replace with a single IssueHunt username
-otechie: # Replace with a single Otechie username
-lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
-custom: ['https://www.paypal.com/donate/?hosted_button_id=4DVXNL4B8WT98']
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index ed145b5e55..9b9bcce153 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -1,5 +1,128 @@
# Security
-If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
+**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
-All reports will be promptly addressed, and you'll be credited accordingly.
+If you want to responsibly report a security issue you'll have to reach out as a human to **support at pocketbase.io**.
+
+This means:
+- no overconfident and arrogant tone
+- no threatening deadlines
+- no requirement for me to login in your security platform just to read the report
+- no inflated severity (we can discuss the CVSS score after confirming the issue)
+- no LLMs usage as part of your report description or followup communication
+
+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))_.
+
+**Or in other words - a simple _"Hey I think I found a security issue when I do X"_ is enough.**
+
+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.
+
+In case the vulnerability is confirmed:
+
+- I'll start working on a local fix.
+- Once the fix is implemented locally, I'll publish a pre-announcement with a scheduled release date _(and when possible an approximate release time)_.
+- After the release, I'll publish a GitHub security advisory and CVE with remediation steps and **minimal** details regarding the found exploit _(you are free to publish PoC and more details in your own blog, gist, etc. but it is advised to wait at least a week after the release to allow enough time for people to patch their instances before making it more publicly known)_.
+
+### Below is a short list of previous reports that are NOT considered security issues:
+
+
+Stored XSS
+
+This was discussed several times, both privately and [publicly](https://github.com/pocketbase/pocketbase/discussions/6694), but I remain on the opinion that it should be handled primarily on the client-side.
+
+Modern browsers recently introduced a basic [`Sanitizer` interface](https://developer.mozilla.org/en-US/docs/Web/API/Sanitizer) that could help filtering HTML strings without external libraries.
+
+Having also a default [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as meta tag or response header is always a good idea to minimize the risk of XSS.
+
+
+
+SQL injection in low level DB methods like app.DeleteTable(dangerousName)
+
+This is working correctly and it is not an issue but it is a common report most likely found by LLM or some other automated tools that may have stumbled on the [NB! code comments](https://pkg.go.dev/github.com/pocketbase/pocketbase@master/core#BaseApp.DeleteTable).
+
+Raw SQL statements, table and column names are not parameterized and they are vulnerable to SQL injection if used with untrusted input. The documentation as seen above already warns against it. In recent PocketBase releases, many of the arguments of these methods were also prefixed with `dangerous*` to make it even more clear that they should be used with caution.
+
+
+
+Race conditions
+
+To avoid DB locks PocketBase deliberately tries to minimize the use of DB transactions.
+This means that operations like record update don't wrap out of the box for example the `SELECT` and `UPDATE` SQL statements in a single transaction, and this can technically lead to a race condition if multiple users edit the same record.
+
+This is an accepted tradeoff and for the majority of cases it has no security implications.
+
+This also apply for the read and delete of MFA and OTP records but for those cases, since they operate in a security sensitive context, they have an extra short-lived duration that is configurable from the collection settings _(there are also system cron jobs that takes care for deleting forgotten/expired entries to prevent accumulation of invalid records)_.
+
+For the cases where transactions are really needed, users can utilize the [Batch Web API](https://pocketbase.io/docs/api-records/#batch-createupdateupsertdelete-records) or [create a transaction programmatically](https://pocketbase.io/docs/go-records/#transaction) _(with PocketBase v0.23+ it is also possible to wrap an entire hook chain in a single transaction)_.
+
+
+
+List/Search side-channel attacks
+
+Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG_23_39.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
+
+This is by design and it is accepted tradeoff between performance, security and usability.
+
+If you are concerned about timing attacks and have security sensitive collection data such as `secret`, `code`, `token`, etc. then the general recommendation is to mark their related fields as "Hidden" in order to disallow use in client-side filters.
+
+
+
+Connecting to a vulnerable OAuth2 provider
+
+Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on user create _(need to be specified from the auth collection OAuth2 fields mapping)_ some security researchers raised a concern regarding a Blind SSRF but this implies that an attacker controls the OAuth2 vendor and this is a very serious assumption in the first place.
+
+The entire OAuth2 flow relies that the application server (PocketBase) trusts the configured OAuth2 vendor.
+If you suspect that an OAuth2 vendor is malicious and cannot be trusted then you MUST NOT use that OAuth2 vendor at all and you should report it.
+
+If someone is able to tamper with the OAuth2 responses then the entire OAuth2 flow can be thrown out of the window because they will be practically able to authenticate as any of your existing users and the eventual avatar URL probing request is the least of your problem.
+
+~Nonetheless, in future PocketBase releases there will be [extra `localhost` domain like checks](https://github.com/orgs/pocketbase/projects/2/views/1?pane=issue&itemId=159545722) when assigning the OAuth2 avatar URL to a `file` field that will further minimize the risk of internal network probing requests in case of a vulnerable OAuth2 provider.~ _Done._
+
+
+
+Users enumeration
+
+This is a common and usually valid report but there is no easy solution without confusing and degrading the users experience.
+
+Some endpoints, like the user create/register, can be used for usernames or emails enumeration based on various response heuristics - timing, specific error messages, etc.
+
+In many places where applicable we've tried to minimize the impact by using constant time checks, returning non-descriptive error messages, applying an internal rate limit for some operations, etc. but it is not bulletproof and if somebody wants to find out if a user is registered they will be able to do it one way or another.
+
+If you think that there is a place where we can improve the handling without hurting too much the user experience, feel free to open a regular public issue and it will be considered.
+
+
+
+Attack-vectors relying on social engineering
+
+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)_.
+
+
+
+disintegration/imaging CVE-2023-36308
+
+Just for the past month, due to some corporate security scanners 5 different people raised concerns over [CVE-2023-36308](https://nvd.nist.gov/vuln/detail/CVE-2023-36308) but this is not really a vulnerability, especially not in PocketBase.
+
+[`disintegration/imaging`](https://github.com/disintegration/imaging) is a direct PocketBase dependency responsible for the thumbs generation.
+
+First, a panic (similar to exception in other languages) is NOT a security issue and Go programs usually have to be written defensively with that in mind. In PocketBase specifically all routes have auto panic-recover handling, no matter what the source of the panic is, so the worst case scenario would be an HTTP error response when attempting to access the thumb.
+
+Second, the related issue that the CVE describes is probably caused by a bug in an outdated `golang.org/x/image` dependency listed in the `go.mod` of that package but PocketBase uses a newer patched version of it that is expected to take precedence.
+
+Third, even if that issue is still available, with PocketBase it would have been triggerable ONLY if we supported TIFF thumbs generation but we don't. The supported thumbs formats at the moment are JPG, PNG, GIF (its first frame) and partially WebP (stored as PNG). All other images are served as it is, without any transformation.
+
+In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
+
+
+
+JSVM "sandboxing"
+
+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.
+
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 3cedc55a72..1d8970d5e2 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -7,21 +7,28 @@ on:
jobs:
goreleaser:
runs-on: ubuntu-latest
+ env:
+ flags: ""
steps:
+ # re-enable auto-snapshot from goreleaser-action@v3
+ # (https://github.com/goreleaser/goreleaser-action-v4-auto-snapshot-example)
+ - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+ run: echo "flags=--snapshot" >> $GITHUB_ENV
+
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Node.js
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v6
with:
- node-version: latest
+ node-version: '>=25.2.1'
- name: Set up Go
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v7
with:
- go-version: '>=1.21.1'
+ go-version: '>=1.27.2'
# 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.
@@ -36,19 +43,14 @@ jobs:
# - name: Generate jsvm types
# run: go run ./plugins/jsvm/internal/types/types.go
- # The prebuilt golangci-lint doesn't support go 1.18+ yet
- # https://github.com/golangci/golangci-lint/issues/2649
- # - name: Run linter
- # uses: golangci/golangci-lint-action@v3
-
- name: Run tests
run: go test ./...
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v3
+ uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
- version: latest
- args: release --clean
+ version: '~> v2'
+ args: release --clean ${{ env.flags }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 168542592a..9b99d92f63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,6 @@ coverage.out
# generated markdown previews
README.html
CHANGELOG.html
+CHANGELOG_16_22.html
+CHANGELOG_8_15.html
LICENSE.html
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 437c22e049..4958bd9f22 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,3 +1,5 @@
+version: 2
+
project_name: pocketbase
dist: .builds
@@ -7,19 +9,6 @@ before:
- go mod tidy
builds:
- # used only for tests
- - id: build_cgo
- main: ./examples/base
- binary: pocketbase
- ldflags:
- - -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
- env:
- - CGO_ENABLED=1
- goos:
- - linux
- goarch:
- - amd64
-
- id: build_noncgo
main: ./examples/base
binary: pocketbase
@@ -35,13 +24,23 @@ builds:
- amd64
- arm64
- arm
+ - s390x
+ - ppc64le
goarm:
- 7
ignore:
- goos: windows
goarch: arm
+ - goos: windows
+ goarch: s390x
+ - goos: windows
+ goarch: ppc64le
- goos: darwin
goarch: arm
+ - goos: darwin
+ goarch: s390x
+ - goos: darwin
+ goarch: ppc64le
release:
draft: true
@@ -51,14 +50,14 @@ archives:
builds: [build_noncgo]
format: zip
files:
- - LICENSE*
- - CHANGELOG*
+ - LICENSE.md
+ - CHANGELOG.md
checksum:
name_template: 'checksums.txt'
snapshot:
- name_template: '{{ incpatch .Version }}-next'
+ version_template: '{{ incpatch .Version }}-next'
changelog:
sort: asc
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ec2a8e58a..a38a4e9d43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,1877 +1,102 @@
-## v0.18.5
+## v0.40.5
-- Fixed minor Admin UI JS error in the auth collection options panel introduced with the change from v0.18.4.
+- Minor improvements for the JSVM migration error handling:
+ - Included the recovered panic stack trace of `routine.SafeWrap` in the returned error (max 2KB).
+ - Wrap the individual `up`/`down` JSVM migration arguments in `routine.SafeWrap` so that in case of panic we can still print the failed js migration filename.
+- Updated `modernc.org/sqlite` to 1.60.1 _(minor performance improvements for queries with large text fields and/or many params)_.
-## v0.18.4
+- Bumped the min Go GitHub action version to 1.27.2 as it includes several [`net/http` security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.2).
+ _One of the fixes is for a DoS that could potentially affect PocketBase applications and it is advised to update._
-- Added escape character (`\`) support in the Admin UI to allow using `select` field values with comma ([#2197](https://github.com/pocketbase/pocketbase/discussions/2197)).
+- Other minor fixes _(godoc typos, normalized negative jsvm pool size, extra CORS wildcard subdomain checks to prevent misuse, support `Promise` as autocomplete suggestions return, autoexpand textarea scrollbar-gutter fix, etc.)_.
-## v0.18.3
+## v0.40.4
-- Exposed a global JSVM `readerToString(reader)` helper function to allow reading Go `io.Reader` values ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)).
+- Fixed migration deadlock if a logs db write happens to run while the migration is still executing ([#7836](https://github.com/pocketbase/pocketbase/issues/7836)).
-- Bumped the GitHub action to use [min Go 1.21.1](https://github.com/golang/go/issues?q=milestone%3AGo1.21.1+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `html/template` and `net/http` security fixes.
+- `app.ResetBootstrapState()` was soft-deprecated in favour of `app.ClearBootstrap()`.
+ _Additionally a new `app.OnBootstrapClear()` hook was added to allow clearing custom allocated `OnBootstrap` resources in case the app uses a non-standard initialization (e.g. doesn't call `Start()` or intentionally skip the `OnTerminate` hook)._
+- Bumped `golang.org/x/*` dependencies.
-## v0.18.2
-- Prevent breaking the record form in the Admin UI in case the browser's localStorage quota has been exceeded when uploading or storing large `editor` values ([#3265](https://github.com/pocketbase/pocketbase/issues/3265)).
+## v0.40.3
-- Updated docs and missing JSVM typings.
+- Write the status header for JSON responses only if the fields picker succeed or has acceptable fallback.
+ _This is to allow custom response status code for failed json writes._
-- Exposed additional crypto primitives under the `$security.*` JSVM namespace ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)):
- ```js
- // HMAC with SHA256
- $security.hs256("hello", "secret")
+- Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
- // HMAC with SHA512
- $security.hs512("hello", "secret")
+- Clamped arccosine to [-1,1] in the Harvesine formula for the `geoDistance()` filter function to workaround edge case related to float rounding errors for some coordinates.
- // compare 2 strings with a constant time
- $security.equal(hash1, hash2)
- ```
+- Prevent unnecessary body chunk read if we already known that we are beyond the allowed limit.
+- Updated the `json` field validator to check the `encoding/json/v2` semantics and allow duplicated keys on record marshalize for compliance with old jsonv1 data.
-## v0.18.1
+- Fixed nested cascade delete of self-referenced relation records.
-- Excluded the local temp dir from the backups ([#3261](https://github.com/pocketbase/pocketbase/issues/3261)).
+- Minor UI fixes (updated dark primary btn color contrast, force reload the records list if the deleted record has self-referenced cascade relation field, etc.).
+- Changed JSVM `$app` variable definition from TS type to interface ([#7834](https://github.com/pocketbase/pocketbase/issues/7834)).
-## v0.18.0
+- Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
-- Simplified the `serve` command to accept domain name(s) as argument to reduce any additional manual hosts setup that sometimes previously was needed when deploying on production ([#3190](https://github.com/pocketbase/pocketbase/discussions/3190)).
- ```sh
- ./pocketbase serve yourdomain.com
- ```
-- Added `fields` wildcard (`*`) support.
+## v0.40.2
-- Added option to upload a backup file from the Admin UI ([#2599](https://github.com/pocketbase/pocketbase/issues/2599)).
+- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
-- Registered a custom Deflate compressor to speedup (_nearly 2-3x_) the backups generation for the sake of a small zip size increase.
- _Based on several local tests, `pb_data` of ~500MB (from which ~350MB+ are several hundred small files) results in a ~280MB zip generated for ~11s (previously it resulted in ~250MB zip but for ~35s)._
+- Fixed collection index parsing error for indexes with missing name.
-- Added the application name as part of the autogenerated backup name for easier identification ([#3066](https://github.com/pocketbase/pocketbase/issues/3066)).
+- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_.
-- Added new `SmtpConfig.LocalName` option to specify a custom domain name (or IP address) for the initial EHLO/HELO exchange ([#3097](https://github.com/pocketbase/pocketbase/discussions/3097)).
- _This is usually required for verification purposes only by some SMTP providers, such as on-premise [Gmail SMTP-relay](https://support.google.com/a/answer/2956491)._
+- Fixed linter warnings and comment typos.
-- Added `NoDecimal` `number` field option.
+- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
-- `editor` field improvements:
- - Added new "Strip urls domain" option to allow controlling the default TinyMCE urls behavior (_default to `false` for new content_).
- - Normalized pasted text while still preserving links, lists, tables, etc. formatting ([#3257](https://github.com/pocketbase/pocketbase/issues/3257)).
+- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
-- Added option to auto generate admin and auth record passwords from the Admin UI.
-- Added JSON validation and syntax highlight for the `json` field in the Admin UI ([#3191](https://github.com/pocketbase/pocketbase/issues/3191)).
+## v0.40.1
-- Added datetime filter macros:
- ```
- // all macros are UTC based
- @second - @now second number (0-59)
- @minute - @now minute number (0-59)
- @hour - @now hour number (0-23)
- @weekday - @now weekday number (0-6)
- @day - @now day number
- @month - @now month number
- @year - @now year number
- @todayStart - beginning of the current day as datetime string
- @todayEnd - end of the current day as datetime string
- @monthStart - beginning of the current month as datetime string
- @monthEnd - end of the current month as datetime string
- @yearStart - beginning of the current year as datetime string
- @yearEnd - end of the current year as datetime string
- ```
+- Fixes for some reported regressions related to the `encoding/json/v2` update:
+ - allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814))
+ - fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815))
-- Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)):
- ```
- @yearly - "0 0 1 1 *"
- @annually - "0 0 1 1 *"
- @monthly - "0 0 1 * *"
- @weekly - "0 0 * * 0"
- @daily - "0 0 * * *"
- @midnight - "0 0 * * *"
- @hourly - "0 * * * *"
- ```
-- ⚠️ Added offset argument `Dao.FindRecordsByFilter(collection, filter, sort, limit, offset, [params...])`.
- _If you don't need an offset, you can set it to `0`._
+## v0.40.0
-- To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input.
- The placeholders are in the same format as when binding regular SQL parameters.
- ```go
- // unsanitized and untrusted filter variables
- status := "..."
- author := "..."
+- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_.
+ _⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._
+ _Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._
- app.Dao().FindFirstRecordByFilter("articles", "status={:status} && author={:author}", dbx.Params{
- "status": status,
- "author": author,
- })
+- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided.
- app.Dao().FindRecordsByFilter("articles", "status={:status} && author={:author}", "-created", 10, 0, dbx.Params{
- "status": status,
- "author": author,
- })
- ```
+- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers.
+ _This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._
-- Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions.
+- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1).
-- Added JSVM helper crypto primitives under the `$security.*` namespace:
- ```js
- $security.md5(text)
- $security.sha256(text)
- $security.sha512(text)
- ```
+- Added `Store.Keys()` method that returns a slice with all of the store keys.
-- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
+- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting.
-- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
+- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB).
+ _This is an extra precaution for the cases when logging user supplied data without validating it beforehand._
+ _If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_
+ _Additionally, for just in case the log message is also truncated at max 8k characters._
-- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
+- Added new `filesystem` low-level helper methods:
+ - `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value.
+ - `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
+ - `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
-- ⚠️ Changes to `tests.ApiScenario` struct:
+- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)).
- - The `ApiScenario.AfterTestFunc` now receive as 3rd argument `*http.Response` pointer instead of `*echo.Echo` as the latter is not really useful in this context.
- ```go
- // old
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo)
+- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).
- // new
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response)
- ```
-
- - The `ApiScenario.TestAppFactory` now accept the test instance as argument and no longer expect an error as return result ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025#discussioncomment-6592272)).
- ```go
- // old
- TestAppFactory: func() (*tests.TestApp, error)
-
- // new
- TestAppFactory: func(t *testing.T) *tests.TestApp
- ```
- _Returning a `nil` app instance from the factory results in test failure. You can enforce a custom test failure by calling `t.Fatal(err)` inside the factory._
-
-- Bumped the min required TLS version to 1.2 in order to improve the cert reputation score.
-
-- Reduced the default JSVM prewarmed pool size to 25 to reduce the initial memory consumptions (_you can manually adjust the pool size with `--hooksPool=50` if you need to, but the default should suffice for most cases_).
-
-- Update `gocloud.dev` dependency to v0.34 and explicitly set the new `NoTempDir` fileblob option to prevent the cross-device link error introduced with v0.33.
-
-- Other minor Admin UI and docs improvements.
-
-
-## v0.17.7
-
-- Fixed the autogenerated `down` migrations to properly revert the old collection rules in case a change was made in `up` ([#3192](https://github.com/pocketbase/pocketbase/pull/3192); thanks @impact-merlinmarek).
- _Existing `down` migrations can't be fixed but that should be ok as usually the `down` migrations are rarely used against prod environments since they can cause data loss and, while not ideal, the previous old behavior of always setting the rules to `null/nil` is safer than not updating the rules at all._
-
-- Updated some Go deps.
-
-
-## v0.17.6
-
-- Fixed JSVM `require()` file path error when using Windows-style path delimiters ([#3163](https://github.com/pocketbase/pocketbase/issues/3163#issuecomment-1685034438)).
-
-
-## v0.17.5
-
-- Added quotes around the wrapped view query columns introduced with v0.17.4.
-
-
-## v0.17.4
-
-- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)).
- _With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._
-
-- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)).
-
-- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev).
-
-- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)).
-
-- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it.
-
-
-## v0.17.3
-
-- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)).
-
-- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)).
-
-- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`.
-
-- Enabled lazy loading for the Admin UI thumb images.
-
-
-## v0.17.2
-
-- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })`
- to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)).
- The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
-
-- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)).
-
-- Fixed `apis.RequestData` deprecation log note ([#3068](https://github.com/pocketbase/pocketbase/pull/3068); thanks @gungjodi).
-
-
-## v0.17.1
-
-- Use relative path when redirecting to the OAuth2 providers page in the Admin UI to support subpath deployments ([#3026](https://github.com/pocketbase/pocketbase/pull/3026); thanks @sonyarianto).
-
-- Manually trigger the `OnBeforeServe` hook for `tests.ApiScenario` ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025)).
-
-- Trigger the JSVM `cronAdd()` handler only on app `serve` to prevent unexpected (and eventually duplicated) cron handler calls when custom console commands are used ([#3024](https://github.com/pocketbase/pocketbase/discussions/3024#discussioncomment-6592703)).
-
-- The `console.log()` messages are now written to the `stdout` instead of `stderr`.
-
-
-## v0.17.0
-
-- New more detailed guides for using PocketBase as framework (both Go and JS).
- _If you find any typos or issues with the docs please report them in https://github.com/pocketbase/site._
-
-- Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja).
- They are available by default with the prebuilt executable if you create `*.pb.js` file(s) in the `pb_hooks` directory.
- Lower your expectations because the integration comes with some limitations. For more details please check the [Extend with JavaScript](https://pocketbase.io/docs/js-overview/) guide.
- Optionally, you can also enable the JS app hooks as part of a custom Go build for dynamic scripting but you need to register the `jsvm` plugin manually:
- ```go
- jsvm.MustRegister(app core.App, config jsvm.Config{})
- ```
-
-- Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta).
-
-- Added VK OAuth2 provider ([#2533](https://github.com/pocketbase/pocketbase/pull/2533); thanks @imperatrona).
-
-- Added Yandex OAuth2 provider ([#2762](https://github.com/pocketbase/pocketbase/pull/2762); thanks @imperatrona).
-
-- Added new fields to `core.ServeEvent`:
- ```go
- type ServeEvent struct {
- App App
- Router *echo.Echo
- // new fields
- Server *http.Server // allows adjusting the HTTP server config (global timeouts, TLS options, etc.)
- CertManager *autocert.Manager // allows adjusting the autocert options (cache dir, host policy, etc.)
- }
- ```
-
-- Added `record.ExpandedOne(rel)` and `record.ExpandedAll(rel)` helpers to retrieve casted single or multiple expand relations from the already loaded "expand" Record data.
-
-- Added rule and filter record `Dao` helpers:
- ```go
- app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
- app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true")
- app.Dao().CanAccessRecord(record, requestInfo, rule)
- ```
-
-- Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks.
-
-- Use a default fetch function that will return all relations in case the `fetchFunc` argument of `Dao.ExpandRecord(record, expands, fetchFunc)` and `Dao.ExpandRecords(records, expands, fetchFunc)` is `nil`.
-
-- For convenience it is now possible to call `Dao.RecordQuery(collectionModelOrIdentifier)` with just the collection id or name.
- In case an invalid collection id/name string is passed the query will be resolved with cancelled context error.
-
-- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`.
-
-- Added support for wrapped API errors (_in case Go 1.20+ is used with multiple wrapped errors, the first `apis.ApiError` takes precedence_).
-
-- Added `?download=1` file query parameter to the file serving endpoint to force the browser to always download the file and not show its preview.
-
-- Added new utility `github.com/pocketbase/pocketbase/tools/template` subpackage to assist with rendering HTML templates using the standard Go `html/template` and `text/template` syntax.
-
-- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)).
-
-- Soft-deprecated `security.NewToken()` in favor of `security.NewJWT()`.
-
-- `Hook.Add()` and `Hook.PreAdd` now returns a unique string identifier that could be used to remove the registered hook handler via `Hook.Remove(handlerId)`.
-
-- Changed the after* hooks to be called right before writing the user response, allowing users to return response errors from the after hooks.
- There is also no longer need for returning explicitly `hook.StopPropagtion` when writing custom response body in a hook because we will skip the finalizer response body write if a response was already "committed".
-
-- ⚠️ Renamed `*Options{}` to `Config{}` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls:
- ```go
- old: pocketbase.NewWithConfig(config *pocketbase.Config) *pocketbase.PocketBase
- new: pocketbase.NewWithConfig(config pocketbase.Config) *pocketbase.PocketBase
-
- old: core.NewBaseApp(config *core.BaseAppConfig) *core.BaseApp
- new: core.NewBaseApp(config core.BaseAppConfig) *core.BaseApp
-
- old: apis.Serve(app core.App, options *apis.ServeOptions) error
- new: apis.Serve(app core.App, config apis.ServeConfig) (*http.Server, error)
-
- old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions)
- new: jsvm.MustRegister(app core.App, config jsvm.Config)
-
- old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options)
- new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config)
-
- old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options)
- new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config)
- ```
-
-- ⚠️ Changed the type of `subscriptions.Message.Data` from `string` to `[]byte` because `Data` usually is a json bytes slice anyway.
-
-- ⚠️ Renamed `models.RequestData` to `models.RequestInfo` and soft-deprecated `apis.RequestData(c)` in favor of `apis.RequestInfo(c)` to avoid the stuttering with the `Data` field.
- _The old `apis.RequestData()` method still works to minimize the breaking changes but it is recommended to replace it with `apis.RequestInfo(c)`._
-
-- ⚠️ Changes to the List/Search APIs
- - Added new query parameter `?skipTotal=1` to skip the `COUNT` query performed with the list/search actions ([#2965](https://github.com/pocketbase/pocketbase/discussions/2965)).
- If `?skipTotal=1` is set, the response fields `totalItems` and `totalPages` will have `-1` value (this is to avoid having different JSON responses and to differentiate from the zero default).
- With the latest JS SDK 0.16+ and Dart SDK v0.11+ versions `skipTotal=1` is set by default for the `getFirstListItem()` and `getFullList()` requests.
-
- - The count and regular select statements also now executes concurrently, meaning that we no longer perform normalization over the `page` parameter and in case the user
- request a page that doesn't exist (eg. `?page=99999999`) we'll return empty `items` array.
-
- - Reverted the default `COUNT` column to `id` as there are some common situations where it can negatively impact the query performance.
- Additionally, from this version we also set `PRAGMA temp_store = MEMORY` so that also helps with the temp B-TREE creation when `id` is used.
- _There are still scenarios where `COUNT` queries with `rowid` executes faster, but the majority of the time when nested relations lookups are used it seems to have the opposite effect (at least based on the benchmarks dataset)._
-
-- ⚠️ Disallowed relations to views **from non-view** collections ([#3000](https://github.com/pocketbase/pocketbase/issues/3000)).
- The change was necessary because I wasn't able to find an efficient way to track view changes and the previous behavior could have too many unexpected side-effects (eg. view with computed ids).
- There is a system migration that will convert the existing view `relation` fields to `json` (multiple) and `text` (single) fields.
- This could be a breaking change if you have `relation` to view and use `expand` or some of the `relation` view fields as part of a collection rule.
-
-- ⚠️ Added an extra `action` argument to the `Dao` hooks to allow skipping the default persist behavior.
- In preparation for the logs generalization, the `Dao.After*Func` methods now also allow returning an error.
-
-- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
-
-- Fixed zero-default value not being used if the field is not explicitly set when manually creating records ([#2992](https://github.com/pocketbase/pocketbase/issues/2992)).
- Additionally, `record.Get(field)` will now always return normalized value (the same as in the json serialization) for consistency and to avoid ambiguities with what is stored in the related DB table.
- The schema fields columns `DEFAULT` definition was also updated for new collections to ensure that `NULL` values can't be accidentally inserted.
-
-- Fixed `migrate down` not returning the correct `lastAppliedMigrations()` when the stored migration applied time is in seconds.
-
-- Fixed realtime delete event to be called after the record was deleted from the DB (_including transactions and cascade delete operations_).
-
-- Other minor fixes and improvements (typos and grammar fixes, updated dependencies, removed unnecessary 404 error check in the Admin UI, etc.).
-
-
-## v0.16.10
-
-- Added multiple valued fields (`relation`, `select`, `file`) normalizations to ensure that the zero-default value of a newly created multiple field is applied for already existing data ([#2930](https://github.com/pocketbase/pocketbase/issues/2930)).
-
-
-## v0.16.9
-
-- Register the `eagerRequestInfoCache` middleware only for the internal `api` group routes to avoid conflicts with custom route handlers ([#2914](https://github.com/pocketbase/pocketbase/issues/2914)).
-
-
-## v0.16.8
-
-- Fixed unique validator detailed error message not being returned when camelCase field name is used ([#2868](https://github.com/pocketbase/pocketbase/issues/2868)).
-
-- Updated the index parser to allow no space between the table name and the columns list ([#2864](https://github.com/pocketbase/pocketbase/discussions/2864#discussioncomment-6373736)).
-
-- Updated go deps.
-
-
-## v0.16.7
-
-- Minor optimization for the list/search queries to use `rowid` with the `COUNT` statement when available.
- _This eliminates the temp B-TREE step when executing the query and for large datasets (eg. 150k) it could have 10x improvement (from ~580ms to ~60ms)._
-
-
-## v0.16.6
-
-- Fixed collection index column sort normalization in the Admin UI ([#2681](https://github.com/pocketbase/pocketbase/pull/2681); thanks @SimonLoir).
-
-- Removed unnecessary admins count in `apis.RequireAdminAuthOnlyIfAny()` middleware ([#2726](https://github.com/pocketbase/pocketbase/pull/2726); thanks @svekko).
-
-- Fixed `multipart/form-data` request bind not populating map array values ([#2763](https://github.com/pocketbase/pocketbase/discussions/2763#discussioncomment-6278902)).
-
-- Upgraded npm and Go dependencies.
-
-
-## v0.16.5
-
-- Fixed the Admin UI serialization of implicit relation display fields ([#2675](https://github.com/pocketbase/pocketbase/issues/2675)).
-
-- Reset the Admin UI sort in case the active sort collection field is renamed or deleted.
-
-
-## v0.16.4
-
-- Fixed the selfupdate command not working on Windows due to missing `.exe` in the extracted binary path ([#2589](https://github.com/pocketbase/pocketbase/discussions/2589)).
- _Note that the command on Windows will work from v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._
-
-- Added `int64`, `int32`, `uint`, `uint64` and `uint32` support when scanning `types.DateTime` ([#2602](https://github.com/pocketbase/pocketbase/discussions/2602))
-
-- Updated dependencies.
-
-
-## v0.16.3
-
-- Fixed schema fields sort not working on Safari/Gnome Web ([#2567](https://github.com/pocketbase/pocketbase/issues/2567)).
-
-- Fixed default `PRAGMA`s not being applied for new connections ([#2570](https://github.com/pocketbase/pocketbase/discussions/2570)).
-
-
-## v0.16.2
-
-- Fixed backups archive not excluding the local `backups` directory on Windows ([#2548](https://github.com/pocketbase/pocketbase/discussions/2548#discussioncomment-5979712)).
-
-- Changed file field to not use `dataTransfer.effectAllowed` when dropping files since it is not reliable and consistent across different OS and browsers ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
-
-- Auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on Docker restart ([#2551](https://github.com/pocketbase/pocketbase/discussions/2551)).
-
-- Fixed missing view id field error message typo.
-
-
-## v0.16.1
-
-- Fixed backup restore not working in a container environment when `pb_data` is mounted as volume ([#2519](https://github.com/pocketbase/pocketbase/issues/2519)).
-
-- Fixed Dart SDK realtime API preview example ([#2523](https://github.com/pocketbase/pocketbase/pull/2523); thanks @xFrann).
-
-- Fixed typo in the backups create panel ([#2526](https://github.com/pocketbase/pocketbase/pull/2526); thanks @dschissler).
-
-- Removed unnecessary slice length check in `list.ExistInSlice` ([#2527](https://github.com/pocketbase/pocketbase/pull/2527); thanks @KunalSin9h).
-
-- Avoid mutating the cached request data on OAuth2 user create ([#2535](https://github.com/pocketbase/pocketbase/discussions/2535)).
-
-- Fixed Export Collections "Download as JSON" ([#2540](https://github.com/pocketbase/pocketbase/issues/2540)).
-
-- Fixed file field drag and drop not working in Firefox and Safari ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
-
-
-## v0.16.0
-
-- Added automated backups (_+ cron rotation_) APIs and UI for the `pb_data` directory.
- The backups can be also initialized programmatically using `app.CreateBackup("backup.zip")`.
- There is also experimental restore method - `app.RestoreBackup("backup.zip")` (_currently works only on UNIX systems as it relies on execve_).
- The backups can be stored locally or in external S3 storage (_it has its own configuration, separate from the file uploads storage filesystem_).
-
-- Added option to limit the returned API fields using the `?fields` query parameter.
- The "fields picker" is applied for `SearchResult.Items` and every other JSON response. For example:
- ```js
- // original: {"id": "RECORD_ID", "name": "abc", "description": "...something very big...", "items": ["id1", "id2"], "expand": {"items": [{"id": "id1", "name": "test1"}, {"id": "id2", "name": "test2"}]}}
- // output: {"name": "abc", "expand": {"items": [{"name": "test1"}, {"name": "test2"}]}}
- const result = await pb.collection("example").getOne("RECORD_ID", {
- expand: "items",
- fields: "name,expand.items.name",
- })
- ```
-
-- Added new `./pocketbase update` command to selfupdate the prebuilt executable (with option to generate a backup of your `pb_data`).
-
-- Added new `./pocketbase admin` console command:
- ```sh
- // creates new admin account
- ./pocketbase admin create test@example.com 123456890
-
- // changes the password of an existing admin account
- ./pocketbase admin update test@example.com 0987654321
-
- // deletes single admin account (if exists)
- ./pocketbase admin delete test@example.com
- ```
-
-- Added `apis.Serve(app, options)` helper to allow starting the API server programmatically.
-
-- Updated the schema fields Admin UI for "tidier" fields visualization.
-
-- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
-
-- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
-
-- Added new `tools/cron` helper subpackage for scheduling task using cron-like syntax (_this eventually may get exported in the future in a separate repo_).
-
-- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix.
-
-- Added new `App.NewBackupsFilesystem()` helper to create a dedicated filesystem abstraction for managing app data backups.
-
-- Added new `App.OnTerminate()` hook (_executed right before app termination, eg. on `SIGTERM` signal_).
-
-- Added `accept` file field attribute with the field MIME types ([#2466](https://github.com/pocketbase/pocketbase/pull/2466); thanks @Nikhil1920).
-
-- Added support for multiple files sort in the Admin UI ([#2445](https://github.com/pocketbase/pocketbase/issues/2445)).
-
-- Added support for multiple relations sort in the Admin UI.
-
-- Added `meta.isNew` to the OAuth2 auth JSON response to indicate a newly OAuth2 created PocketBase user.
-
-
-## v0.15.3
-
-- Updated the Admin UI to use the latest JS SDK to resolve the `isNew` record field conflict ([#2385](https://github.com/pocketbase/pocketbase/discussions/2385)).
-
-- Fixed `editor` field fullscreen `z-index` ([#2410](https://github.com/pocketbase/pocketbase/issues/2410)).
-
-- Inserts the default app settings as part of the system init migration so that they are always available when accessed from within a user defined migration ([#2423](https://github.com/pocketbase/pocketbase/discussions/2423)).
-
-
-## v0.15.2
-
-- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
-
-- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
-
-- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
-
-
-## v0.15.1
-
-- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
-
-- Fixed `Ctrl + S` in the `editor` field not propagating the quick save shortcut to the parent form.
-
-- Added `⌘ + S` alias for the record quick save shortcut (_I have no Mac device to test it but it should work based on [`e.metaKey` docs](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey)_).
-
-- Enabled RTL for the TinyMCE editor ([#2327](https://github.com/pocketbase/pocketbase/issues/2327)).
-
-- Reduced the record form vertical layout shifts and slightly improved the rendering speed when loading multiple `relation` fields.
-
-- Enabled Admin UI assets cache.
-
-
-## v0.15.0
-
-- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
- Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
- The manual code-token exchange flow is still supported but the SDK method is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
- For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
-
-- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
- Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
- It works with a short lived (~5min) file token passed as query param with the file url.
- For more details and example, you could check https://pocketbase.io/docs/files-handling/#protected-files.
-
-- ⚠️ Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
-
-- Added simple loose wildcard search term support in the Admin UI.
-
-- Added auto "draft" to allow restoring previous record state in case of accidental reload or power outage.
-
-- Added `Ctrl + S` shortcut to save the record changes without closing the panel.
-
-- Added "drop files" support for the file upload field.
-
-- Refreshed the OAuth2 Admin UI.
-
-
-## v0.14.5
-
-- Added checks for `nil` hooks in `forms.RecordUpsert` when used with custom `Dao` ([#2277](https://github.com/pocketbase/pocketbase/issues/2277)).
-
-- Fixed unique detailed field error not returned on record create failure ([#2287](https://github.com/pocketbase/pocketbase/discussions/2287)).
-
-
-## v0.14.4
-
-- Fixed concurrent map write pannic on `list.ExistInSliceWithRegex()` cache ([#2272](https://github.com/pocketbase/pocketbase/issues/2272)).
-
-
-## v0.14.3
-
-- Fixed Admin UI Logs `meta` visualization in Firefox ([#2221](https://github.com/pocketbase/pocketbase/issues/2221)).
-
-- Downgraded to v1 of the `aws/aws-sdk-go` package since v2 has compatibility issues with GCS ([#2231](https://github.com/pocketbase/pocketbase/issues/2231)).
-
-- Upgraded the GitHub action to use [min Go 1.20.3](https://github.com/golang/go/issues?q=milestone%3AGo1.20.3+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `net/http` security fixes.
-
-
-## v0.14.2
-
-- Reverted part of the old `COALESCE` handling as a fallback to support empty string comparison with missing joined relation fields.
-
-
-## v0.14.1
-
-- Fixed realtime events firing before the files upload completion.
-
-- Updated the underlying S3 lib to use `aws-sdk-go-v2` ([#1346](https://github.com/pocketbase/pocketbase/pull/1346); thanks @yuxiang-gao).
-
-- Updated TinyMCE to v6.4.1.
-
-- Updated the godoc of `Dao.Save*` methods.
-
-
-## v0.14.0
-
-- Added _experimental_ Apple OAuth2 integration.
-
-- Added `@request.headers.*` filter rule support.
-
-- Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544))
-
-- Simplified the collections fields UI to allow easier and quicker scaffolding of the data schema.
-
-- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes.
- The `Unique` field is a no-op and will be removed in future version.
-
-- Removed the `COALESCE` wrapping from some of the generated filter conditions to make better use of the indexes ([#1939](https://github.com/pocketbase/pocketbase/issues/1939)).
-
-- Detect `id` aliased view columns as single `relation` fields ([#2029](https://github.com/pocketbase/pocketbase/discussions/2029)).
-
-- Optimized single relation lookups.
-
-- Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`).
- When changing **from single to multiple** all already inserted single values are converted to an array.
- When changing **from multiple to single** only the last item of the already inserted array items is kept.
-
-- Changed the cost/round factor of bcrypt hash generation from 13 to 12 since several users complained about the slow authWithPassword responses on lower spec hardware.
- _The change will affect only new users. Depending on the demand, we might make it configurable from the auth options._
-
-- Simplified the default mail template styles to allow more control over the template layout ([#1904](https://github.com/pocketbase/pocketbase/issues/1904)).
-
-- Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)).
-
-- Added `migrate history-sync` command to clean `_migrations` history table from deleted migration files references.
-
-- Added new fields to the `core.RecordAuthWithOAuth2Event` struct:
- ```
- IsNewRecord bool, // boolean field indicating whether the OAuth2 action created a new auth record
- ProviderName string, // the name of the OAuth2 provider (eg. "google")
- ProviderClient auth.Provider, // the loaded Provider client instance
- ```
-
-- Added CGO linux target for the prebuilt executable.
-
-- ⚠️ Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
-
-- ⚠️ Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
-
-- ⚠️ Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
- If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
-
-- ⚠️ Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
-
-- ⚠️ Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
-
-- Other minor UI improvements.
-
-
-## v0.13.4
-
-- Removed eager unique collection name check to support lazy validation during bulk import.
-
-
-## v0.13.3
-
-- Fixed view collections import ([#2044](https://github.com/pocketbase/pocketbase/issues/2044)).
-
-- Updated the records picker Admin UI to show properly view collection relations.
-
-
-## v0.13.2
-
-- Fixed Admin UI js error when selecting multiple `file` field as `relation` "Display fields" ([#1989](https://github.com/pocketbase/pocketbase/issues/1989)).
-
-
-## v0.13.1
-
-- Added `HEAD` request method support for the `/api/files/:collection/:recordId/:filename` route ([#1976](https://github.com/pocketbase/pocketbase/discussions/1976)).
-
-
-## v0.13.0
-
-- Added new "View" collection type allowing you to create a read-only collection from a custom SQL `SELECT` statement. It supports:
- - aggregations (`COUNT()`, `MIN()`, `MAX()`, `GROUP BY`, etc.)
- - column and table aliases
- - CTEs and subquery expressions
- - auto `relation` fields association
- - `file` fields proxying (up to 5 linked relations, eg. view1->view2->...->base)
- - `filter`, `sort` and `expand`
- - List and View API rules
-
-- Added auto fail/retry (default to 8 attempts) for the `SELECT` queries to gracefully handle the `database is locked` errors ([#1795](https://github.com/pocketbase/pocketbase/discussions/1795#discussioncomment-4882169)).
- _The default max attempts can be accessed or changed via `Dao.MaxLockRetries`._
-
-- Added default max query execution timeout (30s).
- _The default timeout can be accessed or changed via `Dao.ModelQueryTimeout`._
- _For the prebuilt executables it can be also changed via the `--queryTimeout=10` flag._
-
-- Added support for `dao.RecordQuery(collection)` to scan directly the `One()` and `All()` results in `*models.Record` or `[]*models.Record` without the need of explicit `NullStringMap`.
-
-- Added support to overwrite the default file serve headers if an explicit response header is set.
-
-- Added file thumbs when visualizing `relation` display file fields.
-
-- Added "Min select" `relation` field option.
-
-- Enabled `process.env` in JS migrations to allow accessing `os.Environ()`.
-
-- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
-
-- ⚠️ Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
-
-- ⚠️ Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
-
-- ⚠️ Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
-
- If you are sending custom emails, you'll have to replace:
- ```go
- message := &mailer.Message{
- ...
-
- // (old) To: mail.Address{Address: "to@example.com"}
- To: []mail.Address{{Address: "to@example.com", Name: "Some optional name"}},
-
- // (old) Cc: []string{"cc@example.com"}
- Cc: []mail.Address{{Address: "cc@example.com", Name: "Some optional name"}},
-
- // (old) Bcc: []string{"bcc@example.com"}
- Bcc: []mail.Address{{Address: "bcc@example.com", Name: "Some optional name"}},
-
- ...
- }
- ```
-
-- ⚠️ Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
- _If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
- _To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
-
-- ⚠️ Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
-
-- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
-
-- Other minor Admin UI improvements.
-
-
-## v0.12.3
-
-- Fixed "Toggle column" reactivity when navigating between collections ([#1836](https://github.com/pocketbase/pocketbase/pull/1836)).
-
-- Logged the current datetime on server start ([#1822](https://github.com/pocketbase/pocketbase/issues/1822)).
-
-
-## v0.12.2
-
-- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
-
-- Increased slightly the fields contrast ([#1742](https://github.com/pocketbase/pocketbase/issues/1742)).
-
-- Auto close the multi-select dropdown if "Max select" is reached.
-
-
-## v0.12.1
-
-- Fixed js error on empty relation save.
-
-- Fixed `overlay-active` css class not being removed on nested overlay panel close ([#1718](https://github.com/pocketbase/pocketbase/issues/1718)).
-
-- Added the collection name in the page title ([#1711](https://github.com/pocketbase/pocketbase/issues/1711)).
-
-
-## v0.12.0
-
-- Refactored the relation picker UI to allow server-side search, sort, create, update and delete of relation records ([#976](https://github.com/pocketbase/pocketbase/issues/976)).
-
-- Added new `RelationOptions.DisplayFields` option to specify custom relation field(s) visualization in the Admin UI.
-
-- Added Authentik OAuth2 provider ([#1377](https://github.com/pocketbase/pocketbase/pull/1377); thanks @pr0ton11).
-
-- Added LiveChat OAuth2 provider ([#1573](https://github.com/pocketbase/pocketbase/pull/1573); thanks @mariosant).
-
-- Added Gitea OAuth2 provider ([#1643](https://github.com/pocketbase/pocketbase/pull/1643); thanks @hlanderdev).
-
-- Added PDF file previews ([#1548](https://github.com/pocketbase/pocketbase/pull/1548); thanks @mjadobson).
-
-- Added video and audio file previews.
-
-- Added rich text editor (`editor`) field for HTML content based on TinyMCE ([#370](https://github.com/pocketbase/pocketbase/issues/370)).
- _Currently the new field doesn't have any configuration options or validations but this may change in the future depending on how devs ended up using it._
-
-- Added "Duplicate" Collection and Record options in the Admin UI ([#1656](https://github.com/pocketbase/pocketbase/issues/1656)).
-
-- Added `filesystem.GetFile()` helper to read files through the FileSystem abstraction ([#1578](https://github.com/pocketbase/pocketbase/pull/1578); thanks @avarabyeu).
-
-- Added new auth event hooks for finer control and more advanced auth scenarios handling:
-
- ```go
- // auth record
- OnRecordBeforeAuthWithPasswordRequest()
- OnRecordAfterAuthWithPasswordRequest()
- OnRecordBeforeAuthWithOAuth2Request()
- OnRecordAfterAuthWithOAuth2Request()
- OnRecordBeforeAuthRefreshRequest()
- OnRecordAfterAuthRefreshRequest()
-
- // admin
- OnAdminBeforeAuthWithPasswordRequest()
- OnAdminAfterAuthWithPasswordRequest()
- OnAdminBeforeAuthRefreshRequest()
- OnAdminAfterAuthRefreshRequest()
- OnAdminBeforeRequestPasswordResetRequest()
- OnAdminAfterRequestPasswordResetRequest()
- OnAdminBeforeConfirmPasswordResetRequest()
- OnAdminAfterConfirmPasswordResetRequest()
- ```
-
-- Added `models.Record.CleanCopy()` helper that creates a new record copy with only the latest data state of the existing one and all other options reset to their defaults.
-
-- Added new helper `apis.RecordAuthResponse(app, httpContext, record, meta)` to return a standard Record auth API response ([#1623](https://github.com/pocketbase/pocketbase/issues/1623)).
-
-- Refactored `models.Record` expand and data change operations to be concurrent safe.
-
-- Refactored all `forms` Submit interceptors to use a generic data type as their payload.
-
-- Added several `store.Store` helpers:
- ```go
- store.Reset(newData map[string]T)
- store.Length() int
- store.GetAll() map[string]T
- ```
-
-- Added "tags" support for all Record and Model related event hooks.
-
- The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
- For example:
- ```go
- app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
- // called only on "articles" record creation
- log.Println(e.Record)
- return nil
- })
- ```
- For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
-
-- ⚠️ Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
-
- In order to support seamlessly both `application/json` and `multipart/form-data`
- requests, the following normalization rules are applied if the `json` field is a
- **plain string value**:
-
- - "true" is converted to the json `true`
- - "false" is converted to the json `false`
- - "null" is converted to the json `null`
- - "[1,2,3]" is converted to the json `[1,2,3]`
- - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
- - numeric strings are converted to json number
- - double quoted strings are left as they are (aka. without normalizations)
- - any other string (empty string too) is double quoted
-
- Additionally, the "Nonempty" `json` field constraint now checks for `null`, `[]`, `{}` and `""` (empty string).
-
-- Added `aria-label` to some of the buttons in the Admin UI for better accessibility ([#1702](https://github.com/pocketbase/pocketbase/pull/1702); thanks @ndarilek).
-
-- Updated the filename extension checks in the Admin UI to be case-insensitive ([#1707](https://github.com/pocketbase/pocketbase/pull/1707); thanks @hungcrush).
-
-- Other minor improvements (more detailed API file upload errors, UI optimizations, docs improvements, etc.)
-
-
-## v0.11.4
-
-- Fixed cascade delete for rel records with the same id as the main record ([#1689](https://github.com/pocketbase/pocketbase/issues/1689)).
-
-
-## v0.11.3
-
-- Fix realtime API panic on concurrent clients iteration ([#1628](https://github.com/pocketbase/pocketbase/issues/1628))
-
- - `app.SubscriptionsBroker().Clients()` now returns a shallow copy of the underlying map.
-
- - Added `Discard()` and `IsDiscarded()` helper methods to the `subscriptions.Client` interface.
-
- - Slow clients should no longer "block" the main action completion.
-
-
-## v0.11.2
-
-- Fixed `fs.DeleteByPrefix()` hang on invalid S3 settings ([#1575](https://github.com/pocketbase/pocketbase/discussions/1575#discussioncomment-4661089)).
-
-- Updated file(s) delete to run in the background on record/collection delete to avoid blocking the delete model transaction.
- _Currently the cascade files delete operation is treated as "non-critical" and in case of an error it is just logged during debug._
- _This will be improved in the near future with the planned async job queue implementation._
-
-
-## v0.11.1
-
-- Unescaped path parameter values ([#1552](https://github.com/pocketbase/pocketbase/issues/1552)).
-
-
-## v0.11.0
-
-- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
- ```js
- {
- // oldValue + 2
- "someNumber+": 2,
-
- // oldValue + ["id1", "id2"] - ["id3"]
- "someRelation+": ["id1", "id2"],
- "someRelation-": ["id3"],
-
- // delete single file by its name (file fields supports only the "-" modifier!)
- "someFile-": "filename.png",
- }
- ```
- _Note1: `@request.data.someField` will contain the final resolved value._
-
- _Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
-
-- ⚠️ Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
- If you want a "at least one of" type of condition, you can prefix the operator with `?`.
- ```js
- // for each someRelA.someRelB record require the "status" field to be "active"
- someRelA.someRelB.status = "active"
-
- // OR for "at least one of" condition
- someRelA.someRelB.status ?= "active"
- ```
- _**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
- The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
- If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
-
-- Added support for querying `@request.data.someRelField.*` relation fields.
- ```js
- // example submitted data: {"someRel": "REL_RECORD_ID"}
- @request.data.someRel.status = "active"
- ```
-
-- Added `:isset` modifier for the static request data fields.
- ```js
- // prevent changing the "role" field
- @request.data.role:isset = false
- ```
-
-- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
- ```js
- // example submitted data: {"someSelectField": ["val1", "val2"]}
- @request.data.someSelectField:length = 2
-
- // check existing record field length
- someSelectField:length = 2
- ```
-
-- Added `:each` modifier support for the multi-`select` request data and collection field.
- ```js
- // check if all selected rows has "pb_" prefix
- roles:each ~ 'pb_%'
- ```
-
-- Improved the Admin UI filters autocomplete.
-
-- Added `@random` sort key for `RANDOM()` sorted list results.
-
-- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
-
-- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
-
-- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
-
-- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
-
-- Added support for reordering uploaded multiple files.
-
-- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
-
-- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
-
-- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
-
-- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
-
-- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
-
-- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
-
-- `migrate down` now reverts migrations in the applied order.
-
-- Added additional list-bucket check in the S3 config test API.
-
-- Other minor improvements.
-
-
-## v0.10.4
-
-- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
-
-
-## v0.10.3
-
-- ⚠️ Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
-
-- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
-
-- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
-
-
-## v0.10.2
-
-- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
- A new helper method `models.Record.MergeExpand(map[string]any)` was also added to simplify the expand handling and unit testing.
-
-
-## v0.10.1
-
-- Fixed nested transactions deadlock when authenticating with OAuth2 ([#1291](https://github.com/pocketbase/pocketbase/issues/1291)).
-
-
-## v0.10.0
-
-- Added `/api/health` endpoint (thanks @MarvinJWendt).
-
-- Added support for SMTP `LOGIN` auth for Microsoft/Outlook and other providers that don't support the `PLAIN` auth method ([#1217](https://github.com/pocketbase/pocketbase/discussions/1217#discussioncomment-4387970)).
-
-- Reduced memory consumption (you can expect ~20% less allocated memory).
-
-- Added support for split (concurrent and nonconcurrent) DB connections pool increasing even further the concurrent throughput without blocking reads on heavy write load.
-
-- Improved record references delete performance.
-
-- Removed the unnecessary parenthesis in the generated filter SQL query, reducing the "_parse stack overflow_" errors.
-
-- Fixed `~` expressions backslash literal escaping ([#1231](https://github.com/pocketbase/pocketbase/discussions/1231)).
-
-- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
-
-- ⚠️ Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
- ```go
- DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
- DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
- LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns
- LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns
- ```
-
-- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
-
-- ⚠️ Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
-
-- ⚠️ Removed `rest.UploadedFile` struct (see below `filesystem.File`).
-
-- Added generic file resource struct that allows loading and uploading file content from
- different sources (at the moment multipart/form-data requests and from the local filesystem).
- ```
- filesystem.File{}
- filesystem.NewFileFromPath(path)
- filesystem.NewFileFromMultipart(multipartHeader)
- filesystem/System.UploadFile(file)
- ```
-
-- Refactored `forms.RecordUpsert` to allow more easily loading and removing files programmatically.
- ```
- forms.RecordUpsert.AddFiles(key, filesystem.File...) // add new filesystem.File to the form for upload
- forms.RecordUpsert.RemoveFiles(key, filenames...) // marks the filenames for deletion
- ```
-
-- Trigger the `password` validators if any of the others password change fields is set.
-
-
-## v0.9.2
-
-- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
-
-
-## v0.9.1
-
-- Moved the record file upload and delete out of the db transaction to minimize the locking times.
-
-- Added `Dao` query semaphore and base fail/retry handling to improve the concurrent writes throughput ([#1187](https://github.com/pocketbase/pocketbase/issues/1187)).
-
-- Fixed records cascade deletion when there are "A<->B" relation references.
-
-- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
-
-- Fixed the datetime field not triggering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
-
-- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
-
-
-## v0.9.0
-
-- Fixed concurrent multi-relation cascade update/delete ([#1138](https://github.com/pocketbase/pocketbase/issues/1138)).
-
-- Added the raw OAuth2 user data (`meta.rawUser`) and OAuth2 access token (`meta.accessToken`) to the auth response ([#654](https://github.com/pocketbase/pocketbase/discussions/654)).
-
-- `BaseModel.UnmarkAsNew()` method was renamed to `BaseModel.MarkAsNotNew()`.
- Additionally, to simplify the insert model queries with custom IDs, it is no longer required to call `MarkAsNew()` for manually initialized models with set ID since now this is the default state.
- When the model is populated with values from the database (eg. after row `Scan`) it will be marked automatically as "not new".
-
-- Added `Record.OriginalCopy()` method that returns a new `Record` copy populated with the initially loaded record data (useful if you want to compare old and new field values).
-
-- Added new event hooks:
- ```go
- app.OnBeforeBootstrap()
- app.OnAfterBootstrap()
- app.OnBeforeApiError()
- app.OnAfterApiError()
- app.OnRealtimeDisconnectRequest()
- app.OnRealtimeBeforeMessageSend()
- app.OnRealtimeAfterMessageSend()
- app.OnRecordBeforeRequestPasswordResetRequest()
- app.OnRecordAfterRequestPasswordResetRequest()
- app.OnRecordBeforeConfirmPasswordResetRequest()
- app.OnRecordAfterConfirmPasswordResetRequest()
- app.OnRecordBeforeRequestVerificationRequest()
- app.OnRecordAfterRequestVerificationRequest()
- app.OnRecordBeforeConfirmVerificationRequest()
- app.OnRecordAfterConfirmVerificationRequest()
- app.OnRecordBeforeRequestEmailChangeRequest()
- app.OnRecordAfterRequestEmailChangeRequest()
- app.OnRecordBeforeConfirmEmailChangeRequest()
- app.OnRecordAfterConfirmEmailChangeRequest()
- ```
-
-- The original uploaded file name is now stored as metadata under the `original_filename` key. It could be accessed via:
- ```go
- fs, _ := app.NewFilesystem()
- defer fs.Close()
-
- attrs, _ := fs.Attributes(fikeKey)
- attrs.Metadata["original_name"]
- ```
-
-- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)).
- This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook):
- ```go
- // old
- filesystem.Serve(res, e.ServedPath, e.ServedName)
-
- // new
- filesystem.Serve(res, req, e.ServedPath, e.ServedName)
- ```
-
-- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
- This allow writing custom migration scripts such as programmatically creating collections,
- initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
-
- The `migrate` command is available by default for the prebuilt executable,
- but if you use PocketBase as framework you need register it manually:
- ```go
- migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
-
- // load js files if you want to allow loading external JavaScript migrations
- jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
- Dir: migrationsDir,
- })
-
- // register the `migrate` command
- migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
- TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default)
- Dir: migrationsDir,
- Automigrate: true,
- })
- ```
-
- **The refactoring also comes with automigrations support.**
-
- If `Automigrate` is enabled (`true` by default for the prebuilt executable; can be disabled with `--automigrate=0`),
- PocketBase will generate seamlessly in the background JS (or Go) migration file with your collection changes.
- **The directory with the JS migrations can be committed to your git repo.**
- All migrations (Go and JS) are automatically executed on server start.
- Also note that the auto generated migrations are granural (in contrast to the `migrate collections` snapshot command)
- and allow multiple developers to do changes on the collections independently (even editing the same collection) miniziming the eventual merge conflicts.
- Here is a sample JS migration file that will be generated if you for example edit a single collection name:
- ```js
- // pb_migrations/1669663597_updated_posts_old.js
- migrate((db) => {
- // up
- const dao = new Dao(db)
- const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
- collection.name = "posts_new"
- return dao.saveCollection(collection)
- }, (db) => {
- // down
- const dao = new Dao(db)
- const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
- collection.name = "posts_old"
- return dao.saveCollection(collection)
- })
- ```
-
-- Added new `Dao` helpers to make it easier fetching and updating the app settings from a migration:
- ```go
- dao.FindSettings([optEncryptionKey])
- dao.SaveSettings(newSettings, [optEncryptionKey])
- ```
-
-- Moved `core.Settings` to `models/settings.Settings`:
- ```
- core.Settings{} -> settings.Settings{}
- core.NewSettings() -> settings.New()
- core.MetaConfig{} -> settings.MetaConfig{}
- core.LogsConfig{} -> settings.LogsConfig{}
- core.SmtpConfig{} -> settings.SmtpConfig{}
- core.S3Config{} -> settings.S3Config{}
- core.TokenConfig{} -> settings.TokenConfig{}
- core.AuthProviderConfig{} -> settings.AuthProviderConfig{}
- ```
-
-- Changed the `mailer.Mailer` interface (**minor breaking if you are sending custom emails**):
- ```go
- // Old:
- app.NewMailClient().Send(from, to, subject, html, attachments?)
-
- // New:
- app.NewMailClient().Send(&mailer.Message{
- From: from,
- To: to,
- Subject: subject,
- HTML: html,
- Attachments: attachments,
- // new configurable fields
- Bcc: []string{"bcc1@example.com", "bcc2@example.com"},
- Cc: []string{"cc1@example.com", "cc2@example.com"},
- Headers: map[string]string{"Custom-Header": "test"},
- Text: "custom plain text version",
- })
- ```
- The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events.
-
-- Other minor UI fixes and improvements
-
-
-## v0.8.0
-
-**⚠️ This release contains breaking changes and requires some manual migration steps!**
-
-The biggest change is the merge of the `User` models and the `profiles` collection per [#376](https://github.com/pocketbase/pocketbase/issues/376).
-There is no longer `user` type field and the users are just an "auth" collection (we now support **collection types**, currently only "base" and "auth").
-This should simplify the users management and at the same time allow us to have unlimited multiple "auth" collections each with their own custom fields and authentication options (eg. staff, client, etc.).
-
-In addition to the `Users` and `profiles` merge, this release comes with several other improvements:
-
-- Added indirect expand support [#312](https://github.com/pocketbase/pocketbase/issues/312#issuecomment-1242893496).
-
-- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
-
-- The `relation` field now allows unlimited `maxSelect` (aka. without upper limit).
-
-- Added support for combined email/username + password authentication (see below `authWithPassword()`).
-
-- Added support for full _"manager-subordinate"_ users management, including a special API rule to allow directly changing system fields like email, password, etc. without requiring `oldPassword` or other user verification.
-
-- Enabled OAuth2 account linking on authorized request from the same auth collection (_this is useful for example if the OAuth2 provider doesn't return an email and you want to associate it with the current logged in user_).
-
-- Added option to toggle the record columns visibility from the table listing.
-
-- Added support for collection schema fields reordering.
-
-- Added several new OAuth2 providers (Microsoft Azure AD, Spotify, Twitch, Kakao).
-
-- Improved memory usage on large file uploads [#835](https://github.com/pocketbase/pocketbase/discussions/835).
-
-- More detailed API preview docs and site documentation (the repo is located at https://github.com/pocketbase/site).
-
-- Other minor performance improvements (mostly related to the search apis).
-
-### Migrate from v0.7.x
-
-- **[Data](#data)**
-- **[SDKs](#sdks)**
-- **[API](#api)**
-- **[Internals](#internals)**
-
-#### Data
-
-The merge of users and profiles comes with several required db changes.
-The easiest way to apply them is to use the new temporary `upgrade` command:
-
-```sh
-# make sure to have a copy of your pb_data in case something fails
-cp -r ./pb_data ./pb_data_backup
-
-# run the upgrade command
-./pocketbase08 upgrade
-
-# start the application as usual
-./pocketbase08 serve
-```
-
-The upgrade command:
-
-- Creates a new `users` collection with merged fields from the `_users` table and the `profiles` collection.
- The new user records will have the ids from the `profiles` collection.
-- Changes all `user` type fields to `relation` and update the references to point to the new user ids.
-- Renames all `@collection.profiles.*`, `@request.user.*` and `@request.user.profile.*` filters to `@collection.users.*` and `@request.auth.*`.
-- Appends `2` to all **schema field names** and **api filter rules** that conflicts with the new system reserved ones:
- ```
- collectionId => collectionId2
- collectionName => collectionName2
- expand => expand2
-
- // only for the "profiles" collection fields:
- username => username2
- email => email2
- emailVisibility => emailVisibility2
- verified => verified2
- tokenKey => tokenKey2
- passwordHash => passwordHash2
- lastResetSentAt => lastResetSentAt2
- lastVerificationSentAt => lastVerificationSentAt2
- ```
-
-#### SDKs
-
-Please check the individual SDK package changelog and apply the necessary changes in your code:
-
-- [**JavaScript SDK changelog**](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
- ```sh
- npm install pocketbase@latest --save
- ```
-
-- [**Dart SDK changelog**](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
-
- ```sh
- dart pub add pocketbase:^0.5.0
- # or with Flutter:
- flutter pub add pocketbase:^0.5.0
- ```
-
-#### API
-
-> _**You don't have to read this if you are using an official SDK.**_
-
-- The authorization schema is no longer necessary. Now it is auto detected from the JWT token payload:
-
-
- Old
- New
-
-
- Authorization: Admin TOKEN
- Authorization: TOKEN
-
-
- Authorization: User TOKEN
- Authorization: TOKEN
-
-
-
-- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
-
-
- Old
- New
-
-
- 2022-01-02 03:04:05.678
- 2022-01-02 03:04:05.678Z
-
-
-
-- Removed the `@` prefix from the system record fields for easier json parsing:
-
-
- Old
- New
-
-
- @ collectionId
- collectionId
-
-
- @ collectionName
- collectionName
-
-
- @ expand
- expand
-
-
-
-- All users api handlers are moved under `/api/collections/:collection/`:
-
-
- Old
- New
-
-
-
- GET /api/users /auth-methods
-
-
- GET /api/collections/:collection /auth-methods
-
-
-
-
- POST /api/users/refresh
-
-
- POST /api/collections/:collection/auth-refresh
-
-
-
- POST /api/users/auth-via-oauth2
-
- POST /api/collections/:collection/auth-with-oauth2
-
- You can now also pass optional createData object on OAuth2 sign-up.
-
- Also please note that now required user/profile fields are properly validated when creating new auth model on OAuth2 sign-up.
-
-
-
- POST /api/users/auth-via-email
-
- POST /api/collections/:collection/auth-with-password
-
- Handles username/email + password authentication.
-
- {"identity": "usernameOrEmail", "password": "123456"}
-
-
-
- POST /api/users /request-password-reset
- POST /api/collections/:collection /request-password-reset
-
-
- POST /api/users /confirm-password-reset
- POST /api/collections/:collection /confirm-password-reset
-
-
- POST /api/users /request-verification
- POST /api/collections/:collection /request-verification
-
-
- POST /api/users /confirm-verification
- POST /api/collections/:collection /confirm-verification
-
-
- POST /api/users /request-email-change
- POST /api/collections/:collection /request-email-change
-
-
- POST /api/users /confirm-email-change
- POST /api/collections/:collection /confirm-email-change
-
-
- GET /api/users
- GET /api/collections/:collection/records
-
-
- GET /api/users /:id
- GET /api/collections/:collection/records /:id
-
-
- POST /api/users
- POST /api/collections/:collection/records
-
-
- PATCH /api/users /:id
- PATCH /api/collections/:collection/records /:id
-
-
- DELETE /api/users /:id
- DELETE /api/collections/:collection/records /:id
-
-
- GET /api/users /:id/external-auths
- GET /api/collections/:collection/records /:id/external-auths
-
-
- DELETE /api/users /:id/external-auths/:provider
- DELETE /api/collections/:collection/records /:id/external-auths/:provider
-
-
-
- _In relation to the above changes, the `user` property in the auth response is renamed to `record`._
-
-- The admins api was also updated for consistency with the users api changes:
-
-
- Old
- New
-
-
-
- POST /api/admins/refresh
-
-
- POST /api/admins/auth-refresh
-
-
-
- POST /api/admins/auth-via-email
-
- POST /api/admins/auth-with-password
-
- {"identity": "test@example.com", "password": "123456"}
-
- (notice that the email body field was renamed to identity)
-
-
-
-
-- To prevent confusion with the auth method responses, the following endpoints now returns 204 with empty body (previously 200 with token and auth model):
- ```
- POST /api/admins/confirm-password-reset
- POST /api/collections/:collection/confirm-password-reset
- POST /api/collections/:collection/confirm-verification
- POST /api/collections/:collection/confirm-email-change
- ```
-
-- Renamed the "user" related settings fields returned by `GET /api/settings`:
-
-
- Old
- New
-
-
- user AuthToken
- record AuthToken
-
-
- user PasswordResetToken
- record PasswordResetToken
-
-
- user EmailChangeToken
- record EmailChangeToken
-
-
- user VerificationToken
- record VerificationToken
-
-
-
-#### Internals
-
-> _**You don't have to read this if you are not using PocketBase as framework.**_
-
-- Removed `forms.New*WithConfig()` factories to minimize ambiguities.
- If you need to pass a transaction Dao you can use the new `SetDao(dao)` method available to the form instances.
-
-- `forms.RecordUpsert.LoadData(data map[string]any)` now can bulk load external data from a map.
- To load data from a request instance, you could use `forms.RecordUpsert.LoadRequest(r, optKeysPrefix = "")`.
-
-- `schema.RelationOptions.MaxSelect` has new type `*int` (_you can use the new `types.Pointer(123)` helper to assign pointer values_).
-
-- Renamed the constant `apis.ContextUserKey` (_"user"_) to `apis.ContextAuthRecordKey` (_"authRecord"_).
-
-- Replaced user related middlewares with their auth record alternative:
-
-
- Old
- New
-
-
- apis.RequireUser Auth()
- apis.RequireRecord Auth(optCollectionNames ...string )
-
-
- apis.RequireAdminOrUser Auth()
- apis.RequireAdminOrRecord Auth(optCollectionNames ...string )
-
-
- N/A
-
- RequireSameContextRecordAuth()
-
- (requires the auth record to be from the same context collection)
-
-
-
-
-- The following record Dao helpers now uses the collection id or name instead of `*models.Collection` instance to reduce the verbosity when fetching records:
-
-
- Old
- New
-
-
- dao.FindRecordById(collection , ...)
- dao.FindRecordById(collectionNameOrId , ...)
-
-
- dao.FindRecordsByIds(collection , ...)
- dao.FindRecordsByIds(collectionNameOrId , ...)
-
-
- dao.FindRecordsByExpr(collection , ...)
- dao.FindRecordsByExpr(collectionNameOrId , ...)
-
-
- dao.FindFirstRecordByData(collection , ...)
- dao.FindFirstRecordByData(collectionNameOrId , ...)
-
-
- dao.IsRecordValueUnique(collection , ...)
- dao.IsRecordValueUnique(collectionNameOrId , ...)
-
-
-
-- Replaced all User related Dao helpers with Record equivalents:
-
-
- Old
- New
-
-
- dao.UserQuery()
- dao.RecordQuery(collection)
-
-
- dao.FindUserById(id)
- dao.FindRecordById(collectionNameOrId, id)
-
-
- dao.FindUserByToken(token, baseKey)
- dao.FindAuthRecordByToken(token, baseKey)
-
-
- dao.FindUserByEmail(email)
- dao.FindAuthRecordByEmail(collectionNameOrId, email)
-
-
- N/A
- dao.FindAuthRecordByUsername(collectionNameOrId, username)
-
-
-
-- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage:
-
-
- Old
- New
-
-
- Import path
-
-
- github.com/pocketbase/pocketbase/tools/rest
- github.com/pocketbase/pocketbase/apis
-
-
- Fields
-
-
- rest .ApiError{}
- apis .ApiError{}
-
-
- rest .NewNotFoundError()
- apis .NewNotFoundError()
-
-
- rest .NewBadRequestError()
- apis .NewBadRequestError()
-
-
- rest .NewForbiddenError()
- apis .NewForbiddenError()
-
-
- rest .NewUnauthorizedError()
- apis .NewUnauthorizedError()
-
-
- rest .NewApiError()
- apis .NewApiError()
-
-
-
-- Renamed `models.Record` helper getters:
-
-
- Old
- New
-
-
- SetDataValue
- Set
-
-
- GetDataValue
- Get
-
-
- GetBoolDataValue
- GetBool
-
-
- GetStringDataValue
- GetString
-
-
- GetIntDataValue
- GetInt
-
-
- GetFloatDataValue
- GetFloat
-
-
- GetTimeDataValue
- GetTime
-
-
- GetDateTimeDataValue
- GetDateTime
-
-
- GetStringSliceDataValue
- GetStringSlice
-
-
-
-- Added new auth collection `models.Record` helpers:
- ```go
- func (m *Record) Username() string
- func (m *Record) SetUsername(username string) error
- func (m *Record) Email() string
- func (m *Record) SetEmail(email string) error
- func (m *Record) EmailVisibility() bool
- func (m *Record) SetEmailVisibility(visible bool) error
- func (m *Record) IgnoreEmailVisibility(state bool)
- func (m *Record) Verified() bool
- func (m *Record) SetVerified(verified bool) error
- func (m *Record) TokenKey() string
- func (m *Record) SetTokenKey(key string) error
- func (m *Record) RefreshTokenKey() error
- func (m *Record) LastResetSentAt() types.DateTime
- func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error
- func (m *Record) LastVerificationSentAt() types.DateTime
- func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error
- func (m *Record) ValidatePassword(password string) bool
- func (m *Record) SetPassword(password string) error
- ```
-
-- Added option to return serialized custom `models.Record` fields data:
- ```go
- func (m *Record) UnknownData() map[string]any
- func (m *Record) WithUnknownData(state bool)
- ```
-
-- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
-
-
- Old
- New
-
-
- User.Email
- Record.Email()
-
-
- User.TokenKey
- Record.TokenKey()
-
-
- User.Verified
- Record.Verified()
-
-
- User.SetPassword()
- Record.SetPassword()
-
-
- User.RefreshTokenKey()
- Record.RefreshTokenKey()
-
-
- etc.
-
-
-
-- Replaced `User` related event hooks with their `Record` alternative:
-
-
- Old
- New
-
-
- OnMailerBeforeUser ResetPasswordSend() *hook.Hook[*MailerUser Event]
- OnMailerBeforeRecord ResetPasswordSend() *hook.Hook[*MailerRecord Event]
-
-
- OnMailerAfterUser ResetPasswordSend() *hook.Hook[*MailerUser Event]
- OnMailerAfterRecord ResetPasswordSend() *hook.Hook[*MailerRecord Event]
-
-
- OnMailerBeforeUser VerificationSend() *hook.Hook[*MailerUser Event]
- OnMailerBeforeRecord VerificationSend() *hook.Hook[*MailerRecord Event]
-
-
- OnMailerAfterUser VerificationSend() *hook.Hook[*MailerUser Event]
- OnMailerAfterRecord VerificationSend() *hook.Hook[*MailerRecord Event]
-
-
- OnMailerBeforeUser ChangeEmailSend() *hook.Hook[*MailerUser Event]
- OnMailerBeforeRecord ChangeEmailSend() *hook.Hook[*MailerRecord Event]
-
-
- OnMailerAfterUser ChangeEmailSend() *hook.Hook[*MailerUser Event]
- OnMailerAfterRecord ChangeEmailSend() *hook.Hook[*MailerRecord Event]
-
-
- OnUsers ListRequest() *hook.Hook[*User ListEvent]
- OnRecords ListRequest() *hook.Hook[*Records ListEvent]
-
-
- OnUser ViewRequest() *hook.Hook[*User ViewEvent]
- OnRecord ViewRequest() *hook.Hook[*Record ViewEvent]
-
-
- OnUser BeforeCreateRequest() *hook.Hook[*User CreateEvent]
- OnRecord BeforeCreateRequest() *hook.Hook[*Record CreateEvent]
-
-
- OnUser AfterCreateRequest() *hook.Hook[*User CreateEvent]
- OnRecord AfterCreateRequest() *hook.Hook[*Record CreateEvent]
-
-
- OnUser BeforeUpdateRequest() *hook.Hook[*User UpdateEvent]
- OnRecord BeforeUpdateRequest() *hook.Hook[*Record UpdateEvent]
-
-
- OnUser AfterUpdateRequest() *hook.Hook[*User UpdateEvent]
- OnRecord AfterUpdateRequest() *hook.Hook[*Record UpdateEvent]
-
-
- OnUser BeforeDeleteRequest() *hook.Hook[*User DeleteEvent]
- OnRecord BeforeDeleteRequest() *hook.Hook[*Record DeleteEvent]
-
-
- OnUser AfterDeleteRequest() *hook.Hook[*User DeleteEvent]
- OnRecord AfterDeleteRequest() *hook.Hook[*Record DeleteEvent]
-
-
- OnUser AuthRequest() *hook.Hook[*User AuthEvent]
- OnRecord AuthRequest() *hook.Hook[*Record AuthEvent]
-
-
- OnUser ListExternalAuths() *hook.Hook[*User ListExternalAuthsEvent]
- OnRecord ListExternalAuths() *hook.Hook[*Record ListExternalAuthsEvent]
-
-
- OnUser BeforeUnlinkExternalAuthRequest() *hook.Hook[*User UnlinkExternalAuthEvent]
- OnRecord BeforeUnlinkExternalAuthRequest() *hook.Hook[*Record UnlinkExternalAuthEvent]
-
-
- OnUser AfterUnlinkExternalAuthRequest() *hook.Hook[*User UnlinkExternalAuthEvent]
- OnRecord AfterUnlinkExternalAuthRequest() *hook.Hook[*Record UnlinkExternalAuthEvent]
-
-
-
-- Replaced `forms.UserEmailLogin{}` with `forms.RecordPasswordLogin{}` (for both username and email depending on which is enabled for the collection).
-
-- Renamed user related `core.Settings` fields:
-
-
- Old
- New
-
-
- core.Settings.User AuthToken{}
- core.Settings.Record AuthToken{}
-
-
- core.Settings.User PasswordResetToken{}
- core.Settings.Record PasswordResetToken{}
-
-
- core.Settings.User EmailChangeToken{}
- core.Settings.Record EmailChangeToken{}
-
-
- core.Settings.User VerificationToken{}
- core.Settings.Record VerificationToken{}
-
-
-
-- Marked as "Deprecated" and will be removed in v0.9+:
- ```
- core.Settings.EmailAuth{}
- core.EmailAuthConfig{}
- schema.FieldTypeUser
- schema.UserOptions{}
- ```
-
-- The second argument of `apis.StaticDirectoryHandler(fileSystem, enableIndexFallback)` now is used to enable/disable index.html forwarding on missing file (eg. in case of SPA).
+- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package.
+ _⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._
+ _I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._
diff --git a/CHANGELOG_08_15.md b/CHANGELOG_08_15.md
new file mode 100644
index 0000000000..e17c35bd71
--- /dev/null
+++ b/CHANGELOG_08_15.md
@@ -0,0 +1,1384 @@
+> List with changes from v0.8.x to v0.15.x.
+> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
+---
+
+## v0.15.3
+
+- Updated the Admin UI to use the latest JS SDK to resolve the `isNew` record field conflict ([#2385](https://github.com/pocketbase/pocketbase/discussions/2385)).
+
+- Fixed `editor` field fullscreen `z-index` ([#2410](https://github.com/pocketbase/pocketbase/issues/2410)).
+
+- Inserts the default app settings as part of the system init migration so that they are always available when accessed from within a user defined migration ([#2423](https://github.com/pocketbase/pocketbase/discussions/2423)).
+
+
+## v0.15.2
+
+- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
+
+- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
+
+- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
+
+
+## v0.15.1
+
+- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
+
+- Fixed `Ctrl + S` in the `editor` field not propagating the quick save shortcut to the parent form.
+
+- Added `⌘ + S` alias for the record quick save shortcut (_I have no Mac device to test it but it should work based on [`e.metaKey` docs](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey)_).
+
+- Enabled RTL for the TinyMCE editor ([#2327](https://github.com/pocketbase/pocketbase/issues/2327)).
+
+- Reduced the record form vertical layout shifts and slightly improved the rendering speed when loading multiple `relation` fields.
+
+- Enabled Admin UI assets cache.
+
+
+## v0.15.0
+
+- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
+ Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
+ The manual code-token exchange flow is still supported but the SDK method is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
+ For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
+
+- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
+ Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
+ It works with a short lived (~5min) file token passed as query param with the file url.
+ For more details and example, you could check https://pocketbase.io/docs/files-handling/#protected-files.
+
+- ⚠️ Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
+
+- Added simple loose wildcard search term support in the Admin UI.
+
+- Added auto "draft" to allow restoring previous record state in case of accidental reload or power outage.
+
+- Added `Ctrl + S` shortcut to save the record changes without closing the panel.
+
+- Added "drop files" support for the file upload field.
+
+- Refreshed the OAuth2 Admin UI.
+
+
+## v0.14.5
+
+- Added checks for `nil` hooks in `forms.RecordUpsert` when used with custom `Dao` ([#2277](https://github.com/pocketbase/pocketbase/issues/2277)).
+
+- Fixed unique detailed field error not returned on record create failure ([#2287](https://github.com/pocketbase/pocketbase/discussions/2287)).
+
+
+## v0.14.4
+
+- Fixed concurrent map write pannic on `list.ExistInSliceWithRegex()` cache ([#2272](https://github.com/pocketbase/pocketbase/issues/2272)).
+
+
+## v0.14.3
+
+- Fixed Admin UI Logs `meta` visualization in Firefox ([#2221](https://github.com/pocketbase/pocketbase/issues/2221)).
+
+- Downgraded to v1 of the `aws/aws-sdk-go` package since v2 has compatibility issues with GCS ([#2231](https://github.com/pocketbase/pocketbase/issues/2231)).
+
+- Upgraded the GitHub action to use [min Go 1.20.3](https://github.com/golang/go/issues?q=milestone%3AGo1.20.3+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `net/http` security fixes.
+
+
+## v0.14.2
+
+- Reverted part of the old `COALESCE` handling as a fallback to support empty string comparison with missing joined relation fields.
+
+
+## v0.14.1
+
+- Fixed realtime events firing before the files upload completion.
+
+- Updated the underlying S3 lib to use `aws-sdk-go-v2` ([#1346](https://github.com/pocketbase/pocketbase/pull/1346); thanks @yuxiang-gao).
+
+- Updated TinyMCE to v6.4.1.
+
+- Updated the godoc of `Dao.Save*` methods.
+
+
+## v0.14.0
+
+- Added _experimental_ Apple OAuth2 integration.
+
+- Added `@request.headers.*` filter rule support.
+
+- Added support for advanced unique constraints and indexes management ([#345](https://github.com/pocketbase/pocketbase/issues/345), [#544](https://github.com/pocketbase/pocketbase/issues/544))
+
+- Simplified the collections fields UI to allow easier and quicker scaffolding of the data schema.
+
+- Deprecated `SchemaField.Unique`. Unique constraints are now managed via indexes.
+ The `Unique` field is a no-op and will be removed in future version.
+
+- Removed the `COALESCE` wrapping from some of the generated filter conditions to make better use of the indexes ([#1939](https://github.com/pocketbase/pocketbase/issues/1939)).
+
+- Detect `id` aliased view columns as single `relation` fields ([#2029](https://github.com/pocketbase/pocketbase/discussions/2029)).
+
+- Optimized single relation lookups.
+
+- Normalized record values on `maxSelect` field option change (`select`, `file`, `relation`).
+ When changing **from single to multiple** all already inserted single values are converted to an array.
+ When changing **from multiple to single** only the last item of the already inserted array items is kept.
+
+- Changed the cost/round factor of bcrypt hash generation from 13 to 12 since several users complained about the slow authWithPassword responses on lower spec hardware.
+ _The change will affect only new users. Depending on the demand, we might make it configurable from the auth options._
+
+- Simplified the default mail template styles to allow more control over the template layout ([#1904](https://github.com/pocketbase/pocketbase/issues/1904)).
+
+- Added option to explicitly set the record id from the Admin UI ([#2118](https://github.com/pocketbase/pocketbase/issues/2118)).
+
+- Added `migrate history-sync` command to clean `_migrations` history table from deleted migration files references.
+
+- Added new fields to the `core.RecordAuthWithOAuth2Event` struct:
+ ```
+ IsNewRecord bool, // boolean field indicating whether the OAuth2 action created a new auth record
+ ProviderName string, // the name of the OAuth2 provider (eg. "google")
+ ProviderClient auth.Provider, // the loaded Provider client instance
+ ```
+
+- Added CGO linux target for the prebuilt executable.
+
+- ⚠️ Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
+
+- ⚠️ Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
+
+- ⚠️ Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
+ If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
+
+- ⚠️ Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
+
+- ⚠️ Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
+
+- Other minor UI improvements.
+
+
+## v0.13.4
+
+- Removed eager unique collection name check to support lazy validation during bulk import.
+
+
+## v0.13.3
+
+- Fixed view collections import ([#2044](https://github.com/pocketbase/pocketbase/issues/2044)).
+
+- Updated the records picker Admin UI to show properly view collection relations.
+
+
+## v0.13.2
+
+- Fixed Admin UI js error when selecting multiple `file` field as `relation` "Display fields" ([#1989](https://github.com/pocketbase/pocketbase/issues/1989)).
+
+
+## v0.13.1
+
+- Added `HEAD` request method support for the `/api/files/:collection/:recordId/:filename` route ([#1976](https://github.com/pocketbase/pocketbase/discussions/1976)).
+
+
+## v0.13.0
+
+- Added new "View" collection type allowing you to create a read-only collection from a custom SQL `SELECT` statement. It supports:
+ - aggregations (`COUNT()`, `MIN()`, `MAX()`, `GROUP BY`, etc.)
+ - column and table aliases
+ - CTEs and subquery expressions
+ - auto `relation` fields association
+ - `file` fields proxying (up to 5 linked relations, eg. view1->view2->...->base)
+ - `filter`, `sort` and `expand`
+ - List and View API rules
+
+- Added auto fail/retry (default to 8 attempts) for the `SELECT` queries to gracefully handle the `database is locked` errors ([#1795](https://github.com/pocketbase/pocketbase/discussions/1795#discussioncomment-4882169)).
+ _The default max attempts can be accessed or changed via `Dao.MaxLockRetries`._
+
+- Added default max query execution timeout (30s).
+ _The default timeout can be accessed or changed via `Dao.ModelQueryTimeout`._
+ _For the prebuilt executables it can be also changed via the `--queryTimeout=10` flag._
+
+- Added support for `dao.RecordQuery(collection)` to scan directly the `One()` and `All()` results in `*models.Record` or `[]*models.Record` without the need of explicit `NullStringMap`.
+
+- Added support to overwrite the default file serve headers if an explicit response header is set.
+
+- Added file thumbs when visualizing `relation` display file fields.
+
+- Added "Min select" `relation` field option.
+
+- Enabled `process.env` in JS migrations to allow accessing `os.Environ()`.
+
+- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
+
+- ⚠️ Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
+
+- ⚠️ Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
+
+- ⚠️ Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
+
+ If you are sending custom emails, you'll have to replace:
+ ```go
+ message := &mailer.Message{
+ ...
+
+ // (old) To: mail.Address{Address: "to@example.com"}
+ To: []mail.Address{{Address: "to@example.com", Name: "Some optional name"}},
+
+ // (old) Cc: []string{"cc@example.com"}
+ Cc: []mail.Address{{Address: "cc@example.com", Name: "Some optional name"}},
+
+ // (old) Bcc: []string{"bcc@example.com"}
+ Bcc: []mail.Address{{Address: "bcc@example.com", Name: "Some optional name"}},
+
+ ...
+ }
+ ```
+
+- ⚠️ Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
+ _If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
+ _To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
+
+- ⚠️ Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
+
+- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
+
+- Other minor Admin UI improvements.
+
+
+## v0.12.3
+
+- Fixed "Toggle column" reactivity when navigating between collections ([#1836](https://github.com/pocketbase/pocketbase/pull/1836)).
+
+- Logged the current datetime on server start ([#1822](https://github.com/pocketbase/pocketbase/issues/1822)).
+
+
+## v0.12.2
+
+- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
+
+- Increased slightly the fields contrast ([#1742](https://github.com/pocketbase/pocketbase/issues/1742)).
+
+- Auto close the multi-select dropdown if "Max select" is reached.
+
+
+## v0.12.1
+
+- Fixed js error on empty relation save.
+
+- Fixed `overlay-active` css class not being removed on nested overlay panel close ([#1718](https://github.com/pocketbase/pocketbase/issues/1718)).
+
+- Added the collection name in the page title ([#1711](https://github.com/pocketbase/pocketbase/issues/1711)).
+
+
+## v0.12.0
+
+- Refactored the relation picker UI to allow server-side search, sort, create, update and delete of relation records ([#976](https://github.com/pocketbase/pocketbase/issues/976)).
+
+- Added new `RelationOptions.DisplayFields` option to specify custom relation field(s) visualization in the Admin UI.
+
+- Added Authentik OAuth2 provider ([#1377](https://github.com/pocketbase/pocketbase/pull/1377); thanks @pr0ton11).
+
+- Added LiveChat OAuth2 provider ([#1573](https://github.com/pocketbase/pocketbase/pull/1573); thanks @mariosant).
+
+- Added Gitea OAuth2 provider ([#1643](https://github.com/pocketbase/pocketbase/pull/1643); thanks @hlanderdev).
+
+- Added PDF file previews ([#1548](https://github.com/pocketbase/pocketbase/pull/1548); thanks @mjadobson).
+
+- Added video and audio file previews.
+
+- Added rich text editor (`editor`) field for HTML content based on TinyMCE ([#370](https://github.com/pocketbase/pocketbase/issues/370)).
+ _Currently the new field doesn't have any configuration options or validations but this may change in the future depending on how devs ended up using it._
+
+- Added "Duplicate" Collection and Record options in the Admin UI ([#1656](https://github.com/pocketbase/pocketbase/issues/1656)).
+
+- Added `filesystem.GetFile()` helper to read files through the FileSystem abstraction ([#1578](https://github.com/pocketbase/pocketbase/pull/1578); thanks @avarabyeu).
+
+- Added new auth event hooks for finer control and more advanced auth scenarios handling:
+
+ ```go
+ // auth record
+ OnRecordBeforeAuthWithPasswordRequest()
+ OnRecordAfterAuthWithPasswordRequest()
+ OnRecordBeforeAuthWithOAuth2Request()
+ OnRecordAfterAuthWithOAuth2Request()
+ OnRecordBeforeAuthRefreshRequest()
+ OnRecordAfterAuthRefreshRequest()
+
+ // admin
+ OnAdminBeforeAuthWithPasswordRequest()
+ OnAdminAfterAuthWithPasswordRequest()
+ OnAdminBeforeAuthRefreshRequest()
+ OnAdminAfterAuthRefreshRequest()
+ OnAdminBeforeRequestPasswordResetRequest()
+ OnAdminAfterRequestPasswordResetRequest()
+ OnAdminBeforeConfirmPasswordResetRequest()
+ OnAdminAfterConfirmPasswordResetRequest()
+ ```
+
+- Added `models.Record.CleanCopy()` helper that creates a new record copy with only the latest data state of the existing one and all other options reset to their defaults.
+
+- Added new helper `apis.RecordAuthResponse(app, httpContext, record, meta)` to return a standard Record auth API response ([#1623](https://github.com/pocketbase/pocketbase/issues/1623)).
+
+- Refactored `models.Record` expand and data change operations to be concurrent safe.
+
+- Refactored all `forms` Submit interceptors to use a generic data type as their payload.
+
+- Added several `store.Store` helpers:
+ ```go
+ store.Reset(newData map[string]T)
+ store.Length() int
+ store.GetAll() map[string]T
+ ```
+
+- Added "tags" support for all Record and Model related event hooks.
+
+ The "tags" allow registering event handlers that will be called only on matching table name(s) or collection id(s)/name(s).
+ For example:
+ ```go
+ app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
+ // called only on "articles" record creation
+ log.Println(e.Record)
+ return nil
+ })
+ ```
+ For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
+
+- ⚠️ Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
+
+ In order to support seamlessly both `application/json` and `multipart/form-data`
+ requests, the following normalization rules are applied if the `json` field is a
+ **plain string value**:
+
+ - "true" is converted to the json `true`
+ - "false" is converted to the json `false`
+ - "null" is converted to the json `null`
+ - "[1,2,3]" is converted to the json `[1,2,3]`
+ - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
+ - numeric strings are converted to json number
+ - double quoted strings are left as they are (aka. without normalizations)
+ - any other string (empty string too) is double quoted
+
+ Additionally, the "Nonempty" `json` field constraint now checks for `null`, `[]`, `{}` and `""` (empty string).
+
+- Added `aria-label` to some of the buttons in the Admin UI for better accessibility ([#1702](https://github.com/pocketbase/pocketbase/pull/1702); thanks @ndarilek).
+
+- Updated the filename extension checks in the Admin UI to be case-insensitive ([#1707](https://github.com/pocketbase/pocketbase/pull/1707); thanks @hungcrush).
+
+- Other minor improvements (more detailed API file upload errors, UI optimizations, docs improvements, etc.)
+
+
+## v0.11.4
+
+- Fixed cascade delete for rel records with the same id as the main record ([#1689](https://github.com/pocketbase/pocketbase/issues/1689)).
+
+
+## v0.11.3
+
+- Fix realtime API panic on concurrent clients iteration ([#1628](https://github.com/pocketbase/pocketbase/issues/1628))
+
+ - `app.SubscriptionsBroker().Clients()` now returns a shallow copy of the underlying map.
+
+ - Added `Discard()` and `IsDiscarded()` helper methods to the `subscriptions.Client` interface.
+
+ - Slow clients should no longer "block" the main action completion.
+
+
+## v0.11.2
+
+- Fixed `fs.DeleteByPrefix()` hang on invalid S3 settings ([#1575](https://github.com/pocketbase/pocketbase/discussions/1575#discussioncomment-4661089)).
+
+- Updated file(s) delete to run in the background on record/collection delete to avoid blocking the delete model transaction.
+ _Currently the cascade files delete operation is treated as "non-critical" and in case of an error it is just logged during debug._
+ _This will be improved in the near future with the planned async job queue implementation._
+
+
+## v0.11.1
+
+- Unescaped path parameter values ([#1552](https://github.com/pocketbase/pocketbase/issues/1552)).
+
+
+## v0.11.0
+
+- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
+ ```js
+ {
+ // oldValue + 2
+ "someNumber+": 2,
+
+ // oldValue + ["id1", "id2"] - ["id3"]
+ "someRelation+": ["id1", "id2"],
+ "someRelation-": ["id3"],
+
+ // delete single file by its name (file fields supports only the "-" modifier!)
+ "someFile-": "filename.png",
+ }
+ ```
+ _Note1: `@request.data.someField` will contain the final resolved value._
+
+ _Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
+
+- ⚠️ Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
+ If you want a "at least one of" type of condition, you can prefix the operator with `?`.
+ ```js
+ // for each someRelA.someRelB record require the "status" field to be "active"
+ someRelA.someRelB.status = "active"
+
+ // OR for "at least one of" condition
+ someRelA.someRelB.status ?= "active"
+ ```
+ _**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
+ The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
+ If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
+
+- Added support for querying `@request.data.someRelField.*` relation fields.
+ ```js
+ // example submitted data: {"someRel": "REL_RECORD_ID"}
+ @request.data.someRel.status = "active"
+ ```
+
+- Added `:isset` modifier for the static request data fields.
+ ```js
+ // prevent changing the "role" field
+ @request.data.role:isset = false
+ ```
+
+- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
+ ```js
+ // example submitted data: {"someSelectField": ["val1", "val2"]}
+ @request.data.someSelectField:length = 2
+
+ // check existing record field length
+ someSelectField:length = 2
+ ```
+
+- Added `:each` modifier support for the multi-`select` request data and collection field.
+ ```js
+ // check if all selected rows has "pb_" prefix
+ roles:each ~ 'pb_%'
+ ```
+
+- Improved the Admin UI filters autocomplete.
+
+- Added `@random` sort key for `RANDOM()` sorted list results.
+
+- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
+
+- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
+
+- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
+
+- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
+
+- Added support for reordering uploaded multiple files.
+
+- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
+
+- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
+
+- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
+
+- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
+
+- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
+
+- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
+
+- `migrate down` now reverts migrations in the applied order.
+
+- Added additional list-bucket check in the S3 config test API.
+
+- Other minor improvements.
+
+
+## v0.10.4
+
+- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
+
+
+## v0.10.3
+
+- ⚠️ Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
+
+- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
+
+- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
+
+
+## v0.10.2
+
+- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
+ A new helper method `models.Record.MergeExpand(map[string]any)` was also added to simplify the expand handling and unit testing.
+
+
+## v0.10.1
+
+- Fixed nested transactions deadlock when authenticating with OAuth2 ([#1291](https://github.com/pocketbase/pocketbase/issues/1291)).
+
+
+## v0.10.0
+
+- Added `/api/health` endpoint (thanks @MarvinJWendt).
+
+- Added support for SMTP `LOGIN` auth for Microsoft/Outlook and other providers that don't support the `PLAIN` auth method ([#1217](https://github.com/pocketbase/pocketbase/discussions/1217#discussioncomment-4387970)).
+
+- Reduced memory consumption (you can expect ~20% less allocated memory).
+
+- Added support for split (concurrent and nonconcurrent) DB connections pool increasing even further the concurrent throughput without blocking reads on heavy write load.
+
+- Improved record references delete performance.
+
+- Removed the unnecessary parenthesis in the generated filter SQL query, reducing the "_parse stack overflow_" errors.
+
+- Fixed `~` expressions backslash literal escaping ([#1231](https://github.com/pocketbase/pocketbase/discussions/1231)).
+
+- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
+
+- ⚠️ Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
+ ```go
+ DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
+ DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
+ LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns
+ LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns
+ ```
+
+- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
+
+- ⚠️ Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
+
+- ⚠️ Removed `rest.UploadedFile` struct (see below `filesystem.File`).
+
+- Added generic file resource struct that allows loading and uploading file content from
+ different sources (at the moment multipart/form-data requests and from the local filesystem).
+ ```
+ filesystem.File{}
+ filesystem.NewFileFromPath(path)
+ filesystem.NewFileFromMultipart(multipartHeader)
+ filesystem/System.UploadFile(file)
+ ```
+
+- Refactored `forms.RecordUpsert` to allow more easily loading and removing files programmatically.
+ ```
+ forms.RecordUpsert.AddFiles(key, filesystem.File...) // add new filesystem.File to the form for upload
+ forms.RecordUpsert.RemoveFiles(key, filenames...) // marks the filenames for deletion
+ ```
+
+- Trigger the `password` validators if any of the others password change fields is set.
+
+
+## v0.9.2
+
+- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
+
+
+## v0.9.1
+
+- Moved the record file upload and delete out of the db transaction to minimize the locking times.
+
+- Added `Dao` query semaphore and base fail/retry handling to improve the concurrent writes throughput ([#1187](https://github.com/pocketbase/pocketbase/issues/1187)).
+
+- Fixed records cascade deletion when there are "A<->B" relation references.
+
+- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
+
+- Fixed the datetime field not triggering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
+
+- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
+
+
+## v0.9.0
+
+- Fixed concurrent multi-relation cascade update/delete ([#1138](https://github.com/pocketbase/pocketbase/issues/1138)).
+
+- Added the raw OAuth2 user data (`meta.rawUser`) and OAuth2 access token (`meta.accessToken`) to the auth response ([#654](https://github.com/pocketbase/pocketbase/discussions/654)).
+
+- `BaseModel.UnmarkAsNew()` method was renamed to `BaseModel.MarkAsNotNew()`.
+ Additionally, to simplify the insert model queries with custom IDs, it is no longer required to call `MarkAsNew()` for manually initialized models with set ID since now this is the default state.
+ When the model is populated with values from the database (eg. after row `Scan`) it will be marked automatically as "not new".
+
+- Added `Record.OriginalCopy()` method that returns a new `Record` copy populated with the initially loaded record data (useful if you want to compare old and new field values).
+
+- Added new event hooks:
+ ```go
+ app.OnBeforeBootstrap()
+ app.OnAfterBootstrap()
+ app.OnBeforeApiError()
+ app.OnAfterApiError()
+ app.OnRealtimeDisconnectRequest()
+ app.OnRealtimeBeforeMessageSend()
+ app.OnRealtimeAfterMessageSend()
+ app.OnRecordBeforeRequestPasswordResetRequest()
+ app.OnRecordAfterRequestPasswordResetRequest()
+ app.OnRecordBeforeConfirmPasswordResetRequest()
+ app.OnRecordAfterConfirmPasswordResetRequest()
+ app.OnRecordBeforeRequestVerificationRequest()
+ app.OnRecordAfterRequestVerificationRequest()
+ app.OnRecordBeforeConfirmVerificationRequest()
+ app.OnRecordAfterConfirmVerificationRequest()
+ app.OnRecordBeforeRequestEmailChangeRequest()
+ app.OnRecordAfterRequestEmailChangeRequest()
+ app.OnRecordBeforeConfirmEmailChangeRequest()
+ app.OnRecordAfterConfirmEmailChangeRequest()
+ ```
+
+- The original uploaded file name is now stored as metadata under the `original_filename` key. It could be accessed via:
+ ```go
+ fs, _ := app.NewFilesystem()
+ defer fs.Close()
+
+ attrs, _ := fs.Attributes(fikeKey)
+ attrs.Metadata["original_name"]
+ ```
+
+- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)).
+ This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook):
+ ```go
+ // old
+ filesystem.Serve(res, e.ServedPath, e.ServedName)
+
+ // new
+ filesystem.Serve(res, req, e.ServedPath, e.ServedName)
+ ```
+
+- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
+ This allow writing custom migration scripts such as programmatically creating collections,
+ initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
+
+ The `migrate` command is available by default for the prebuilt executable,
+ but if you use PocketBase as framework you need register it manually:
+ ```go
+ migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
+
+ // load js files if you want to allow loading external JavaScript migrations
+ jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
+ Dir: migrationsDir,
+ })
+
+ // register the `migrate` command
+ migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
+ TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default)
+ Dir: migrationsDir,
+ Automigrate: true,
+ })
+ ```
+
+ **The refactoring also comes with automigrations support.**
+
+ If `Automigrate` is enabled (`true` by default for the prebuilt executable; can be disabled with `--automigrate=0`),
+ PocketBase will generate seamlessly in the background JS (or Go) migration file with your collection changes.
+ **The directory with the JS migrations can be committed to your git repo.**
+ All migrations (Go and JS) are automatically executed on server start.
+ Also note that the auto generated migrations are granural (in contrast to the `migrate collections` snapshot command)
+ and allow multiple developers to do changes on the collections independently (even editing the same collection) miniziming the eventual merge conflicts.
+ Here is a sample JS migration file that will be generated if you for example edit a single collection name:
+ ```js
+ // pb_migrations/1669663597_updated_posts_old.js
+ migrate((db) => {
+ // up
+ const dao = new Dao(db)
+ const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
+ collection.name = "posts_new"
+ return dao.saveCollection(collection)
+ }, (db) => {
+ // down
+ const dao = new Dao(db)
+ const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
+ collection.name = "posts_old"
+ return dao.saveCollection(collection)
+ })
+ ```
+
+- Added new `Dao` helpers to make it easier fetching and updating the app settings from a migration:
+ ```go
+ dao.FindSettings([optEncryptionKey])
+ dao.SaveSettings(newSettings, [optEncryptionKey])
+ ```
+
+- Moved `core.Settings` to `models/settings.Settings`:
+ ```
+ core.Settings{} -> settings.Settings{}
+ core.NewSettings() -> settings.New()
+ core.MetaConfig{} -> settings.MetaConfig{}
+ core.LogsConfig{} -> settings.LogsConfig{}
+ core.SmtpConfig{} -> settings.SmtpConfig{}
+ core.S3Config{} -> settings.S3Config{}
+ core.TokenConfig{} -> settings.TokenConfig{}
+ core.AuthProviderConfig{} -> settings.AuthProviderConfig{}
+ ```
+
+- Changed the `mailer.Mailer` interface (**minor breaking if you are sending custom emails**):
+ ```go
+ // Old:
+ app.NewMailClient().Send(from, to, subject, html, attachments?)
+
+ // New:
+ app.NewMailClient().Send(&mailer.Message{
+ From: from,
+ To: to,
+ Subject: subject,
+ HTML: html,
+ Attachments: attachments,
+ // new configurable fields
+ Bcc: []string{"bcc1@example.com", "bcc2@example.com"},
+ Cc: []string{"cc1@example.com", "cc2@example.com"},
+ Headers: map[string]string{"Custom-Header": "test"},
+ Text: "custom plain text version",
+ })
+ ```
+ The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events.
+
+- Other minor UI fixes and improvements
+
+
+## v0.8.0
+
+**⚠️ This release contains breaking changes and requires some manual migration steps!**
+
+The biggest change is the merge of the `User` models and the `profiles` collection per [#376](https://github.com/pocketbase/pocketbase/issues/376).
+There is no longer `user` type field and the users are just an "auth" collection (we now support **collection types**, currently only "base" and "auth").
+This should simplify the users management and at the same time allow us to have unlimited multiple "auth" collections each with their own custom fields and authentication options (eg. staff, client, etc.).
+
+In addition to the `Users` and `profiles` merge, this release comes with several other improvements:
+
+- Added indirect expand support [#312](https://github.com/pocketbase/pocketbase/issues/312#issuecomment-1242893496).
+
+- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
+
+- The `relation` field now allows unlimited `maxSelect` (aka. without upper limit).
+
+- Added support for combined email/username + password authentication (see below `authWithPassword()`).
+
+- Added support for full _"manager-subordinate"_ users management, including a special API rule to allow directly changing system fields like email, password, etc. without requiring `oldPassword` or other user verification.
+
+- Enabled OAuth2 account linking on authorized request from the same auth collection (_this is useful for example if the OAuth2 provider doesn't return an email and you want to associate it with the current logged in user_).
+
+- Added option to toggle the record columns visibility from the table listing.
+
+- Added support for collection schema fields reordering.
+
+- Added several new OAuth2 providers (Microsoft Azure AD, Spotify, Twitch, Kakao).
+
+- Improved memory usage on large file uploads [#835](https://github.com/pocketbase/pocketbase/discussions/835).
+
+- More detailed API preview docs and site documentation (the repo is located at https://github.com/pocketbase/site).
+
+- Other minor performance improvements (mostly related to the search apis).
+
+### Migrate from v0.7.x
+
+- **[Data](#data)**
+- **[SDKs](#sdks)**
+- **[API](#api)**
+- **[Internals](#internals)**
+
+#### Data
+
+The merge of users and profiles comes with several required db changes.
+The easiest way to apply them is to use the new temporary `upgrade` command:
+
+```sh
+# make sure to have a copy of your pb_data in case something fails
+cp -r ./pb_data ./pb_data_backup
+
+# run the upgrade command
+./pocketbase08 upgrade
+
+# start the application as usual
+./pocketbase08 serve
+```
+
+The upgrade command:
+
+- Creates a new `users` collection with merged fields from the `_users` table and the `profiles` collection.
+ The new user records will have the ids from the `profiles` collection.
+- Changes all `user` type fields to `relation` and update the references to point to the new user ids.
+- Renames all `@collection.profiles.*`, `@request.user.*` and `@request.user.profile.*` filters to `@collection.users.*` and `@request.auth.*`.
+- Appends `2` to all **schema field names** and **api filter rules** that conflicts with the new system reserved ones:
+ ```
+ collectionId => collectionId2
+ collectionName => collectionName2
+ expand => expand2
+
+ // only for the "profiles" collection fields:
+ username => username2
+ email => email2
+ emailVisibility => emailVisibility2
+ verified => verified2
+ tokenKey => tokenKey2
+ passwordHash => passwordHash2
+ lastResetSentAt => lastResetSentAt2
+ lastVerificationSentAt => lastVerificationSentAt2
+ ```
+
+#### SDKs
+
+Please check the individual SDK package changelog and apply the necessary changes in your code:
+
+- [**JavaScript SDK changelog**](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
+ ```sh
+ npm install pocketbase@latest --save
+ ```
+
+- [**Dart SDK changelog**](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
+
+ ```sh
+ dart pub add pocketbase:^0.5.0
+ # or with Flutter:
+ flutter pub add pocketbase:^0.5.0
+ ```
+
+#### API
+
+> _**You don't have to read this if you are using an official SDK.**_
+
+- The authorization schema is no longer necessary. Now it is auto detected from the JWT token payload:
+
+
+ Old
+ New
+
+
+ Authorization: Admin TOKEN
+ Authorization: TOKEN
+
+
+ Authorization: User TOKEN
+ Authorization: TOKEN
+
+
+
+- All datetime strings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
+
+
+ Old
+ New
+
+
+ 2022-01-02 03:04:05.678
+ 2022-01-02 03:04:05.678Z
+
+
+
+- Removed the `@` prefix from the system record fields for easier json parsing:
+
+
+ Old
+ New
+
+
+ @ collectionId
+ collectionId
+
+
+ @ collectionName
+ collectionName
+
+
+ @ expand
+ expand
+
+
+
+- All users api handlers are moved under `/api/collections/:collection/`:
+
+
+ Old
+ New
+
+
+
+ GET /api/users /auth-methods
+
+
+ GET /api/collections/:collection /auth-methods
+
+
+
+
+ POST /api/users/refresh
+
+
+ POST /api/collections/:collection/auth-refresh
+
+
+
+ POST /api/users/auth-via-oauth2
+
+ POST /api/collections/:collection/auth-with-oauth2
+
+ You can now also pass optional createData object on OAuth2 sign-up.
+
+ Also please note that now required user/profile fields are properly validated when creating new auth model on OAuth2 sign-up.
+
+
+
+ POST /api/users/auth-via-email
+
+ POST /api/collections/:collection/auth-with-password
+
+ Handles username/email + password authentication.
+
+ {"identity": "usernameOrEmail", "password": "123456"}
+
+
+
+ POST /api/users /request-password-reset
+ POST /api/collections/:collection /request-password-reset
+
+
+ POST /api/users /confirm-password-reset
+ POST /api/collections/:collection /confirm-password-reset
+
+
+ POST /api/users /request-verification
+ POST /api/collections/:collection /request-verification
+
+
+ POST /api/users /confirm-verification
+ POST /api/collections/:collection /confirm-verification
+
+
+ POST /api/users /request-email-change
+ POST /api/collections/:collection /request-email-change
+
+
+ POST /api/users /confirm-email-change
+ POST /api/collections/:collection /confirm-email-change
+
+
+ GET /api/users
+ GET /api/collections/:collection/records
+
+
+ GET /api/users /:id
+ GET /api/collections/:collection/records /:id
+
+
+ POST /api/users
+ POST /api/collections/:collection/records
+
+
+ PATCH /api/users /:id
+ PATCH /api/collections/:collection/records /:id
+
+
+ DELETE /api/users /:id
+ DELETE /api/collections/:collection/records /:id
+
+
+ GET /api/users /:id/external-auths
+ GET /api/collections/:collection/records /:id/external-auths
+
+
+ DELETE /api/users /:id/external-auths/:provider
+ DELETE /api/collections/:collection/records /:id/external-auths/:provider
+
+
+
+ _In relation to the above changes, the `user` property in the auth response is renamed to `record`._
+
+- The admins api was also updated for consistency with the users api changes:
+
+
+ Old
+ New
+
+
+
+ POST /api/admins/refresh
+
+
+ POST /api/admins/auth-refresh
+
+
+
+ POST /api/admins/auth-via-email
+
+ POST /api/admins/auth-with-password
+
+ {"identity": "test@example.com", "password": "123456"}
+
+ (notice that the email body field was renamed to identity)
+
+
+
+
+- To prevent confusion with the auth method responses, the following endpoints now returns 204 with empty body (previously 200 with token and auth model):
+ ```
+ POST /api/admins/confirm-password-reset
+ POST /api/collections/:collection/confirm-password-reset
+ POST /api/collections/:collection/confirm-verification
+ POST /api/collections/:collection/confirm-email-change
+ ```
+
+- Renamed the "user" related settings fields returned by `GET /api/settings`:
+
+
+ Old
+ New
+
+
+ user AuthToken
+ record AuthToken
+
+
+ user PasswordResetToken
+ record PasswordResetToken
+
+
+ user EmailChangeToken
+ record EmailChangeToken
+
+
+ user VerificationToken
+ record VerificationToken
+
+
+
+#### Internals
+
+> _**You don't have to read this if you are not using PocketBase as framework.**_
+
+- Removed `forms.New*WithConfig()` factories to minimize ambiguities.
+ If you need to pass a transaction Dao you can use the new `SetDao(dao)` method available to the form instances.
+
+- `forms.RecordUpsert.LoadData(data map[string]any)` now can bulk load external data from a map.
+ To load data from a request instance, you could use `forms.RecordUpsert.LoadRequest(r, optKeysPrefix = "")`.
+
+- `schema.RelationOptions.MaxSelect` has new type `*int` (_you can use the new `types.Pointer(123)` helper to assign pointer values_).
+
+- Renamed the constant `apis.ContextUserKey` (_"user"_) to `apis.ContextAuthRecordKey` (_"authRecord"_).
+
+- Replaced user related middlewares with their auth record alternative:
+
+
+ Old
+ New
+
+
+ apis.RequireUser Auth()
+ apis.RequireRecord Auth(optCollectionNames ...string )
+
+
+ apis.RequireAdminOrUser Auth()
+ apis.RequireAdminOrRecord Auth(optCollectionNames ...string )
+
+
+ N/A
+
+ RequireSameContextRecordAuth()
+
+ (requires the auth record to be from the same context collection)
+
+
+
+
+- The following record Dao helpers now uses the collection id or name instead of `*models.Collection` instance to reduce the verbosity when fetching records:
+
+
+ Old
+ New
+
+
+ dao.FindRecordById(collection , ...)
+ dao.FindRecordById(collectionNameOrId , ...)
+
+
+ dao.FindRecordsByIds(collection , ...)
+ dao.FindRecordsByIds(collectionNameOrId , ...)
+
+
+ dao.FindRecordsByExpr(collection , ...)
+ dao.FindRecordsByExpr(collectionNameOrId , ...)
+
+
+ dao.FindFirstRecordByData(collection , ...)
+ dao.FindFirstRecordByData(collectionNameOrId , ...)
+
+
+ dao.IsRecordValueUnique(collection , ...)
+ dao.IsRecordValueUnique(collectionNameOrId , ...)
+
+
+
+- Replaced all User related Dao helpers with Record equivalents:
+
+
+ Old
+ New
+
+
+ dao.UserQuery()
+ dao.RecordQuery(collection)
+
+
+ dao.FindUserById(id)
+ dao.FindRecordById(collectionNameOrId, id)
+
+
+ dao.FindUserByToken(token, baseKey)
+ dao.FindAuthRecordByToken(token, baseKey)
+
+
+ dao.FindUserByEmail(email)
+ dao.FindAuthRecordByEmail(collectionNameOrId, email)
+
+
+ N/A
+ dao.FindAuthRecordByUsername(collectionNameOrId, username)
+
+
+
+- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage:
+
+
+ Old
+ New
+
+
+ Import path
+
+
+ github.com/pocketbase/pocketbase/tools/rest
+ github.com/pocketbase/pocketbase/apis
+
+
+ Fields
+
+
+ rest .ApiError{}
+ apis .ApiError{}
+
+
+ rest .NewNotFoundError()
+ apis .NewNotFoundError()
+
+
+ rest .NewBadRequestError()
+ apis .NewBadRequestError()
+
+
+ rest .NewForbiddenError()
+ apis .NewForbiddenError()
+
+
+ rest .NewUnauthorizedError()
+ apis .NewUnauthorizedError()
+
+
+ rest .NewApiError()
+ apis .NewApiError()
+
+
+
+- Renamed `models.Record` helper getters:
+
+
+ Old
+ New
+
+
+ SetDataValue
+ Set
+
+
+ GetDataValue
+ Get
+
+
+ GetBoolDataValue
+ GetBool
+
+
+ GetStringDataValue
+ GetString
+
+
+ GetIntDataValue
+ GetInt
+
+
+ GetFloatDataValue
+ GetFloat
+
+
+ GetTimeDataValue
+ GetTime
+
+
+ GetDateTimeDataValue
+ GetDateTime
+
+
+ GetStringSliceDataValue
+ GetStringSlice
+
+
+
+- Added new auth collection `models.Record` helpers:
+ ```go
+ func (m *Record) Username() string
+ func (m *Record) SetUsername(username string) error
+ func (m *Record) Email() string
+ func (m *Record) SetEmail(email string) error
+ func (m *Record) EmailVisibility() bool
+ func (m *Record) SetEmailVisibility(visible bool) error
+ func (m *Record) IgnoreEmailVisibility(state bool)
+ func (m *Record) Verified() bool
+ func (m *Record) SetVerified(verified bool) error
+ func (m *Record) TokenKey() string
+ func (m *Record) SetTokenKey(key string) error
+ func (m *Record) RefreshTokenKey() error
+ func (m *Record) LastResetSentAt() types.DateTime
+ func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error
+ func (m *Record) LastVerificationSentAt() types.DateTime
+ func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error
+ func (m *Record) ValidatePassword(password string) bool
+ func (m *Record) SetPassword(password string) error
+ ```
+
+- Added option to return serialized custom `models.Record` fields data:
+ ```go
+ func (m *Record) UnknownData() map[string]any
+ func (m *Record) WithUnknownData(state bool)
+ ```
+
+- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
+
+
+ Old
+ New
+
+
+ User.Email
+ Record.Email()
+
+
+ User.TokenKey
+ Record.TokenKey()
+
+
+ User.Verified
+ Record.Verified()
+
+
+ User.SetPassword()
+ Record.SetPassword()
+
+
+ User.RefreshTokenKey()
+ Record.RefreshTokenKey()
+
+
+ etc.
+
+
+
+- Replaced `User` related event hooks with their `Record` alternative:
+
+
+ Old
+ New
+
+
+ OnMailerBeforeUser ResetPasswordSend() *hook.Hook[*MailerUser Event]
+ OnMailerBeforeRecord ResetPasswordSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnMailerAfterUser ResetPasswordSend() *hook.Hook[*MailerUser Event]
+ OnMailerAfterRecord ResetPasswordSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnMailerBeforeUser VerificationSend() *hook.Hook[*MailerUser Event]
+ OnMailerBeforeRecord VerificationSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnMailerAfterUser VerificationSend() *hook.Hook[*MailerUser Event]
+ OnMailerAfterRecord VerificationSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnMailerBeforeUser ChangeEmailSend() *hook.Hook[*MailerUser Event]
+ OnMailerBeforeRecord ChangeEmailSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnMailerAfterUser ChangeEmailSend() *hook.Hook[*MailerUser Event]
+ OnMailerAfterRecord ChangeEmailSend() *hook.Hook[*MailerRecord Event]
+
+
+ OnUsers ListRequest() *hook.Hook[*User ListEvent]
+ OnRecords ListRequest() *hook.Hook[*Records ListEvent]
+
+
+ OnUser ViewRequest() *hook.Hook[*User ViewEvent]
+ OnRecord ViewRequest() *hook.Hook[*Record ViewEvent]
+
+
+ OnUser BeforeCreateRequest() *hook.Hook[*User CreateEvent]
+ OnRecord BeforeCreateRequest() *hook.Hook[*Record CreateEvent]
+
+
+ OnUser AfterCreateRequest() *hook.Hook[*User CreateEvent]
+ OnRecord AfterCreateRequest() *hook.Hook[*Record CreateEvent]
+
+
+ OnUser BeforeUpdateRequest() *hook.Hook[*User UpdateEvent]
+ OnRecord BeforeUpdateRequest() *hook.Hook[*Record UpdateEvent]
+
+
+ OnUser AfterUpdateRequest() *hook.Hook[*User UpdateEvent]
+ OnRecord AfterUpdateRequest() *hook.Hook[*Record UpdateEvent]
+
+
+ OnUser BeforeDeleteRequest() *hook.Hook[*User DeleteEvent]
+ OnRecord BeforeDeleteRequest() *hook.Hook[*Record DeleteEvent]
+
+
+ OnUser AfterDeleteRequest() *hook.Hook[*User DeleteEvent]
+ OnRecord AfterDeleteRequest() *hook.Hook[*Record DeleteEvent]
+
+
+ OnUser AuthRequest() *hook.Hook[*User AuthEvent]
+ OnRecord AuthRequest() *hook.Hook[*Record AuthEvent]
+
+
+ OnUser ListExternalAuths() *hook.Hook[*User ListExternalAuthsEvent]
+ OnRecord ListExternalAuths() *hook.Hook[*Record ListExternalAuthsEvent]
+
+
+ OnUser BeforeUnlinkExternalAuthRequest() *hook.Hook[*User UnlinkExternalAuthEvent]
+ OnRecord BeforeUnlinkExternalAuthRequest() *hook.Hook[*Record UnlinkExternalAuthEvent]
+
+
+ OnUser AfterUnlinkExternalAuthRequest() *hook.Hook[*User UnlinkExternalAuthEvent]
+ OnRecord AfterUnlinkExternalAuthRequest() *hook.Hook[*Record UnlinkExternalAuthEvent]
+
+
+
+- Replaced `forms.UserEmailLogin{}` with `forms.RecordPasswordLogin{}` (for both username and email depending on which is enabled for the collection).
+
+- Renamed user related `core.Settings` fields:
+
+
+ Old
+ New
+
+
+ core.Settings.User AuthToken{}
+ core.Settings.Record AuthToken{}
+
+
+ core.Settings.User PasswordResetToken{}
+ core.Settings.Record PasswordResetToken{}
+
+
+ core.Settings.User EmailChangeToken{}
+ core.Settings.Record EmailChangeToken{}
+
+
+ core.Settings.User VerificationToken{}
+ core.Settings.Record VerificationToken{}
+
+
+
+- Marked as "Deprecated" and will be removed in v0.9+:
+ ```
+ core.Settings.EmailAuth{}
+ core.EmailAuthConfig{}
+ schema.FieldTypeUser
+ schema.UserOptions{}
+ ```
+
+- The second argument of `apis.StaticDirectoryHandler(fileSystem, enableIndexFallback)` now is used to enable/disable index.html forwarding on missing file (eg. in case of SPA).
diff --git a/CHANGELOG_16_22.md b/CHANGELOG_16_22.md
new file mode 100644
index 0000000000..0e1f30be2b
--- /dev/null
+++ b/CHANGELOG_16_22.md
@@ -0,0 +1,1344 @@
+> List with changes from v0.16.x to v0.22.x.
+> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
+---
+
+## v0.22.56
+
+- (_Backported from v0.40.5_) Bumped the min Go GitHub action version to 1.27.2 as it includes several [`net/http` security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.2).
+ _One of the fixes is for a DoS that could potentially affect PocketBase applications and it is advised to update._
+
+
+## v0.22.55
+
+- (_Backported from v0.40.3_) Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
+
+- (_Backported from v0.40.3_) Fixed nested cascade delete of self-referenced relation records.
+
+- (_Backported from v0.40.3_) Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
+
+
+## v0.22.54
+
+- (_Backported from v0.40.2_) Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
+
+- (_Backported from v0.40.2_) Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
+
+- (_Backported from v0.40.2_) Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
+
+
+## v0.22.53
+
+- Workaround `encoding/json` infinite recursion when used with Go 1.27 ([#7808](https://github.com/pocketbase/pocketbase/issues/7808)).
+
+
+## v0.22.52
+
+- (_Backported from v0.39.11_) Bumped `golang.org/x/*` deps and the min Go GitHub action version to 1.26.6 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.6).
+
+
+## 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
+
+- (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
+ _In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
+
+- (_Backported from v0.37.4_) ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662)).
+
+
+## v0.22.41
+
+- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
+
+- (_Backported from v0.36.9_) Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
+
+- (_Backported from v0.36.9_) Bumped min Go GitHub action version to 1.26.2 because it comes with several [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
+
+
+## v0.22.40
+
+- (_Backported from v0.36.7_) Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
+ _⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
+
+- (_Backported from v0.36.7_) Updated other minor Go and npm deps.
+ _The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
+
+
+## v0.22.39
+
+- (_Backported from v0.36.6_) Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
+
+
+## v0.22.38
+
+- (_Backported from v0.36.0_) Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
+
+
+## v0.22.37
+
+- (_Backported from v0.34.1_) - Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353)).
+
+- (_Backported from v0.34.1_) Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
+ _The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
+
+
+## v0.22.36
+
+- (_Backported from v0.30.2_) Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
+
+
+## v0.22.35
+
+- (_Backported from v0.29.2_) Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
+
+
+## v0.22.34
+
+- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
+
+
+## v0.22.33
+
+- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
+
+
+## v0.22.32
+
+- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
+
+
+## v0.22.31
+
+- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
+
+
+## v0.22.30
+
+- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
+
+- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
+
+
+## v0.22.29
+
+- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
+ _PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
+
+
+## v0.22.28
+
+- (_Backported from v0.23.10_) Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
+
+- (_Backported from v0.23.10_) Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
+ _If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
+
+
+## v0.22.27
+
+- Instead of unregistering the realtime clients, we now just unset their auth state on delete of the related auth record so that the clients can receive the `delete` event ([#5898](https://github.com/pocketbase/pocketbase/issues/5898)).
+
+
+## v0.22.26
+
+- (_Backported from v0.23.0-rc_) Added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data.
+
+
+## v0.22.25
+
+- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
+
+- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibility of eventual side-channel attacks.
+
+
+## v0.22.24
+
+- Delete new uploaded record files in case of DB persist error ([#5845](https://github.com/pocketbase/pocketbase/issues/5845)).
+
+
+## v0.22.23
+
+- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
+
+- _(Backported from v0.23.0-rc)_ Registered a default `http.Server.ErrorLog` handler to report general server connection errors as app Debug level logs (e.g. invalid TLS handshakes caused by bots trying to access your server via its IP or other similar errors).
+
+- Other minor fixes (updated npm dev deps to fix the vulnerabilities warning, added more user friendly realtime topic length error, regenerated JSVM types, etc.)
+
+
+## v0.22.22
+
+- Added deprecation log in case Instagram OAuth2 is used (_related to [#5652](https://github.com/pocketbase/pocketbase/discussions/5652)_).
+
+- Added `update` command warning to prevent unnecessary downloading PocketBase v0.23.0 since it will contain breaking changes.
+
+- Added global JSVM `toString()` helper (_successor of `readerToString()`_) to stringify any value (bool, number, multi-byte array, io.Reader, etc.).
+ _`readerToString` is still available but it is marked as deprecated. You can also use `toString` as replacement for of `String.fromCharCode` to properly stringify multi-byte unicode characters like emojis._
+ ```js
+ decodeURIComponent(escape(String.fromCharCode(...bytes))) -> toString(bytes)
+ ```
+
+- Updated `aws-sdk-go-v2` and removed deprecated `WithEndpointResolverWithOptions`.
+
+- Backported some of the v0.23.0-rc form validators, fixes and tests.
+
+- Bumped GitHub action min Go version and dependencies.
+
+
+## v0.22.21
+
+- Lock the logs database during backup to prevent `database disk image is malformed` errors in case there is a log write running in the background ([#5541](https://github.com/pocketbase/pocketbase/discussions/5541)).
+
+
+## v0.22.20
+
+- Fixed the Admin UI `isEmpty` check to allow submitting zero uuid, datetime and date strings ([#5398](https://github.com/pocketbase/pocketbase/issues/5398)).
+
+- Updated goja and the other Go deps.
+
+
+## v0.22.19
+
+- Added additional parsing for the Apple OAuth2 `user` token response field to attempt returning the name of the authenticated user ([#5074](https://github.com/pocketbase/pocketbase/discussions/5074#discussioncomment-10317207)).
+ _Note that Apple only returns the user object the first time the user authorizes the app (at least based on [their docs](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292))._
+
+
+## v0.22.18
+
+- Improved files delete performance when using the local filesystem by adding a trailing slash to the `DeletePrefix` call to ensure that the list iterator will start "walking" from the prefix directory and not from its parent ([#5246](https://github.com/pocketbase/pocketbase/discussions/5246)).
+
+- Updated Go deps.
+
+
+## v0.22.17
+
+- Updated the `editor` field to use the latest TinyMCE 6.8.4 and enabled `convert_unsafe_embeds:true` by default per the security advisories.
+ _The Admin UI shouldn't be affected by the older TinyMCE because we don't use directly the vulnerable options/plugins and we have a default CSP, but it is recommended to update even just for silencing the CI/CD warnings._
+
+- Disabled mouse selection when changing the sidebar width.
+ _This should also fix the reported Firefox issue when the sidebar width "resets" on mouse release out of the page window._
+
+- Other minor improvements (updated the logs delete check and tests, normalized internal errors formatting, updated Go deps, etc.)
+
+
+## v0.22.16
+
+- Fixed the days calculation for triggering old logs deletion ([#5179](https://github.com/pocketbase/pocketbase/pull/5179); thanks @nehmeroumani).
+ _Note that the previous versions correctly delete only the logs older than the configured setting but due to the typo the delete query is invoked unnecessary on each logs batch write._
+
+
+## v0.22.15
+
+- Added mutex to `tests.TestMailer()` to minimize tests data race warnings ([#5157](https://github.com/pocketbase/pocketbase/issues/5157)).
+
+- Updated goja and the other Go dependencies.
+
+- Bumped the min Go version in the GitHub release action to Go 1.22.5 since it comes with [`net/http` security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.5).
+
+
+## v0.22.14
+
+- Added OAuth2 POST redirect support (in case of `response_mode=form_post`) to allow specifying scopes for the Apple OAuth2 integration.
+
+ Note 1: If you are using the "Manual code exchange" flow with Apple (aka. `authWithOAuth2Code()`), you need to either update your custom
+ redirect handler to accept POST requests OR if you want to keep the old behavior and don't need the Apple user's email - replace in the Apple authorization url `response_mode=form_post` back to `response_mode=query`.
+
+ Note 2: Existing users that have already logged in with Apple may need to revoke their access in order to see the email sharing options as shown in [this screenshot](https://github.com/pocketbase/pocketbase/discussions/5074#discussioncomment-9801855).
+ If you want to force the new consent screen you could register a new Apple OAuth2 app.
+
+- ⚠️ Fixed a security vulnerability related to the OAuth2 email autolinking (thanks to @dalurness for reporting it).
+
+ Just to be safe I've also published a [GitHub security advisory](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-m93w-4fxv-r35v) (_may take some time to show up in the related security databases_).
+
+ In order to be exploited you must have **both** OAuth2 and Password auth methods enabled.
+
+ A possible attack scenario could be:
+ - a malicious actor register with the targeted user's email (it is unverified)
+ - at some later point in time the targeted user stumble on your app and decides to sign-up with OAuth2 (_this step could be also initiated by the attacker by sending an invite email to the targeted user_)
+ - on successful OAuth2 auth we search for an existing PocketBase user matching with the OAuth2 user's email and associate them
+ - because we haven't changed the password of the existing PocketBase user during the linking, the malicious actor has access to the targeted user account and will be able to login with the initially created email/password
+
+ To prevent this for happening we now reset the password for this specific case if the previously created user wasn't verified (an exception to this is if the linking is explicit/manual, aka. when you send `Authorization:TOKEN` with the OAuth2 auth call).
+
+ Additionally to warn users we now send an email alert in case the user has logged in with password but has at least one OAuth2 account linked. It looks something like:
+
+ _Hello,
+ Just to let you know that someone has logged in to your Acme account using a password while you already have OAuth2 GitLab auth linked.
+ If you have recently signed in with a password, you may disregard this email.
+ **If you don't recognize the above action, you should immediately change your Acme account password.**
+ Thanks,
+ Acme team_
+
+ The flow will be further improved with the [ongoing refactoring](https://github.com/pocketbase/pocketbase/discussions/4355) and we will start sending emails for "unrecognized device" logins (OTP and MFA is already implemented and will be available with the next v0.23.0 release in the near future).
+
+
+## v0.22.13
+
+- Fixed rules inconsistency for text literals when inside parenthesis ([#5017](https://github.com/pocketbase/pocketbase/issues/5017)).
+
+- Updated Go deps.
+
+
+## v0.22.12
+
+- Fixed calendar picker grid layout misalignment on Firefox ([#4865](https://github.com/pocketbase/pocketbase/issues/4865)).
+
+- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.3 since it comes with [some minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.3).
+
+
+## v0.22.11
+
+- Load the full record in the relation picker edit panel ([#4857](https://github.com/pocketbase/pocketbase/issues/4857)).
+
+
+## v0.22.10
+
+- Updated the uploaded filename normalization to take double extensions in consideration ([#4824](https://github.com/pocketbase/pocketbase/issues/4824))
+
+- Added Collection models cache to help speed up the common List and View requests execution with ~25%.
+ _This was extracted from the ongoing work on [#4355](https://github.com/pocketbase/pocketbase/discussions/4355) and there are many other small optimizations already implemented but they will have to wait for the refactoring to be finalized._
+
+
+## v0.22.9
+
+- Fixed Admin UI OAuth2 "Clear all fields" btn action to properly unset all form fields ([#4737](https://github.com/pocketbase/pocketbase/issues/4737)).
+
+
+## v0.22.8
+
+- Fixed '~' auto wildcard wrapping when the param has escaped `%` character ([#4704](https://github.com/pocketbase/pocketbase/discussions/4704)).
+
+- Other minor UI improvements (added `aria-expanded=true/false` to the dropdown triggers, added contrasting border around the default mail template btn style, etc.).
+
+- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.2 since it comes with [some `net/http` security and bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.2).
+
+
+## v0.22.7
+
+- Replaced the default `s3blob` driver with a trimmed vendored version to reduce the binary size with ~10MB.
+ _It can be further reduced with another ~10MB once we replace entirely the `aws-sdk-go-v2` dependency but I stumbled on some edge cases related to the headers signing and for now is on hold._
+
+- Other minor improvements (updated GitLab OAuth2 provider logo [#4650](https://github.com/pocketbase/pocketbase/pull/4650), normalized error messages, updated npm dependencies, etc.)
+
+
+## v0.22.6
+
+- Admin UI accessibility improvements:
+ - Fixed the dropdowns tab/enter/space keyboard navigation ([#4607](https://github.com/pocketbase/pocketbase/issues/4607)).
+ - Added `role`, `aria-label`, `aria-hidden` attributes to some of the elements in attempt to better assist screen readers.
+
+
+## v0.22.5
+
+- Minor test helpers fixes ([#4600](https://github.com/pocketbase/pocketbase/issues/4600)):
+ - Call the `OnTerminate` hook on `TestApp.Cleanup()`.
+ - Automatically run the DB migrations on initializing the test app with `tests.NewTestApp()`.
+
+- Added more elaborate warning message when restoring a backup explaining how the operation works.
+
+- Skip irregular files (symbolic links, sockets, etc.) when restoring a backup zip from the Admin UI or calling `archive.Extract(src, dst)` because they come with too many edge cases and ambiguities.
+
+ More details
+
+ This was initially reported as security issue (_thanks Harvey Spec_) but in the PocketBase context it is not something that can be exploited without an admin intervention and since the general expectations are that the PocketBase admins can do anything and they are the one who manage their server, this should be treated with the same diligence when using `scp`/`rsync`/`rclone`/etc. with untrusted file sources.
+
+ It is not possible (_or at least I'm not aware how to do that easily_) to perform virus/malicious content scanning on the uploaded backup archive files and some caution is always required when using the Admin UI or running shell commands, hence the backup-restore warning text.
+
+ **Or in other words, if someone sends you a file and tell you to upload it to your server (either as backup zip or manually via scp) obviously you shouldn't do that unless you really trust them.**
+
+ PocketBase is like any other regular application that you run on your server and there is no builtin "sandbox" for what the PocketBase process can execute. This is left to the developers to restrict on application or OS level depending on their needs. If you are self-hosting PocketBase you usually don't have to do that, but if you are offering PocketBase as a service and allow strangers to run their own PocketBase instances on your server then you'll need to implement the isolation mechanisms on your own.
+
+
+
+## v0.22.4
+
+- Removed conflicting styles causing the detailed codeblock log data preview to not visualize properly ([#4505](https://github.com/pocketbase/pocketbase/pull/4505)).
+
+- Minor JSVM improvements:
+ - Added `$filesystem.fileFromUrl(url, optSecTimeout)` helper.
+ - Implemented the `FormData` interface and added support for sending `multipart/form-data` requests with `$http.send()` ([#4544](https://github.com/pocketbase/pocketbase/discussions/4544)).
+
+
+## v0.22.3
+
+- Fixed the z-index of the current admin dropdown on Safari ([#4492](https://github.com/pocketbase/pocketbase/issues/4492)).
+
+- Fixed `OnAfterApiError` debug log `nil` error reference ([#4498](https://github.com/pocketbase/pocketbase/issues/4498)).
+
+- Added the field name as part of the `@request.data.someRelField.*` join to handle the case when a collection has 2 or more relation fields pointing to the same place ([#4500](https://github.com/pocketbase/pocketbase/issues/4500)).
+
+- Updated Go deps and bumped the min Go version in the GitHub release action to Go 1.22.1 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.22.1).
+
+
+## v0.22.2
+
+- Fixed a small regression introduced with v0.22.0 that was causing some missing unknown fields to always return an error instead of applying the specific `nullifyMisingField` resolver option to the query.
+
+
+## v0.22.1
+
+- Fixed Admin UI record and collection panels not reinitializing properly on browser back/forward navigation ([#4462](https://github.com/pocketbase/pocketbase/issues/4462)).
+
+- Initialize `RecordAuthWithOAuth2Event.IsNewRecord` for the `OnRecordBeforeAuthWithOAuth2Request` hook ([#4437](https://github.com/pocketbase/pocketbase/discussions/4437)).
+
+- Added error checks to the autogenerated Go migrations ([#4448](https://github.com/pocketbase/pocketbase/issues/4448)).
+
+
+## v0.22.0
+
+- Added Planning Center OAuth2 provider ([#4393](https://github.com/pocketbase/pocketbase/pull/4393); thanks @alxjsn).
+
+- Admin UI improvements:
+ - Autosync collection changes across multiple open browser tabs.
+ - Fixed vertical image popup preview scrolling.
+ - Added options to export a subset of collections.
+ - Added option to import a subset of collections without deleting the others ([#3403](https://github.com/pocketbase/pocketbase/issues/3403)).
+
+- Added support for back/indirect relation `filter`/`sort` (single and multiple).
+ The syntax to reference back relation fields is `yourCollection_via_yourRelField.*`.
+ ⚠️ To avoid excessive joins, the nested relations resolver is now limited to max 6 level depth (the same as `expand`).
+ _Note that in the future there will be also more advanced and granular options to specify a subset of the fields that are filterable/sortable._
+
+- Added support for multiple back/indirect relation `expand` and updated the keys to use the `_via_` reference syntax (`yourCollection_via_yourRelField`).
+ _To minimize the breaking changes, the old parenthesis reference syntax (`yourCollection(yourRelField)`) will still continue to work but it is soft-deprecated and there will be a console log reminding you to change it to the new one._
+
+- ⚠️ Collections and fields are no longer allowed to have `_via_` in their name to avoid collisions with the back/indirect relation reference syntax.
+
+- Added `jsvm.Config.OnInit` optional config function to allow registering custom Go bindings to the JSVM.
+
+- Added `@request.context` rule field that can be used to apply a different set of constraints based on the API rule execution context.
+ For example, to disallow user creation by an OAuth2 auth, you could set for the users Create API rule `@request.context != "oauth2"`.
+ The currently supported `@request.context` values are:
+ ```
+ default
+ realtime
+ protectedFile
+ oauth2
+ ```
+
+- Adjusted the `cron.Start()` to start the ticker at the `00` second of the cron interval ([#4394](https://github.com/pocketbase/pocketbase/discussions/4394)).
+ _Note that the cron format has only minute granularity and there is still no guarantee that the scheduled job will be always executed at the `00` second._
+
+- Fixed auto backups cron not reloading properly after app settings change ([#4431](https://github.com/pocketbase/pocketbase/discussions/4431)).
+
+- Upgraded to `aws-sdk-go-v2` and added special handling for GCS to workaround the previous [GCS headers signature issue](https://github.com/pocketbase/pocketbase/issues/2231) that we had with v2.
+ _This should also fix the SVG/JSON zero response when using Cloudflare R2 ([#4287](https://github.com/pocketbase/pocketbase/issues/4287#issuecomment-1925168142), [#2068](https://github.com/pocketbase/pocketbase/discussions/2068), [#2952](https://github.com/pocketbase/pocketbase/discussions/2952))._
+ _⚠️ If you are using S3 for uploaded files or backups, please verify that you have a green check in the Admin UI for your S3 configuration (I've tested the new version with GCS, MinIO, Cloudflare R2 and Wasabi)._
+
+- Added `:each` modifier support for `file` and `relation` type fields (_previously it was supported only for `select` type fields_).
+
+- Other minor improvements (updated the `ghupdate` plugin to use the configured executable name when printing to the console, fixed the error reporting of `admin update/delete` commands, etc.).
+
+
+## v0.21.3
+
+- Ignore the JS required validations for disabled OIDC providers ([#4322](https://github.com/pocketbase/pocketbase/issues/4322)).
+
+- Allow `HEAD` requests to the `/api/health` endpoint ([#4310](https://github.com/pocketbase/pocketbase/issues/4310)).
+
+- Fixed the `editor` field value when visualized inside the View collection preview panel.
+
+- Manually clear all TinyMCE events on editor removal (_workaround for [tinymce#9377](https://github.com/tinymce/tinymce/issues/9377)_).
+
+
+## v0.21.2
+
+- Fixed `@request.auth.*` initialization side-effect which caused the current authenticated user email to not being returned in the user auth response ([#2173](https://github.com/pocketbase/pocketbase/issues/2173#issuecomment-1932332038)).
+ _The current authenticated user email should be accessible always no matter of the `emailVisibility` state._
+
+- Fixed `RecordUpsert.RemoveFiles` godoc example.
+
+- Bumped to `NumCPU()+2` the `thumbGenSem` limit as some users reported that it was too restrictive.
+
+
+## v0.21.1
+
+- Small fix for the Admin UI related to the _Settings > Sync_ menu not being visible even when the "Hide controls" toggle is off.
+
+
+## v0.21.0
+
+- Added Bitbucket OAuth2 provider ([#3948](https://github.com/pocketbase/pocketbase/pull/3948); thanks @aabajyan).
+
+- Mark user as verified on confirm password reset ([#4066](https://github.com/pocketbase/pocketbase/issues/4066)).
+ _If the user email has changed after issuing the reset token (eg. updated by an admin), then the `verified` user state remains unchanged._
+
+- Added support for loading a serialized json payload for `multipart/form-data` requests using the special `@jsonPayload` key.
+ _This is intended to be used primarily by the SDKs to resolve [js-sdk#274](https://github.com/pocketbase/js-sdk/issues/274)._
+
+- Added graceful OAuth2 redirect error handling ([#4177](https://github.com/pocketbase/pocketbase/issues/4177)).
+ _Previously on redirect error we were returning directly a standard json error response. Now on redirect error we'll redirect to a generic OAuth2 failure screen (similar to the success one) and will attempt to auto close the OAuth2 popup._
+ _The SDKs are also updated to handle the OAuth2 redirect error and it will be returned as Promise rejection of the `authWithOAuth2()` call._
+
+- Exposed `$apis.gzip()` and `$apis.bodyLimit(bytes)` middlewares to the JSVM.
+
+- Added `TestMailer.SentMessages` field that holds all sent test app emails until cleanup.
+
+- Optimized the cascade delete of records with multiple `relation` fields.
+
+- Updated the `serve` and `admin` commands error reporting.
+
+- Minor Admin UI improvements (reduced the min table row height, added option to duplicate fields, added new TinyMCE codesample plugin languages, hide the collection sync settings when the `Settings.Meta.HideControls` is enabled, etc.)
+
+
+## v0.20.7
+
+- Fixed the Admin UI auto indexes update when renaming fields with a common prefix ([#4160](https://github.com/pocketbase/pocketbase/issues/4160)).
+
+
+## v0.20.6
+
+- Fixed JSVM types generation for functions with omitted arg types ([#4145](https://github.com/pocketbase/pocketbase/issues/4145)).
+
+- Updated Go deps.
+
+
+## v0.20.5
+
+- Minor CSS fix for the Admin UI to prevent the searchbar within a popup from expanding too much and pushing the controls out of the visible area ([#4079](https://github.com/pocketbase/pocketbase/issues/4079#issuecomment-1876994116)).
+
+
+## v0.20.4
+
+- Small fix for a regression introduced with the recent `json` field changes that was causing View collection column expressions recognized as `json` to fail to resolve ([#4072](https://github.com/pocketbase/pocketbase/issues/4072)).
+
+
+## v0.20.3
+
+- Fixed the `json` field query comparisons to work correctly with plain JSON values like `null`, `bool` `number`, etc. ([#4068](https://github.com/pocketbase/pocketbase/issues/4068)).
+ Since there are plans in the future to allow custom SQLite builds and also in some situations it may be useful to be able to distinguish `NULL` from `''`,
+ for the `json` fields (and for any other future non-standard field) we no longer apply `COALESCE` by default, aka.:
+ ```
+ Dataset:
+ 1) data: json(null)
+ 2) data: json('')
+
+ For the filter "data = null" only 1) will resolve to TRUE.
+ For the filter "data = ''" only 2) will resolve to TRUE.
+ ```
+
+- Minor Go tests improvements
+ - Sorted the record cascade delete references to ensure that the delete operation will preserve the order of the fired events when running the tests.
+ - Marked some of the tests as safe for parallel execution to speed up a little the GitHub action build times.
+
+
+## v0.20.2
+
+- Added `sleep(milliseconds)` JSVM binding.
+ _It works the same way as Go `time.Sleep()`, aka. it pauses the goroutine where the JSVM code is running._
+
+- Fixed multi-line text paste in the Admin UI search bar ([#4022](https://github.com/pocketbase/pocketbase/discussions/4022)).
+
+- Fixed the monospace font loading in the Admin UI.
+
+- Fixed various reported docs and code comment typos.
+
+
+## v0.20.1
+
+- Added `--dev` flag and its accompanying `app.IsDev()` method (_in place of the previously removed `--debug`_) to assist during development ([#3918](https://github.com/pocketbase/pocketbase/discussions/3918)).
+ The `--dev` flag prints in the console "everything" and more specifically:
+ - the data DB SQL statements
+ - all `app.Logger().*` logs (debug, info, warning, error, etc.), no matter of the logs persistence settings in the Admin UI
+
+- Minor Admin UI fixes:
+ - Fixed the log `error` label text wrapping.
+ - Added the log `referer` (_when it is from a different source_) and `details` labels in the logs listing.
+ - Removed the blank current time entry from the logs chart because it was causing confusion when used with custom time ranges.
+ - Updated the SQL syntax highlighter and keywords autocompletion in the Admin UI to recognize `CAST(x as bool)` expressions.
+
+- Replaced the default API tests timeout with a new `ApiScenario.Timeout` option ([#3930](https://github.com/pocketbase/pocketbase/issues/3930)).
+ A negative or zero value means no tests timeout.
+ If a single API test takes more than 3s to complete it will have a log message visible when the test fails or when `go test -v` flag is used.
+
+- Added timestamp at the beginning of the generated JSVM types file to avoid creating it every time with the app startup.
+
+
+## v0.20.0
+
+- Added `expand`, `filter`, `fields`, custom query and headers parameters support for the realtime subscriptions.
+ _Requires JS SDK v0.20.0+ or Dart SDK v0.17.0+._
+
+ ```js
+ // JS SDK v0.20.0
+ pb.collection("example").subscribe("*", (e) => {
+ ...
+ }, {
+ expand: "someRelField",
+ filter: "status = 'active'",
+ fields: "id,expand.someRelField.*:excerpt(100)",
+ })
+ ```
+
+ ```dart
+ // Dart SDK v0.17.0
+ pb.collection("example").subscribe("*", (e) {
+ ...
+ },
+ expand: "someRelField",
+ filter: "status = 'active'",
+ fields: "id,expand.someRelField.*:excerpt(100)",
+ )
+ ```
+
+- Generalized the logs to allow any kind of application logs, not just requests.
+
+ The new `app.Logger()` implements the standard [`log/slog` interfaces](https://pkg.go.dev/log/slog) available with Go 1.21.
+ ```
+ // Go: https://pocketbase.io/docs/go-logging/
+ app.Logger().Info("Example message", "total", 123, "details", "lorem ipsum...")
+
+ // JS: https://pocketbase.io/docs/js-logging/
+ $app.logger().info("Example message", "total", 123, "details", "lorem ipsum...")
+ ```
+
+ For better performance and to minimize blocking on hot paths, logs are currently written with
+ debounce and on batches:
+ - 3 seconds after the last debounced log write
+ - when the batch threshold is reached (currently 200)
+ - right before app termination to attempt saving everything from the existing logs queue
+
+ Some notable log related changes:
+
+ - ⚠️ Bumped the minimum required Go version to 1.21.
+
+ - ⚠️ Removed `_requests` table in favor of the generalized `_logs`.
+ _Note that existing logs will be deleted!_
+
+ - ⚠️ Renamed the following `Dao` log methods:
+ ```go
+ Dao.RequestQuery(...) -> Dao.LogQuery(...)
+ Dao.FindRequestById(...) -> Dao.FindLogById(...)
+ Dao.RequestsStats(...) -> Dao.LogsStats(...)
+ Dao.DeleteOldRequests(...) -> Dao.DeleteOldLogs(...)
+ Dao.SaveRequest(...) -> Dao.SaveLog(...)
+ ```
+ - ⚠️ Removed `app.IsDebug()` and the `--debug` flag.
+ This was done to avoid the confusion with the new logger and its debug severity level.
+ If you want to store debug logs you can set `-4` as min log level from the Admin UI.
+
+ - Refactored Admin UI Logs:
+ - Added new logs table listing.
+ - Added log settings option to toggle the IP logging for the activity logger.
+ - Added log settings option to specify a minimum log level.
+ - Added controls to export individual or bulk selected logs as json.
+ - Other minor improvements and fixes.
+
+- Added new `filesystem/System.Copy(src, dest)` method to copy existing files from one location to another.
+ _This is usually useful when duplicating records with `file` field(s) programmatically._
+
+- Added `filesystem.NewFileFromUrl(ctx, url)` helper method to construct a `*filesystem.BytesReader` file from the specified url.
+
+- OAuth2 related additions:
+
+ - Added new `PKCE()` and `SetPKCE(enable)` OAuth2 methods to indicate whether the PKCE flow is supported or not.
+ _The PKCE value is currently configurable from the UI only for the OIDC providers._
+ _This was added to accommodate OIDC providers that may throw an error if unsupported PKCE params are submitted with the auth request (eg. LinkedIn; see [#3799](https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312))._
+
+ - Added new `displayName` field for each `listAuthMethods()` OAuth2 provider item.
+ _The value of the `displayName` property is currently configurable from the UI only for the OIDC providers._
+
+ - Added `expiry` field to the OAuth2 user response containing the _optional_ expiration time of the OAuth2 access token ([#3617](https://github.com/pocketbase/pocketbase/discussions/3617)).
+
+ - Allow a single OAuth2 user to be used for authentication in multiple auth collection.
+ _⚠️ Because now you can have more than one external provider with `collectionId-provider-providerId` pair, `Dao.FindExternalAuthByProvider(provider, providerId)` method was removed in favour of the more generic `Dao.FindFirstExternalAuthByExpr(expr)`._
+
+- Added `onlyVerified` auth collection option to globally disallow authentication requests for unverified users.
+
+- Added support for single line comments (ex. `// your comment`) in the API rules and filter expressions.
+
+- Added support for specifying a collection alias in `@collection.someCollection:alias.*`.
+
+- Soft-deprecated and renamed `app.Cache()` with `app.Store()`.
+
+- Minor JSVM updates and fixes:
+
+ - Updated `$security.parseUnverifiedJWT(token)` and `$security.parseJWT(token, key)` to return the token payload result as plain object.
+
+ - Added `$apis.requireGuestOnly()` middleware JSVM binding ([#3896](https://github.com/pocketbase/pocketbase/issues/3896)).
+
+- Use `IS NOT` instead of `!=` as not-equal SQL query operator to handle the cases when comparing with nullable columns or expressions (eg. `json_extract` over `json` field).
+ _Based on my local dataset I wasn't able to find a significant difference in the performance between the 2 operators, but if you stumble on a query that you think may be affected negatively by this, please report it and I'll test it further._
+
+- Added `MaxSize` `json` field option to prevent storing large json data in the db ([#3790](https://github.com/pocketbase/pocketbase/issues/3790)).
+ _Existing `json` fields are updated with a system migration to have a ~2MB size limit (it can be adjusted from the Admin UI)._
+
+- Fixed negative string number normalization support for the `json` field type.
+
+- Trigger the `app.OnTerminate()` hook on `app.Restart()` call.
+ _A new bool `IsRestart` field was also added to the `core.TerminateEvent` event._
+
+- Fixed graceful shutdown handling and speed up a little the app termination time.
+
+- Limit the concurrent thumbs generation to avoid high CPU and memory usage in spiky scenarios ([#3794](https://github.com/pocketbase/pocketbase/pull/3794); thanks @t-muehlberger).
+ _Currently the max concurrent thumbs generation processes are limited to "total of logical process CPUs + 1"._
+ _This is arbitrary chosen and may change in the future depending on the users feedback and usage patterns._
+ _If you are experiencing OOM errors during large image thumb generations, especially in container environment, you can try defining the `GOMEMLIMIT=500MiB` env variable before starting the executable._
+
+- Slightly speed up (~10%) the thumbs generation by changing from cubic (`CatmullRom`) to bilinear (`Linear`) resampling filter (_the quality difference is very little_).
+
+- Added a default red colored Stderr output in case of a console command error.
+ _You can now also silence individually custom commands errors using the `cobra.Command.SilenceErrors` field._
+
+- Fixed links formatting in the autogenerated html->text mail body.
+
+- Removed incorrectly imported empty `local('')` font-face declarations.
+
+
+## v0.19.4
+
+- Fixed TinyMCE source code viewer textarea styles ([#3715](https://github.com/pocketbase/pocketbase/issues/3715)).
+
+- Fixed `text` field min/max validators to properly count multi-byte characters ([#3735](https://github.com/pocketbase/pocketbase/issues/3735)).
+
+- Allowed hyphens in `username` ([#3697](https://github.com/pocketbase/pocketbase/issues/3697)).
+ _More control over the system fields settings will be available in the future._
+
+- Updated the JSVM generated types to use directly the value type instead of `* | undefined` union in functions/methods return declarations.
+
+
+## v0.19.3
+
+- Added the release notes to the console output of `./pocketbase update` ([#3685](https://github.com/pocketbase/pocketbase/discussions/3685)).
+
+- Added missing documentation for the JSVM `$mails.*` bindings.
+
+- Relaxed the OAuth2 redirect url validation to allow any string value ([#3689](https://github.com/pocketbase/pocketbase/pull/3689); thanks @sergeypdev).
+ _Note that the redirect url format is still bound to the accepted values by the specific OAuth2 provider._
+
+
+## v0.19.2
+
+- Updated the JSVM generated types ([#3627](https://github.com/pocketbase/pocketbase/issues/3627), [#3662](https://github.com/pocketbase/pocketbase/issues/3662)).
+
+
+## v0.19.1
+
+- Fixed `tokenizer.Scan()/ScanAll()` to ignore the separators from the default trim cutset.
+ An option to return also the empty found tokens was also added via `Tokenizer.KeepEmptyTokens(true)`.
+ _This should fix the parsing of whitespace characters around view query column names when no quotes are used ([#3616](https://github.com/pocketbase/pocketbase/discussions/3616#discussioncomment-7398564))._
+
+- Fixed the `:excerpt(max, withEllipsis?)` `fields` query param modifier to properly add space to the generated text fragment after block tags.
+
+
+## v0.19.0
+
+- Added Patreon OAuth2 provider ([#3323](https://github.com/pocketbase/pocketbase/pull/3323); thanks @ghostdevv).
+
+- Added mailcow OAuth2 provider ([#3364](https://github.com/pocketbase/pocketbase/pull/3364); thanks @thisni1s).
+
+- Added support for `:excerpt(max, withEllipsis?)` `fields` modifier that will return a short plain text version of any string value (html tags are stripped).
+ This could be used to minimize the downloaded json data when listing records with large `editor` html values.
+ ```js
+ await pb.collection("example").getList(1, 20, {
+ "fields": "*,description:excerpt(100)"
+ })
+ ```
+
+- Several Admin UI improvements:
+ - Count the total records separately to speed up the query execution for large datasets ([#3344](https://github.com/pocketbase/pocketbase/issues/3344)).
+ - Enclosed the listing scrolling area within the table so that the horizontal scrollbar and table header are always reachable ([#2505](https://github.com/pocketbase/pocketbase/issues/2505)).
+ - Allowed opening the record preview/update form via direct URL ([#2682](https://github.com/pocketbase/pocketbase/discussions/2682)).
+ - Reintroduced the local `date` field tooltip on hover.
+ - Speed up the listing loading times for records with large `editor` field values by initially fetching only a partial of the records data (the complete record data is loaded on record preview/update).
+ - Added "Media library" (collection images picker) support for the TinyMCE `editor` field.
+ - Added support to "pin" collections in the sidebar.
+ - Added support to manually resize the collections sidebar.
+ - More clear "Nonempty" field label style.
+ - Removed the legacy `.woff` and `.ttf` fonts and keep only `.woff2`.
+
+- Removed the explicit `Content-Type` charset from the realtime response due to compatibility issues with IIS ([#3461](https://github.com/pocketbase/pocketbase/issues/3461)).
+ _The `Connection:keep-alive` realtime response header was also removed as it is not really used with HTTP2 anyway._
+
+- Added new JSVM bindings:
+ - `new Cookie({ ... })` constructor for creating `*http.Cookie` equivalent value.
+ - `new SubscriptionMessage({ ... })` constructor for creating a custom realtime subscription payload.
+ - Soft-deprecated `$os.exec()` in favour of `$os.cmd()` to make it more clear that the call only prepares the command and doesn't execute it.
+
+- ⚠️ Bumped the min required Go version to 1.19.
+
+
+## v0.18.10
+
+- Added global `raw` template function to allow outputting raw/verbatim HTML content in the JSVM templates ([#3476](https://github.com/pocketbase/pocketbase/discussions/3476)).
+ ```
+ {{.description|raw}}
+ ```
+
+- Trimmed view query semicolon and allowed single quotes for column aliases ([#3450](https://github.com/pocketbase/pocketbase/issues/3450#issuecomment-1748044641)).
+ _Single quotes are usually [not a valid identifier quote characters](https://www.sqlite.org/lang_keywords.html), but for resilience and compatibility reasons SQLite allows them in some contexts where only an identifier is expected._
+
+- Bumped the GitHub action to use [min Go 1.21.2](https://github.com/golang/go/issues?q=milestone%3AGo1.21.2) (_the fixed issues are not critical as they are mostly related to the compiler/build tools_).
+
+
+## v0.18.9
+
+- Fixed empty thumbs directories not getting deleted on Windows after deleting a record img file ([#3382](https://github.com/pocketbase/pocketbase/issues/3382)).
+
+- Updated the generated JSVM typings to silent the TS warnings when trying to access a field/method in a Go->TS interface.
+
+
+## v0.18.8
+
+- Minor fix for the View collections API Preview and Admin UI listings incorrectly showing the `created` and `updated` fields as `N/A` when the view query doesn't have them.
+
+
+## v0.18.7
+
+- Fixed JS error in the Admin UI when listing records with invalid `relation` field value ([#3372](https://github.com/pocketbase/pocketbase/issues/3372)).
+ _This could happen usually only during custom SQL import scripts or when directly modifying the record field value without data validations._
+
+- Updated Go deps and the generated JSVM types.
+
+
+## v0.18.6
+
+- Return the response headers and cookies in the `$http.send()` result ([#3310](https://github.com/pocketbase/pocketbase/discussions/3310)).
+
+- Added more descriptive internal error message for missing user/admin email on password reset requests.
+
+- Updated Go deps.
+
+
+## v0.18.5
+
+- Fixed minor Admin UI JS error in the auth collection options panel introduced with the change from v0.18.4.
+
+
+## v0.18.4
+
+- Added escape character (`\`) support in the Admin UI to allow using `select` field values with comma ([#2197](https://github.com/pocketbase/pocketbase/discussions/2197)).
+
+
+## v0.18.3
+
+- Exposed a global JSVM `readerToString(reader)` helper function to allow reading Go `io.Reader` values ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)).
+
+- Bumped the GitHub action to use [min Go 1.21.1](https://github.com/golang/go/issues?q=milestone%3AGo1.21.1+label%3ACherryPickApproved) for the prebuilt executable since it contains some minor `html/template` and `net/http` security fixes.
+
+
+## v0.18.2
+
+- Prevent breaking the record form in the Admin UI in case the browser's localStorage quota has been exceeded when uploading or storing large `editor` values ([#3265](https://github.com/pocketbase/pocketbase/issues/3265)).
+
+- Updated docs and missing JSVM typings.
+
+- Exposed additional crypto primitives under the `$security.*` JSVM namespace ([#3273](https://github.com/pocketbase/pocketbase/discussions/3273)):
+ ```js
+ // HMAC with SHA256
+ $security.hs256("hello", "secret")
+
+ // HMAC with SHA512
+ $security.hs512("hello", "secret")
+
+ // compare 2 strings with a constant time
+ $security.equal(hash1, hash2)
+ ```
+
+
+## v0.18.1
+
+- Excluded the local temp dir from the backups ([#3261](https://github.com/pocketbase/pocketbase/issues/3261)).
+
+
+## v0.18.0
+
+- Simplified the `serve` command to accept domain name(s) as argument to reduce any additional manual hosts setup that sometimes previously was needed when deploying on production ([#3190](https://github.com/pocketbase/pocketbase/discussions/3190)).
+ ```sh
+ ./pocketbase serve yourdomain.com
+ ```
+
+- Added `fields` wildcard (`*`) support.
+
+- Added option to upload a backup file from the Admin UI ([#2599](https://github.com/pocketbase/pocketbase/issues/2599)).
+
+- Registered a custom Deflate compressor to speedup (_nearly 2-3x_) the backups generation for the sake of a small zip size increase.
+ _Based on several local tests, `pb_data` of ~500MB (from which ~350MB+ are several hundred small files) results in a ~280MB zip generated for ~11s (previously it resulted in ~250MB zip but for ~35s)._
+
+- Added the application name as part of the autogenerated backup name for easier identification ([#3066](https://github.com/pocketbase/pocketbase/issues/3066)).
+
+- Added new `SmtpConfig.LocalName` option to specify a custom domain name (or IP address) for the initial EHLO/HELO exchange ([#3097](https://github.com/pocketbase/pocketbase/discussions/3097)).
+ _This is usually required for verification purposes only by some SMTP providers, such as on-premise [Gmail SMTP-relay](https://support.google.com/a/answer/2956491)._
+
+- Added `NoDecimal` `number` field option.
+
+- `editor` field improvements:
+ - Added new "Strip urls domain" option to allow controlling the default TinyMCE urls behavior (_default to `false` for new content_).
+ - Normalized pasted text while still preserving links, lists, tables, etc. formatting ([#3257](https://github.com/pocketbase/pocketbase/issues/3257)).
+
+- Added option to auto generate admin and auth record passwords from the Admin UI.
+
+- Added JSON validation and syntax highlight for the `json` field in the Admin UI ([#3191](https://github.com/pocketbase/pocketbase/issues/3191)).
+
+- Added datetime filter macros:
+ ```
+ // all macros are UTC based
+ @second - @now second number (0-59)
+ @minute - @now minute number (0-59)
+ @hour - @now hour number (0-23)
+ @weekday - @now weekday number (0-6)
+ @day - @now day number
+ @month - @now month number
+ @year - @now year number
+ @todayStart - beginning of the current day as datetime string
+ @todayEnd - end of the current day as datetime string
+ @monthStart - beginning of the current month as datetime string
+ @monthEnd - end of the current month as datetime string
+ @yearStart - beginning of the current year as datetime string
+ @yearEnd - end of the current year as datetime string
+ ```
+
+- Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)):
+ ```
+ @yearly - "0 0 1 1 *"
+ @annually - "0 0 1 1 *"
+ @monthly - "0 0 1 * *"
+ @weekly - "0 0 * * 0"
+ @daily - "0 0 * * *"
+ @midnight - "0 0 * * *"
+ @hourly - "0 * * * *"
+ ```
+
+- ⚠️ Added offset argument `Dao.FindRecordsByFilter(collection, filter, sort, limit, offset, [params...])`.
+ _If you don't need an offset, you can set it to `0`._
+
+- To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input.
+ The placeholders are in the same format as when binding regular SQL parameters.
+ ```go
+ // unsanitized and untrusted filter variables
+ status := "..."
+ author := "..."
+
+ app.Dao().FindFirstRecordByFilter("articles", "status={:status} && author={:author}", dbx.Params{
+ "status": status,
+ "author": author,
+ })
+
+ app.Dao().FindRecordsByFilter("articles", "status={:status} && author={:author}", "-created", 10, 0, dbx.Params{
+ "status": status,
+ "author": author,
+ })
+ ```
+
+- Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions.
+
+- Added JSVM helper crypto primitives under the `$security.*` namespace:
+ ```js
+ $security.md5(text)
+ $security.sha256(text)
+ $security.sha512(text)
+ ```
+
+- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
+
+- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successful email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
+
+- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
+
+- ⚠️ Changes to `tests.ApiScenario` struct:
+
+ - The `ApiScenario.AfterTestFunc` now receive as 3rd argument `*http.Response` pointer instead of `*echo.Echo` as the latter is not really useful in this context.
+ ```go
+ // old
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo)
+
+ // new
+ AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response)
+ ```
+
+ - The `ApiScenario.TestAppFactory` now accept the test instance as argument and no longer expect an error as return result ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025#discussioncomment-6592272)).
+ ```go
+ // old
+ TestAppFactory: func() (*tests.TestApp, error)
+
+ // new
+ TestAppFactory: func(t *testing.T) *tests.TestApp
+ ```
+ _Returning a `nil` app instance from the factory results in test failure. You can enforce a custom test failure by calling `t.Fatal(err)` inside the factory._
+
+- Bumped the min required TLS version to 1.2 in order to improve the cert reputation score.
+
+- Reduced the default JSVM prewarmed pool size to 25 to reduce the initial memory consumptions (_you can manually adjust the pool size with `--hooksPool=50` if you need to, but the default should suffice for most cases_).
+
+- Update `gocloud.dev` dependency to v0.34 and explicitly set the new `NoTempDir` fileblob option to prevent the cross-device link error introduced with v0.33.
+
+- Other minor Admin UI and docs improvements.
+
+
+## v0.17.7
+
+- Fixed the autogenerated `down` migrations to properly revert the old collection rules in case a change was made in `up` ([#3192](https://github.com/pocketbase/pocketbase/pull/3192); thanks @impact-merlinmarek).
+ _Existing `down` migrations can't be fixed but that should be ok as usually the `down` migrations are rarely used against prod environments since they can cause data loss and, while not ideal, the previous old behavior of always setting the rules to `null/nil` is safer than not updating the rules at all._
+
+- Updated some Go deps.
+
+
+## v0.17.6
+
+- Fixed JSVM `require()` file path error when using Windows-style path delimiters ([#3163](https://github.com/pocketbase/pocketbase/issues/3163#issuecomment-1685034438)).
+
+
+## v0.17.5
+
+- Added quotes around the wrapped view query columns introduced with v0.17.4.
+
+
+## v0.17.4
+
+- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)).
+ _With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._
+
+- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)).
+
+- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev).
+
+- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)).
+
+- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it.
+
+
+## v0.17.3
+
+- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)).
+
+- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)).
+
+- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`.
+
+- Enabled lazy loading for the Admin UI thumb images.
+
+
+## v0.17.2
+
+- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })`
+ to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)).
+ The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
+
+- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)).
+
+- Fixed `apis.RequestData` deprecation log note ([#3068](https://github.com/pocketbase/pocketbase/pull/3068); thanks @gungjodi).
+
+
+## v0.17.1
+
+- Use relative path when redirecting to the OAuth2 providers page in the Admin UI to support subpath deployments ([#3026](https://github.com/pocketbase/pocketbase/pull/3026); thanks @sonyarianto).
+
+- Manually trigger the `OnBeforeServe` hook for `tests.ApiScenario` ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025)).
+
+- Trigger the JSVM `cronAdd()` handler only on app `serve` to prevent unexpected (and eventually duplicated) cron handler calls when custom console commands are used ([#3024](https://github.com/pocketbase/pocketbase/discussions/3024#discussioncomment-6592703)).
+
+- The `console.log()` messages are now written to the `stdout` instead of `stderr`.
+
+
+## v0.17.0
+
+- New more detailed guides for using PocketBase as framework (both Go and JS).
+ _If you find any typos or issues with the docs please report them in https://github.com/pocketbase/site._
+
+- Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja).
+ They are available by default with the prebuilt executable if you create `*.pb.js` file(s) in the `pb_hooks` directory.
+ Lower your expectations because the integration comes with some limitations. For more details please check the [Extend with JavaScript](https://pocketbase.io/docs/js-overview/) guide.
+ Optionally, you can also enable the JS app hooks as part of a custom Go build for dynamic scripting but you need to register the `jsvm` plugin manually:
+ ```go
+ jsvm.MustRegister(app core.App, config jsvm.Config{})
+ ```
+
+- Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta).
+
+- Added VK OAuth2 provider ([#2533](https://github.com/pocketbase/pocketbase/pull/2533); thanks @imperatrona).
+
+- Added Yandex OAuth2 provider ([#2762](https://github.com/pocketbase/pocketbase/pull/2762); thanks @imperatrona).
+
+- Added new fields to `core.ServeEvent`:
+ ```go
+ type ServeEvent struct {
+ App App
+ Router *echo.Echo
+ // new fields
+ Server *http.Server // allows adjusting the HTTP server config (global timeouts, TLS options, etc.)
+ CertManager *autocert.Manager // allows adjusting the autocert options (cache dir, host policy, etc.)
+ }
+ ```
+
+- Added `record.ExpandedOne(rel)` and `record.ExpandedAll(rel)` helpers to retrieve casted single or multiple expand relations from the already loaded "expand" Record data.
+
+- Added rule and filter record `Dao` helpers:
+ ```go
+ app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
+ app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true")
+ app.Dao().CanAccessRecord(record, requestInfo, rule)
+ ```
+
+- Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks.
+
+- Use a default fetch function that will return all relations in case the `fetchFunc` argument of `Dao.ExpandRecord(record, expands, fetchFunc)` and `Dao.ExpandRecords(records, expands, fetchFunc)` is `nil`.
+
+- For convenience it is now possible to call `Dao.RecordQuery(collectionModelOrIdentifier)` with just the collection id or name.
+ In case an invalid collection id/name string is passed the query will be resolved with cancelled context error.
+
+- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`.
+
+- Added support for wrapped API errors (_in case Go 1.20+ is used with multiple wrapped errors, the first `apis.ApiError` takes precedence_).
+
+- Added `?download=1` file query parameter to the file serving endpoint to force the browser to always download the file and not show its preview.
+
+- Added new utility `github.com/pocketbase/pocketbase/tools/template` subpackage to assist with rendering HTML templates using the standard Go `html/template` and `text/template` syntax.
+
+- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)).
+
+- Soft-deprecated `security.NewToken()` in favor of `security.NewJWT()`.
+
+- `Hook.Add()` and `Hook.PreAdd` now returns a unique string identifier that could be used to remove the registered hook handler via `Hook.Remove(handlerId)`.
+
+- Changed the after* hooks to be called right before writing the user response, allowing users to return response errors from the after hooks.
+ There is also no longer need for returning explicitly `hook.StopPropagtion` when writing custom response body in a hook because we will skip the finalizer response body write if a response was already "committed".
+
+- ⚠️ Renamed `*Options{}` to `Config{}` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls:
+ ```go
+ old: pocketbase.NewWithConfig(config *pocketbase.Config) *pocketbase.PocketBase
+ new: pocketbase.NewWithConfig(config pocketbase.Config) *pocketbase.PocketBase
+
+ old: core.NewBaseApp(config *core.BaseAppConfig) *core.BaseApp
+ new: core.NewBaseApp(config core.BaseAppConfig) *core.BaseApp
+
+ old: apis.Serve(app core.App, options *apis.ServeOptions) error
+ new: apis.Serve(app core.App, config apis.ServeConfig) (*http.Server, error)
+
+ old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions)
+ new: jsvm.MustRegister(app core.App, config jsvm.Config)
+
+ old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options)
+ new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config)
+
+ old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options)
+ new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config)
+ ```
+
+- ⚠️ Changed the type of `subscriptions.Message.Data` from `string` to `[]byte` because `Data` usually is a json bytes slice anyway.
+
+- ⚠️ Renamed `models.RequestData` to `models.RequestInfo` and soft-deprecated `apis.RequestData(c)` in favor of `apis.RequestInfo(c)` to avoid the stuttering with the `Data` field.
+ _The old `apis.RequestData()` method still works to minimize the breaking changes but it is recommended to replace it with `apis.RequestInfo(c)`._
+
+- ⚠️ Changes to the List/Search APIs
+ - Added new query parameter `?skipTotal=1` to skip the `COUNT` query performed with the list/search actions ([#2965](https://github.com/pocketbase/pocketbase/discussions/2965)).
+ If `?skipTotal=1` is set, the response fields `totalItems` and `totalPages` will have `-1` value (this is to avoid having different JSON responses and to differentiate from the zero default).
+ With the latest JS SDK 0.16+ and Dart SDK v0.11+ versions `skipTotal=1` is set by default for the `getFirstListItem()` and `getFullList()` requests.
+
+ - The count and regular select statements also now executes concurrently, meaning that we no longer perform normalization over the `page` parameter and in case the user
+ request a page that doesn't exist (eg. `?page=99999999`) we'll return empty `items` array.
+
+ - Reverted the default `COUNT` column to `id` as there are some common situations where it can negatively impact the query performance.
+ Additionally, from this version we also set `PRAGMA temp_store = MEMORY` so that also helps with the temp B-TREE creation when `id` is used.
+ _There are still scenarios where `COUNT` queries with `rowid` executes faster, but the majority of the time when nested relations lookups are used it seems to have the opposite effect (at least based on the benchmarks dataset)._
+
+- ⚠️ Disallowed relations to views **from non-view** collections ([#3000](https://github.com/pocketbase/pocketbase/issues/3000)).
+ The change was necessary because I wasn't able to find an efficient way to track view changes and the previous behavior could have too many unexpected side-effects (eg. view with computed ids).
+ There is a system migration that will convert the existing view `relation` fields to `json` (multiple) and `text` (single) fields.
+ This could be a breaking change if you have `relation` to view and use `expand` or some of the `relation` view fields as part of a collection rule.
+
+- ⚠️ Added an extra `action` argument to the `Dao` hooks to allow skipping the default persist behavior.
+ In preparation for the logs generalization, the `Dao.After*Func` methods now also allow returning an error.
+
+- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
+
+- Fixed zero-default value not being used if the field is not explicitly set when manually creating records ([#2992](https://github.com/pocketbase/pocketbase/issues/2992)).
+ Additionally, `record.Get(field)` will now always return normalized value (the same as in the json serialization) for consistency and to avoid ambiguities with what is stored in the related DB table.
+ The schema fields columns `DEFAULT` definition was also updated for new collections to ensure that `NULL` values can't be accidentally inserted.
+
+- Fixed `migrate down` not returning the correct `lastAppliedMigrations()` when the stored migration applied time is in seconds.
+
+- Fixed realtime delete event to be called after the record was deleted from the DB (_including transactions and cascade delete operations_).
+
+- Other minor fixes and improvements (typos and grammar fixes, updated dependencies, removed unnecessary 404 error check in the Admin UI, etc.).
+
+
+## v0.16.10
+
+- Added multiple valued fields (`relation`, `select`, `file`) normalizations to ensure that the zero-default value of a newly created multiple field is applied for already existing data ([#2930](https://github.com/pocketbase/pocketbase/issues/2930)).
+
+
+## v0.16.9
+
+- Register the `eagerRequestInfoCache` middleware only for the internal `api` group routes to avoid conflicts with custom route handlers ([#2914](https://github.com/pocketbase/pocketbase/issues/2914)).
+
+
+## v0.16.8
+
+- Fixed unique validator detailed error message not being returned when camelCase field name is used ([#2868](https://github.com/pocketbase/pocketbase/issues/2868)).
+
+- Updated the index parser to allow no space between the table name and the columns list ([#2864](https://github.com/pocketbase/pocketbase/discussions/2864#discussioncomment-6373736)).
+
+- Updated go deps.
+
+
+## v0.16.7
+
+- Minor optimization for the list/search queries to use `rowid` with the `COUNT` statement when available.
+ _This eliminates the temp B-TREE step when executing the query and for large datasets (eg. 150k) it could have 10x improvement (from ~580ms to ~60ms)._
+
+
+## v0.16.6
+
+- Fixed collection index column sort normalization in the Admin UI ([#2681](https://github.com/pocketbase/pocketbase/pull/2681); thanks @SimonLoir).
+
+- Removed unnecessary admins count in `apis.RequireAdminAuthOnlyIfAny()` middleware ([#2726](https://github.com/pocketbase/pocketbase/pull/2726); thanks @svekko).
+
+- Fixed `multipart/form-data` request bind not populating map array values ([#2763](https://github.com/pocketbase/pocketbase/discussions/2763#discussioncomment-6278902)).
+
+- Upgraded npm and Go dependencies.
+
+
+## v0.16.5
+
+- Fixed the Admin UI serialization of implicit relation display fields ([#2675](https://github.com/pocketbase/pocketbase/issues/2675)).
+
+- Reset the Admin UI sort in case the active sort collection field is renamed or deleted.
+
+
+## v0.16.4
+
+- Fixed the selfupdate command not working on Windows due to missing `.exe` in the extracted binary path ([#2589](https://github.com/pocketbase/pocketbase/discussions/2589)).
+ _Note that the command on Windows will work from v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._
+
+- Added `int64`, `int32`, `uint`, `uint64` and `uint32` support when scanning `types.DateTime` ([#2602](https://github.com/pocketbase/pocketbase/discussions/2602))
+
+- Updated dependencies.
+
+
+## v0.16.3
+
+- Fixed schema fields sort not working on Safari/Gnome Web ([#2567](https://github.com/pocketbase/pocketbase/issues/2567)).
+
+- Fixed default `PRAGMA`s not being applied for new connections ([#2570](https://github.com/pocketbase/pocketbase/discussions/2570)).
+
+
+## v0.16.2
+
+- Fixed backups archive not excluding the local `backups` directory on Windows ([#2548](https://github.com/pocketbase/pocketbase/discussions/2548#discussioncomment-5979712)).
+
+- Changed file field to not use `dataTransfer.effectAllowed` when dropping files since it is not reliable and consistent across different OS and browsers ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
+
+- Auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on Docker restart ([#2551](https://github.com/pocketbase/pocketbase/discussions/2551)).
+
+- Fixed missing view id field error message typo.
+
+
+## v0.16.1
+
+- Fixed backup restore not working in a container environment when `pb_data` is mounted as volume ([#2519](https://github.com/pocketbase/pocketbase/issues/2519)).
+
+- Fixed Dart SDK realtime API preview example ([#2523](https://github.com/pocketbase/pocketbase/pull/2523); thanks @xFrann).
+
+- Fixed typo in the backups create panel ([#2526](https://github.com/pocketbase/pocketbase/pull/2526); thanks @dschissler).
+
+- Removed unnecessary slice length check in `list.ExistInSlice` ([#2527](https://github.com/pocketbase/pocketbase/pull/2527); thanks @KunalSin9h).
+
+- Avoid mutating the cached request data on OAuth2 user create ([#2535](https://github.com/pocketbase/pocketbase/discussions/2535)).
+
+- Fixed Export Collections "Download as JSON" ([#2540](https://github.com/pocketbase/pocketbase/issues/2540)).
+
+- Fixed file field drag and drop not working in Firefox and Safari ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
+
+
+## v0.16.0
+
+- Added automated backups (_+ cron rotation_) APIs and UI for the `pb_data` directory.
+ The backups can be also initialized programmatically using `app.CreateBackup("backup.zip")`.
+ There is also experimental restore method - `app.RestoreBackup("backup.zip")` (_currently works only on UNIX systems as it relies on execve_).
+ The backups can be stored locally or in external S3 storage (_it has its own configuration, separate from the file uploads storage filesystem_).
+
+- Added option to limit the returned API fields using the `?fields` query parameter.
+ The "fields picker" is applied for `SearchResult.Items` and every other JSON response. For example:
+ ```js
+ // original: {"id": "RECORD_ID", "name": "abc", "description": "...something very big...", "items": ["id1", "id2"], "expand": {"items": [{"id": "id1", "name": "test1"}, {"id": "id2", "name": "test2"}]}}
+ // output: {"name": "abc", "expand": {"items": [{"name": "test1"}, {"name": "test2"}]}}
+ const result = await pb.collection("example").getOne("RECORD_ID", {
+ expand: "items",
+ fields: "name,expand.items.name",
+ })
+ ```
+
+- Added new `./pocketbase update` command to selfupdate the prebuilt executable (with option to generate a backup of your `pb_data`).
+
+- Added new `./pocketbase admin` console command:
+ ```sh
+ // creates new admin account
+ ./pocketbase admin create test@example.com 123456890
+
+ // changes the password of an existing admin account
+ ./pocketbase admin update test@example.com 0987654321
+
+ // deletes single admin account (if exists)
+ ./pocketbase admin delete test@example.com
+ ```
+
+- Added `apis.Serve(app, options)` helper to allow starting the API server programmatically.
+
+- Updated the schema fields Admin UI for "tidier" fields visualization.
+
+- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
+
+- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
+
+- Added new `tools/cron` helper subpackage for scheduling task using cron-like syntax (_this eventually may get exported in the future in a separate repo_).
+
+- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix.
+
+- Added new `App.NewBackupsFilesystem()` helper to create a dedicated filesystem abstraction for managing app data backups.
+
+- Added new `App.OnTerminate()` hook (_executed right before app termination, eg. on `SIGTERM` signal_).
+
+- Added `accept` file field attribute with the field MIME types ([#2466](https://github.com/pocketbase/pocketbase/pull/2466); thanks @Nikhil1920).
+
+- Added support for multiple files sort in the Admin UI ([#2445](https://github.com/pocketbase/pocketbase/issues/2445)).
+
+- Added support for multiple relations sort in the Admin UI.
+
+- Added `meta.isNew` to the OAuth2 auth JSON response to indicate a newly OAuth2 created PocketBase user.
diff --git a/CHANGELOG_23_39.md b/CHANGELOG_23_39.md
new file mode 100644
index 0000000000..98f9809972
--- /dev/null
+++ b/CHANGELOG_23_39.md
@@ -0,0 +1,1243 @@
+> List with changes from v0.23.x to v0.39.x.
+> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
+---
+
+## v0.39.11
+
+- Fixed "API preview" examples ([#7782](https://github.com/pocketbase/pocketbase/issues/7782), [#7785](https://github.com/pocketbase/pocketbase/issues/7785)).
+
+- Other minor UI improvements (fixed sortable `dragend` event handling, allow ESC to workaround TAB trap for the rule fields, allow new duplicated collection to edit the collection of relation fields, updated shablon, updated npm dev dependencies, etc.).
+
+- Bumped `golang.org/x/*` deps and the min Go GitHub action version to 1.26.6 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.6).
+
+
+## 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
+
+- Fixed password fields not being detected as changed ([#7670](https://github.com/pocketbase/pocketbase/issues/7670)).
+
+- Added the local time zone name next to the `date` field label.
+
+- Reload trusted proxy info UI after settings save.
+
+- Other minor improvements (skips the duplicated record ids from the `IN` expand list, reordered confirm-email-change error checks to minimize enumeration attacks, etc.).
+
+
+## v0.37.4
+
+- Added backups list scroll container ([#7655](https://github.com/pocketbase/pocketbase/issues/7655)).
+
+- Optimized record upsert and preview modals data loading to minimize layout jumps.
+
+- Fixed SMTP IPv6 network address format ([#7659](https://github.com/pocketbase/pocketbase/issues/7659)).
+
+- Fixed autocomplete selection not properly updating the underlying input value ([#7664](https://github.com/pocketbase/pocketbase/issues/7664)).
+
+- Added `ghupdate.BaseURL` config option ([#7665](https://github.com/pocketbase/pocketbase/issues/7665)).
+
+- Added dummy bcrypt password check for the failure auth path to minimize enumeration timing attacks when registrations are disabled.
+
+- Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
+ _In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
+
+- ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662); thanks @Alardiians for reporting it privately).
+
+- Bumped Go and npm dependencies.
+
+
+## v0.37.3
+
+- Fixed total count load on page back/forward navigation.
+
+- Fixed `editor` floating dialogs position when scrolling ([#7653](https://github.com/pocketbase/pocketbase/issues/7653)).
+
+- Enabled text wrapping for the API rule fields.
+
+- Added view query sample loading indicator.
+
+- Other minor light UI contrast and styles improvements.
+
+
+## v0.37.2
+
+- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
+
+- Slightly adjusted the dark theme colors for better readability ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
+
+- Removed unnecessary tags stripping from the displayed log attributes ([#7649](https://github.com/pocketbase/pocketbase/issues/7649)).
+
+- Workarounded Safari freeze caused by a buggy CSS popover property ([#7650](https://github.com/pocketbase/pocketbase/issues/7650)).
+
+
+## v0.37.1
+
+- Minor UI bugfixes:
+ - Fixed `number` field input values normalization ([#7646](https://github.com/pocketbase/pocketbase/issues/7646)).
+ - Allow opening collections in new tab with middle click.
+ - Show collection name in the page title on initial load.
+
+
+## v0.37.0
+
+- New UI rewritten from scratch and with support for external customization in mind.
+ > Note that as explained in [#7612](https://github.com/pocketbase/pocketbase/discussions/7612) the new UI kit and extensions APIs will intentionally remain undocumented until "Stage 2 completion" _(there no ETAs)_.
+
+ The new UI also introduced several other small improvements:
+ - ~2MB smaller bundle size.
+ - Dark mode and theming support.
+ - Basic responsive/mobile support _(it is far from perfect but certainly more usable than before)_.
+ - Help text option for the collection fields.
+ - Lifted the max nested level restriction of presentable relations _(children are lazy loaded)_.
+ - Lighter rules autocomplete.
+ - Live view query preview.
+ - Insert of an audio/video embed tag in the richtext editor from a collection file.
+ - Option to bulk export records as JSON.
+ - Local search history for all searchbars.
+ - API rules overview across all collections.
+ - Very basic ERD-like visualization for the collections structure and relations.
+ - New stepped logs chart visualization with panning support.
+ - `listAuthMethods()` (aka. `/api/collection/{col}/auth-methods`) now returns the OAuth2 provider logo for each provider as inlined SVG string in its response data.
+ _⚠️ Note that if your app for whatever reason rely on the dashboard OAuth2 logos available under `/_/images/oauth2/*` they are still available for now but will be removed in future versions and it is recommended to use the new inline SVGs!_
+
+- Added optional `no_ui` build tag to exclude the UI from bundling with the executable ([#7548](https://github.com/pocketbase/pocketbase/issues/7548)).
+ ```sh
+ go build -tags no_ui
+ ```
+
+- Exported the internal JSVM bind functions ([#7600](https://github.com/pocketbase/pocketbase/discussions/7600)).
+ ```go
+ jsvm.BindCore(vm)
+ jsvm.BindDbx(vm)
+ jsvm.BindSecurity(vm)
+ jsvm.BindOS(vm)
+ jsvm.BindFilepath(vm)
+ jsvm.BindHTTP(vm)
+ jsvm.BindFilesystem(vm)
+ jsvm.BindForms(vm)
+ jsvm.BindMails(vm)
+ jsvm.BindApis(vm)
+ ```
+
+- Updated `modernc.org/sqlite` to v1.49.1 (SQLite 3.53.0).
+
+
+## v0.36.9
+
+- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
+
+- Fixed settings SMTP password clear persistence.
+
+- Added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor.
+
+- Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
+
+- Updated min Go GitHub action version to 1.26.2 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
+
+- Other small improvements _(updated `$apis.static` JSVM documentation, fixed comment typos, added missing file close on seek error, etc.)_.
+
+
+## v0.36.8
+
+- Fixed OAuth2 client secret reset when serializing a cached collection model.
+
+- Bumped all Go and npm deps.
+ _This should also silence recent spam reports and security scanners regarding `golang.org/x/image` [CVE-2026-33809](https://www.cve.org/CVERecord?id=CVE-2026-33809) (it is not an issue in PocketBase because we don't support TIFF thumbs)._
+
+
+## v0.36.7
+
+- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
+
+- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
+ _Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
+
+- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
+ _⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
+
+- Updated other minor Go and npm deps.
+ _The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
+
+
+## v0.36.6
+
+- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
+
+- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)).
+
+- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value.
+
+- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)).
+
+- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB.
+
+- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
+
+- Typos and other minor doc fixes.
+
+
+## v0.36.5
+
+- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607).
+
+- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_.
+
+
+## v0.36.4
+
+- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming).
+
+- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)).
+
+
+## v0.36.3
+
+- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523).
+ _This should help fixing the 0-bytes file response when S3 API compression is enabled._
+
+- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_.
+
+- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_.
+
+
+## v0.36.2
+
+- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps.
+
+- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_.
+
+
+## v0.36.1
+
+- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries
+and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)).
+ _A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._
+
+- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_.
+
+- Fixed code comment typos.
+
+
+## v0.36.0
+
+- List query and API rules optimizations:
+ - Removed unnecessary correlated subquery expression when using back-relations via single `relation` field.
+ - Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe.
+ _This should help with having a more stable and predictable performance even if the collection records are on the larger side._
+
+ For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading,
+ please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed.
+
+- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function.
+ It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html)
+ with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields.
+ Multi-match expressions are also supported and works the same as if the collection field is referenced, for example:
+ ```js
+ // requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01"
+ strftime('%Y-%m', multiRel.created) ?= '2026-01'
+
+ // requires ALL multiRel records to have "created" date matching the formatted string "2026-01"
+ strftime('%Y-%m', multiRel.created) = '2026-01'
+ ```
+
+- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_:
+ - Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_.
+ - Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field.
+
+- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_.
+
+- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
+
+
+## v0.35.1
+
+- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_.
+
+- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps).
+
+
+## v0.35.0
+
+- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)).
+
+- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_.
+
+- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization.
+ _This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._
+
+- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps.
+
+
+## v0.34.2
+
+- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
+
+
+## v0.34.1
+
+- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite).
+
+- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)).
+
+- Updated Go deps and JS SDK.
+
+- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
+ _The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
+
+
+## v0.34.0
+
+- Added `@request.body.someField:changed` modifier.
+ It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value.
+ Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent:
+ ```js
+ // (old)
+ (@request.body.someField:isset = false || @request.body.someField = someField)
+
+ // (new)
+ @request.body.someField:changed = false
+ ```
+
+- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook.
+
+- Updated the backup restore popup with a short info about the performed restore steps.
+
+- Updated Go deps.
+
+
+## v0.33.0
+
+- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)).
+ _The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._
+ _The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._
+
+- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)).
+ _⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._
+
+- Updated Go deps.
+
+
+## v0.32.0
+
+- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations.
+
+ This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_.
+
+ So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_.
+
+ Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it.
+
+ For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields.
+
+ Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is.
+
+ An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.).
+
+ If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it.
+
+- Increased the default SQLite `PRAGMA cache_size` to ~32MB.
+
+- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools).
+
+- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies.
+
+- Updated `modernc.org/sqlite` to 1.40.0.
+
+
+## v0.31.0
+
+- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)).
+
+- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome).
+
+- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)).
+
+- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)).
+
+- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack.
+ _This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._
+ _Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._
+
+- Regenerated JSVM types and updated npm and Go deps.
+
+
+## v0.30.4
+
+- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
+
+
+## v0.30.3
+
+- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
+
+- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
+
+- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
+
+
+## v0.30.2
+
+- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
+
+
+## v0.30.1
+
+- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
+ _If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
+
+- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
+
+- Updated `modernc.org/sqlite` and other Go dependencies.
+
+
+## v0.30.0
+
+- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
+
+- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora).
+
+- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)).
+
+- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)).
+
+- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`.
+
+- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.).
+
+- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies.
+
+
+## v0.29.3
+
+- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)).
+
+- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05).
+
+- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211).
+ _Note that WASM is not officially supported PocketBase build target and many things may not work as expected._
+
+- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)).
+
+- Regenerated JSVM types to include methods from structs with single generic parameter.
+
+- Updated Go dependencies.
+
+
+## v0.29.2
+
+- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
+
+
+## v0.29.1
+
+- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)).
+
+- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson).
+
+- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3).
+
+- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra).
+
+
+## v0.29.0
+
+- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens.
+ _When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._
+
+- Added the triggered rate rimit rule in the error log `details`.
+
+- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)).
+
+- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)).
+
+- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)).
+
+- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.).
+
+
+## v0.28.4
+
+- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)).
+
+- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools).
+
+- Minor docs and typos fixes.
+
+
+## v0.28.3
+
+- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)).
+
+- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1).
+
+- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved).
+
+
+## v0.28.2
+
+- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
+
+- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
+
+- Updated Go and npm dependencies.
+
+
+## v0.28.1
+
+- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
+
+
+## v0.28.0
+
+- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
+
+- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
+ _For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
+
+- ⚠️ Changed the default `json` field max size to 1MB.
+ _Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
+
+- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
+ _The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
+
+- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
+ _This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
+
+- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
+
+
+## v0.27.2
+
+- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
+
+- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
+
+- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
+
+
+## v0.27.1
+
+- Updated example `geoPoint` API preview body data.
+
+- Added JSVM `new GeoPointField({ ... })` constructor.
+
+- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
+
+- Updated npm dev dependencies.
+
+
+## v0.27.0
+
+- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
+ This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
+ It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
+ ```go
+ tests.ApiScenario{
+ Name: "Example test that checks a Create API rule failure"
+ Method: http.MethodPost,
+ URL: "/api/collections/example/records",
+ ...
+ // old:
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ },
+ // new:
+ ExpectedEvents: map[string]int{"*": 0},
+ }
+ ```
+ If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
+
+- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
+ In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
+
+- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
+
+- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
+
+- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
+
+- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
+ ```js
+ // the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
+ new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
+
+ // the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
+ new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
+ ```
+
+- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
+
+- Updated `modernc.org/sqlite` to 1.37.0.
+
+- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
+
+
+## v0.26.6
+
+- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
+
+- Updated `modernc.org/sqlite` to 1.36.3.
+
+
+## v0.26.5
+
+- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
+
+
+## v0.26.4
+
+- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
+
+- Fixed logs details copy to clipboard action.
+
+- Updated `modernc.org/sqlite` to 1.36.2.
+
+
+## v0.26.3
+
+- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
+
+
+## v0.26.2
+
+- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
+
+
+## v0.26.1
+
+- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
+
+
+## v0.26.0
+
+- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
+ As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
+ _Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
+
+- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
+
+- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
+ This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
+ For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
+
+- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
+
+- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
+
+- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
+
+- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
+
+- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
+
+- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
+ _If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
+
+- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
+
+- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
+
+
+## v0.25.9
+
+- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
+
+
+## v0.25.8
+
+- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
+ It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
+
+
+## v0.25.7
+
+- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
+
+
+## v0.25.6
+
+- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
+
+- Updated npm dependencies.
+
+
+## v0.25.5
+
+- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
+
+- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
+
+
+## v0.25.4
+
+- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
+
+ While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
+
+ This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
+
+ For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
+ ```
+ // go.mod
+ github.com/aws/aws-sdk-go-v2 v1.36.1
+ github.com/aws/aws-sdk-go-v2/config v1.28.10
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.51
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
+
+ // after that run
+ go clean -modcache && go mod tidy
+ ```
+ _The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
+
+
+## v0.25.3
+
+- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
+
+
+## v0.25.2
+
+- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
+
+
+## v0.25.1
+
+- Fixed the batch API Preview success sample response.
+
+- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
+
+
+## v0.25.0
+
+- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
+ _For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
+ - `/v3/userinfo` auth response changes:
+ ```
+ meta.rawUser.id => meta.rawUser.sub
+ meta.rawUser.verified_email => meta.rawUser.email_verified
+ ```
+ - `/v2/auth` query parameters changes:
+ If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
+
+- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
+
+- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
+
+- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
+ _You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
+
+- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
+
+- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
+
+- Upgraded to `golang-jwt/jwt/v5`.
+
+- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
+
+- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
+
+- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
+
+- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
+
+
+## v0.24.4
+
+- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
+
+- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
+
+
+## v0.24.3
+
+- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
+
+- Reload the created/edited records data in the RecordsPicker UI.
+
+- Updated Go dependencies.
+
+
+## v0.24.2
+
+- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
+
+
+## v0.24.1
+
+- Added missing time macros in the UI autocomplete.
+
+- Fixed JSVM types for structs and functions with multiple generic parameters.
+
+
+## v0.24.0
+
+- ⚠️ Removed the "dry submit" when executing the collections Create API rule
+ (you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
+ For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
+ With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
+ (_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
+ As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
+
+- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
+ For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
+
+- Added `@yesterday` and `@tomorrow` datetime filter macros.
+
+- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
+
+- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
+
+- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
+
+- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
+
+- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
+ _The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
+
+- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
+
+- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
+
+- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
+
+- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
+
+- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
+
+- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
+
+- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
+
+- Normalized the hidden fields filter checks and allow targeting hidden fields in the List API rule.
+
+- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
+
+
+## v0.23.12
+
+- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
+
+- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
+
+
+## v0.23.11
+
+- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
+ _PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
+
+
+## v0.23.10
+
+- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
+
+- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
+ _If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
+
+
+## v0.23.9
+
+- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
+
+
+## v0.23.8
+
+- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
+
+- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
+
+
+## v0.23.7
+
+- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
+
+
+## v0.23.6
+
+- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
+
+- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
+
+- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
+
+- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
+
+
+## v0.23.5
+
+- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
+
+- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
+
+- Other minor fixes (comment typos, JSVM types update).
+
+- Updated Go deps and the min Go release GitHub action version to 1.23.4.
+
+
+## v0.23.4
+
+- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
+
+- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
+
+- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
+
+- Updated Go and npm dependencies.
+
+
+## v0.23.3
+
+- Fixed Gzip middleware not applying when serving static files.
+
+- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
+
+
+## v0.23.2
+
+- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
+
+- Fixed `--dev` log query print formatting.
+
+- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
+
+- Added collection rules change list in the confirmation popup
+ (_to avoid getting annoying during development, the rules confirmation currently is enabled only when using https_).
+
+
+## v0.23.1
+
+- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
+
+- Added more descriptive error messages.
+
+
+## v0.23.0
+
+> [!NOTE]
+> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
+> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
+> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
+> will be backported for at least until Q1 of 2025 (_if not longer_).
+>
+> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
+> - JS SDK: `<0.22.0`
+> - Dart SDK: `<0.19.0`
+
+> [!CAUTION]
+> This release introduces many Go/JSVM and Web APIs breaking changes!
+>
+> Existing `pb_data` will be automatically upgraded with the start of the new executable,
+> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
+> Please refer to the below upgrade guides:
+> - Go: https://pocketbase.io/v023upgrade/go/.
+> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
+>
+> If you had already switched to some of the earlier ` - Go: https://pocketbase.io/v023upgrade/go/.
+> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
+
+#### SDKs changes
+
+- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
+- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
+
+#### Web APIs changes
+
+- New `POST /api/batch` endpoint.
+
+- New `GET /api/collections/meta/scaffolds` endpoint.
+
+- New `DELETE /api/collections/{collection}/truncate` endpoint.
+
+- New `POST /api/collections/{collection}/request-otp` endpoint.
+
+- New `POST /api/collections/{collection}/auth-with-otp` endpoint.
+
+- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
+
+- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_).
+
+- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records.
+
+- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values.
+ This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix:
+ ```js
+ "documents": [file1, file2] // => [file1_name, file2_name]
+ "+documents": [file1, file2] // => [file1_name, file2_name, old1_name, old2_name]
+ "documents+": [file1, file2] // => [old1_name, old2_name, file1_name, file2_name]
+ ```
+
+- ⚠️ Removed `GET /records/{id}/external-auths` and `DELETE /records/{id}/external-auths/{provider}` endpoints because this is now handled by sending list and delete requests to the `_externalAuths` collection.
+
+- ⚠️ Changes to the app settings model fields and response (+new options such as `trustedProxy`, `rateLimits`, `batch`, etc.). The app settings Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all settings changes please refer to the [Settings Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/settings_model.go#L121).
+
+- ⚠️ New flatten Collection model and fields structure. The Collection model Web APIs are mostly used by the Dashboard UI and rarely by the end users, but if you want to check all changes please refer to the [Collection Go struct](https://github.com/pocketbase/pocketbase/blob/develop/core/collection_model.go#L308).
+
+- ⚠️ The top level error response `code` key was renamed to `status` for consistency with the Go APIs.
+ The error field key remains `code`:
+ ```js
+ {
+ "status": 400, // <-- old: "code"
+ "message": "Failed to create record.",
+ "data": {
+ "title": {
+ "code": "validation_required",
+ "message": "Missing required value."
+ }
+ }
+ }
+ ```
+
+- ⚠️ New fields in the `GET /api/collections/{collection}/auth-methods` response.
+ _The old `authProviders`, `usernamePassword`, `emailPassword` fields are still returned in the response but are considered deprecated and will be removed in the future._
+ ```js
+ {
+ "mfa": {
+ "duration": 100,
+ "enabled": true
+ },
+ "otp": {
+ "duration": 0,
+ "enabled": false
+ },
+ "password": {
+ "enabled": true,
+ "identityFields": ["email", "username"]
+ },
+ "oauth2": {
+ "enabled": true,
+ "providers": [{"name": "gitlab", ...}, {"name": "google", ...}]
+ },
+ // old fields...
+ }
+ ```
+
+- ⚠️ Soft-deprecated the OAuth2 auth success `meta.avatarUrl` field in favour of `meta.avatarURL`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c20144c440..fa7ac61e9d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,80 +1,85 @@
-Contributing to PocketBase
-======================================================================
+# Contributing to PocketBase
-Thanks for taking the time to improve PocketBase!
+> [!IMPORTANT]
+> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
+> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
+> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code)
-- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
-
+- [Making changes in the Superuser UI](#making-changes-in-the-superuser-ui)
## Prerequisites
-- Go 1.18+ (for making changes in the Go code)
-- Node 16+ (for making changes in the Admin UI)
+- Go 1.27+ (for making changes in the Go code)
+- Node 24+ (for making changes in the Superuser UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
+
```
git clone https://github.com/your_username/pocketbase.git
```
+> [!IMPORTANT]
> It is recommended to create a new branch from master for each of your bugfixes and features.
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
-
## Making changes in the Go code
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
-The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
+The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
1. Navigate to `examples/base`
2. Run `go run main.go serve`
-This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
+This will start a web server on `http://localhost:8090` with the embedded prebuilt Superuser UI from `ui/dist`. And that's it!
**Before making a PR to the main repository, it is a good idea to:**
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
To run the tests, you could execute (while in the root project directory):
- ```sh
- go test ./...
- # or using the Makefile
- make test
- ```
+ ```sh
+ go test ./...
+
+ # or using the Makefile
+ make test
+ ```
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
- ```sh
- golangci-lint run -c ./golangci.yml ./...
- # or using the Makefile
- make lint
- ```
+ ```sh
+ golangci-lint run -c ./golangci.yml ./...
+
+ # or using the Makefile
+ make lint
+ ```
-## Making changes in the Admin UI
+## Making changes in the Superuser UI
-PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
+PocketBase Superuser UI is a single-page application (SPA) built with Svelte and Vite.
-To start the Admin UI:
+To start the Superuser UI:
1. Navigate to the `ui` project directory
2. Run `npm install` to install the node dependencies
3. Start vite's dev server
- ```sh
- npm run dev
- ```
+ ```sh
+ npm run dev
+ ```
-You could open the browser and access the running Admin UI at `http://localhost:3000`.
+You could open the browser and access the running Superuser UI at `http://localhost:5173`.
-Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
+Since the Superuser UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
-> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
+> [!NOTE]
+> By default, the Superuser UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
-Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
+Every change you make in the Superuser UI should be automatically reflected in the browser at `http://localhost:5173` without reloading the page.
-Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
+Once you are done with your changes, you have to build the Superuser UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
diff --git a/README.md b/README.md
index 07cc98564e..dd7dc596db 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
-
+
-
+
-[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of:
+[PocketBase](https://pocketbase.io) is an open source Go backend that includes:
- embedded database (_SQLite_) with **realtime subscriptions**
- built-in **files and users management**
@@ -19,109 +19,116 @@
**For documentation and examples, please visit https://pocketbase.io/docs.**
-> ⚠️ Please keep in mind that PocketBase is still under active development
+> [!WARNING]
+> Please keep in mind that PocketBase is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
-
## API SDK clients
-The easiest way to interact with the API is to use one of the official SDK clients:
+The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
+
+- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
+- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
-- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
-- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
+You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
## Overview
-PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
+### Use as standalone app
+
+You could download the prebuilt executable for your platform from the [Releases page](https://github.com/pocketbase/pocketbase/releases).
+Once downloaded, extract the archive and run `./pocketbase serve` in the extracted directory.
+
+The prebuilt executables are based on the [`examples/base/main.go` file](https://github.com/pocketbase/pocketbase/blob/master/examples/base/main.go) and comes with the JS VM plugin enabled by default which allows to extend PocketBase with JavaScript (_for more details please refer to [Extend with JavaScript](https://pocketbase.io/docs/js-overview/)_).
+
+### Use as a Go framework/toolkit
+
+PocketBase is distributed as a regular Go library package which allows you to build
your own custom app specific business logic and still have a single portable executable at the end.
-### Installation
+Here is a minimal example:
-```sh
-# go 1.18+
-go get github.com/pocketbase/pocketbase
-```
-> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
-
-### Example
-
-```go
-package main
-
-import (
- "log"
- "net/http"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase"
- "github.com/pocketbase/pocketbase/apis"
- "github.com/pocketbase/pocketbase/core"
-)
-
-func main() {
- app := pocketbase.New()
-
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- // add new "GET /hello" route to the app router (echo)
- e.Router.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/hello",
- Handler: func(c echo.Context) error {
- return c.String(200, "Hello world!")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.ActivityLogger(app),
- },
- })
+0. [Install Go 1.27+](https://go.dev/doc/install) (_if you haven't already_)
+
+1. Create a new project directory with the following `main.go` file inside it:
+ ```go
+ package main
+
+ import (
+ "log"
- return nil
- })
+ "github.com/pocketbase/pocketbase"
+ "github.com/pocketbase/pocketbase/core"
+ )
- if err := app.Start(); err != nil {
- log.Fatal(err)
+ func main() {
+ app := pocketbase.New()
+
+ app.OnServe().BindFunc(func(se *core.ServeEvent) error {
+ // registers new "GET /hello" route
+ se.Router.GET("/hello", func(re *core.RequestEvent) error {
+ return re.String(200, "Hello world!")
+ })
+
+ return se.Next()
+ })
+
+ if err := app.Start(); err != nil {
+ log.Fatal(err)
+ }
}
-}
-```
+ ```
+
+2. To init the dependencies, run `go mod init myapp && go mod tidy`.
-### Running and building
+3. To start the application, run `go run main.go serve`.
-Running/building the application is the same as for any other Go program, aka. just `go run` and `go build`.
+4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
-**PocketBase embeds SQLite, but doesn't require CGO.**
+_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
-If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
-Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
+### Building and running the repo main.go example
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
-0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
+0. [Install Go 1.27+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo
2. Navigate to `examples/base`
-3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
- (_https://go.dev/doc/install/source#environment_)
+3. Run `CGO_ENABLED=0 go build` to build a binary for your current environment
+ _(or to target other platforms use `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`; see https://go.dev/doc/install/source#environment)_
4. Start the created executable by running `./base serve`.
-The supported build targets by the non-cgo driver at the moment are:
-```
-darwin amd64
-darwin arm64
-freebsd amd64
-freebsd arm64
-linux 386
-linux amd64
-linux arm
-linux arm64
-linux ppc64le
-linux riscv64
-windows amd64
-windows arm64
-```
+Note that the supported build targets by the pure Go SQLite driver at the moment are:
+
+| GOOS | GOARCH |
+|---------|---------|
+| darwin | amd64 |
+| darwin | arm64 |
+| freebsd | 386 |
+| freebsd | amd64 |
+| freebsd | arm |
+| freebsd | arm64 |
+| linux | 386 |
+| linux | amd64 |
+| linux | arm |
+| linux | arm64 |
+| linux | loong64 |
+| linux | ppc64le |
+| linux | riscv64 |
+| linux | s390x |
+| netbsd | amd64 |
+| openbsd | amd64 |
+| openbsd | arm64 |
+| windows | 386 |
+| windows | amd64 |
+| windows | arm64 |
### Testing
PocketBase comes with mixed bag of unit and integration tests.
-To run them, use the default `go test` command:
+To run them, use the standard `go test` command:
+
```sh
go test ./...
```
@@ -132,8 +139,7 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
-All reports will be promptly addressed, and you'll be credited accordingly.
-
+You could find more details in the project [Security policy](https://github.com/pocketbase/pocketbase/security/policy).
## Contributing
@@ -144,15 +150,14 @@ You could help continuing its development by:
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
-- [Donate a small amount](https://pocketbase.io/support-us)
-
-PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
-But please refrain creating PRs for _new features_ without previously discussing the implementation details.
+Please refrain creating PRs for _new features_ without previously discussing the implementation details.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
-_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
-so all feature requests will be first aligned with what we need for Presentator v3._
+> [!IMPORTANT]
+> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
+> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
+> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
diff --git a/apis/admin.go b/apis/admin.go
deleted file mode 100644
index f2640d0e90..0000000000
--- a/apis/admin.go
+++ /dev/null
@@ -1,355 +0,0 @@
-package apis
-
-import (
- "log"
- "net/http"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/routine"
- "github.com/pocketbase/pocketbase/tools/search"
-)
-
-// bindAdminApi registers the admin api endpoints and the corresponding handlers.
-func bindAdminApi(app core.App, rg *echo.Group) {
- api := adminApi{app: app}
-
- subGroup := rg.Group("/admins", ActivityLogger(app))
- subGroup.POST("/auth-with-password", api.authWithPassword)
- subGroup.POST("/request-password-reset", api.requestPasswordReset)
- subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
- subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
- subGroup.GET("", api.list, RequireAdminAuth())
- subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
- subGroup.GET("/:id", api.view, RequireAdminAuth())
- subGroup.PATCH("/:id", api.update, RequireAdminAuth())
- subGroup.DELETE("/:id", api.delete, RequireAdminAuth())
-}
-
-type adminApi struct {
- app core.App
-}
-
-func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
- token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
- if tokenErr != nil {
- return NewBadRequestError("Failed to create auth token.", tokenErr)
- }
-
- for _, f := range finalizers {
- if err := f(token); err != nil {
- return err
- }
- }
-
- event := new(core.AdminAuthEvent)
- event.HttpContext = c
- event.Admin = admin
- event.Token = token
-
- return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(200, map[string]any{
- "token": e.Token,
- "admin": e.Admin,
- })
- })
-}
-
-func (api *adminApi) authRefresh(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin == nil {
- return NewNotFoundError("Missing auth admin context.", nil)
- }
-
- event := new(core.AdminAuthRefreshEvent)
- event.HttpContext = c
- event.Admin = admin
-
- return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
- return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
- return api.authResponse(e.HttpContext, e.Admin)
- })
- })
-}
-
-func (api *adminApi) authWithPassword(c echo.Context) error {
- form := forms.NewAdminLogin(api.app)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- event := new(core.AdminAuthWithPasswordEvent)
- event.HttpContext = c
- event.Password = form.Password
- event.Identity = form.Identity
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- event.Admin = admin
-
- return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
- if err := next(e.Admin); err != nil {
- return NewBadRequestError("Failed to authenticate.", err)
- }
-
- return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
- return api.authResponse(e.HttpContext, e.Admin)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *adminApi) requestPasswordReset(c echo.Context) error {
- form := forms.NewAdminPasswordResetRequest(api.app)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- event := new(core.AdminRequestPasswordResetEvent)
- event.HttpContext = c
-
- submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(Admin *models.Admin) error {
- event.Admin = Admin
-
- return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
- // run in background because we don't need to show the result to the client
- routine.FireAndForget(func() {
- if err := next(e.Admin); err != nil && api.app.IsDebug() {
- // @todo replace after logs generalization
- log.Println(err)
- }
- })
-
- return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- // eagerly write 204 response and skip submit errors
- // as a measure against admins enumeration
- if !c.Response().Committed {
- c.NoContent(http.StatusNoContent)
- }
-
- return submitErr
-}
-
-func (api *adminApi) confirmPasswordReset(c echo.Context) error {
- form := forms.NewAdminPasswordResetConfirm(api.app)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.AdminConfirmPasswordResetEvent)
- event.HttpContext = c
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- event.Admin = admin
-
- return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
- if err := next(e.Admin); err != nil {
- return NewBadRequestError("Failed to set new password.", err)
- }
-
- return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *adminApi) list(c echo.Context) error {
- fieldResolver := search.NewSimpleFieldResolver(
- "id", "created", "updated", "name", "email",
- )
-
- admins := []*models.Admin{}
-
- result, err := search.NewProvider(fieldResolver).
- Query(api.app.Dao().AdminQuery()).
- ParseAndExec(c.QueryParams().Encode(), &admins)
-
- if err != nil {
- return NewBadRequestError("", err)
- }
-
- event := new(core.AdminsListEvent)
- event.HttpContext = c
- event.Admins = admins
- event.Result = result
-
- return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Result)
- })
-}
-
-func (api *adminApi) view(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return NewNotFoundError("", nil)
- }
-
- admin, err := api.app.Dao().FindAdminById(id)
- if err != nil || admin == nil {
- return NewNotFoundError("", err)
- }
-
- event := new(core.AdminViewEvent)
- event.HttpContext = c
- event.Admin = admin
-
- return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Admin)
- })
-}
-
-func (api *adminApi) create(c echo.Context) error {
- admin := &models.Admin{}
-
- form := forms.NewAdminUpsert(api.app, admin)
-
- // load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := new(core.AdminCreateEvent)
- event.HttpContext = c
- event.Admin = admin
-
- // create the admin
- submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- event.Admin = m
-
- return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
- if err := next(e.Admin); err != nil {
- return NewBadRequestError("Failed to create admin.", err)
- }
-
- return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Admin)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *adminApi) update(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return NewNotFoundError("", nil)
- }
-
- admin, err := api.app.Dao().FindAdminById(id)
- if err != nil || admin == nil {
- return NewNotFoundError("", err)
- }
-
- form := forms.NewAdminUpsert(api.app, admin)
-
- // load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
-
- event := new(core.AdminUpdateEvent)
- event.HttpContext = c
- event.Admin = admin
-
- // update the admin
- submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- event.Admin = m
-
- return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
- if err := next(e.Admin); err != nil {
- return NewBadRequestError("Failed to update admin.", err)
- }
-
- return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Admin)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *adminApi) delete(c echo.Context) error {
- id := c.PathParam("id")
- if id == "" {
- return NewNotFoundError("", nil)
- }
-
- admin, err := api.app.Dao().FindAdminById(id)
- if err != nil || admin == nil {
- return NewNotFoundError("", err)
- }
-
- event := new(core.AdminDeleteEvent)
- event.HttpContext = c
- event.Admin = admin
-
- return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
- if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
- return NewBadRequestError("Failed to delete admin.", err)
- }
-
- return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
-}
diff --git a/apis/admin_test.go b/apis/admin_test.go
deleted file mode 100644
index f7cf3a0586..0000000000
--- a/apis/admin_test.go
+++ /dev/null
@@ -1,907 +0,0 @@
-package apis_test
-
-import (
- "errors"
- "net/http"
- "strings"
- "testing"
- "time"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestAdminAuthWithPassword(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "wrong email",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "wrong password",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid email/password (guest)",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"admin":{"id":"sywbhecnh46rhm0"`,
- `"token":`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthWithPasswordRequest": 1,
- "OnAdminAfterAuthWithPasswordRequest": 1,
- "OnAdminAuthRequest": 1,
- },
- },
- {
- Name: "valid email/password (already authorized)",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"admin":{"id":"sywbhecnh46rhm0"`,
- `"token":`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthWithPasswordRequest": 1,
- "OnAdminAfterAuthWithPasswordRequest": 1,
- "OnAdminAuthRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterAuthWithPasswordRequest error response",
- Method: http.MethodPost,
- Url: "/api/admins/auth-with-password",
- Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthWithPasswordRequest": 1,
- "OnAdminAfterAuthWithPasswordRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminRequestPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/admins/request-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/admins/request-password-reset",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing admin",
- Method: http.MethodPost,
- Url: "/api/admins/request-password-reset",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing admin",
- Method: http.MethodPost,
- Url: "/api/admins/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnMailerBeforeAdminResetPasswordSend": 1,
- "OnMailerAfterAdminResetPasswordSend": 1,
- "OnAdminBeforeRequestPasswordResetRequest": 1,
- "OnAdminAfterRequestPasswordResetRequest": 1,
- },
- },
- {
- Name: "existing admin (after already sent)",
- Method: http.MethodPost,
- Url: "/api/admins/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent password request
- admin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- admin.LastResetSentAt = types.NowDateTime()
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(admin); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminConfirmPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{"password`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
- "password":"1234567890",
- "passwordConfirm":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
- },
- {
- Name: "valid token + invalid password",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
- "password":"123456",
- "passwordConfirm":"123456"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
- },
- {
- Name: "valid token + valid password",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
- "password":"1234567891",
- "passwordConfirm":"1234567891"
- }`),
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnAdminBeforeConfirmPasswordResetRequest": 1,
- "OnAdminAfterConfirmPasswordResetRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterConfirmPasswordResetRequest error response",
- Method: http.MethodPost,
- Url: "/api/admins/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
- "password":"1234567891",
- "passwordConfirm":"1234567891"
- }`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnAdminBeforeConfirmPasswordResetRequest": 1,
- "OnAdminAfterConfirmPasswordResetRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminRefresh(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPost,
- Url: "/api/admins/auth-refresh",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user",
- Method: http.MethodPost,
- Url: "/api/admins/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin (expired token)",
- Method: http.MethodPost,
- Url: "/api/admins/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin (valid token)",
- Method: http.MethodPost,
- Url: "/api/admins/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"admin":{"id":"sywbhecnh46rhm0"`,
- `"token":`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminAuthRequest": 1,
- "OnAdminBeforeAuthRefreshRequest": 1,
- "OnAdminAfterAuthRefreshRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterAuthRefreshRequest error response",
- Method: http.MethodPost,
- Url: "/api/admins/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnAdminBeforeAuthRefreshRequest": 1,
- "OnAdminAfterAuthRefreshRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminsList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/admins",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user",
- Method: http.MethodGet,
- Url: "/api/admins",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin",
- Method: http.MethodGet,
- Url: "/api/admins",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"sywbhecnh46rhm0"`,
- `"id":"sbmbsdb40jyxf7h"`,
- `"id":"9q2trqumvlyr3bd"`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminsListRequest": 1,
- },
- },
- {
- Name: "authorized as admin + paging and sorting",
- Method: http.MethodGet,
- Url: "/api/admins?page=2&perPage=1&sort=-created",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":2`,
- `"perPage":1`,
- `"totalItems":3`,
- `"items":[{`,
- `"id":"sbmbsdb40jyxf7h"`,
- },
- NotExpectedContent: []string{
- `"tokenKey"`,
- `"passwordHash"`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminsListRequest": 1,
- },
- },
- {
- Name: "authorized as admin + invalid filter",
- Method: http.MethodGet,
- Url: "/api/admins?filter=invalidfield~'test2'",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + valid filter",
- Method: http.MethodGet,
- Url: "/api/admins?filter=email~'test3'",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"page":1`,
- `"perPage":30`,
- `"totalItems":1`,
- `"items":[{`,
- `"id":"9q2trqumvlyr3bd"`,
- },
- NotExpectedContent: []string{
- `"tokenKey"`,
- `"passwordHash"`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminsListRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminView(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user",
- Method: http.MethodGet,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + nonexisting admin id",
- Method: http.MethodGet,
- Url: "/api/admins/nonexisting",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing admin id",
- Method: http.MethodGet,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"sbmbsdb40jyxf7h"`,
- },
- NotExpectedContent: []string{
- `"tokenKey"`,
- `"passwordHash"`,
- },
- ExpectedEvents: map[string]int{
- "OnAdminViewRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminDelete(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user",
- Method: http.MethodDelete,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + missing admin id",
- Method: http.MethodDelete,
- Url: "/api/admins/missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + existing admin id",
- Method: http.MethodDelete,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnAdminBeforeDeleteRequest": 1,
- "OnAdminAfterDeleteRequest": 1,
- },
- },
- {
- Name: "authorized as admin - try to delete the only remaining admin",
- Method: http.MethodDelete,
- Url: "/api/admins/sywbhecnh46rhm0",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // delete all admins except the authorized one
- adminModel := &models.Admin{}
- _, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
- "id": "sywbhecnh46rhm0",
- })).Execute()
- if err != nil {
- t.Fatal(err)
- }
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnAdminBeforeDeleteRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterDeleteRequest error response",
- Method: http.MethodDelete,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnAdminBeforeDeleteRequest": 1,
- "OnAdminAfterDeleteRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminCreate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized (while having at least 1 existing admin)",
- Method: http.MethodPost,
- Url: "/api/admins",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "unauthorized (while having 0 existing admins)",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // delete all admins
- _, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
- if err != nil {
- t.Fatal(err)
- }
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"email":"testnew@example.com"`,
- `"avatar":3`,
- },
- ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnAdminBeforeCreateRequest": 1,
- "OnAdminAfterCreateRequest": 1,
- },
- },
- {
- Name: "authorized as user",
- Method: http.MethodPost,
- Url: "/api/admins",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + empty data",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "authorized as admin + invalid data format",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(`{`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + invalid data",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(`{
- "email":"test@example.com",
- "password":"1234",
- "passwordConfirm":"4321",
- "avatar":99
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"avatar":{"code":"validation_max_less_equal_than_required"`,
- `"email":{"code":"validation_admin_email_exists"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
- },
- },
- {
- Name: "authorized as admin + valid data",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(`{
- "email":"testnew@example.com",
- "password":"1234567890",
- "passwordConfirm":"1234567890",
- "avatar":3
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":`,
- `"email":"testnew@example.com"`,
- `"avatar":3`,
- },
- NotExpectedContent: []string{
- `"password"`,
- `"passwordConfirm"`,
- `"tokenKey"`,
- `"passwordHash"`,
- },
- ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnAdminBeforeCreateRequest": 1,
- "OnAdminAfterCreateRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterCreateRequest error response",
- Method: http.MethodPost,
- Url: "/api/admins",
- Body: strings.NewReader(`{
- "email":"testnew@example.com",
- "password":"1234567890",
- "passwordConfirm":"1234567890",
- "avatar":3
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnAdminBeforeCreateRequest": 1,
- "OnAdminAfterCreateRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestAdminUpdate(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as user",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + missing admin",
- Method: http.MethodPatch,
- Url: "/api/admins/missing",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + empty data",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"sbmbsdb40jyxf7h"`,
- `"email":"test2@example.com"`,
- `"avatar":2`,
- },
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnAdminBeforeUpdateRequest": 1,
- "OnAdminAfterUpdateRequest": 1,
- },
- },
- {
- Name: "authorized as admin + invalid formatted data",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- Body: strings.NewReader(`{`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "authorized as admin + invalid data",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- Body: strings.NewReader(`{
- "email":"test@example.com",
- "password":"1234",
- "passwordConfirm":"4321",
- "avatar":99
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"avatar":{"code":"validation_max_less_equal_than_required"`,
- `"email":{"code":"validation_admin_email_exists"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
- },
- },
- {
- Name: "authorized as admin + valid data",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- Body: strings.NewReader(`{
- "email":"testnew@example.com",
- "password":"1234567891",
- "passwordConfirm":"1234567891",
- "avatar":5
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"sbmbsdb40jyxf7h"`,
- `"email":"testnew@example.com"`,
- `"avatar":5`,
- },
- NotExpectedContent: []string{
- `"password"`,
- `"passwordConfirm"`,
- `"tokenKey"`,
- `"passwordHash"`,
- },
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnAdminBeforeUpdateRequest": 1,
- "OnAdminAfterUpdateRequest": 1,
- },
- },
- {
- Name: "OnAdminAfterUpdateRequest error response",
- Method: http.MethodPatch,
- Url: "/api/admins/sbmbsdb40jyxf7h",
- Body: strings.NewReader(`{
- "email":"testnew@example.com",
- "password":"1234567891",
- "passwordConfirm":"1234567891",
- "avatar":5
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnAdminBeforeUpdateRequest": 1,
- "OnAdminAfterUpdateRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
diff --git a/apis/api_error.go b/apis/api_error.go
deleted file mode 100644
index 8658ac8fe3..0000000000
--- a/apis/api_error.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package apis
-
-import (
- "net/http"
- "strings"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/tools/inflector"
-)
-
-// ApiError defines the struct for a basic api error response.
-type ApiError struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Data map[string]any `json:"data"`
-
- // stores unformatted error data (could be an internal error, text, etc.)
- rawData any
-}
-
-// Error makes it compatible with the `error` interface.
-func (e *ApiError) Error() string {
- return e.Message
-}
-
-// RawData returns the unformatted error data (could be an internal error, text, etc.)
-func (e *ApiError) RawData() any {
- return e.rawData
-}
-
-// NewNotFoundError creates and returns 404 `ApiError`.
-func NewNotFoundError(message string, data any) *ApiError {
- if message == "" {
- message = "The requested resource wasn't found."
- }
-
- return NewApiError(http.StatusNotFound, message, data)
-}
-
-// NewBadRequestError creates and returns 400 `ApiError`.
-func NewBadRequestError(message string, data any) *ApiError {
- if message == "" {
- message = "Something went wrong while processing your request."
- }
-
- return NewApiError(http.StatusBadRequest, message, data)
-}
-
-// NewForbiddenError creates and returns 403 `ApiError`.
-func NewForbiddenError(message string, data any) *ApiError {
- if message == "" {
- message = "You are not allowed to perform this request."
- }
-
- return NewApiError(http.StatusForbidden, message, data)
-}
-
-// NewUnauthorizedError creates and returns 401 `ApiError`.
-func NewUnauthorizedError(message string, data any) *ApiError {
- if message == "" {
- message = "Missing or invalid authentication token."
- }
-
- return NewApiError(http.StatusUnauthorized, message, data)
-}
-
-// NewApiError creates and returns new normalized `ApiError` instance.
-func NewApiError(status int, message string, data any) *ApiError {
- return &ApiError{
- rawData: data,
- Data: safeErrorsData(data),
- Code: status,
- Message: strings.TrimSpace(inflector.Sentenize(message)),
- }
-}
-
-func safeErrorsData(data any) map[string]any {
- switch v := data.(type) {
- case validation.Errors:
- return resolveSafeErrorsData[error](v)
- case map[string]validation.Error:
- return resolveSafeErrorsData[validation.Error](v)
- case map[string]error:
- return resolveSafeErrorsData[error](v)
- case map[string]any:
- return resolveSafeErrorsData[any](v)
- default:
- return map[string]any{} // not nil to ensure that is json serialized as object
- }
-}
-
-func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
- result := map[string]any{}
-
- for name, err := range data {
- if isNestedError(err) {
- result[name] = safeErrorsData(err)
- continue
- }
- result[name] = resolveSafeErrorItem(err)
- }
-
- return result
-}
-
-func isNestedError(err any) bool {
- switch err.(type) {
- case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
- return true
- }
-
- return false
-}
-
-// resolveSafeErrorItem extracts from each validation error its
-// public safe error code and message.
-func resolveSafeErrorItem(err any) map[string]string {
- // default public safe error values
- code := "validation_invalid_value"
- msg := "Invalid value."
-
- // only validation errors are public safe
- if obj, ok := err.(validation.Error); ok {
- code = obj.Code()
- msg = inflector.Sentenize(obj.Error())
- }
-
- return map[string]string{
- "code": code,
- "message": msg,
- }
-}
diff --git a/apis/api_error_aliases.go b/apis/api_error_aliases.go
new file mode 100644
index 0000000000..95b708b6a5
--- /dev/null
+++ b/apis/api_error_aliases.go
@@ -0,0 +1,47 @@
+package apis
+
+import "github.com/pocketbase/pocketbase/tools/router"
+
+// ApiError aliases to minimize the breaking changes with earlier versions
+// and for consistency with the JSVM binds.
+// -------------------------------------------------------------------
+
+// ToApiError wraps err into ApiError instance (if not already).
+func ToApiError(err error) *router.ApiError {
+ return router.ToApiError(err)
+}
+
+// NewApiError is an alias for [router.NewApiError].
+func NewApiError(status int, message string, errData any) *router.ApiError {
+ return router.NewApiError(status, message, errData)
+}
+
+// NewBadRequestError is an alias for [router.NewBadRequestError].
+func NewBadRequestError(message string, errData any) *router.ApiError {
+ return router.NewBadRequestError(message, errData)
+}
+
+// NewNotFoundError is an alias for [router.NewNotFoundError].
+func NewNotFoundError(message string, errData any) *router.ApiError {
+ return router.NewNotFoundError(message, errData)
+}
+
+// NewForbiddenError is an alias for [router.NewForbiddenError].
+func NewForbiddenError(message string, errData any) *router.ApiError {
+ return router.NewForbiddenError(message, errData)
+}
+
+// NewUnauthorizedError is an alias for [router.NewUnauthorizedError].
+func NewUnauthorizedError(message string, errData any) *router.ApiError {
+ return router.NewUnauthorizedError(message, errData)
+}
+
+// NewTooManyRequestsError is an alias for [router.NewTooManyRequestsError].
+func NewTooManyRequestsError(message string, errData any) *router.ApiError {
+ return router.NewTooManyRequestsError(message, errData)
+}
+
+// NewInternalServerError is an alias for [router.NewInternalServerError].
+func NewInternalServerError(message string, errData any) *router.ApiError {
+ return router.NewInternalServerError(message, errData)
+}
diff --git a/apis/api_error_test.go b/apis/api_error_test.go
deleted file mode 100644
index b2ff52fb3d..0000000000
--- a/apis/api_error_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package apis_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/apis"
-)
-
-func TestNewApiErrorWithRawData(t *testing.T) {
- e := apis.NewApiError(
- 300,
- "message_test",
- "rawData_test",
- )
-
- result, _ := json.Marshal(e)
- expected := `{"code":300,"message":"Message_test.","data":{}}`
-
- if string(result) != expected {
- t.Errorf("Expected %v, got %v", expected, string(result))
- }
-
- if e.Error() != "Message_test." {
- t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
- }
-
- if e.RawData() != "rawData_test" {
- t.Errorf("Expected rawData %v, got %v", "rawData_test", e.RawData())
- }
-}
-
-func TestNewApiErrorWithValidationData(t *testing.T) {
- e := apis.NewApiError(
- 300,
- "message_test",
- validation.Errors{
- "err1": errors.New("test error"), // should be normalized
- "err2": validation.ErrRequired,
- "err3": validation.Errors{
- "sub1": errors.New("test error"), // should be normalized
- "sub2": validation.ErrRequired,
- "sub3": validation.Errors{
- "sub11": validation.ErrRequired,
- },
- },
- },
- )
-
- result, _ := json.Marshal(e)
- expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
-
- if string(result) != expected {
- t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
- }
-
- if e.Error() != "Message_test." {
- t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
- }
-
- if e.RawData() == nil {
- t.Error("Expected non-nil rawData")
- }
-}
-
-func TestNewNotFoundError(t *testing.T) {
- scenarios := []struct {
- message string
- data any
- expected string
- }{
- {"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
- {"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
- {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
- }
-
- for i, scenario := range scenarios {
- e := apis.NewNotFoundError(scenario.message, scenario.data)
- result, _ := json.Marshal(e)
-
- if string(result) != scenario.expected {
- t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
- }
- }
-}
-
-func TestNewBadRequestError(t *testing.T) {
- scenarios := []struct {
- message string
- data any
- expected string
- }{
- {"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
- {"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
- {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
- }
-
- for i, scenario := range scenarios {
- e := apis.NewBadRequestError(scenario.message, scenario.data)
- result, _ := json.Marshal(e)
-
- if string(result) != scenario.expected {
- t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
- }
- }
-}
-
-func TestNewForbiddenError(t *testing.T) {
- scenarios := []struct {
- message string
- data any
- expected string
- }{
- {"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
- {"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
- {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
- }
-
- for i, scenario := range scenarios {
- e := apis.NewForbiddenError(scenario.message, scenario.data)
- result, _ := json.Marshal(e)
-
- if string(result) != scenario.expected {
- t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
- }
- }
-}
-
-func TestNewUnauthorizedError(t *testing.T) {
- scenarios := []struct {
- message string
- data any
- expected string
- }{
- {"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
- {"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
- {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
- }
-
- for i, scenario := range scenarios {
- e := apis.NewUnauthorizedError(scenario.message, scenario.data)
- result, _ := json.Marshal(e)
-
- if string(result) != scenario.expected {
- t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
- }
- }
-}
diff --git a/apis/backup.go b/apis/backup.go
index 8769367240..289a37fc5e 100644
--- a/apis/backup.go
+++ b/apis/backup.go
@@ -2,47 +2,41 @@ package apis
import (
"context"
- "log"
"net/http"
"path/filepath"
"time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// bindBackupApi registers the file api endpoints and the corresponding handlers.
-//
-// @todo add hooks once the app hooks api restructuring is finalized
-func bindBackupApi(app core.App, rg *echo.Group) {
- api := backupApi{app: app}
-
- subGroup := rg.Group("/backups", ActivityLogger(app))
- subGroup.GET("", api.list, RequireAdminAuth())
- subGroup.POST("", api.create, RequireAdminAuth())
- subGroup.POST("/upload", api.upload, RequireAdminAuth())
- subGroup.GET("/:key", api.download)
- subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
- subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
+func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ sub := rg.Group("/backups")
+ sub.GET("", backupsList).Bind(RequireSuperuserAuth())
+ sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
+ sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
+ sub.GET("/{key}", backupDownload) // relies on superuser file token
+ sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
+ sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
}
-type backupApi struct {
- app core.App
+type backupFileInfo struct {
+ Modified types.DateTime `json:"modified"`
+ Key string `json:"key"`
+ Size int64 `json:"size"`
}
-func (api *backupApi) list(c echo.Context) error {
+func backupsList(e *core.RequestEvent) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- fsys, err := api.app.NewBackupsFilesystem()
+ fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem.", err)
+ return e.BadRequestError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
@@ -50,166 +44,117 @@ func (api *backupApi) list(c echo.Context) error {
backups, err := fsys.List("")
if err != nil {
- return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
+ return e.BadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
}
- result := make([]models.BackupFileInfo, len(backups))
+ result := make([]backupFileInfo, len(backups))
for i, obj := range backups {
modified, _ := types.ParseDateTime(obj.ModTime)
- result[i] = models.BackupFileInfo{
+ result[i] = backupFileInfo{
Key: obj.Key,
Size: obj.Size,
Modified: modified,
}
}
- return c.JSON(http.StatusOK, result)
+ return e.JSON(http.StatusOK, result)
}
-func (api *backupApi) create(c echo.Context) error {
- if api.app.Cache().Has(core.CacheKeyActiveBackup) {
- return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
- }
+func backupDownload(e *core.RequestEvent) error {
+ fileToken := e.Request.URL.Query().Get("token")
- form := forms.NewBackupCreate(api.app)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ authRecord, err := e.App.FindAuthRecordByToken(fileToken, core.TokenTypeFile)
+ if err != nil || !authRecord.IsSuperuser() {
+ return e.ForbiddenError("Insufficient permissions to access the resource.", err)
}
- return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
- return func(name string) error {
- if err := next(name); err != nil {
- return NewBadRequestError("Failed to create backup.", err)
- }
-
- // we don't retrieve the generated backup file because it may not be
- // available yet due to the eventually consistent nature of some S3 providers
- return c.NoContent(http.StatusNoContent)
- }
- })
-}
+ allowedIPs := e.App.Settings().SuperuserIPs
+ if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
+ return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
+ }
-func (api *backupApi) upload(c echo.Context) error {
- files, err := rest.FindUploadedFiles(c.Request(), "file")
+ fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Missing or invalid uploaded file.", err)
+ return e.InternalServerError("Failed to load backups filesystem.", err)
}
+ defer fsys.Close()
- form := forms.NewBackupUpload(api.app)
- form.File = files[0]
-
- return form.Submit(func(next forms.InterceptorNextFunc[*filesystem.File]) forms.InterceptorNextFunc[*filesystem.File] {
- return func(file *filesystem.File) error {
- if err := next(file); err != nil {
- return NewBadRequestError("Failed to upload backup.", err)
- }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
- // we don't retrieve the generated backup file because it may not be
- // available yet due to the eventually consistent nature of some S3 providers
- return c.NoContent(http.StatusNoContent)
- }
- })
-}
+ fsys.SetContext(ctx)
-func (api *backupApi) download(c echo.Context) error {
- fileToken := c.QueryParam("token")
+ key := e.Request.PathValue("key")
- _, err := api.app.Dao().FindAdminByToken(
- fileToken,
- api.app.Settings().AdminFileToken.Secret,
+ return fsys.Serve(
+ e.Response,
+ e.Request,
+ key,
+ filepath.Base(key), // without the path prefix (if any)
)
- if err != nil {
- return NewForbiddenError("Insufficient permissions to access the resource.", err)
- }
+}
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+func backupDelete(e *core.RequestEvent) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- fsys, err := api.app.NewBackupsFilesystem()
+ fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem.", err)
+ return e.InternalServerError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(ctx)
- key := c.PathParam("key")
+ key := e.Request.PathValue("key")
- br, err := fsys.GetFile(key)
- if err != nil {
- return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
+ if key != "" && cast.ToString(e.App.Store().Get(core.StoreKeyActiveBackup)) == key {
+ return e.BadRequestError("The backup is currently being used and cannot be deleted.", nil)
}
- defer br.Close()
- return fsys.Serve(
- c.Response(),
- c.Request(),
- key,
- filepath.Base(key), // without the path prefix (if any)
- )
+ if err := fsys.Delete(key); err != nil {
+ return e.BadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
+ }
+
+ return e.NoContent(http.StatusNoContent)
}
-func (api *backupApi) restore(c echo.Context) error {
- if api.app.Cache().Has(core.CacheKeyActiveBackup) {
- return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
+func backupRestore(e *core.RequestEvent) error {
+ if e.App.Store().Has(core.StoreKeyActiveBackup) {
+ return e.BadRequestError("Try again later - another backup/restore process has already been started.", nil)
}
- key := c.PathParam("key")
+ key := e.Request.PathValue("key")
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- fsys, err := api.app.NewBackupsFilesystem()
+ fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
- return NewBadRequestError("Failed to load backups filesystem.", err)
+ return e.InternalServerError("Failed to load backups filesystem.", err)
}
defer fsys.Close()
fsys.SetContext(existsCtx)
if exists, err := fsys.Exists(key); !exists {
- return NewBadRequestError("Missing or invalid backup file.", err)
+ return e.BadRequestError("Missing or invalid backup file.", err)
}
- go func() {
- // wait max 15 minutes to fetch the backup
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
- defer cancel()
-
- // give some optimistic time to write the response
+ routine.FireAndForget(func() {
+ // give some optimistic time to write the response before restarting the app
time.Sleep(1 * time.Second)
- if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- }()
-
- return c.NoContent(http.StatusNoContent)
-}
-
-func (api *backupApi) delete(c echo.Context) error {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- fsys, err := api.app.NewBackupsFilesystem()
- if err != nil {
- return NewBadRequestError("Failed to load backups filesystem.", err)
- }
- defer fsys.Close()
-
- fsys.SetContext(ctx)
-
- key := c.PathParam("key")
-
- if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
- return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
- }
+ // wait max 10 minutes to fetch the backup
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
- if err := fsys.Delete(key); err != nil {
- return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
- }
+ if err := e.App.RestoreBackup(ctx, key); err != nil {
+ e.App.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
+ }
+ })
- return c.NoContent(http.StatusNoContent)
+ return e.NoContent(http.StatusNoContent)
}
diff --git a/apis/backup_create.go b/apis/backup_create.go
new file mode 100644
index 0000000000..42a3ee7754
--- /dev/null
+++ b/apis/backup_create.go
@@ -0,0 +1,78 @@
+package apis
+
+import (
+ "context"
+ "net/http"
+ "regexp"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func backupCreate(e *core.RequestEvent) error {
+ if e.App.Store().Has(core.StoreKeyActiveBackup) {
+ return e.BadRequestError("Try again later - another backup/restore process has already been started", nil)
+ }
+
+ form := new(backupCreateForm)
+ form.app = e.App
+
+ err := e.BindBody(form)
+ if err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
+ }
+
+ err = form.validate()
+ if err != nil {
+ return e.BadRequestError("An error occurred while validating the submitted data.", err)
+ }
+
+ err = e.App.CreateBackup(context.Background(), form.Name)
+ if err != nil {
+ return e.BadRequestError("Failed to create backup.", err)
+ }
+
+ // we don't retrieve the generated backup file because it may not be
+ // available yet due to the eventually consistent nature of some S3 providers
+ return e.NoContent(http.StatusNoContent)
+}
+
+// -------------------------------------------------------------------
+
+var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
+
+type backupCreateForm struct {
+ app core.App
+
+ Name string `form:"name" json:"name"`
+}
+
+func (form *backupCreateForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Name,
+ validation.Length(1, 150),
+ validation.Match(backupNameRegex),
+ validation.By(form.checkUniqueName),
+ ),
+ )
+}
+
+func (form *backupCreateForm) checkUniqueName(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ fsys, err := form.app.NewBackupsFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+
+ if exists, err := fsys.Exists(v); err != nil || exists {
+ return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
+ }
+
+ return nil
+}
diff --git a/apis/backup_test.go b/apis/backup_test.go
index 18a3cc2c4b..637ef2df96 100644
--- a/apis/backup_test.go
+++ b/apis/backup_test.go
@@ -10,61 +10,64 @@ import (
"strings"
"testing"
- "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
- "gocloud.dev/blob"
+ "github.com/pocketbase/pocketbase/tools/filesystem/blob"
)
func TestBackupsList(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/backups",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (empty list)",
+ Name: "authorized as superuser (empty list)",
Method: http.MethodGet,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `[]`,
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`[]`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin",
+ Name: "authorized as superuser",
Method: http.MethodGet,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
@@ -75,6 +78,7 @@ func TestBackupsList(t *testing.T) {
`"test2.zip"`,
`"test3.zip"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -84,54 +88,59 @@ func TestBackupsList(t *testing.T) {
}
func TestBackupsCreate(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/backups",
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ URL: "/api/backups",
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (pending backup)",
+ Name: "authorized as superuser (pending backup)",
Method: http.MethodPost,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.Cache().Set(core.CacheKeyActiveBackup, "")
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Store().Set(core.StoreKeyActiveBackup, "")
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (autogenerated name)",
+ Name: "authorized as superuser (autogenerated name)",
Method: http.MethodPost,
- Url: "/api/backups",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -147,16 +156,20 @@ func TestBackupsCreate(t *testing.T) {
}
},
ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBackupCreate": 1,
+ },
},
{
- Name: "authorized as admin (invalid name)",
+ Name: "authorized as superuser (invalid name)",
Method: http.MethodPost,
- Url: "/api/backups",
+ URL: "/api/backups",
Body: strings.NewReader(`{"name":"!test.zip"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
@@ -164,16 +177,17 @@ func TestBackupsCreate(t *testing.T) {
`"data":{`,
`"name":{"code":"validation_match_invalid"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (valid name)",
+ Name: "authorized as superuser (valid name)",
Method: http.MethodPost,
- Url: "/api/backups",
+ URL: "/api/backups",
Body: strings.NewReader(`{"name":"test.zip"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -189,6 +203,10 @@ func TestBackupsCreate(t *testing.T) {
}
},
ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBackupCreate": 1,
+ },
},
}
@@ -197,7 +215,9 @@ func TestBackupsCreate(t *testing.T) {
}
}
-func TestBackupsUpload(t *testing.T) {
+func TestBackupUpload(t *testing.T) {
+ t.Parallel()
+
// create dummy form data bodies
type body struct {
buffer io.Reader
@@ -237,55 +257,58 @@ func TestBackupsUpload(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/backups/upload",
+ URL: "/api/backups/upload",
Body: bodies[0].buffer,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": bodies[0].contentType,
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/backups/upload",
+ URL: "/api/backups/upload",
Body: bodies[1].buffer,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": bodies[1].contentType,
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (missing file)",
+ Name: "authorized as superuser (missing file)",
Method: http.MethodPost,
- Url: "/api/backups/upload",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/upload",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureNoBackups(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (existing backup name)",
+ Name: "authorized as superuser (existing backup name)",
Method: http.MethodPost,
- Url: "/api/backups/upload",
+ URL: "/api/backups/upload",
Body: bodies[3].buffer,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": bodies[3].contentType,
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
fsys, err := app.NewBackupsFilesystem()
if err != nil {
t.Fatal(err)
@@ -296,7 +319,7 @@ func TestBackupsUpload(t *testing.T) {
t.Fatal(err)
}
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
@@ -304,23 +327,38 @@ func TestBackupsUpload(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"file":{`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (valid file)",
+ Name: "authorized as superuser (valid file)",
Method: http.MethodPost,
- Url: "/api/backups/upload",
+ URL: "/api/backups/upload",
Body: bodies[4].buffer,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": bodies[4].contentType,
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, _ := getBackupFiles(app)
if total := len(files); total != 1 {
t.Fatalf("Expected %d backup file, got %d", 1, total)
}
},
ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "ensure that the default body limit is skipped",
+ Method: http.MethodPost,
+ URL: "/api/backups/upload",
+ Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400, // it doesn't matter as long as it is not 413
+ ExpectedContent: []string{`"data":{`},
+ NotExpectedContent: []string{"entity too large"},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -330,152 +368,217 @@ func TestBackupsUpload(t *testing.T) {
}
func TestBackupsDownload(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with record auth header",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/backups/test1.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with admin auth header",
+ Name: "with superuser auth header",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/test1.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with empty or invalid token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid record auth token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "with valid record file token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8ifQ.nSTLuCPcGpWn2K2l-BFkC3Vlzc-ZTDPByYq8dN1oPSo",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with valid admin auth token",
+ Name: "with valid superuser auth token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with expired admin file token",
+ Name: "with expired superuser file token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with valid admin file token but missing backup name",
+ Name: "with valid superuser file token but missing backup name",
Method: http.MethodGet,
- Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- ExpectedStatus: 400,
+ ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with valid admin file token",
+ Name: "with valid superuser file token",
Method: http.MethodGet,
- Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `storage/`,
- `data.db`,
- `logs.db`,
+ "storage/",
+ "data.db",
+ "auxiliary.db",
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "with valid admin file token and backup name with escaped char",
+ Name: "with valid superuser file token and backup name with escaped char",
Method: http.MethodGet,
- Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `storage/`,
- `data.db`,
- `logs.db`,
+ "storage/",
+ "data.db",
+ "auxiliary.db",
},
+ 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},
},
}
@@ -485,7 +588,9 @@ func TestBackupsDownload(t *testing.T) {
}
func TestBackupsDelete(t *testing.T) {
- noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
+ t.Parallel()
+
+ noTestBackupFilesChanges := func(t testing.TB, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -501,91 +606,95 @@ func TestBackupsDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
- Url: "/api/backups/test1.zip",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodDelete,
- Url: "/api/backups/test1.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/backups/test1.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (missing file)",
+ Name: "authorized as superuser (missing file)",
Method: http.MethodDelete,
- Url: "/api/backups/missing.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/missing.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (existing file with matching active backup)",
+ Name: "authorized as superuser (existing file with matching active backup)",
Method: http.MethodDelete,
- Url: "/api/backups/test1.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/test1.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with the same name to delete
- app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
+ app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
noTestBackupFilesChanges(t, app)
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (existing file and no matching active backup)",
+ Name: "authorized as superuser (existing file and no matching active backup)",
Method: http.MethodDelete,
- Url: "/api/backups/test1.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/test1.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
// mock active backup with different name
- app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
+ app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -604,20 +713,21 @@ func TestBackupsDelete(t *testing.T) {
}
},
ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (backup with escaped character)",
+ Name: "authorized as superuser (backup with escaped character)",
Method: http.MethodDelete,
- Url: "/api/backups/%40test4.zip",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/%40test4.zip",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
@@ -636,6 +746,7 @@ func TestBackupsDelete(t *testing.T) {
}
},
ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -645,65 +756,71 @@ func TestBackupsDelete(t *testing.T) {
}
func TestBackupsRestore(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/backups/test1.zip/restore",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ URL: "/api/backups/test1.zip/restore",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/backups/test1.zip/restore",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/backups/test1.zip/restore",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (missing file)",
+ Name: "authorized as superuser (missing file)",
Method: http.MethodPost,
- Url: "/api/backups/missing.zip/restore",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/missing.zip/restore",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (active backup process)",
+ Name: "authorized as superuser (active backup process)",
Method: http.MethodPost,
- Url: "/api/backups/test1.zip/restore",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/backups/test1.zip/restore",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := createTestBackups(app); err != nil {
t.Fatal(err)
}
- app.Cache().Set(core.CacheKeyActiveBackup, "")
+ app.Store().Set(core.StoreKeyActiveBackup, "")
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -746,7 +863,7 @@ func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
return fsys.List("")
}
-func ensureNoBackups(t *testing.T, app *tests.TestApp) {
+func ensureNoBackups(t testing.TB, app *tests.TestApp) {
files, err := getBackupFiles(app)
if err != nil {
t.Fatal(err)
diff --git a/apis/backup_upload.go b/apis/backup_upload.go
new file mode 100644
index 0000000000..d90901e070
--- /dev/null
+++ b/apis/backup_upload.go
@@ -0,0 +1,72 @@
+package apis
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+)
+
+func backupUpload(e *core.RequestEvent) error {
+ fsys, err := e.App.NewBackupsFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+
+ form := new(backupUploadForm)
+ form.fsys = fsys
+ files, _ := e.FindUploadedFiles("file")
+ if len(files) > 0 {
+ form.File = files[0]
+ }
+
+ err = form.validate()
+ if err != nil {
+ return e.BadRequestError("An error occurred while validating the submitted data.", err)
+ }
+
+ err = fsys.UploadFile(form.File, form.File.OriginalName)
+ if err != nil {
+ return e.BadRequestError("Failed to upload backup.", err)
+ }
+
+ // we don't retrieve the generated backup file because it may not be
+ // available yet due to the eventually consistent nature of some S3 providers
+ return e.NoContent(http.StatusNoContent)
+}
+
+// -------------------------------------------------------------------
+
+type backupUploadForm struct {
+ fsys *filesystem.System
+
+ File *filesystem.File `json:"file"`
+}
+
+func (form *backupUploadForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(
+ &form.File,
+ validation.Required,
+ validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
+ validation.By(form.checkUniqueName),
+ ),
+ )
+}
+
+func (form *backupUploadForm) checkUniqueName(value any) error {
+ v, _ := value.(*filesystem.File)
+ if v == nil {
+ return nil // nothing to check
+ }
+
+ // note: we use the original name because that is what we upload
+ if exists, err := form.fsys.Exists(v.OriginalName); err != nil || exists {
+ return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
+ }
+
+ return nil
+}
diff --git a/apis/base.go b/apis/base.go
index 12958ee7f3..2ea469bc4f 100644
--- a/apis/base.go
+++ b/apis/base.go
@@ -1,268 +1,180 @@
-// Package apis implements the default PocketBase api services and middlewares.
package apis
import (
- "database/sql"
"errors"
"fmt"
"io/fs"
- "log"
"net/http"
- "net/url"
"path/filepath"
"strings"
- "github.com/labstack/echo/v5"
- "github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/tools/rest"
- "github.com/pocketbase/pocketbase/ui"
- "github.com/spf13/cast"
+ "github.com/pocketbase/pocketbase/tools/router"
)
-const trailedAdminPath = "/_/"
+// StaticWildcardParam is the name of Static handler wildcard parameter.
+const StaticWildcardParam = "path"
-// InitApi creates a configured echo instance with registered
-// system and app specific routes and middlewares.
-func InitApi(app core.App) (*echo.Echo, error) {
- e := echo.New()
- e.Debug = app.IsDebug()
- e.JSONSerializer = &rest.Serializer{
- FieldsParam: "fields",
- }
+// NewRouter returns a new router instance loaded with the default app middlewares and routes.
+func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
+ pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
+ event := new(core.RequestEvent)
+ event.Response = w
+ event.Request = r
+ event.App = app
- // configure a custom router
- e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
- return echo.NewRouter(echo.RouterConfig{
- UnescapePathParamValues: true,
- AllowOverwritingRoute: true,
- })
+ return event, nil
})
- // default middlewares
- e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
- Skipper: func(c echo.Context) bool {
- // enable by default only for the API routes
- return !strings.HasPrefix(c.Request().URL.Path, "/api/")
- },
- }))
- e.Pre(LoadAuthContext(app))
- e.Use(middleware.Recover())
- e.Use(middleware.Secure())
-
- // custom error handler
- e.HTTPErrorHandler = func(c echo.Context, err error) {
- if err == nil {
- return // no error
- }
-
- if c.Response().Committed {
- if app.IsDebug() {
- log.Println("HTTPErrorHandler response was already committed:", err)
- }
- return
- }
-
- var apiErr *ApiError
-
- if errors.As(err, &apiErr) {
- if app.IsDebug() && apiErr.RawData() != nil {
- log.Println(apiErr.RawData())
- }
- } else if v := new(echo.HTTPError); errors.As(err, &v) {
- if v.Internal != nil && app.IsDebug() {
- log.Println(v.Internal)
- }
- msg := fmt.Sprintf("%v", v.Message)
- apiErr = NewApiError(v.Code, msg, v)
- } else {
- if app.IsDebug() {
- log.Println(err)
- }
-
- if errors.Is(err, sql.ErrNoRows) {
- apiErr = NewNotFoundError("", err)
- } else {
- apiErr = NewBadRequestError("", err)
- }
- }
-
- event := new(core.ApiErrorEvent)
- event.HttpContext = c
- event.Error = apiErr
-
- // send error response
- hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
- if c.Response().Committed {
- return nil
- }
-
- // @see https://github.com/labstack/echo/issues/608
- if e.HttpContext.Request().Method == http.MethodHead {
- return e.HttpContext.NoContent(apiErr.Code)
- }
-
- return e.HttpContext.JSON(apiErr.Code, apiErr)
- })
+ // register default middlewares
+ pbRouter.Bind(activityLogger())
+ pbRouter.Bind(panicRecover())
+ pbRouter.Bind(rateLimit())
+ pbRouter.Bind(loadAuthToken())
+ pbRouter.Bind(superuserIPsWhitelist())
+ pbRouter.Bind(securityHeaders())
+ pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
+
+ // API routes
+ apiGroup := pbRouter.Group("/api")
+ bindSettingsApi(app, apiGroup)
+ bindCollectionApi(app, apiGroup)
+ bindRecordCrudApi(app, apiGroup)
+ bindRecordAuthApi(app, apiGroup)
+ bindLogsApi(app, apiGroup)
+ bindBackupApi(app, apiGroup)
+ bindCronApi(app, apiGroup)
+ bindFileApi(app, apiGroup)
+ bindBatchApi(app, apiGroup)
+ bindRealtimeApi(app, apiGroup)
+ bindHealthApi(app, apiGroup)
+ bindSQLApi(app, apiGroup)
+
+ // UI routes
+ bindUIExtensions(app)
+
+ return pbRouter, nil
+}
- if hookErr == nil {
- if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
- log.Println(hookErr)
- }
- } else if app.IsDebug() {
- // truly rare case; eg. client already disconnected
- log.Println(hookErr)
- }
+// WrapStdHandler wraps Go [http.Handler] into a PocketBase handler func.
+func WrapStdHandler(h http.Handler) func(*core.RequestEvent) error {
+ return func(e *core.RequestEvent) error {
+ h.ServeHTTP(e.Response, e.Request)
+ return nil
}
+}
- // admin ui routes
- bindStaticAdminUI(app, e)
+// WrapStdMiddleware wraps Go [func(http.Handler) http.Handle] into a PocketBase middleware func.
+func WrapStdMiddleware(m func(http.Handler) http.Handler) func(*core.RequestEvent) error {
+ return func(e *core.RequestEvent) (err error) {
+ m(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ e.Response = w
+ e.Request = r
+ err = e.Next()
+ })).ServeHTTP(e.Response, e.Request)
+ return err
+ }
+}
- // default routes
- api := e.Group("/api", eagerRequestInfoCache(app))
- bindSettingsApi(app, api)
- bindAdminApi(app, api)
- bindCollectionApi(app, api)
- bindRecordCrudApi(app, api)
- bindRecordAuthApi(app, api)
- bindFileApi(app, api)
- bindRealtimeApi(app, api)
- bindLogsApi(app, api)
- bindHealthApi(app, api)
- bindBackupApi(app, api)
+// MustSubFS returns an [fs.FS] corresponding to the subtree rooted at fsys's dir.
+//
+// This is similar to [fs.Sub] but panics on failure.
+func MustSubFS(fsys fs.FS, dir string) fs.FS {
+ dir = filepath.ToSlash(filepath.Clean(dir)) // ToSlash in case of Windows path
- // catch all any route
- api.Any("/*", func(c echo.Context) error {
- return echo.ErrNotFound
- }, ActivityLogger(app))
+ sub, err := fs.Sub(fsys, dir)
+ if err != nil {
+ panic(fmt.Errorf("failed to create sub FS: %w", err))
+ }
- return e, nil
+ return sub
}
-// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
-// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
+// Static is a handler function to serve static directory content from fsys.
+//
+// If a file resource is missing and indexFallback is true, the request
+// will be forwarded to the base index.html (useful for SPA with pretty urls).
+//
+// NB! Expects the route to have a "{path...}" wildcard parameter.
//
-// If a file resource is missing and indexFallback is set, the request
-// will be forwarded to the base index.html (useful also for SPA).
+// Special redirects:
+// - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
+// - if "path" is a directory that has index.html, the index.html file is rendered,
+// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
//
-// @see https://github.com/labstack/echo/issues/2211
-func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
- return func(c echo.Context) error {
- p := c.PathParam("*")
+// Example:
+//
+// fsys := os.DirFS("./pb_public")
+// router.GET("/files/{path...}", apis.Static(fsys, false))
+func Static(fsys fs.FS, indexFallback bool) func(*core.RequestEvent) error {
+ if fsys == nil {
+ panic("Static: the provided fs.FS argument is nil")
+ }
- // escape url path
- tmpPath, err := url.PathUnescape(p)
- if err != nil {
- return fmt.Errorf("failed to unescape path variable: %w", err)
+ return func(e *core.RequestEvent) error {
+ // disable the activity logger to avoid flooding with messages
+ //
+ // note: errors are still logged
+ if e.Get(requestEventKeySkipSuccessActivityLog) == nil {
+ e.Set(requestEventKeySkipSuccessActivityLog, true)
}
- p = tmpPath
-
- // fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
- name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
- fileErr := c.FileFS(name, fileSystem)
+ filename := e.Request.PathValue(StaticWildcardParam)
+ filename = filepath.ToSlash(filepath.Clean(strings.TrimPrefix(filename, "/")))
- if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
- return c.FileFS("index.html", fileSystem)
+ // eagerly check for directory traversal
+ //
+ // note: this is just out of an abundance of caution because the fs.FS implementation could be non-std,
+ // but usually shouldn't be necessary since os.DirFS.Open is expected to fail if the filename starts with dots
+ if len(filename) > 2 && filename[0] == '.' && filename[1] == '.' && (filename[2] == '/' || filename[2] == '\\') {
+ if indexFallback && filename != router.IndexPage {
+ return e.FileFS(fsys, router.IndexPage)
+ }
+ return router.ErrFileNotFound
}
- return fileErr
- }
-}
-
-// bindStaticAdminUI registers the endpoints that serves the static admin UI.
-func bindStaticAdminUI(app core.App, e *echo.Echo) error {
- // redirect to trailing slash to ensure that relative urls will still work properly
- e.GET(
- strings.TrimRight(trailedAdminPath, "/"),
- func(c echo.Context) error {
- return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
- },
- )
-
- // serves static files from the /ui/dist directory
- // (similar to echo.StaticFS but with gzip middleware enabled)
- e.GET(
- trailedAdminPath+"*",
- echo.StaticDirectoryHandler(ui.DistDirFS, false),
- installerRedirect(app),
- uiCacheControl(),
- middleware.Gzip(),
- )
-
- return nil
-}
-
-func uiCacheControl() echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- // add default Cache-Control header for all Admin UI resources
- // (ignoring the root admin path)
- if c.Request().URL.Path != trailedAdminPath {
- c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
+ fi, err := fs.Stat(fsys, filename)
+ if err != nil {
+ if indexFallback && filename != router.IndexPage {
+ return e.FileFS(fsys, router.IndexPage)
}
-
- return next(c)
+ return router.ErrFileNotFound
}
- }
-}
-
-const hasAdminsCacheKey = "@hasAdmins"
-
-func updateHasAdminsCache(app core.App) error {
- total, err := app.Dao().TotalAdmins()
- if err != nil {
- return err
- }
- app.Cache().Set(hasAdminsCacheKey, total > 0)
-
- return nil
-}
-
-// installerRedirect redirects the user to the installer admin UI page
-// when the application needs some preliminary configurations to be done.
-func installerRedirect(app core.App) echo.MiddlewareFunc {
- // keep hasAdminsCacheKey value up-to-date
- app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
- return updateHasAdminsCache(app)
- })
-
- app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
- return updateHasAdminsCache(app)
- })
-
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- // skip redirect checks for non-root level index.html requests
- path := c.Request().URL.Path
- if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
- return next(c)
+ if fi.IsDir() {
+ // redirect to a canonical dir url, aka. with trailing slash
+ if !strings.HasSuffix(e.Request.URL.Path, "/") {
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(e.Request.URL.Path+"/"))
}
-
- hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
-
- if !hasAdmins {
- // update the cache to make sure that the admin wasn't created by another process
- if err := updateHasAdminsCache(app); err != nil {
- return err
+ } else {
+ urlPath := e.Request.URL.Path
+ if strings.HasSuffix(urlPath, "/") {
+ // redirect to a non-trailing slash file route
+ urlPath = strings.TrimRight(urlPath, "/")
+ if len(urlPath) > 0 {
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(urlPath))
}
- hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
+ } else if stripped, ok := strings.CutSuffix(urlPath, router.IndexPage); ok {
+ // redirect without the index.html
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(stripped))
}
+ }
- _, hasInstallerParam := c.Request().URL.Query()["installer"]
+ fileErr := e.FileFS(fsys, filename)
- if !hasAdmins && !hasInstallerParam {
- // redirect to the installer page
- return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
- }
+ if fileErr != nil && indexFallback && filename != router.IndexPage && errors.Is(fileErr, router.ErrFileNotFound) {
+ return e.FileFS(fsys, router.IndexPage)
+ }
- if hasAdmins && hasInstallerParam {
- // clear the installer param
- return c.Redirect(http.StatusTemporaryRedirect, "?")
- }
+ return fileErr
+ }
+}
- return next(c)
- }
+// safeRedirectPath normalizes the path string by replacing all beginning slashes
+// (`\\`, `//`, `\/`) with a single forward slash to prevent open redirect attacks
+func safeRedirectPath(path string) string {
+ if len(path) > 1 && (path[0] == '\\' || path[0] == '/') && (path[1] == '\\' || path[1] == '/') {
+ path = "/" + strings.TrimLeft(path, `/\`)
}
+ return path
}
diff --git a/apis/base_test.go b/apis/base_test.go
index b5888576d5..19d96a514b 100644
--- a/apis/base_test.go
+++ b/apis/base_test.go
@@ -1,401 +1,313 @@
package apis_test
import (
- "database/sql"
- "errors"
"fmt"
"net/http"
- "strings"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
- "github.com/spf13/cast"
+ "github.com/pocketbase/pocketbase/tools/router"
)
-func Test404(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Method: http.MethodGet,
- Url: "/api/missing",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Method: http.MethodPost,
- Url: "/api/missing",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Method: http.MethodPatch,
- Url: "/api/missing",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Method: http.MethodDelete,
- Url: "/api/missing",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Method: http.MethodHead,
- Url: "/api/missing",
- ExpectedStatus: 404,
- },
+func TestWrapStdHandler(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ e := new(core.RequestEvent)
+ e.App = app
+ e.Request = req
+ e.Response = rec
+
+ err := apis.WrapStdHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("test"))
+ }))(e)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if body := rec.Body.String(); body != "test" {
+ t.Fatalf("Expected body %q, got %q", "test", body)
+ }
+}
+
+func TestWrapStdMiddleware(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ e := new(core.RequestEvent)
+ e.App = app
+ e.Request = req
+ e.Response = rec
+
+ err := apis.WrapStdMiddleware(func(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("test"))
+ })
+ })(e)
+ if err != nil {
+ t.Fatal(err)
}
- for _, scenario := range scenarios {
- scenario.Test(t)
+ if body := rec.Body.String(); body != "test" {
+ t.Fatalf("Expected body %q, got %q", "test", body)
}
}
-func TestCustomRoutesAndErrorsHandling(t *testing.T) {
- scenarios := []tests.ApiScenario{
+func TestStatic(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ dir := createTestDir(t)
+ defer os.RemoveAll(dir)
+
+ fsys := os.DirFS(filepath.Join(dir, "sub"))
+
+ type staticScenario struct {
+ path string
+ indexFallback bool
+ expectedStatus int
+ expectBody string
+ expectError bool
+ }
+
+ scenarios := []staticScenario{
{
- Name: "custom route",
- Method: http.MethodGet,
- Url: "/custom",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ path: "",
+ indexFallback: false,
+ expectedStatus: 200,
+ expectBody: "sub index.html",
+ expectError: false,
},
{
- Name: "custom route with url encoded parameter",
- Method: http.MethodGet,
- Url: "/a%2Bb%2Bc",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/:param",
- Handler: func(c echo.Context) error {
- return c.String(200, c.PathParam("param"))
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"a+b+c"},
+ path: "missing/a/b/c",
+ indexFallback: false,
+ expectedStatus: 404,
+ expectBody: "",
+ expectError: true,
},
{
- Name: "route with HTTPError",
- Method: http.MethodGet,
- Url: "/http-error",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/http-error",
- Handler: func(c echo.Context) error {
- return echo.ErrBadRequest
- },
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`{"code":400,"message":"Bad Request.","data":{}}`},
+ path: "missing/a/b/c",
+ indexFallback: true,
+ expectedStatus: 200,
+ expectBody: "sub index.html",
+ expectError: false,
},
{
- Name: "route with api error",
- Method: http.MethodGet,
- Url: "/api-error",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/api-error",
- Handler: func(c echo.Context) error {
- return apis.NewApiError(500, "test message", errors.New("internal_test"))
- },
- })
- },
- ExpectedStatus: 500,
- ExpectedContent: []string{`{"code":500,"message":"Test message.","data":{}}`},
+ path: "testroot", // parent directory file
+ indexFallback: false,
+ expectedStatus: 404,
+ expectBody: "",
+ expectError: true,
},
{
- Name: "route with plain error",
- Method: http.MethodGet,
- Url: "/plain-error",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/plain-error",
- Handler: func(c echo.Context) error {
- return errors.New("Test error")
- },
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
+ path: "test",
+ indexFallback: false,
+ expectedStatus: 200,
+ expectBody: "sub test",
+ expectError: false,
},
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRemoveTrailingSlashMiddleware(t *testing.T) {
- scenarios := []tests.ApiScenario{
{
- Name: "non /api/* route (exact match)",
- Method: http.MethodGet,
- Url: "/custom",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ path: "sub2",
+ indexFallback: false,
+ expectedStatus: 301,
+ expectBody: "",
+ expectError: false,
},
{
- Name: "non /api/* route (with trailing slash)",
- Method: http.MethodGet,
- Url: "/custom/",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- })
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
+ path: "sub2/",
+ indexFallback: false,
+ expectedStatus: 200,
+ expectBody: "sub2 index.html",
+ expectError: false,
},
{
- Name: "/api/* route (exact match)",
- Method: http.MethodGet,
- Url: "/api/custom",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/api/custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ path: "sub2/test",
+ indexFallback: false,
+ expectedStatus: 200,
+ expectBody: "sub2 test",
+ expectError: false,
},
{
- Name: "/api/* route (with trailing slash)",
- Method: http.MethodGet,
- Url: "/api/custom/",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/api/custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ path: "sub2/test/",
+ indexFallback: false,
+ expectedStatus: 301,
+ expectBody: "",
+ expectError: false,
},
}
- for _, scenario := range scenarios {
- scenario.Test(t)
+ // extra directory traversal checks
+ dtp := []string{
+ "/../",
+ "\\../",
+ "../",
+ "../../",
+ "..\\",
+ "..\\..\\",
+ "../..\\",
+ "..\\..//",
+ `%2e%2e%2f`,
+ `%2e%2e%2f%2e%2e%2f`,
+ `%2e%2e/`,
+ `%2e%2e/%2e%2e/`,
+ `..%2f`,
+ `..%2f..%2f`,
+ `%2e%2e%5c`,
+ `%2e%2e%5c%2e%2e%5c`,
+ `%2e%2e\`,
+ `%2e%2e\%2e%2e\`,
+ `..%5c`,
+ `..%5c..%5c`,
+ `%252e%252e%255c`,
+ `%252e%252e%255c%252e%252e%255c`,
+ `..%255c`,
+ `..%255c..%255c`,
}
-}
-
-func TestEagerRequestInfoCache(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "custom non-api group route",
- Method: "POST",
- Url: "/custom",
- Body: strings.NewReader(`{"name":"test123"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: "POST",
- Path: "/custom",
- Handler: func(c echo.Context) error {
- data := &struct {
- Name string `json:"name"`
- }{}
-
- if err := c.Bind(data); err != nil {
- return err
- }
-
- // since the unknown method is not eager cache support
- // it should fail reading the json body twice
- r := apis.RequestInfo(c)
- if v := cast.ToString(r.Data["name"]); v != "" {
- t.Fatalf("Expected empty request data body, got, %v", r.Data)
- }
-
- return c.NoContent(200)
- },
- })
+ for _, p := range dtp {
+ scenarios = append(scenarios,
+ staticScenario{
+ path: p + "testroot",
+ indexFallback: false,
+ expectedStatus: 404,
+ expectBody: "",
+ expectError: true,
},
- ExpectedStatus: 200,
- },
- {
- Name: "api group route with unsupported eager cache request method",
- Method: "GET",
- Url: "/api/admins",
- Body: strings.NewReader(`{"name":"test123"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- // it is not important whether the route handler return an error since
- // we just need to ensure that the eagerRequestInfoCache was registered
- next(c)
-
- // ensure that the body was read at least once
- data := &struct {
- Name string `json:"name"`
- }{}
- c.Bind(data)
-
- // since the unknown method is not eager cache support
- // it should fail reading the json body twice
- r := apis.RequestInfo(c)
- if v := cast.ToString(r.Data["name"]); v != "" {
- t.Fatalf("Expected empty request data body, got, %v", r.Data)
- }
-
- return nil
- }
- })
+ staticScenario{
+ path: p + "testroot",
+ indexFallback: true,
+ expectedStatus: 200,
+ expectBody: "sub index.html",
+ expectError: false,
},
- ExpectedStatus: 200,
- },
- {
- Name: "api group route with supported eager cache request method",
- Method: "POST",
- Url: "/api/admins",
- Body: strings.NewReader(`{"name":"test123"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- // it is not important whether the route handler return an error since
- // we just need to ensure that the eagerRequestInfoCache was registered
- next(c)
-
- // ensure that the body was read at least once
- data := &struct {
- Name string `json:"name"`
- }{}
- c.Bind(data)
-
- // try to read the body again
- r := apis.RequestInfo(c)
- if v := cast.ToString(r.Data["name"]); v != "test123" {
- t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
- }
-
- return nil
- }
- })
- },
- ExpectedStatus: 200,
- },
+ )
}
- for _, scenario := range scenarios {
- scenario.Test(t)
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%v", i, s.path, s.indexFallback), func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/"+s.path, nil)
+ req.SetPathValue(apis.StaticWildcardParam, s.path)
+
+ rec := httptest.NewRecorder()
+
+ e := new(core.RequestEvent)
+ e.App = app
+ e.Request = req
+ e.Response = rec
+
+ err := apis.Static(fsys, s.indexFallback)(e)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ body := rec.Body.String()
+ if body != s.expectBody {
+ t.Fatalf("Expected body %q, got %q", s.expectBody, body)
+ }
+
+ if hasErr {
+ apiErr := router.ToApiError(err)
+ if apiErr.Status != s.expectedStatus {
+ t.Fatalf("Expected status code %d, got %d", s.expectedStatus, apiErr.Status)
+ }
+ }
+ })
}
}
-func TestErrorHandler(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "apis.ApiError",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return apis.NewApiError(418, "test", nil)
- })
- },
- ExpectedStatus: 418,
- ExpectedContent: []string{`"message":"Test."`},
- },
- {
- Name: "wrapped apis.ApiError",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
- })
- },
- ExpectedStatus: 418,
- ExpectedContent: []string{`"message":"Test."`},
- NotExpectedContent: []string{"example", "123"},
- },
- {
- Name: "echo.HTTPError",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return echo.NewHTTPError(418, "test")
- })
- },
- ExpectedStatus: 418,
- ExpectedContent: []string{`"message":"Test."`},
- },
- {
- Name: "wrapped echo.HTTPError",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
- })
- },
- ExpectedStatus: 418,
- ExpectedContent: []string{`"message":"Test."`},
- NotExpectedContent: []string{"example", "123"},
- },
- {
- Name: "wrapped sql.ErrNoRows",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return fmt.Errorf("example 123: %w", sql.ErrNoRows)
- })
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- NotExpectedContent: []string{"example", "123"},
- },
- {
- Name: "custom error",
- Method: http.MethodGet,
- Url: "/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.GET("/test", func(c echo.Context) error {
- return fmt.Errorf("example 123")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- NotExpectedContent: []string{"example", "123"},
- },
+func TestMustSubFS(t *testing.T) {
+ t.Parallel()
+
+ dir := createTestDir(t)
+ defer os.RemoveAll(dir)
+
+ // invalid path (no beginning and ending slashes)
+ if !hasPanicked(func() {
+ apis.MustSubFS(os.DirFS(dir), "/test/")
+ }) {
+ t.Fatalf("Expected to panic")
+ }
+
+ // valid path
+ if hasPanicked(func() {
+ apis.MustSubFS(os.DirFS(dir), "./////a/b/c") // checks if ToSlash was called
+ }) {
+ t.Fatalf("Didn't expect to panic")
+ }
+
+ // check sub content
+ sub := apis.MustSubFS(os.DirFS(dir), "sub")
+
+ _, err := sub.Open("test")
+ if err != nil {
+ t.Fatalf("Missing expected file sub/test")
+ }
+}
+
+// -------------------------------------------------------------------
+
+func hasPanicked(f func()) (didPanic bool) {
+ defer func() {
+ if r := recover(); r != nil {
+ didPanic = true
+ }
+ }()
+ f()
+ return
+}
+
+// note: make sure to call os.RemoveAll(dir) after you are done
+// working with the created test dir.
+func createTestDir(t *testing.T) string {
+ dir, err := os.MkdirTemp(os.TempDir(), "test_dir")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("root index.html"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "testroot"), []byte("root test"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := os.MkdirAll(filepath.Join(dir, "sub"), os.ModePerm); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "sub/index.html"), []byte("sub index.html"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "sub/test"), []byte("sub test"), 0644); err != nil {
+ t.Fatal(err)
}
- for _, scenario := range scenarios {
- scenario.Test(t)
+ if err := os.MkdirAll(filepath.Join(dir, "sub", "sub2"), os.ModePerm); err != nil {
+ t.Fatal(err)
}
+ if err := os.WriteFile(filepath.Join(dir, "sub/sub2/index.html"), []byte("sub2 index.html"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "sub/sub2/test"), []byte("sub2 test"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ return dir
}
diff --git a/apis/batch.go b/apis/batch.go
new file mode 100644
index 0000000000..3f7367faeb
--- /dev/null
+++ b/apis/batch.go
@@ -0,0 +1,550 @@
+package apis
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/routine"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+func bindBatchApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ sub := rg.Group("/batch")
+ sub.POST("", batchTransaction).Unbind(DefaultBodyLimitMiddlewareId) // the body limit is inlined
+}
+
+type HandleFunc func(e *core.RequestEvent) error
+
+type BatchActionHandlerFunc func(app core.App, ir *core.InternalRequest, params map[string]string, next func(data any) error) HandleFunc
+
+// ValidBatchActions defines a map with the supported batch InternalRequest actions.
+//
+// Note: when adding new routes make sure that their middlewares are inlined!
+var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
+ // "upsert" handler
+ regexp.MustCompile(`^PUT /api/collections/(?P[^\/\?]+)/records(?P\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
+ var id string
+ if len(ir.Body) > 0 && ir.Body["id"] != "" {
+ id = cast.ToString(ir.Body["id"])
+ }
+ if id != "" {
+ _, err := app.FindRecordById(params["collection"], id)
+ if err == nil {
+ // update
+ // ---
+ params["id"] = id // required for the path value
+ ir.Method = "PATCH"
+ ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
+ return recordUpdate(false, next)
+ }
+ }
+
+ // create
+ // ---
+ ir.Method = "POST"
+ ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
+ return recordCreate(false, next)
+ },
+ regexp.MustCompile(`^POST /api/collections/(?P[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
+ return recordCreate(false, next)
+ },
+ regexp.MustCompile(`^PATCH /api/collections/(?P[^\/\?]+)/records/(?P[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
+ return recordUpdate(false, next)
+ },
+ regexp.MustCompile(`^DELETE /api/collections/(?P[^\/\?]+)/records/(?P[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
+ return recordDelete(false, next)
+ },
+}
+
+type BatchRequestResult struct {
+ Body any `json:"body"`
+ Status int `json:"status"`
+}
+
+type batchRequestsForm struct {
+ Requests []*core.InternalRequest `form:"requests" json:"requests"`
+
+ max int
+}
+
+func (brs batchRequestsForm) validate() error {
+ return validation.ValidateStruct(&brs,
+ validation.Field(&brs.Requests, validation.Required, validation.Length(0, brs.max)),
+ )
+}
+
+// NB! When the request is submitted as multipart/form-data,
+// the regular fields data is expected to be submitted as serialized
+// json under the @jsonPayload field and file keys need to follow the
+// pattern "requests.N.fileField" or requests[N].fileField.
+func batchTransaction(e *core.RequestEvent) error {
+ maxRequests := e.App.Settings().Batch.MaxRequests
+ if !e.App.Settings().Batch.Enabled || maxRequests <= 0 {
+ return e.ForbiddenError("Batch requests are not allowed.", nil)
+ }
+
+ txTimeout := time.Duration(e.App.Settings().Batch.Timeout) * time.Second
+ if txTimeout <= 0 {
+ txTimeout = 3 * time.Second // for now always limit
+ }
+
+ maxBodySize := e.App.Settings().Batch.MaxBodySize
+ if maxBodySize <= 0 {
+ maxBodySize = 128 << 20
+ }
+
+ err := applyBodyLimit(e, maxBodySize)
+ if err != nil {
+ return err
+ }
+
+ form := &batchRequestsForm{max: maxRequests}
+
+ // load base requests data
+ err = e.BindBody(form)
+ if err != nil {
+ return e.BadRequestError("Failed to read the submitted batch data.", err)
+ }
+
+ // load uploaded files into each request item
+ // note: expects the files to be under "requests.N.fileField" or "requests[N].fileField" format
+ // (the other regular fields must be put under `@jsonPayload` as serialized json)
+ if strings.HasPrefix(e.Request.Header.Get("Content-Type"), "multipart/form-data") {
+ for i, ir := range form.Requests {
+ iStr := strconv.Itoa(i)
+
+ files, err := extractPrefixedFiles(e.Request, "requests."+iStr+".", "requests["+iStr+"].")
+ if err != nil {
+ return e.BadRequestError("Failed to read the submitted batch files data.", err)
+ }
+
+ for key, files := range files {
+ if ir.Body == nil {
+ ir.Body = map[string]any{}
+ }
+ ir.Body[key] = files
+ }
+ }
+ }
+
+ // validate batch request form
+ err = form.validate()
+ if err != nil {
+ return e.BadRequestError("Invalid batch request data.", err)
+ }
+
+ event := new(core.BatchRequestEvent)
+ event.RequestEvent = e
+ event.Batch = form.Requests
+
+ return e.App.OnBatchRequest().Trigger(event, func(e *core.BatchRequestEvent) error {
+ bp := batchProcessor{
+ app: e.App,
+ baseEvent: e.RequestEvent,
+ infoContext: core.RequestInfoContextBatch,
+ }
+
+ if err := bp.Process(e.Batch, txTimeout); err != nil {
+ return firstApiError(err, e.BadRequestError("Batch transaction failed.", err))
+ }
+
+ return e.JSON(http.StatusOK, bp.results)
+ })
+}
+
+type batchProcessor struct {
+ app core.App
+ baseEvent *core.RequestEvent
+ infoContext string
+ results []*BatchRequestResult
+ failedIndex int
+ errCh chan error
+ stopCh chan struct{}
+}
+
+func (p *batchProcessor) Process(batch []*core.InternalRequest, timeout time.Duration) error {
+ p.results = make([]*BatchRequestResult, 0, len(batch))
+
+ if p.stopCh != nil {
+ close(p.stopCh)
+ }
+ p.stopCh = make(chan struct{}, 1)
+
+ if p.errCh != nil {
+ close(p.errCh)
+ }
+ p.errCh = make(chan error, 1)
+
+ return p.app.RunInTransaction(func(txApp core.App) error {
+ // used to interupts the recursive processing calls in case of a timeout or connection close
+ defer func() {
+ p.stopCh <- struct{}{}
+ }()
+
+ routine.FireAndForget(func() {
+ err := p.process(txApp, batch, 0)
+
+ if err != nil {
+ err = validation.Errors{
+ "requests": validation.Errors{
+ strconv.Itoa(p.failedIndex): &BatchResponseError{
+ code: "batch_request_failed",
+ message: "Batch request failed.",
+ err: router.ToApiError(err),
+ },
+ },
+ }
+ }
+
+ // note: to avoid copying and due to the process recursion the final results order is reversed
+ if err == nil {
+ slices.Reverse(p.results)
+ }
+
+ p.errCh <- err
+ })
+
+ select {
+ case responseErr := <-p.errCh:
+ return responseErr
+ case <-time.After(timeout):
+ // note: we don't return 408 Reques Timeout error because
+ // some browsers perform automatic retry behind the scenes
+ // which are hard to debug and unnecessary
+ return errors.New("batch transaction timeout")
+ case <-p.baseEvent.Request.Context().Done():
+ return errors.New("batch request interrupted")
+ }
+ })
+}
+
+func (p *batchProcessor) process(activeApp core.App, batch []*core.InternalRequest, i int) error {
+ select {
+ case <-p.stopCh:
+ return nil
+ default:
+ if len(batch) == 0 {
+ return nil
+ }
+
+ result, err := processInternalRequest(
+ activeApp,
+ p.baseEvent,
+ batch[0],
+ p.infoContext,
+ func(_ any) error {
+ if len(batch) == 1 {
+ return nil
+ }
+
+ err := p.process(activeApp, batch[1:], i+1)
+
+ // update the failed batch index (if not already)
+ if err != nil && p.failedIndex == 0 {
+ p.failedIndex = i + 1
+ }
+
+ return err
+ },
+ )
+
+ if err != nil {
+ return err
+ }
+
+ p.results = append(p.results, result)
+
+ return nil
+ }
+}
+
+func processInternalRequest(
+ activeApp core.App,
+ baseEvent *core.RequestEvent,
+ ir *core.InternalRequest,
+ infoContext string,
+ optNext func(data any) error,
+) (*BatchRequestResult, error) {
+ handle, params, ok := prepareInternalAction(activeApp, ir, optNext)
+ if !ok {
+ return nil, errors.New("unknown batch request action")
+ }
+
+ // construct a new http.Request
+ // ---------------------------------------------------------------
+ buf, mw, err := multipartDataFromInternalRequest(ir)
+ if err != nil {
+ return nil, err
+ }
+
+ r, err := http.NewRequest(strings.ToUpper(ir.Method), ir.URL, buf)
+ if err != nil {
+ return nil, err
+ }
+
+ // cleanup multipart temp files
+ defer func() {
+ if r.MultipartForm != nil {
+ if err := r.MultipartForm.RemoveAll(); err != nil {
+ activeApp.Logger().Warn("failed to cleanup temp batch files", "error", err)
+ }
+ }
+ }()
+
+ // load batch request path params
+ // ---
+ for k, v := range params {
+ r.SetPathValue(k, v)
+ }
+
+ // clone original request
+ // ---
+ r.RequestURI = r.URL.RequestURI()
+ r.Proto = baseEvent.Request.Proto
+ r.ProtoMajor = baseEvent.Request.ProtoMajor
+ r.ProtoMinor = baseEvent.Request.ProtoMinor
+ r.Host = baseEvent.Request.Host
+ r.RemoteAddr = baseEvent.Request.RemoteAddr
+ r.TLS = baseEvent.Request.TLS
+
+ if s := baseEvent.Request.TransferEncoding; s != nil {
+ s2 := make([]string, len(s))
+ copy(s2, s)
+ r.TransferEncoding = s2
+ }
+
+ if baseEvent.Request.Trailer != nil {
+ r.Trailer = baseEvent.Request.Trailer.Clone()
+ }
+
+ if baseEvent.Request.Header != nil {
+ r.Header = baseEvent.Request.Header.Clone()
+ }
+
+ // apply batch request specific headers
+ // ---
+ for k, v := range ir.Headers {
+ // individual Authorization header keys don't have affect
+ // because the auth state is populated from the base event
+ if strings.EqualFold(k, "authorization") {
+ continue
+ }
+
+ r.Header.Set(k, v)
+ }
+ r.Header.Set("Content-Type", mw.FormDataContentType())
+
+ // construct a new RequestEvent
+ // ---------------------------------------------------------------
+ event := &core.RequestEvent{}
+ event.App = activeApp
+ event.Auth = baseEvent.Auth
+ event.SetAll(baseEvent.GetAll())
+
+ // load RequestInfo context
+ if infoContext == "" {
+ infoContext = core.RequestInfoContextDefault
+ }
+ event.Set(core.RequestEventKeyInfoContext, infoContext)
+
+ // assign request
+ event.Request = r
+ event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads
+ defer event.Request.Body.Close()
+
+ // assign response
+ rec := httptest.NewRecorder()
+ event.Response = &router.ResponseWriter{ResponseWriter: rec} // enables status and write tracking
+
+ // execute
+ // ---------------------------------------------------------------
+ if err := handle(event); err != nil {
+ return nil, err
+ }
+
+ result := rec.Result()
+ defer result.Body.Close()
+
+ body, _ := types.ParseJSONRaw(rec.Body.Bytes())
+
+ return &BatchRequestResult{
+ Status: result.StatusCode,
+ Body: body,
+ }, nil
+}
+
+func multipartDataFromInternalRequest(ir *core.InternalRequest) (*bytes.Buffer, *multipart.Writer, error) {
+ buf := &bytes.Buffer{}
+
+ mw := multipart.NewWriter(buf)
+
+ regularFields := map[string]any{}
+ fileFields := map[string][]*filesystem.File{}
+
+ // separate regular fields from files
+ // ---
+ for k, rawV := range ir.Body {
+ switch v := rawV.(type) {
+ case *filesystem.File:
+ fileFields[k] = append(fileFields[k], v)
+ case []*filesystem.File:
+ fileFields[k] = append(fileFields[k], v...)
+ default:
+ regularFields[k] = v
+ }
+ }
+
+ // submit regularFields as @jsonPayload
+ // ---
+ rawBody, err := json.Marshal(regularFields)
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+
+ jsonPayload, err := mw.CreateFormField("@jsonPayload")
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+ _, err = jsonPayload.Write(rawBody)
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+
+ // submit fileFields as multipart files
+ // ---
+ for key, files := range fileFields {
+ for _, file := range files {
+ part, err := mw.CreateFormFile(key, file.Name)
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+
+ fr, err := file.Reader.Open()
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+
+ _, err = io.Copy(part, fr)
+ if err != nil {
+ return nil, nil, errors.Join(err, fr.Close(), mw.Close())
+ }
+
+ err = fr.Close()
+ if err != nil {
+ return nil, nil, errors.Join(err, mw.Close())
+ }
+ }
+ }
+
+ return buf, mw, mw.Close()
+}
+
+func extractPrefixedFiles(request *http.Request, prefixes ...string) (map[string][]*filesystem.File, error) {
+ if request.MultipartForm == nil {
+ if err := request.ParseMultipartForm(router.DefaultMaxMemory); err != nil {
+ return nil, err
+ }
+ }
+
+ result := make(map[string][]*filesystem.File)
+
+ for k, fhs := range request.MultipartForm.File {
+ for _, p := range prefixes {
+ if strings.HasPrefix(k, p) {
+ resultKey := strings.TrimPrefix(k, p)
+
+ for _, fh := range fhs {
+ file, err := filesystem.NewFileFromMultipart(fh)
+ if err != nil {
+ return nil, err
+ }
+
+ result[resultKey] = append(result[resultKey], file)
+ }
+ }
+ }
+ }
+
+ return result, nil
+}
+
+func prepareInternalAction(activeApp core.App, ir *core.InternalRequest, optNext func(data any) error) (HandleFunc, map[string]string, bool) {
+ full := strings.ToUpper(ir.Method) + " " + ir.URL
+
+ for re, actionFactory := range ValidBatchActions {
+ params, ok := findNamedMatches(re, full)
+ if ok {
+ return actionFactory(activeApp, ir, params, optNext), params, true
+ }
+ }
+
+ return nil, nil, false
+}
+
+func findNamedMatches(re *regexp.Regexp, str string) (map[string]string, bool) {
+ match := re.FindStringSubmatch(str)
+ if match == nil {
+ return nil, false
+ }
+
+ result := map[string]string{}
+
+ names := re.SubexpNames()
+
+ for i, m := range match {
+ if names[i] != "" {
+ result[names[i]] = m
+ }
+ }
+
+ return result, true
+}
+
+// -------------------------------------------------------------------
+
+var (
+ _ router.SafeErrorItem = (*BatchResponseError)(nil)
+ _ router.SafeErrorResolver = (*BatchResponseError)(nil)
+)
+
+type BatchResponseError struct {
+ err *router.ApiError
+ code string
+ message string
+}
+
+func (e *BatchResponseError) Error() string {
+ return e.message
+}
+
+func (e *BatchResponseError) Code() string {
+ return e.code
+}
+
+func (e *BatchResponseError) Resolve(errData map[string]any) any {
+ errData["response"] = e.err
+ return errData
+}
+
+func (e BatchResponseError) MarshalJSON() ([]byte, error) {
+ return json.Marshal(map[string]any{
+ "message": e.message,
+ "code": e.code,
+ "response": e.err,
+ })
+}
diff --git a/apis/batch_test.go b/apis/batch_test.go
new file mode 100644
index 0000000000..566e7886aa
--- /dev/null
+++ b/apis/batch_test.go
@@ -0,0 +1,755 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+func TestBatchRequest(t *testing.T) {
+ t.Parallel()
+
+ formData, mp, err := tests.MockMultipartData(
+ map[string]string{
+ router.JSONPayloadKey: `{
+ "requests":[
+ {"method":"POST", "url":"/api/collections/demo3/records", "body": {"title": "batch1"}},
+ {"method":"POST", "url":"/api/collections/demo3/records", "body": {"title": "batch2"}},
+ {"method":"POST", "url":"/api/collections/demo3/records", "body": {"title": "batch3"}},
+ {"method":"PATCH", "url":"/api/collections/demo3/records/lcl9d87w22ml6jy", "body": {"files-": "test_FLurQTgrY8.txt"}}
+ ]
+ }`,
+ },
+ "requests.0.files",
+ "requests.0.files",
+ "requests.0.files",
+ "requests[2].files",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "disabled batch requets",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().Batch.Enabled = false
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "max request limits reached",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"GET", "url":"/test1"},
+ {"method":"GET", "url":"/test2"},
+ {"method":"GET", "url":"/test3"}
+ ]
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().Batch.Enabled = true
+ app.Settings().Batch.MaxRequests = 2
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"requests":{"code":"validation_length_too_long"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "trigger requests validations",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {},
+ {"method":"GET", "url":"/valid"},
+ {"method":"invalid", "url":"/valid"},
+ {"method":"POST", "url":"` + strings.Repeat("a", 2001) + `"}
+ ]
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().Batch.Enabled = true
+ app.Settings().Batch.MaxRequests = 100
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"requests":{`,
+ `"0":{"method":{"code":"validation_required"`,
+ `"2":{"method":{"code":"validation_in_invalid"`,
+ `"3":{"url":{"code":"validation_length_too_long"`,
+ },
+ NotExpectedContent: []string{
+ `"1":`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "unknown batch request action",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"GET", "url":"/api/health"}
+ ]
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"requests":{`,
+ `0":{"code":"batch_request_failed"`,
+ `"response":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ },
+ },
+ {
+ Name: "base 2 successful and 1 failed (public collection)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch1"}},
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch2"}},
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": ""}}
+ ]
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"response":{`,
+ `"2":{"code":"batch_request_failed"`,
+ `"response":{"data":{"title":{"code":"validation_required"`,
+ },
+ NotExpectedContent: []string{
+ `"0":`,
+ `"1":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ "OnRecordCreateRequest": 3,
+ "OnModelCreate": 3,
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateError": 3,
+ "OnModelValidate": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateError": 3,
+ "OnRecordValidate": 3,
+ "OnRecordEnrich": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ records, err := app.FindRecordsByFilter("demo2", `title~"batch"`, "", 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(records) != 0 {
+ t.Fatalf("Expected no batch records to be persisted, got %d", len(records))
+ }
+ },
+ },
+ {
+ Name: "base 4 successful (public collection)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch1"}},
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch2"}},
+ {"method":"PUT", "url":"/api/collections/demo2/records", "body": {"title": "batch3"}},
+ {"method":"PUT", "url":"/api/collections/demo2/records?fields=*,id:excerpt(4,true)", "body": {"id":"achvryl401bhse3","title": "batch4"}}
+ ]
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"title":"batch1"`,
+ `"title":"batch2"`,
+ `"title":"batch3"`,
+ `"title":"batch4"`,
+ `"id":"achv..."`,
+ `"active":false`,
+ `"active":true`,
+ `"status":200`,
+ `"body":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ "OnRecordEnrich": 4,
+
+ "OnRecordCreateRequest": 3,
+ "OnModelCreate": 3,
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ records, err := app.FindRecordsByFilter("demo2", `title~"batch"`, "", 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(records) != 4 {
+ t.Fatalf("Expected %d batch records to be persisted, got %d", 3, len(records))
+ }
+ },
+ },
+ {
+ Name: "mixed create/update/delete (non-superuser rule failure)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
+ {"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
+ {"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
+ ]
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"requests":{`,
+ `"2":{"code":"batch_request_failed"`,
+ `"response":{`,
+ },
+ NotExpectedContent: []string{
+ // only demo3 requires authentication
+ `"0":`,
+ `"1":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteError": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateError": 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.FindFirstRecordByFilter("demo2", `title="batch_create"`)
+ if err == nil {
+ t.Fatal("Expected record to not be created")
+ }
+
+ _, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
+ if err == nil {
+ t.Fatal("Expected record to not be updated")
+ }
+
+ _, err = app.FindRecordById("demo2", "achvryl401bhse3")
+ if err != nil {
+ t.Fatal("Expected record to not be deleted")
+ }
+ },
+ },
+ {
+ Name: "mixed create/update/delete (superuser rule failure)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, clients
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
+ {"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}},
+ {"method":"POST", "url":"/api/collections/_superusers/records", "body": {"email":"test_batch@example.com","password":"1234567890"}}
+ ]
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"requests":{`,
+ `"2":{"code":"batch_request_failed"`,
+ `403`,
+ },
+ NotExpectedContent: []string{
+ `"0":`,
+ `"1":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteError": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateError": 1,
+ "OnRecordDeleteRequest": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteError": 1,
+ "OnRecordEnrich": 1,
+ "OnRecordValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err = app.FindRecordById("demo2", "achvryl401bhse3")
+ if err != nil {
+ t.Fatal("Expected record to not be deleted")
+ }
+
+ _, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
+ if err == nil {
+ t.Fatal("Expected record to not be updated")
+ }
+
+ _, err = app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test_batch@example.com")
+ if err == nil {
+ t.Fatal("Expected superuser to not be created")
+ }
+ },
+ },
+ {
+ Name: "mixed create/update/delete (rules success)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, clients
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
+ {"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"}}
+ ]
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"title":"batch_create"`,
+ `"title":"batch_update"`,
+ `"status":200`,
+ `"status":204`,
+ `"body":{`,
+ `"body":null`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ // ---
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 2,
+ // ---
+ "OnRecordCreateRequest": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordDeleteRequest": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 2,
+ "OnRecordEnrich": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err := app.FindFirstRecordByFilter("demo2", `title="batch_create"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = app.FindRecordById("demo2", "achvryl401bhse3")
+ if err == nil {
+ t.Fatal("Expected record to be deleted")
+ }
+ },
+ },
+ {
+ Name: "mixed create/update/delete (superuser auth)",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, _superusers
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
+ {"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
+ {"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
+ ]
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"title":"batch_create"`,
+ `"title":"batch_update"`,
+ `"status":200`,
+ `"status":204`,
+ `"body":{`,
+ `"body":null`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ // ---
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 2,
+ // ---
+ "OnRecordCreateRequest": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordDeleteRequest": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 2,
+ "OnRecordEnrich": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err := app.FindFirstRecordByFilter("demo2", `title="batch_create"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = app.FindRecordById("demo2", "achvryl401bhse3")
+ if err == nil {
+ t.Fatal("Expected record to be deleted")
+ }
+ },
+ },
+ {
+ Name: "cascade delete/update",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, _superusers
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"DELETE", "url":"/api/collections/demo3/records/1tmknxy2868d869"},
+ {"method":"DELETE", "url":"/api/collections/demo3/records/mk5fmymtx4wsprk"}
+ ]
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"status":204`,
+ `"body":null`,
+ },
+ NotExpectedContent: []string{
+ `"status":200`,
+ `"body":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ // ---
+ "OnModelDelete": 3, // 2 batch + 1 cascade delete
+ "OnModelDeleteExecute": 3,
+ "OnModelAfterDeleteSuccess": 3,
+ "OnModelUpdate": 5, // 5 cascade update
+ "OnModelUpdateExecute": 5,
+ "OnModelAfterUpdateSuccess": 5,
+ // ---
+ "OnRecordDeleteRequest": 2,
+ "OnRecordDelete": 3,
+ "OnRecordDeleteExecute": 3,
+ "OnRecordAfterDeleteSuccess": 3,
+ "OnRecordUpdate": 5,
+ "OnRecordUpdateExecute": 5,
+ "OnRecordAfterUpdateSuccess": 5,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ ids := []string{
+ "1tmknxy2868d869",
+ "mk5fmymtx4wsprk",
+ "qzaqccwrmva4o1n",
+ }
+
+ for _, id := range ids {
+ _, err := app.FindRecordById("demo2", id)
+ if err == nil {
+ t.Fatalf("Expected record %q to be deleted", id)
+ }
+ }
+ },
+ },
+ {
+ Name: "transaction timeout",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch1"}},
+ {"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch2"}}
+ ]
+ }`),
+ Headers: map[string]string{
+ // test@example.com, _superusers
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().Batch.Timeout = 1
+ app.OnRecordCreateRequest("demo2").BindFunc(func(e *core.RecordRequestEvent) error {
+ time.Sleep(600 * time.Millisecond) // < 1s so that the first request can succeed
+ return e.Next()
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ "OnRecordCreateRequest": 2,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateError": 1,
+ "OnRecordEnrich": 1,
+ "OnRecordValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ records, err := app.FindRecordsByFilter("demo2", `title~"batch"`, "", 0, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(records) != 0 {
+ t.Fatalf("Expected %d batch records to be persisted, got %d", 0, len(records))
+ }
+ },
+ },
+ {
+ Name: "multipart/form-data + file upload",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Body: formData,
+ Headers: map[string]string{
+ // test@example.com, clients
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ "Content-Type": mp.FormDataContentType(),
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"title":"batch1"`,
+ `"title":"batch2"`,
+ `"title":"batch3"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ `"files":["300_UhLKX91HVb.png"]`,
+ `"tmpfile_`,
+ `"status":200`,
+ `"body":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ // ---
+ "OnModelCreate": 3,
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 4,
+ // ---
+ "OnRecordCreateRequest": 3,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 4,
+ "OnRecordEnrich": 4,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ batch1, err := app.FindFirstRecordByFilter("demo3", `title="batch1"`)
+ if err != nil {
+ t.Fatalf("missing batch1: %v", err)
+ }
+ batch1Files := batch1.GetStringSlice("files")
+ if len(batch1Files) != 3 {
+ t.Fatalf("Expected %d batch1 file(s), got %d", 3, len(batch1Files))
+ }
+
+ batch2, err := app.FindFirstRecordByFilter("demo3", `title="batch2"`)
+ if err != nil {
+ t.Fatalf("missing batch2: %v", err)
+ }
+ batch2Files := batch2.GetStringSlice("files")
+ if len(batch2Files) != 0 {
+ t.Fatalf("Expected %d batch2 file(s), got %d", 0, len(batch2Files))
+ }
+
+ batch3, err := app.FindFirstRecordByFilter("demo3", `title="batch3"`)
+ if err != nil {
+ t.Fatalf("missing batch3: %v", err)
+ }
+ batch3Files := batch3.GetStringSlice("files")
+ if len(batch3Files) != 1 {
+ t.Fatalf("Expected %d batch3 file(s), got %d", 1, len(batch3Files))
+ }
+
+ batch4, err := app.FindRecordById("demo3", "lcl9d87w22ml6jy")
+ if err != nil {
+ t.Fatalf("missing batch4: %v", err)
+ }
+ batch4Files := batch4.GetStringSlice("files")
+ if len(batch4Files) != 1 {
+ t.Fatalf("Expected %d batch4 file(s), got %d", 1, len(batch4Files))
+ }
+ },
+ },
+ {
+ Name: "create/update with expand query params",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, _superusers
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo5/records?expand=rel_one", "body": {"total": 9, "rel_one":"qzaqccwrmva4o1n"}},
+ {"method":"PATCH", "url":"/api/collections/demo5/records/qjeql998mtp1azp?expand=rel_many", "body": {"total": 10}}
+ ]
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"body":{`,
+ `"id":"qjeql998mtp1azp"`,
+ `"id":"qzaqccwrmva4o1n"`,
+ `"id":"i9naidtvr6qsgb4"`,
+ `"expand":{"rel_one"`,
+ `"expand":{"rel_many"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnBatchRequest": 1,
+ // ---
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 2,
+ // ---
+ "OnRecordCreateRequest": 1,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 2,
+ "OnRecordEnrich": 5,
+ },
+ },
+ {
+ Name: "check body limit middleware",
+ Method: http.MethodPost,
+ URL: "/api/batch",
+ Headers: map[string]string{
+ // test@example.com, _superusers
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{
+ "requests": [
+ {"method":"POST", "url":"/api/collections/demo5/records?expand=rel_one", "body": {"total": 9, "rel_one":"qzaqccwrmva4o1n"}},
+ {"method":"PATCH", "url":"/api/collections/demo5/records/qjeql998mtp1azp?expand=rel_many", "body": {"total": 10}}
+ ]
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().Batch.MaxBodySize = 10
+ },
+ ExpectedStatus: 413,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/collection.go b/apis/collection.go
index 05fc154b00..b5885488e7 100644
--- a/apis/collection.go
+++ b/apis/collection.go
@@ -1,210 +1,296 @@
package apis
import (
+ "errors"
"net/http"
+ "slices"
+ "strings"
- "github.com/labstack/echo/v5"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
)
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
-func bindCollectionApi(app core.App, rg *echo.Group) {
- api := collectionApi{app: app}
-
- subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
- subGroup.GET("", api.list)
- subGroup.POST("", api.create)
- subGroup.GET("/:collection", api.view)
- subGroup.PATCH("/:collection", api.update)
- subGroup.DELETE("/:collection", api.delete)
- subGroup.PUT("/import", api.bulkImport)
+func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ subGroup := rg.Group("/collections").Bind(RequireSuperuserAuth())
+ subGroup.GET("", collectionsList)
+ subGroup.POST("", collectionCreate)
+ subGroup.GET("/{collection}", collectionView)
+ subGroup.PATCH("/{collection}", collectionUpdate)
+ subGroup.DELETE("/{collection}", collectionDelete)
+ subGroup.DELETE("/{collection}/truncate", collectionTruncate)
+ subGroup.PUT("/import", collectionsImport)
+ subGroup.GET("/meta/scaffolds", collectionScaffolds)
+
+ // @todo experimental
+ subGroup.GET("/meta/oauth2-providers", collectionListOAuth2Providers)
+ subGroup.POST("/meta/dry-run-view", collectionDryRunView)
}
-type collectionApi struct {
- app core.App
-}
-
-func (api *collectionApi) list(c echo.Context) error {
+func collectionsList(e *core.RequestEvent) error {
fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "name", "system", "type",
)
- collections := []*models.Collection{}
+ collections := []*core.Collection{}
result, err := search.NewProvider(fieldResolver).
- Query(api.app.Dao().CollectionQuery()).
- ParseAndExec(c.QueryParams().Encode(), &collections)
+ Query(e.App.CollectionQuery()).
+ ParseAndExec(e.Request.URL.Query().Encode(), &collections)
if err != nil {
- return NewBadRequestError("", err)
+ return e.BadRequestError("", err)
}
- event := new(core.CollectionsListEvent)
- event.HttpContext = c
+ event := new(core.CollectionsListRequestEvent)
+ event.RequestEvent = e
event.Collections = collections
event.Result = result
- return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Result)
+ return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Result)
+ })
})
}
-func (api *collectionApi) view(c echo.Context) error {
- collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
+func collectionView(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
- return NewNotFoundError("", err)
+ return e.NotFoundError("", err)
}
- event := new(core.CollectionViewEvent)
- event.HttpContext = c
+ event := new(core.CollectionRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
- return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.JSON(http.StatusOK, e.Collection)
+ return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Collection)
+ })
})
}
-func (api *collectionApi) create(c echo.Context) error {
- collection := &models.Collection{}
+func collectionCreate(e *core.RequestEvent) error {
+ // populate the minimal required factory collection data (if any)
+ factoryExtract := struct {
+ Type string `form:"type" json:"type"`
+ Name string `form:"name" json:"name"`
+ }{}
+ if err := e.BindBody(&factoryExtract); err != nil {
+ return e.BadRequestError("Failed to load the collection type data due to invalid formatting.", err)
+ }
- form := forms.NewCollectionUpsert(api.app, collection)
+ // create scaffold
+ collection := core.NewCollection(factoryExtract.Type, factoryExtract.Name)
- // load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ // merge the scaffold with the submitted request data
+ if err := e.BindBody(collection); err != nil {
+ return e.BadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
- event := new(core.CollectionCreateEvent)
- event.HttpContext = c
+ event := new(core.CollectionRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
- // create the collection
- return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
- return func(m *models.Collection) error {
- event.Collection = m
-
- return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
- if err := next(e.Collection); err != nil {
- return NewBadRequestError("Failed to create the collection.", err)
- }
-
- return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ return e.App.OnCollectionCreateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
+ if err := e.App.Save(e.Collection); err != nil {
+ // validation failure
+ var validationErrors validation.Errors
+ if errors.As(err, &validationErrors) {
+ return e.BadRequestError("Failed to create collection.", validationErrors)
+ }
- return e.HttpContext.JSON(http.StatusOK, e.Collection)
- })
- })
+ // other generic db error
+ return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
}
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Collection)
+ })
})
}
-func (api *collectionApi) update(c echo.Context) error {
- collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
+func collectionUpdate(e *core.RequestEvent) error {
+ collection, err := e.App.FindCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
- return NewNotFoundError("", err)
+ return e.NotFoundError("", err)
}
- form := forms.NewCollectionUpsert(api.app, collection)
-
- // load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if err := e.BindBody(collection); err != nil {
+ return e.BadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
- event := new(core.CollectionUpdateEvent)
- event.HttpContext = c
+ event := new(core.CollectionRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
- // update the collection
- return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
- return func(m *models.Collection) error {
- event.Collection = m
-
- return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
- if err := next(e.Collection); err != nil {
- return NewBadRequestError("Failed to update the collection.", err)
- }
-
- return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ return event.App.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
+ if err := e.App.Save(e.Collection); err != nil {
+ // validation failure
+ var validationErrors validation.Errors
+ if errors.As(err, &validationErrors) {
+ return e.BadRequestError("Failed to update collection.", validationErrors)
+ }
- return e.HttpContext.JSON(http.StatusOK, e.Collection)
- })
- })
+ // other generic db error
+ return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
}
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Collection)
+ })
})
}
-func (api *collectionApi) delete(c echo.Context) error {
- collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
+func collectionDelete(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
if err != nil || collection == nil {
- return NewNotFoundError("", err)
+ return e.NotFoundError("", err)
}
- event := new(core.CollectionDeleteEvent)
- event.HttpContext = c
+ event := new(core.CollectionRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
- return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
- if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
- return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
- }
+ return e.App.OnCollectionDeleteRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
+ if err := e.App.Delete(e.Collection); err != nil {
+ msg := "Failed to delete collection"
- return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
+ // check fo references
+ refs, _ := e.App.FindCollectionReferences(e.Collection, e.Collection.Id)
+ if len(refs) > 0 {
+ names := make([]string, 0, len(refs))
+ for ref := range refs {
+ names = append(names, ref.Name)
+ }
+ msg += " probably due to existing reference in " + strings.Join(names, ", ")
}
- return e.HttpContext.NoContent(http.StatusNoContent)
+ return e.BadRequestError(msg, err)
+ }
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
})
})
}
-func (api *collectionApi) bulkImport(c echo.Context) error {
- form := forms.NewCollectionsImport(api.app)
+func collectionTruncate(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("", err)
+ }
+
+ if collection.IsView() {
+ return e.BadRequestError("View collections cannot be truncated since they don't store their own records.", nil)
+ }
- // load request data
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ err = e.App.TruncateCollection(collection)
+ if err != nil {
+ return e.BadRequestError("Failed to truncate collection (most likely due to required cascade delete record references).", err)
}
- event := new(core.CollectionsImportEvent)
- event.HttpContext = c
- event.Collections = form.Collections
+ return e.NoContent(http.StatusNoContent)
+}
- // import collections
- return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
- return func(imports []*models.Collection) error {
- event.Collections = imports
+func collectionScaffolds(e *core.RequestEvent) error {
+ randomId := security.RandomStringWithAlphabet(10, core.DefaultIdAlphabet) // could be used as part of the default indexes name
- return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
- if err := next(e.Collections); err != nil {
- return NewBadRequestError("Failed to import the submitted collections.", err)
- }
+ collections := map[string]*core.Collection{
+ core.CollectionTypeBase: core.NewBaseCollection("", randomId),
+ core.CollectionTypeAuth: core.NewAuthCollection("", randomId),
+ core.CollectionTypeView: core.NewViewCollection("", randomId),
+ }
+
+ for _, c := range collections {
+ c.Id = "" // clear random id
+ }
+
+ return e.JSON(http.StatusOK, collections)
+}
+
+type providerListItem struct {
+ order int
+
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ Logo string `json:"logo"`
+}
+
+func collectionListOAuth2Providers(e *core.RequestEvent) error {
+ providers := make([]*providerListItem, 0, len(auth.Providers))
+
+ for name, factory := range auth.Providers {
+ p := factory()
+
+ providers = append(providers, &providerListItem{
+ order: p.Order(),
+ Name: name,
+ DisplayName: p.DisplayName(),
+ Logo: p.Logo(),
+ })
+ }
- return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ slices.SortStableFunc(providers, func(a, b *providerListItem) int {
+ // sort by order
+ if a.order < b.order {
+ return -1
+ }
+ if a.order > b.order {
+ return 1
+ }
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
+ // fallback sort by name
+ if a.Name < b.Name {
+ return -1
+ }
+ if a.Name > b.Name {
+ return 1
}
+
+ return 0
})
+
+ return e.JSON(http.StatusOK, providers)
+}
+
+func collectionDryRunView(e *core.RequestEvent) error {
+ // extra precaution in case reused in custom route group
+ if !e.HasSuperuserAuth() {
+ return e.ForbiddenError("", nil)
+ }
+
+ form := dryRunViewForm{}
+
+ err := e.BindBody(&form)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+
+ err = form.validate()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ result, err := e.App.DryRunView(form.Query, 10)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Invalid view query. Raw error: \n"+err.Error(), nil))
+ }
+
+ return e.JSON(http.StatusOK, result)
+}
+
+type dryRunViewForm struct {
+ Query string `form:"query" json:"query"`
+}
+
+func (form *dryRunViewForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
+ )
}
diff --git a/apis/collection_import.go b/apis/collection_import.go
new file mode 100644
index 0000000000..ea0c733a36
--- /dev/null
+++ b/apis/collection_import.go
@@ -0,0 +1,62 @@
+package apis
+
+import (
+ "errors"
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func collectionsImport(e *core.RequestEvent) error {
+ form := new(collectionsImportForm)
+
+ 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))
+ }
+
+ event := new(core.CollectionsImportRequestEvent)
+ event.RequestEvent = e
+ event.CollectionsData = form.Collections
+ event.DeleteMissing = form.DeleteMissing
+
+ return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
+ importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
+ if importErr == nil {
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ }
+
+ // validation failure
+ var validationErrors validation.Errors
+ if errors.As(importErr, &validationErrors) {
+ return e.BadRequestError("Failed to import collections.", validationErrors)
+ }
+
+ // generic/db failure
+ return e.BadRequestError("Failed to import collections.", validation.Errors{"collections": validation.NewError(
+ "validation_collections_import_failure",
+ "Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
+ )})
+ })
+}
+
+// -------------------------------------------------------------------
+
+type collectionsImportForm struct {
+ Collections []map[string]any `form:"collections" json:"collections"`
+ DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
+}
+
+func (form *collectionsImportForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Collections, validation.Required),
+ )
+}
diff --git a/apis/collection_import_test.go b/apis/collection_import_test.go
new file mode 100644
index 0000000000..aa0bf65510
--- /dev/null
+++ b/apis/collection_import_test.go
@@ -0,0 +1,369 @@
+package apis_test
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestCollectionsImport(t *testing.T) {
+ t.Parallel()
+
+ totalCollections := 16
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as regular user",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as superuser + empty collections",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{"collections":[]}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"collections":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ collections := []*core.Collection{}
+ if err := app.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+ expected := totalCollections
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
+ }
+ },
+ },
+ {
+ Name: "authorized as superuser + collections validator failure",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{
+ "collections":[
+ {"name": "import1"},
+ {
+ "name": "import2",
+ "fields": [
+ {
+ "id": "koih1lqx",
+ "name": "expand",
+ "type": "text"
+ }
+ ]
+ }
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"collections":{"code":"validation_collections_import_failure"`,
+ `import2`,
+ `fields`,
+ },
+ NotExpectedContent: []string{"Raw error:"},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionsImportRequest": 1,
+ "OnCollectionCreate": 2,
+ "OnCollectionCreateExecute": 2,
+ "OnCollectionAfterCreateError": 2,
+ "OnModelCreate": 2,
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateError": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ collections := []*core.Collection{}
+ if err := app.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+ expected := totalCollections
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
+ }
+ },
+ },
+ {
+ Name: "authorized as superuser + non-validator failure",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{
+ "collections":[
+ {
+ "name": "import1",
+ "fields": [
+ {
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "name": "import2",
+ "fields": [
+ {
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
+ }
+ ],
+ "indexes": [
+ "create index idx_test on import2 (test)"
+ ]
+ }
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"collections":{"code":"validation_collections_import_failure"`,
+ `Raw error:`,
+ `custom_error`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionsImportRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
+ return errors.New("custom_error")
+ })
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ collections := []*core.Collection{}
+ if err := app.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+ expected := totalCollections
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
+ }
+ },
+ },
+ {
+ Name: "authorized as superuser + successful collections create",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{
+ "collections":[
+ {
+ "name": "import1",
+ "fields": [
+ {
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
+ }
+ ]
+ },
+ {
+ "name": "import2",
+ "fields": [
+ {
+ "id": "koih1lqx",
+ "name": "test",
+ "type": "text"
+ }
+ ],
+ "indexes": [
+ "create index idx_test on import2 (test)"
+ ]
+ },
+ {
+ "name": "auth_without_fields",
+ "type": "auth"
+ }
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionsImportRequest": 1,
+ "OnCollectionCreate": 3,
+ "OnCollectionCreateExecute": 3,
+ "OnCollectionAfterCreateSuccess": 3,
+ "OnModelCreate": 3,
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ collections := []*core.Collection{}
+ if err := app.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+
+ expected := totalCollections + 3
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
+ }
+
+ indexes, err := app.TableIndexes("import2")
+ if err != nil || indexes["idx_test"] == "" {
+ t.Fatalf("Missing index %s (%v)", "idx_test", err)
+ }
+ },
+ },
+ {
+ Name: "authorized as superuser + create/update/delete",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{
+ "deleteMissing": true,
+ "collections":[
+ {"name": "test123"},
+ {
+ "id":"wsmn24bux7wo113",
+ "name":"demo1",
+ "fields":[
+ {
+ "id":"_2hlxbmp",
+ "name":"title",
+ "type":"text",
+ "required":true
+ }
+ ],
+ "indexes": []
+ }
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionsImportRequest": 1,
+ // ---
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ // ---
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ // ---
+ "OnModelDelete": 14,
+ "OnModelAfterDeleteSuccess": 14,
+ "OnModelDeleteExecute": 14,
+ "OnCollectionDelete": 9,
+ "OnCollectionDeleteExecute": 9,
+ "OnCollectionAfterDeleteSuccess": 9,
+ "OnRecordAfterDeleteSuccess": 5,
+ "OnRecordDelete": 5,
+ "OnRecordDeleteExecute": 5,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ collections := []*core.Collection{}
+ if err := app.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+
+ systemCollections := 0
+ for _, c := range collections {
+ if c.System {
+ systemCollections++
+ }
+ }
+
+ expected := systemCollections + 2
+ if len(collections) != expected {
+ t.Fatalf("Expected %d collections, got %d", expected, len(collections))
+ }
+ },
+ },
+ {
+ Name: "OnCollectionsImportRequest tx body write check",
+ Method: http.MethodPut,
+ URL: "/api/collections/import",
+ Body: strings.NewReader(`{
+ "deleteMissing": true,
+ "collections":[
+ {"name": "test123"},
+ {
+ "id":"wsmn24bux7wo113",
+ "name":"demo1",
+ "fields":[
+ {
+ "id":"_2hlxbmp",
+ "name":"title",
+ "type":"text",
+ "required":true
+ }
+ ],
+ "indexes": []
+ }
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/collection_test.go b/apis/collection_test.go
index 4b8777a1af..40b0581e56 100644
--- a/apis/collection_test.go
+++ b/apis/collection_test.go
@@ -1,7 +1,6 @@
package apis_test
import (
- "errors"
"net/http"
"os"
"path/filepath"
@@ -9,97 +8,109 @@ import (
"testing"
"time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
)
func TestCollectionsList(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/collections",
+ URL: "/api/collections",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/collections",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin",
+ Name: "authorized as superuser",
Method: http.MethodGet,
- Url: "/api/collections",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
- `"totalItems":11`,
+ `"totalItems":16`,
`"items":[{`,
- `"id":"_pb_users_auth_"`,
- `"id":"v851q4r790rhknl"`,
- `"id":"kpv709sk2lqbqk8"`,
- `"id":"wsmn24bux7wo113"`,
- `"id":"sz5l5z67tg7gku0"`,
- `"id":"wzlqyes4orhoygb"`,
- `"id":"4d1blo5cuycfaca"`,
- `"id":"9n89pl5vkct6330"`,
- `"id":"ib3m2700k5hlsjz"`,
+ `"name":"` + core.CollectionNameSuperusers + `"`,
+ `"name":"` + core.CollectionNameAuthOrigins + `"`,
+ `"name":"` + core.CollectionNameExternalAuths + `"`,
+ `"name":"` + core.CollectionNameMFAs + `"`,
+ `"name":"` + core.CollectionNameOTPs + `"`,
+ `"name":"users"`,
+ `"name":"nologin"`,
+ `"name":"clients"`,
+ `"name":"demo1"`,
+ `"name":"demo2"`,
+ `"name":"demo3"`,
+ `"name":"demo4"`,
+ `"name":"demo5"`,
+ `"name":"numeric_id_view"`,
+ `"name":"view1"`,
+ `"name":"view2"`,
`"type":"auth"`,
`"type":"base"`,
+ `"type":"view"`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnCollectionsListRequest": 1,
},
},
{
- Name: "authorized as admin + paging and sorting",
+ Name: "authorized as superuser + paging and sorting",
Method: http.MethodGet,
- Url: "/api/collections?page=2&perPage=2&sort=-created",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections?page=2&perPage=2&sort=-created",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
- `"totalItems":11`,
+ `"totalItems":16`,
`"items":[{`,
- `"id":"v9gwnfh02gjq1q0"`,
- `"id":"9n89pl5vkct6330"`,
+ `"name":"` + core.CollectionNameMFAs + `"`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnCollectionsListRequest": 1,
},
},
{
- Name: "authorized as admin + invalid filter",
+ Name: "authorized as superuser + invalid filter",
Method: http.MethodGet,
- Url: "/api/collections?filter=invalidfield~'demo2'",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections?filter=invalidfield~'demo2'",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + valid filter",
+ Name: "authorized as superuser + valid filter",
Method: http.MethodGet,
- Url: "/api/collections?filter=name~'demo'",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections?filter=name~'demo'",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -107,16 +118,43 @@ func TestCollectionsList(t *testing.T) {
`"perPage":30`,
`"totalItems":5`,
`"items":[{`,
- `"id":"wsmn24bux7wo113"`,
- `"id":"sz5l5z67tg7gku0"`,
- `"id":"wzlqyes4orhoygb"`,
- `"id":"4d1blo5cuycfaca"`,
- `"id":"9n89pl5vkct6330"`,
+ `"name":"demo1"`,
+ `"name":"demo2"`,
+ `"name":"demo3"`,
+ `"name":"demo4"`,
+ `"name":"demo5"`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnCollectionsListRequest": 1,
},
},
+ {
+ Name: "OnCollectionsListRequest tx body write check",
+ Method: http.MethodGet,
+ URL: "/api/collections",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionsListRequest().BindFunc(func(e *core.CollectionsListRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnCollectionsListRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
}
for _, scenario := range scenarios {
@@ -125,40 +163,45 @@ func TestCollectionsList(t *testing.T) {
}
func TestCollectionView(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/collections/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections/demo1",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + nonexisting collection identifier",
+ Name: "authorized as superuser + nonexisting collection identifier",
Method: http.MethodGet,
- Url: "/api/collections/missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/missing",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + using the collection name",
+ Name: "authorized as superuser + using the collection name",
Method: http.MethodGet,
- Url: "/api/collections/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -166,15 +209,16 @@ func TestCollectionView(t *testing.T) {
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnCollectionViewRequest": 1,
},
},
{
- Name: "authorized as admin + using the collection id",
+ Name: "authorized as superuser + using the collection id",
Method: http.MethodGet,
- Url: "/api/collections/wsmn24bux7wo113",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/wsmn24bux7wo113",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -182,9 +226,36 @@ func TestCollectionView(t *testing.T) {
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnCollectionViewRequest": 1,
},
},
+ {
+ Name: "OnCollectionViewRequest tx body write check",
+ Method: http.MethodGet,
+ URL: "/api/collections/wsmn24bux7wo113",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionViewRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnCollectionViewRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
}
for _, scenario := range scenarios {
@@ -193,6 +264,8 @@ func TestCollectionView(t *testing.T) {
}
func TestCollectionDelete(t *testing.T) {
+ t.Parallel()
+
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
@@ -206,129 +279,163 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
+ Name: "authorized as regular user",
Method: http.MethodDelete,
- Url: "/api/collections/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections/demo1",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + nonexisting collection identifier",
+ Name: "authorized as superuser + nonexisting collection identifier",
Method: http.MethodDelete,
- Url: "/api/collections/missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/missing",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + using the collection name",
+ Name: "authorized as superuser + using the collection name",
Method: http.MethodDelete,
- Url: "/api/collections/demo5",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo5",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnCollectionBeforeDeleteRequest": 1,
- "OnCollectionAfterDeleteRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ "*": 0,
+ "OnCollectionDeleteRequest": 1,
+ "OnCollectionDelete": 1,
+ "OnCollectionDeleteExecute": 1,
+ "OnCollectionAfterDeleteSuccess": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
- Name: "authorized as admin + using the collection id",
+ Name: "authorized as superuser + using the collection id",
Method: http.MethodDelete,
- Url: "/api/collections/9n89pl5vkct6330",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/9n89pl5vkct6330",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnCollectionBeforeDeleteRequest": 1,
- "OnCollectionAfterDeleteRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ "*": 0,
+ "OnCollectionDeleteRequest": 1,
+ "OnCollectionDelete": 1,
+ "OnCollectionDeleteExecute": 1,
+ "OnCollectionAfterDeleteSuccess": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "9n89pl5vkct6330")
},
},
{
- Name: "authorized as admin + trying to delete a system collection",
+ Name: "authorized as superuser + trying to delete a system collection",
Method: http.MethodDelete,
- Url: "/api/collections/nologin",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/" + core.CollectionNameMFAs,
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
- "OnCollectionBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnCollectionDeleteRequest": 1,
+ "OnCollectionDelete": 1,
+ "OnCollectionDeleteExecute": 1,
+ "OnCollectionAfterDeleteError": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteError": 1,
},
},
{
- Name: "authorized as admin + trying to delete a referenced collection",
+ Name: "authorized as superuser + trying to delete a referenced collection",
Method: http.MethodDelete,
- Url: "/api/collections/demo2",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo2",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
- "OnCollectionBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnCollectionDeleteRequest": 1,
+ "OnCollectionDelete": 1,
+ "OnCollectionDeleteExecute": 1,
+ "OnCollectionAfterDeleteError": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteError": 1,
},
},
{
- Name: "authorized as admin + deleting a view",
+ Name: "authorized as superuser + deleting a view",
Method: http.MethodDelete,
- Url: "/api/collections/view2",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/view2",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnCollectionBeforeDeleteRequest": 1,
- "OnCollectionAfterDeleteRequest": 1,
+ "*": 0,
+ "OnCollectionDeleteRequest": 1,
+ "OnCollectionDelete": 1,
+ "OnCollectionDeleteExecute": 1,
+ "OnCollectionAfterDeleteSuccess": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
},
},
{
- Name: "OnCollectionAfterDeleteRequest error response",
+ Name: "OnCollectionDeleteRequest tx body write check",
Method: http.MethodDelete,
- Url: "/api/collections/view2",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
- return errors.New("error")
+ URL: "/api/collections/view2",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionDeleteRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnCollectionBeforeDeleteRequest": 1,
- "OnCollectionAfterDeleteRequest": 1,
- },
+ ExpectedEvents: map[string]int{"OnCollectionDeleteRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
}
@@ -338,61 +445,89 @@ func TestCollectionDelete(t *testing.T) {
}
func TestCollectionCreate(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/collections",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections",
+ Body: strings.NewReader(`{"name":"new","type":"base","fields":[{"type":"text","name":"test"}]}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + empty data",
+ Name: "authorized as superuser + empty data",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"name":{"code":"validation_required"`,
- `"schema":{"code":"validation_required"`,
+ },
+ NotExpectedContent: []string{
+ `"fields":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "authorized as admin + invalid data (eg. existing name)",
+ Name: "authorized as superuser + invalid data (eg. existing name)",
Method: http.MethodPost,
- Url: "/api/collections",
- Body: strings.NewReader(`{"name":"demo1","type":"base","schema":[{"type":"text","name":""}]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections",
+ Body: strings.NewReader(`{"name":"demo1","type":"base","fields":[{"type":"text","name":""}]}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
+ `"fields":{`,
`"name":{"code":"validation_collection_name_exists"`,
- `"schema":{"0":{"name":{"code":"validation_required"`,
+ `"name":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "authorized as admin + valid data",
+ Name: "authorized as superuser + valid data",
Method: http.MethodPost,
- Url: "/api/collections",
- Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections",
+ Body: strings.NewReader(`{"name":"new","type":"base","fields":[{"type":"text","id":"12345789","name":"test"}]}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -400,23 +535,37 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"base"`,
`"system":false`,
- `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
- `"options":{}`,
+ // ensures that id field was prepended
+ `"fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "creating auth collection without specified options",
+ Name: "creating auth collection (default settings merge test)",
Method: http.MethodPost,
- Url: "/api/collections",
- Body: strings.NewReader(`{"name":"new","type":"auth","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"auth",
+ "emailChangeToken":{"duration":123},
+ "fields":[
+ {"type":"text","id":"12345789","name":"test"},
+ {"type":"text","name":"tokenKey","system":true,"required":false,"min":10}
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -424,62 +573,44 @@ func TestCollectionCreate(t *testing.T) {
`"name":"new"`,
`"type":"auth"`,
`"system":false`,
- `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
- `"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
- },
- ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
+ `"passwordAuth":{"enabled":true,"identityFields":["email"]}`,
+ `"authRule":""`,
+ `"manageRule":null`,
+ `"name":"test"`,
+ `"name":"id"`,
+ `"name":"tokenKey"`,
+ `"name":"password"`,
+ `"name":"email"`,
+ `"name":"emailVisibility"`,
+ `"name":"verified"`,
+ `"duration":123`,
+ // should overwrite the user required option but keep the min value
+ `{"autogeneratePattern":"","help":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
},
- },
- {
- Name: "trying to create auth collection with reserved auth fields",
- Method: http.MethodPost,
- Url: "/api/collections",
- Body: strings.NewReader(`{
- "name":"new",
- "type":"auth",
- "schema":[
- {"type":"text","name":"email"},
- {"type":"text","name":"username"},
- {"type":"text","name":"verified"},
- {"type":"text","name":"emailVisibility"},
- {"type":"text","name":"lastResetSentAt"},
- {"type":"text","name":"lastVerificationSentAt"},
- {"type":"text","name":"tokenKey"},
- {"type":"text","name":"passwordHash"},
- {"type":"text","name":"password"},
- {"type":"text","name":"passwordConfirm"},
- {"type":"text","name":"oldPassword"}
- ]
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ NotExpectedContent: []string{
+ `"secret":"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{"schema":{`,
- `"0":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"1":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"2":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"3":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"4":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"5":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"6":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"7":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"8":{"name":{"code":"validation_reserved_auth_field_name"`,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
},
},
{
Name: "creating base collection with reserved auth fields",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
- "schema":[
+ "fields":[
{"type":"text","name":"email"},
{"type":"text","name":"username"},
{"type":"text","name":"verified"},
@@ -493,92 +624,121 @@ func TestCollectionCreate(t *testing.T) {
{"type":"text","name":"oldPassword"}
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"base"`,
- `"schema":[{`,
+ `"fields":[{`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "trying to create base collection with reserved base fields",
+ Name: "trying to create base collection with reserved system fields",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
- "schema":[
+ "fields":[
{"type":"text","name":"id"},
- {"type":"text","name":"created"},
- {"type":"text","name":"updated"},
{"type":"text","name":"expand"},
{"type":"text","name":"collectionId"},
{"type":"text","name":"collectionName"}
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"data":{"schema":{`,
- `"0":{"name":{"code":"validation_not_in_invalid`,
+ `"data":{"fields":{`,
`"1":{"name":{"code":"validation_not_in_invalid`,
`"2":{"name":{"code":"validation_not_in_invalid`,
`"3":{"name":{"code":"validation_not_in_invalid`,
- `"4":{"name":{"code":"validation_not_in_invalid`,
- `"5":{"name":{"code":"validation_not_in_invalid`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "trying to create auth collection with invalid options",
+ Name: "trying to create auth collection with reserved auth fields",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"auth",
- "schema":[{"type":"text","id":"12345789","name":"test"}],
- "options":{"allowUsernameAuth": true}
+ "fields":[
+ {"type":"text","name":"oldPassword"},
+ {"type":"text","name":"passwordConfirm"}
+ ]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"data":{`,
- `"options":{"minPasswordLength":{"code":"validation_required"`,
+ `"data":{"fields":{`,
+ `"1":{"name":{"code":"validation_reserved_field_name`,
+ `"2":{"name":{"code":"validation_reserved_field_name`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "OnCollectionAfterCreateRequest error response",
+ Name: "OnCollectionCreateRequest tx body write check",
Method: http.MethodPost,
- Url: "/api/collections",
- Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
- return errors.New("error")
+ URL: "/api/collections",
+ Body: strings.NewReader(`{"name":"new","type":"base"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionCreateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
- },
+ ExpectedEvents: map[string]int{"OnCollectionCreateRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
// view
@@ -586,48 +746,62 @@ func TestCollectionCreate(t *testing.T) {
{
Name: "trying to create view collection with invalid options",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
- "schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
- "options":{"query": "invalid"}
+ "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
+ "viewQuery":"select '123' as abc"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"options":{"query":{"code":"validation_invalid_view_query`,
+ `"viewQuery":{"code":"validation_invalid_view_query`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
Name: "creating view collection",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"view",
- "schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
- "options": {
- "query": "select 1 as id from _admins"
- }
+ "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
+ "viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + ` limit 1"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"view"`,
- `"schema":[]`,
+ `"fields":[{"autogeneratePattern":"","help":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
},
},
@@ -636,11 +810,11 @@ func TestCollectionCreate(t *testing.T) {
{
Name: "creating base collection with invalid indexes",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
- "schema":[
+ "fields":[
{"type":"text","name":"test"}
],
"indexes": [
@@ -648,8 +822,8 @@ func TestCollectionCreate(t *testing.T) {
"create index idx_test2 on new (missing)"
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -657,18 +831,102 @@ func TestCollectionCreate(t *testing.T) {
`"indexes":{"1":{"code":"`,
},
ExpectedEvents: map[string]int{
- "OnCollectionBeforeCreateRequest": 1,
- "OnModelBeforeCreate": 1,
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
+ },
+ },
+ {
+ Name: "creating base collection with index name from another collection",
+ Method: http.MethodPost,
+ URL: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"base",
+ "fields":[
+ {"type":"text","name":"test"}
+ ],
+ "indexes": [
+ "create index exist_test on new (test)"
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ demo1.AddIndex("exist_test", false, "updated", "")
+ if err = app.Save(demo1); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"indexes":{`,
+ `"0":{"code":"validation_existing_index_name"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
+ },
+ },
+ {
+ Name: "creating base collection with 2 indexes using the same name",
+ Method: http.MethodPost,
+ URL: "/api/collections",
+ Body: strings.NewReader(`{
+ "name":"new",
+ "type":"base",
+ "indexes": [
+ "create index duplicate_idx on new (created)",
+ "create index duplicate_idx on new (updated)"
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"indexes":{`,
+ `"1":{"code":"validation_duplicated_index_name"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionAfterCreateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
},
},
{
Name: "creating base collection with valid indexes (+ random table name)",
Method: http.MethodPost,
- Url: "/api/collections",
+ URL: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
- "schema":[
+ "fields":[
{"type":"text","name":"test"}
],
"indexes": [
@@ -676,8 +934,8 @@ func TestCollectionCreate(t *testing.T) {
"create index idx_test2 on anything (id, test)"
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -688,18 +946,27 @@ func TestCollectionCreate(t *testing.T) {
`idx_test2`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnCollectionBeforeCreateRequest": 1,
- "OnCollectionAfterCreateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- indexes, err := app.Dao().TableIndexes("new")
+ "*": 0,
+ "OnCollectionCreateRequest": 1,
+ "OnCollectionCreate": 1,
+ "OnCollectionCreateExecute": 1,
+ "OnCollectionAfterCreateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ indexes, err := app.TableIndexes("new")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
+ if len(indexes) != len(expected) {
+ t.Fatalf("Expected %d indexes, got %d\n%v", len(expected), len(indexes), indexes)
+ }
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
@@ -715,42 +982,47 @@ func TestCollectionCreate(t *testing.T) {
}
func TestCollectionUpdate(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
+ Name: "authorized as regular user",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections/demo1",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + missing collection",
+ Name: "authorized as superuser + missing collection",
Method: http.MethodPatch,
- Url: "/api/collections/missing",
+ URL: "/api/collections/missing",
Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + empty body",
+ Name: "authorized as superuser + empty body",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -758,44 +1030,55 @@ func TestCollectionUpdate(t *testing.T) {
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
- "OnCollectionAfterUpdateRequest": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "OnCollectionAfterUpdateRequest error response",
+ Name: "OnCollectionUpdateRequest tx body write check",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
- return errors.New("error")
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnCollectionUpdateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnCollectionAfterUpdateRequest": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- },
+ ExpectedEvents: map[string]int{"OnCollectionUpdateRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
{
- Name: "authorized as admin + invalid data (eg. existing name)",
+ Name: "authorized as superuser + invalid data (eg. existing name)",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{
"name":"demo2",
"type":"auth"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -803,14 +1086,24 @@ func TestCollectionUpdate(t *testing.T) {
`"name":{"code":"validation_collection_name_exists"`,
`"type":{"code":"validation_collection_type_change"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ },
},
{
- Name: "authorized as admin + valid data",
+ Name: "authorized as superuser + valid data",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{"name":"new"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -818,144 +1111,183 @@ func TestCollectionUpdate(t *testing.T) {
`"name":"new"`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnCollectionAfterUpdateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
// check if the record table was renamed
- if !app.Dao().HasTable("new") {
+ if !app.HasTable("new") {
t.Fatal("Couldn't find record table 'new'.")
}
},
},
{
- Name: "trying to update auth collection with reserved auth fields",
+ Name: "trying to update collection with reserved fields",
Method: http.MethodPatch,
- Url: "/api/collections/users",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{
- "schema":[
- {"type":"text","name":"email"},
- {"type":"text","name":"username"},
- {"type":"text","name":"verified"},
- {"type":"text","name":"emailVisibility"},
- {"type":"text","name":"lastResetSentAt"},
- {"type":"text","name":"lastVerificationSentAt"},
- {"type":"text","name":"tokenKey"},
- {"type":"text","name":"passwordHash"},
- {"type":"text","name":"password"},
- {"type":"text","name":"passwordConfirm"},
- {"type":"text","name":"oldPassword"}
+ "name":"new",
+ "fields":[
+ {"type":"text","name":"id","id":"_pbf_text_id_"},
+ {"type":"text","name":"created"},
+ {"type":"text","name":"updated"},
+ {"type":"text","name":"expand"},
+ {"type":"text","name":"collectionId"},
+ {"type":"text","name":"collectionName"}
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"data":{"schema":{`,
- `"0":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"1":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"2":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"3":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"4":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"5":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"6":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"7":{"name":{"code":"validation_reserved_auth_field_name"`,
- `"8":{"name":{"code":"validation_reserved_auth_field_name"`,
+ `"data":{"fields":{`,
+ `"3":{"name":{"code":"validation_not_in_invalid`,
+ `"4":{"name":{"code":"validation_not_in_invalid`,
+ `"5":{"name":{"code":"validation_not_in_invalid`,
+ },
+ NotExpectedContent: []string{
+ `"0":`,
+ `"1":`,
+ `"2":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "updating base collection with reserved auth fields",
+ Name: "trying to update collection with changed/removed system fields",
Method: http.MethodPatch,
- Url: "/api/collections/demo4",
+ URL: "/api/collections/demo1",
Body: strings.NewReader(`{
- "schema":[
- {"type":"text","name":"email"},
- {"type":"text","name":"username"},
- {"type":"text","name":"verified"},
- {"type":"text","name":"emailVisibility"},
- {"type":"text","name":"lastResetSentAt"},
- {"type":"text","name":"lastVerificationSentAt"},
- {"type":"text","name":"tokenKey"},
- {"type":"text","name":"passwordHash"},
- {"type":"text","name":"password"},
- {"type":"text","name":"passwordConfirm"},
- {"type":"text","name":"oldPassword"}
+ "name":"new",
+ "fields":[
+ {"type":"text","name":"created"}
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 200,
+ ExpectedStatus: 400,
ExpectedContent: []string{
- `"name":"demo4"`,
- `"type":"base"`,
- `"schema":[{`,
- `"email"`,
- `"username"`,
- `"verified"`,
- `"emailVisibility"`,
- `"lastResetSentAt"`,
- `"lastVerificationSentAt"`,
- `"tokenKey"`,
- `"passwordHash"`,
- `"password"`,
- `"passwordConfirm"`,
- `"oldPassword"`,
+ `"data":{"fields":{`,
+ `"code":"validation_system_field_change"`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnCollectionAfterUpdateRequest": 1,
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "trying to update base collection with reserved base fields",
+ Name: "trying to update auth collection with invalid options",
Method: http.MethodPatch,
- Url: "/api/collections/demo1",
+ URL: "/api/collections/users",
Body: strings.NewReader(`{
- "name":"new",
- "type":"base",
- "schema":[
- {"type":"text","name":"id"},
- {"type":"text","name":"created"},
- {"type":"text","name":"updated"},
- {"type":"text","name":"expand"},
- {"type":"text","name":"collectionId"},
- {"type":"text","name":"collectionName"}
- ]
+ "passwordAuth":{"identityFields": ["missing"]}
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"data":{"schema":{`,
- `"0":{"name":{"code":"validation_not_in_invalid`,
- `"1":{"name":{"code":"validation_not_in_invalid`,
- `"2":{"name":{"code":"validation_not_in_invalid`,
- `"3":{"name":{"code":"validation_not_in_invalid`,
- `"4":{"name":{"code":"validation_not_in_invalid`,
- `"5":{"name":{"code":"validation_not_in_invalid`,
+ `"data":{`,
+ `"passwordAuth":{"identityFields":{"code":"validation_missing_field"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
},
},
{
- Name: "trying to update auth collection with invalid options",
+ Name: "add another OAuth2 provider to an auth collection",
Method: http.MethodPatch,
- Url: "/api/collections/users",
+ URL: "/api/collections/users",
Body: strings.NewReader(`{
- "options":{"minPasswordLength": 4}
+ "oauth2": {
+ "providers": [
+ {"name": "apple", "clientId": "a", "clientSecret": "b"},
+ {
+ "pkce": null,
+ "name": "google",
+ "authURL": "",
+ "displayName": "existing",
+ "extra": {}
+ }
+ ]
+ }
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 400,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // verify that the collection has google and gitlab as OAuth2 providers
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v := len(users.OAuth2.Providers); v != 2 {
+ t.Fatalf("Expected 2 OAuth2 providers, got %d", v)
+ }
+
+ if v := users.OAuth2.Providers[0].Name; v != "gitlab" {
+ t.Fatalf("Expected provider 0 to be %s, got %s", "gitlab", v)
+ }
+
+ if v := users.OAuth2.Providers[1].Name; v != "google" {
+ t.Fatalf("Expected provider 1 to be %s, got %s", "google", v)
+ }
+ },
+ ExpectedStatus: 200,
ExpectedContent: []string{
- `"data":{`,
- `"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
+ `"name":"google"`,
+ `"name":"apple"`,
+ `"displayName":"existing"`,
+ `"clientId":"test"`,
+ `"clientId":"a"`,
+ },
+ NotExpectedContent: []string{
+ `"name":"gitlab"`,
+ `clientSecret`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
},
},
@@ -964,52 +1296,63 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "trying to update view collection with invalid options",
Method: http.MethodPatch,
- Url: "/api/collections/view1",
+ URL: "/api/collections/view1",
Body: strings.NewReader(`{
- "schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
- "options":{"query": "invalid"}
+ "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
+ "viewQuery":"invalid"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"options":{"query":{"code":"validation_invalid_view_query`,
+ `"viewQuery":{"code":"validation_invalid_view_query"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
},
},
{
Name: "updating view collection",
Method: http.MethodPatch,
- Url: "/api/collections/view2",
+ URL: "/api/collections/view2",
Body: strings.NewReader(`{
"name":"view2_update",
- "schema":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
- "options": {
- "query": "select 2 as id, created, updated, email from _admins"
- }
+ "fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
+ "viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + ` limit 1"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"view2_update"`,
`"type":"view"`,
- `"schema":[{`,
+ `"fields":[{`,
`"name":"email"`,
- },
- NotExpectedContent: []string{
- // base model fields are not part of the schema
`"name":"id"`,
`"name":"created"`,
`"name":"updated"`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnCollectionAfterUpdateRequest": 1,
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
},
},
@@ -1018,55 +1361,145 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "updating base collection with invalid indexes",
Method: http.MethodPatch,
- Url: "/api/collections/demo2",
+ URL: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique idx_test1 on demo1 (text)",
"create index idx_test2 on demo2 (id, title)"
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"indexes":{"0":{"code":"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ },
+ },
+
+ {
+ Name: "updating base collection with index name from another collection",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2",
+ Body: strings.NewReader(`{
+ "indexes": [
+ "create index exist_test on new (test)"
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ demo1.AddIndex("exist_test", false, "updated", "")
+ if err = app.Save(demo1); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"indexes":{`,
+ `"0":{"code":"validation_existing_index_name"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ },
+ },
+ {
+ Name: "updating base collection with 2 indexes using the same name",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2",
+ Body: strings.NewReader(`{
+ "indexes": [
+ "create index duplicate_idx on new (created)",
+ "create index duplicate_idx on new (updated)"
+ ]
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"indexes":{`,
+ `"1":{"code":"validation_duplicated_index_name"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionAfterUpdateError": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ },
},
+
{
Name: "updating base collection with valid indexes (+ random table name)",
Method: http.MethodPatch,
- Url: "/api/collections/demo2",
+ URL: "/api/collections/demo2",
Body: strings.NewReader(`{
"indexes": [
"create unique index idx_test1 on demo2 (title)",
"create index idx_test2 on anything (active)"
]
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo2"`,
`"indexes":[`,
- "CREATE UNIQUE INDEX `idx_test1`",
- "CREATE INDEX `idx_test2`",
+ `idx_test1`,
+ `idx_test2`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnCollectionBeforeUpdateRequest": 1,
- "OnCollectionAfterUpdateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- indexes, err := app.Dao().TableIndexes("new")
+ "*": 0,
+ "OnCollectionUpdateRequest": 1,
+ "OnCollectionUpdate": 1,
+ "OnCollectionUpdateExecute": 1,
+ "OnCollectionAfterUpdateSuccess": 1,
+ "OnCollectionValidate": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ indexes, err := app.TableIndexes("demo2")
if err != nil {
t.Fatal(err)
}
expected := []string{"idx_test1", "idx_test2"}
+ if len(indexes) != len(expected) {
+ t.Fatalf("Expected %d indexes, got %d\n%v", len(expected), len(indexes), indexes)
+ }
for name := range indexes {
if !list.ExistInSlice(name, expected) {
t.Fatalf("Missing index %q", name)
@@ -1081,332 +1514,317 @@ func TestCollectionUpdate(t *testing.T) {
}
}
-func TestCollectionsImport(t *testing.T) {
- totalCollections := 11
+func TestCollectionScaffolds(t *testing.T) {
+ t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
- Method: http.MethodPut,
- Url: "/api/collections/import",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/scaffolds",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as user",
- Method: http.MethodPut,
- Url: "/api/collections/import",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ Name: "authorized as regular user",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/scaffolds",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + empty collections",
- Method: http.MethodPut,
- Url: "/api/collections/import",
- Body: strings.NewReader(`{"collections":[]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Name: "authorized as superuser",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/scaffolds",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 400,
+ ExpectedStatus: 200,
ExpectedContent: []string{
- `"data":{`,
- `"collections":{"code":"validation_required"`,
+ `"id":""`,
+ `"name":""`,
+ `"auth":{`,
+ `"base":{`,
+ `"view":{`,
+ `"type":"auth"`,
+ `"type":"base"`,
+ `"type":"view"`,
+ `"fields":[{`,
+ `"fields":[{`,
+ `"id":"text3208210256"`,
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- collections := []*models.Collection{}
- if err := app.Dao().CollectionQuery().All(&collections); err != nil {
- t.Fatal(err)
- }
- expected := totalCollections
- if len(collections) != expected {
- t.Fatalf("Expected %d collections, got %d", expected, len(collections))
- }
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestCollectionTruncate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo5/truncate",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as regular user",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo5/truncate",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + trying to delete system collections",
- Method: http.MethodPut,
- Url: "/api/collections/import",
- Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Name: "authorized as superuser",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo5/truncate",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"collections":{"code":"collections_import_failure"`,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnModelDelete": 2,
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
},
+ },
+ {
+ Name: "authorized as superuser but collection with required cascade delete references",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo3/truncate",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
- "OnCollectionsBeforeImportRequest": 1,
- "OnModelBeforeDelete": 4,
+ "*": 0,
+ "OnModelDelete": 2,
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteError": 2,
+ "OnModelUpdate": 2,
+ "OnModelUpdateExecute": 2,
+ "OnModelAfterUpdateError": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteError": 2,
+ "OnRecordUpdate": 2,
+ "OnRecordUpdateExecute": 2,
+ "OnRecordAfterUpdateError": 2,
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- collections := []*models.Collection{}
- if err := app.Dao().CollectionQuery().All(&collections); err != nil {
- t.Fatal(err)
- }
- expected := totalCollections
- if len(collections) != expected {
- t.Fatalf("Expected %d collections, got %d", expected, len(collections))
- }
+ },
+ {
+ Name: "authorized as superuser trying to truncate view collection",
+ Method: http.MethodDelete,
+ URL: "/api/collections/view2/truncate",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestCollectionOAuth2Providers(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/oauth2-providers",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as regular user",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/oauth2-providers",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + collections validator failure",
- Method: http.MethodPut,
- Url: "/api/collections/import",
- Body: strings.NewReader(`{
- "collections":[
- {
- "name": "import1",
- "schema": [
- {
- "id": "koih1lqx",
- "name": "test",
- "type": "text"
- }
- ]
- },
- {"name": "import2"}
- ]
- }`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Name: "authorized as superuser",
+ Method: http.MethodGet,
+ URL: "/api/collections/meta/oauth2-providers",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `{"name":"oidc3","displayName":"OIDC","logo":" 0 && !isIPInList(allowedIPs, e.RealIP()) {
+ authRecord = nil
}
}
- if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
- return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
+ // create a shallow copy of the cached request data and adjust it to the current auth record (if any)
+ requestInfo := *originalRequestInfo
+ requestInfo.Context = core.RequestInfoContextProtectedFile
+ requestInfo.Auth = authRecord
+
+ if ok, _ := e.App.CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
+ return e.NotFoundError("", errors.New("insufficient permissions to access the file resource"))
}
}
@@ -117,100 +138,106 @@ func (api *fileApi) download(c echo.Context) error {
// fetch the original view file field related record
if collection.IsView() {
- fileRecord, err := api.app.Dao().FindRecordByViewFile(collection.Id, fileField.Name, filename)
+ fileRecord, err := e.App.FindRecordByViewFile(collection.Id, fileField.Name, filename)
if err != nil {
- return NewNotFoundError("", fmt.Errorf("Failed to fetch view file field record: %w", err))
+ return e.NotFoundError("", fmt.Errorf("failed to fetch view file field record: %w", err))
}
baseFilesPath = fileRecord.BaseFilesPath()
}
- fs, err := api.app.NewFilesystem()
+ fsys, err := e.App.NewFilesystem()
if err != nil {
- return NewBadRequestError("Filesystem initialization failure.", err)
+ return e.InternalServerError("Filesystem initialization failure.", err)
}
- defer fs.Close()
+ defer fsys.Close()
originalPath := baseFilesPath + "/" + filename
- servedPath := originalPath
- servedName := filename
+
+ event := new(core.FileDownloadRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+ event.FileField = fileField
+ event.ServedPath = originalPath
+ event.ServedName = filename
// check for valid thumb size param
- thumbSize := c.QueryParam("thumb")
- if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
+ thumbSize := e.Request.URL.Query().Get("thumb")
+ if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, fileField.Thumbs)) {
// extract the original file meta attributes and check it existence
- oAttrs, oAttrsErr := fs.Attributes(originalPath)
+ oAttrs, oAttrsErr := fsys.Attributes(originalPath)
if oAttrsErr != nil {
- return NewNotFoundError("", err)
+ return e.NotFoundError("", err)
}
// check if it is an image
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
// add thumb size as file suffix
- servedName = thumbSize + "_" + filename
- servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
-
- // create a new thumb if it doesn exists
- if exists, _ := fs.Exists(servedPath); !exists {
- if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
- servedPath = originalPath // fallback to the original
+ event.ServedName = thumbSize + "_" + filename
+ event.ServedPath = baseFilesPath + "/thumbs_" + filename + "/" + event.ServedName
+
+ // create a new thumb if it doesn't exist
+ if exists, _ := fsys.Exists(event.ServedPath); !exists {
+ if err := api.createThumb(e, fsys, originalPath, event.ServedPath, thumbSize); err != nil {
+ e.App.Logger().Warn(
+ "Fallback to original - failed to create thumb "+event.ServedName,
+ slog.Any("error", err),
+ slog.String("original", originalPath),
+ slog.String("thumb", event.ServedPath),
+ )
+
+ // fallback to the original
+ event.ThumbError = err
+ event.ServedName = filename
+ event.ServedPath = originalPath
}
}
}
}
- event := new(core.FileDownloadEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
- event.FileField = fileField
- event.ServedPath = servedPath
- event.ServedName = servedName
+ if thumbSize != "" && event.ThumbError == nil && event.ServedPath == originalPath {
+ event.ThumbError = fmt.Errorf("the thumb size %q or the original file format are not supported", thumbSize)
+ }
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (note: it is out of the hook to allow users to customize the behavior)
- c.Response().Header().Del("X-Frame-Options")
-
- return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ e.Response.Header().Del("X-Frame-Options")
- if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
- return NewNotFoundError("", err)
+ return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
+ err = execAfterSuccessTx(true, e.App, func() error {
+ return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
+ })
+ if err != nil {
+ return e.NotFoundError("", err)
}
return nil
})
}
-func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
- fileToken = strings.TrimSpace(fileToken)
- if fileToken == "" {
- return nil, errors.New("missing file token")
- }
-
- claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
- tokenType := cast.ToString(claims["type"])
-
- switch tokenType {
- case tokens.TypeAdmin:
- admin, err := api.app.Dao().FindAdminByToken(
- fileToken,
- api.app.Settings().AdminFileToken.Secret,
- )
- if err == nil && admin != nil {
- return admin, nil
- }
- case tokens.TypeAuthRecord:
- record, err := api.app.Dao().FindAuthRecordByToken(
- fileToken,
- api.app.Settings().RecordFileToken.Secret,
- )
- if err == nil && record != nil {
- return record, nil
+func (api *fileApi) createThumb(
+ e *core.RequestEvent,
+ fsys *filesystem.System,
+ originalPath string,
+ thumbPath string,
+ thumbSize string,
+) error {
+ ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
+ ctx, cancel := context.WithTimeout(e.Request.Context(), api.thumbGenMaxWait)
+ defer cancel()
+
+ if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
+ return nil, err
}
- }
+ defer api.thumbGenSem.Release(1)
+
+ return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
+ })
+
+ res := <-ch
+
+ api.thumbGenPending.Forget(thumbPath)
- return nil, errors.New("missing or invalid file token")
+ return res.Err
}
diff --git a/apis/file_test.go b/apis/file_test.go
index 0fbe356136..59a4c95734 100644
--- a/apis/file_test.go
+++ b/apis/file_test.go
@@ -2,82 +2,84 @@ package apis_test
import (
"net/http"
+ "net/http/httptest"
"os"
"path"
"path/filepath"
"runtime"
+ "sync"
"testing"
- "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestFileToken(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/files/token",
- ExpectedStatus: 400,
+ URL: "/api/files/token",
+ ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnFileBeforeTokenRequest": 1,
- },
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "unauthorized with model and token via hook",
+ Name: "regular user",
Method: http.MethodPost,
- Url: "/api/files/token",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
- record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
- e.Model = record
- e.Token = "test"
- return nil
- })
+ URL: "/api/files/token",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"token":"test"`,
+ `"token":"`,
},
ExpectedEvents: map[string]int{
- "OnFileBeforeTokenRequest": 1,
- "OnFileAfterTokenRequest": 1,
+ "*": 0,
+ "OnFileTokenRequest": 1,
},
},
{
- Name: "auth record",
+ Name: "superuser",
Method: http.MethodPost,
- Url: "/api/files/token",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/files/token",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":"`,
},
ExpectedEvents: map[string]int{
- "OnFileBeforeTokenRequest": 1,
- "OnFileAfterTokenRequest": 1,
+ "*": 0,
+ "OnFileTokenRequest": 1,
},
},
{
- Name: "admin",
+ Name: "hook token overwrite",
Method: http.MethodPost,
- Url: "/api/files/token",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/files/token",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileTokenRequest().BindFunc(func(e *core.FileTokenRequestEvent) error {
+ e.Token = "test"
+ return e.Next()
+ })
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `"token":"`,
+ `"token":"test"`,
},
ExpectedEvents: map[string]int{
- "OnFileBeforeTokenRequest": 1,
- "OnFileAfterTokenRequest": 1,
+ "*": 0,
+ "OnFileTokenRequest": 1,
},
},
}
@@ -88,6 +90,8 @@ func TestFileToken(t *testing.T) {
}
func TestFileDownload(t *testing.T) {
+ t.Parallel()
+
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
@@ -144,233 +148,379 @@ func TestFileDownload(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodGet,
- Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
+ URL: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "missing record",
Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
+ URL: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "missing file",
Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "existing image",
Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - missing thumb (should fallback to the original)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
+ Name: "existing image - missing thumb (should fallback to the original)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError == nil {
+ t.Fatal("Expected thumb error, got nil")
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (crop center)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
+ Name: "existing image - existing thumb (crop center)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (crop top)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
+ Name: "existing image - existing thumb (crop top)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (crop bottom)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
+ Name: "existing image - existing thumb (crop bottom)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (fit)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
+ Name: "existing image - existing thumb (fit)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (zero width)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
+ Name: "existing image - existing thumb (zero width)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing image - existing thumb (zero height)",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
+ Name: "existing image - existing thumb (zero height)",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError != nil {
+ t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "existing non image file - thumb parameter should be ignored",
- Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
+ Name: "existing non image file - thumb parameter should be ignored",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
+ if e.ThumbError == nil {
+ t.Fatal("Expected thumb error, got nil")
+ }
+ return e.Next()
+ })
+ },
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
// protected file access checks
{
- Name: "protected file - expired token",
+ Name: "protected file - superuser with expired file token",
Method: http.MethodGet,
- Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "protected file - superuser with valid file token",
+ Method: http.MethodGet,
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
ExpectedStatus: 200,
- ExpectedContent: []string{string(testFile)},
+ ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
- Name: "protected file - admin with expired file token",
- Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
- ExpectedStatus: 403,
+ 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 - admin with valid file token",
- Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
+ Name: "protected file - superuser with whitelisted IP",
+ Method: http.MethodGet,
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
+ Headers: map[string]string{"x-test-ip": "127.0.0.1"},
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().TrustedProxy = core.TrustedProxyConfig{
+ Headers: []string{"x-test-ip"},
+ }
+
+ app.Settings().SuperuserIPs = []string{"127.0.0.1"}
+
+ if err := app.Save(app.Settings()); err != nil {
+ t.Fatal(err)
+ }
+ },
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - guest without view access",
Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
- ExpectedStatus: 403,
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
+ ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "protected file - guest with view access",
Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- dao := daos.New(app.Dao().DB())
-
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// mock public view access
- c, err := dao.FindCollectionByNameOrId("demo1")
+ c, err := app.FindCachedCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("")
- if err := dao.SaveCollection(c); err != nil {
+ if err := app.UnsafeWithoutHooks().Save(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file - auth record without view access",
Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- dao := daos.New(app.Dao().DB())
-
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8ifQ.nSTLuCPcGpWn2K2l-BFkC3Vlzc-ZTDPByYq8dN1oPSo",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// mock restricted user view access
- c, err := dao.FindCollectionByNameOrId("demo1")
+ c, err := app.FindCachedCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = true")
- if err := dao.SaveCollection(c); err != nil {
+ if err := app.UnsafeWithoutHooks().Save(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
- ExpectedStatus: 403,
+ ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "protected file - auth record with view access",
Method: http.MethodGet,
- Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- dao := daos.New(app.Dao().DB())
-
+ URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8ifQ.nSTLuCPcGpWn2K2l-BFkC3Vlzc-ZTDPByYq8dN1oPSo",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// mock user view access
- c, err := dao.FindCollectionByNameOrId("demo1")
+ c, err := app.FindCachedCollectionByNameOrId("demo1")
if err != nil {
t.Fatalf("Failed to fetch mock collection: %v", err)
}
c.ViewRule = types.Pointer("@request.auth.verified = false")
- if err := dao.SaveCollection(c); err != nil {
+ if err := app.UnsafeWithoutHooks().Save(c); err != nil {
t.Fatalf("Failed to update mock collection: %v", err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{"PNG"},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
{
Name: "protected file in view (view's View API rule failure)",
Method: http.MethodGet,
- Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
- ExpectedStatus: 403,
+ URL: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8ifQ.nSTLuCPcGpWn2K2l-BFkC3Vlzc-ZTDPByYq8dN1oPSo",
+ ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "protected file in view (view's View API rule success)",
Method: http.MethodGet,
- Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
+ URL: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8ifQ.nSTLuCPcGpWn2K2l-BFkC3Vlzc-ZTDPByYq8dN1oPSo",
ExpectedStatus: 200,
ExpectedContent: []string{"test"},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnFileDownloadRequest": 1,
},
},
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:file",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:file"},
+ {MaxRequests: 0, Label: "users:file"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:file",
+ Method: http.MethodGet,
+ URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:file"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
}
for _, scenario := range scenarios {
@@ -385,3 +535,78 @@ func TestFileDownload(t *testing.T) {
scenario.Test(t)
}
}
+
+func TestConcurrentThumbsGeneration(t *testing.T) {
+ t.Parallel()
+
+ app, err := tests.NewTestApp()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer app.Cleanup()
+
+ fsys, err := app.NewFilesystem()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fsys.Close()
+
+ // create a dummy file field collection
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ fileField := demo1.Fields.GetByName("file_one").(*core.FileField)
+ fileField.Protected = false
+ fileField.MaxSelect = 1
+ fileField.MaxSize = 999999
+ // new thumbs
+ fileField.Thumbs = []string{"111x111", "111x222", "111x333"}
+ demo1.Fields.Add(fileField)
+ if err = app.Save(demo1); err != nil {
+ t.Fatal(err)
+ }
+
+ fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
+
+ urls := []string{
+ "/api/files/" + fileKey + "?thumb=111x111",
+ "/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
+ "/api/files/" + fileKey + "?thumb=111x222",
+ "/api/files/" + fileKey + "?thumb=111x333",
+ }
+
+ var wg sync.WaitGroup
+
+ wg.Add(len(urls))
+
+ for _, url := range urls {
+ go func() {
+ defer wg.Done()
+
+ recorder := httptest.NewRecorder()
+
+ req := httptest.NewRequest("GET", url, nil)
+
+ pbRouter, _ := apis.NewRouter(app)
+ mux, _ := pbRouter.BuildMux()
+ if mux != nil {
+ mux.ServeHTTP(recorder, req)
+ }
+ }()
+ }
+
+ wg.Wait()
+
+ // ensure that all new requested thumbs were created
+ thumbKeys := []string{
+ "wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
+ "wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
+ "wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
+ }
+ for _, k := range thumbKeys {
+ if exists, _ := fsys.Exists(k); !exists {
+ t.Fatalf("Missing thumb %q: %v", k, err)
+ }
+ }
+}
diff --git a/apis/health.go b/apis/health.go
index fef22c397a..1d512796c7 100644
--- a/apis/health.go
+++ b/apis/health.go
@@ -2,37 +2,53 @@ package apis
import (
"net/http"
+ "slices"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/router"
)
// bindHealthApi registers the health api endpoint.
-func bindHealthApi(app core.App, rg *echo.Group) {
- api := healthApi{app: app}
-
+func bindHealthApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/health")
- subGroup.GET("", api.healthCheck)
-}
-
-type healthApi struct {
- app core.App
-}
-
-type healthCheckResponse struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Data struct {
- CanBackup bool `json:"canBackup"`
- } `json:"data"`
+ subGroup.GET("", healthCheck)
}
// healthCheck returns a 200 OK response if the server is healthy.
-func (api *healthApi) healthCheck(c echo.Context) error {
- resp := new(healthCheckResponse)
- resp.Code = http.StatusOK
- resp.Message = "API is healthy."
- resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
-
- return c.JSON(http.StatusOK, resp)
+func healthCheck(e *core.RequestEvent) error {
+ resp := struct {
+ Message string `json:"message"`
+ Code int `json:"code"`
+ Data map[string]any `json:"data"`
+ }{
+ Code: http.StatusOK,
+ Message: "API is healthy.",
+ }
+
+ // @todo evaluate whether it is worth removing the extra info from the health endpoint
+ if e.HasSuperuserAuth() {
+ resp.Data = make(map[string]any, 3)
+ resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup)
+ resp.Data["realIP"] = e.RealIP()
+
+ // loosely check if behind a reverse proxy
+ // (usually used in the dashboard to remind superusers in case deployed behind reverse-proxy)
+ possibleProxyHeader := ""
+ headersToCheck := append(
+ slices.Clone(e.App.Settings().TrustedProxy.Headers),
+ // common proxy headers
+ "CF-Connecting-IP", "Fly-Client-IP", "X-Forwarded-For",
+ )
+ for _, header := range headersToCheck {
+ if e.Request.Header.Get(header) != "" {
+ possibleProxyHeader = header
+ break
+ }
+ }
+ resp.Data["possibleProxyHeader"] = possibleProxyHeader
+ } else {
+ resp.Data = map[string]any{} // ensure that it is returned as object
+ }
+
+ return e.JSON(http.StatusOK, resp)
}
diff --git a/apis/health_test.go b/apis/health_test.go
index dc1c491dc9..4f45c47203 100644
--- a/apis/health_test.go
+++ b/apis/health_test.go
@@ -8,17 +8,60 @@ import (
)
func TestHealthAPI(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
- Name: "health status returns 200",
- Method: http.MethodGet,
- Url: "/api/health",
+ Name: "GET health status (guest)",
+ Method: http.MethodGet, // automatically matches also HEAD as a side-effect of the Go std mux
+ URL: "/api/health",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"code":200`,
+ `"data":{}`,
+ },
+ NotExpectedContent: []string{
+ "canBackup",
+ "realIP",
+ "possibleProxyHeader",
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "GET health status (regular user)",
+ Method: http.MethodGet,
+ URL: "/api/health",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"code":200`,
+ `"data":{}`,
+ },
+ NotExpectedContent: []string{
+ "canBackup",
+ "realIP",
+ "possibleProxyHeader",
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "GET health status (superuser)",
+ Method: http.MethodGet,
+ URL: "/api/health",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
ExpectedStatus: 200,
ExpectedContent: []string{
`"code":200`,
`"data":{`,
`"canBackup":true`,
+ `"realIP"`,
+ `"possibleProxyHeader"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
diff --git a/apis/installer.go b/apis/installer.go
new file mode 100644
index 0000000000..7085525f67
--- /dev/null
+++ b/apis/installer.go
@@ -0,0 +1,102 @@
+package apis
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/osutils"
+ "github.com/pocketbase/pocketbase/ui"
+)
+
+// DefaultInstallerFunc is the default PocketBase installer function.
+//
+// It will attempt to open a link in the browser (with a short-lived auth
+// token for the systemSuperuser) to the installer UI so that users can
+// create their own custom superuser record.
+//
+// See https://github.com/pocketbase/pocketbase/discussions/5814.
+func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
+ if ui.DistDirFS == nil {
+ color.Magenta("You can create your first superuser by running: %s superuser upsert EMAIL PASS", executablePath())
+ return nil
+ }
+
+ token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
+ if err != nil {
+ return err
+ }
+
+ // launch url (ignore errors and always print a help text as fallback)
+ url := fmt.Sprintf("%s/_/#/pbinstall/%s", strings.TrimRight(baseURL, "/"), token)
+ _ = osutils.LaunchURL(url)
+ color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
+ color.New(color.Bold).Add(color.FgCyan).Println(url)
+ color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", executablePath())
+
+ return nil
+}
+
+func loadInstaller(
+ app core.App,
+ baseURL string,
+ installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
+) error {
+ if installerFunc == nil || !needInstallerSuperuser(app) {
+ return nil
+ }
+
+ superuser, err := findOrCreateInstallerSuperuser(app)
+ if err != nil {
+ return err
+ }
+
+ return installerFunc(app, superuser, baseURL)
+}
+
+func needInstallerSuperuser(app core.App) bool {
+ total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
+ "email": core.DefaultInstallerEmail,
+ }))
+
+ return err == nil && total == 0
+}
+
+func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
+ col, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ return nil, err
+ }
+
+ record, err := app.FindAuthRecordByEmail(col, core.DefaultInstallerEmail)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return nil, err
+ }
+
+ record = core.NewRecord(col)
+ record.SetEmail(core.DefaultInstallerEmail)
+ record.SetRandomPassword()
+
+ err = app.Save(record)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return record, nil
+}
+
+func executablePath() string {
+ if osutils.IsProbablyGoRun() {
+ return "go run ."
+ }
+
+ return os.Args[0]
+}
diff --git a/apis/logs.go b/apis/logs.go
index 079cd7caa4..350b6c74cb 100644
--- a/apis/logs.go
+++ b/apis/logs.go
@@ -3,79 +3,92 @@ package apis
import (
"net/http"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
)
// bindLogsApi registers the request logs api endpoints.
-func bindLogsApi(app core.App, rg *echo.Group) {
- api := logsApi{app: app}
-
- subGroup := rg.Group("/logs", RequireAdminAuth())
- subGroup.GET("/requests", api.requestsList)
- subGroup.GET("/requests/stats", api.requestsStats)
- subGroup.GET("/requests/:id", api.requestView)
-}
-
-type logsApi struct {
- app core.App
+func bindLogsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ sub := rg.Group("/logs").Bind(
+ RequireSuperuserAuth(),
+ SkipSuccessActivityLog(),
+ )
+
+ sub.GET("", logsList)
+ sub.DELETE("", logsTruncate)
+ sub.GET("/stats", logsStats)
+ sub.GET("/{id}", logsView)
}
-var requestFilterFields = []string{
- "rowid", "id", "created", "updated",
- "url", "method", "status", "auth",
- "remoteIp", "userIp", "referer", "userAgent",
+var logFilterFields = []string{
+ "id", "created", "level", "message", "data",
+ `^data\.[\w\.\:]*\w+$`,
}
-func (api *logsApi) requestsList(c echo.Context) error {
- fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
+func logsList(e *core.RequestEvent) error {
+ fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
result, err := search.NewProvider(fieldResolver).
- Query(api.app.LogsDao().RequestQuery()).
- ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
+ Query(e.App.AuxModelQuery(&core.Log{})).
+ ParseAndExec(e.Request.URL.Query().Encode(), &[]*core.Log{})
if err != nil {
- return NewBadRequestError("", err)
+ return e.BadRequestError("", err)
}
- return c.JSON(http.StatusOK, result)
+ return e.JSON(http.StatusOK, result)
}
-func (api *logsApi) requestsStats(c echo.Context) error {
- fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
+func logsStats(e *core.RequestEvent) error {
+ fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
- filter := c.QueryParam(search.FilterQueryParam)
+ filter := e.Request.URL.Query().Get(search.FilterQueryParam)
var expr dbx.Expression
if filter != "" {
var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil {
- return NewBadRequestError("Invalid filter format.", err)
+ return e.BadRequestError("Invalid filter format.", err)
}
}
- stats, err := api.app.LogsDao().RequestsStats(expr)
+ stats, err := e.App.LogsStats(expr)
if err != nil {
- return NewBadRequestError("Failed to generate requests stats.", err)
+ return e.BadRequestError("Failed to generate logs stats.", err)
}
- return c.JSON(http.StatusOK, stats)
+ return e.JSON(http.StatusOK, stats)
}
-func (api *logsApi) requestView(c echo.Context) error {
- id := c.PathParam("id")
+func logsView(e *core.RequestEvent) error {
+ id := e.Request.PathValue("id")
if id == "" {
- return NewNotFoundError("", nil)
+ return e.NotFoundError("", nil)
}
- request, err := api.app.LogsDao().FindRequestById(id)
- if err != nil || request == nil {
- return NewNotFoundError("", err)
+ log, err := e.App.FindLogById(id)
+ if err != nil || log == nil {
+ return e.NotFoundError("", err)
+ }
+
+ return e.JSON(http.StatusOK, log)
+}
+
+func logsTruncate(e *core.RequestEvent) error {
+ // delete all rows directly (aka. no model hooks will be fired)
+ _, err := e.App.AuxNonconcurrentDB().Delete((&core.Log{}).TableName(), nil).Execute()
+ if err != nil {
+ return e.InternalServerError("Failed to truncate all logs.", err)
+ }
+
+ // try to free the unused disk space
+ err = e.App.AuxVacuum()
+ if err != nil {
+ e.App.Logger().Warn("Failed to VACUUM aux database", "error", err)
}
- return c.JSON(http.StatusOK, request)
+ return e.NoContent(http.StatusNoContent)
}
diff --git a/apis/logs_test.go b/apis/logs_test.go
index 648fb0e2ed..90384cd0fa 100644
--- a/apis/logs_test.go
+++ b/apis/logs_test.go
@@ -4,38 +4,42 @@ import (
"net/http"
"testing"
- "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
-func TestRequestsList(t *testing.T) {
+func TestLogsList(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/logs/requests",
+ URL: "/api/logs",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/logs/requests",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/logs",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin",
+ Name: "authorized as superuser",
Method: http.MethodGet,
- Url: "/api/logs/requests",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -48,16 +52,17 @@ func TestRequestsList(t *testing.T) {
`"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`,
`"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin + filter",
+ Name: "authorized as superuser + filter",
Method: http.MethodGet,
- Url: "/api/logs/requests?filter=status>200",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs?filter=data.status>200",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -69,6 +74,7 @@ func TestRequestsList(t *testing.T) {
`"items":[{`,
`"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -77,49 +83,54 @@ func TestRequestsList(t *testing.T) {
}
}
-func TestRequestView(t *testing.T) {
+func TestLogView(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
+ URL: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (nonexisting request log)",
+ Name: "authorized as superuser (nonexisting request log)",
Method: http.MethodGet,
- Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (existing request log)",
+ Name: "authorized as superuser (existing request log)",
Method: http.MethodGet,
- Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
@@ -127,6 +138,7 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{
`"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -135,58 +147,118 @@ func TestRequestView(t *testing.T) {
}
}
-func TestRequestsStats(t *testing.T) {
+func TestLogsStats(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/logs/requests/stats",
+ URL: "/api/logs/stats",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/logs/requests/stats",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/logs/stats",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin",
+ Name: "authorized as superuser",
Method: http.MethodGet,
- Url: "/api/logs/requests/stats",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs/stats",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
+ `[{"date":"2022-05-01 10:00:00.000Z","total":1},{"date":"2022-05-02 10:00:00.000Z","total":1}]`,
},
},
{
- Name: "authorized as admin + filter",
+ Name: "authorized as superuser + filter",
Method: http.MethodGet,
- Url: "/api/logs/requests/stats?filter=status>200",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/logs/stats?filter=data.status>200",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- if err := tests.MockRequestLogsData(app); err != nil {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
- `[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
+ `[{"date":"2022-05-02 10:00:00.000Z","total":1}]`,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestLogsTruncate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodDelete,
+ URL: "/api/logs",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as regular user",
+ Method: http.MethodDelete,
+ URL: "/api/logs",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as superuser",
+ Method: http.MethodDelete,
+ URL: "/api/logs",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubLogsData(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ var found []core.Log
+
+ if err := app.LogQuery().All(&found); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(found) > 0 {
+ t.Fatalf("Expected all logs to be deleted, found: %v", found)
+ }
},
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
},
}
diff --git a/apis/middlewares.go b/apis/middlewares.go
index 087d236ad5..d033f23751 100644
--- a/apis/middlewares.go
+++ b/apis/middlewares.go
@@ -1,412 +1,472 @@
package apis
import (
+ "errors"
"fmt"
- "log"
- "net"
+ "log/slog"
"net/http"
+ "net/url"
+ "runtime"
+ "slices"
"strings"
"time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tokens"
+ "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
-// Common request context keys used by the middlewares and api handlers.
+// Common request event store keys used by the middlewares and api handlers.
const (
- ContextAdminKey string = "admin"
- ContextAuthRecordKey string = "authRecord"
- ContextCollectionKey string = "collection"
+ RequestEventKeyLogMeta = "pbLogMeta" // extra data to store with the request activity log
+
+ requestEventKeyExecStart = "__execStart" // the value must be time.Time
+ requestEventKeySkipSuccessActivityLog = "__skipSuccessActivityLogger" // the value must be bool
+)
+
+const (
+ DefaultWWWRedirectMiddlewarePriority = -99999
+ DefaultWWWRedirectMiddlewareId = "pbWWWRedirect"
+
+ DefaultActivityLoggerMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 40
+ DefaultActivityLoggerMiddlewareId = "pbActivityLogger"
+ DefaultSkipSuccessActivityLogMiddlewareId = "pbSkipSuccessActivityLog"
+ DefaultEnableAuthIdActivityLog = "pbEnableAuthIdActivityLog"
+
+ DefaultPanicRecoverMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 30
+ DefaultPanicRecoverMiddlewareId = "pbPanicRecover"
+
+ DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
+ DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
+
+ DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
+ DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
+
+ DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
+ DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
+
+ DefaultRequireGuestOnlyMiddlewareId = "pbRequireGuestOnly"
+ DefaultRequireAuthMiddlewareId = "pbRequireAuth"
+ DefaultRequireSuperuserAuthMiddlewareId = "pbRequireSuperuserAuth"
+ DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "pbRequireSuperuserOrOwnerAuth"
+ DefaultRequireSameCollectionContextAuthMiddlewareId = "pbRequireSameCollectionContextAuth"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
// Authorization header.
//
-// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
-func RequireGuestOnly() echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- err := NewBadRequestError("The request can be accessed only by guests.", nil)
-
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record != nil {
- return err
+// This middleware is the opposite of [apis.RequireAuth()].
+func RequireGuestOnly() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRequireGuestOnlyMiddlewareId,
+ Func: func(e *core.RequestEvent) error {
+ if e.Auth != nil {
+ return router.NewBadRequestError("The request can be accessed only by guests.", nil)
}
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return err
- }
-
- return next(c)
- }
+ return e.Next()
+ },
}
}
-// RequireRecordAuth middleware requires a request to have
-// a valid record auth Authorization header.
+// RequireAuth middleware requires a request to have a valid record Authorization header.
//
// The auth record could be from any collection.
-//
-// You can further filter the allowed record auth collections by
-// specifying their names.
+// You can further filter the allowed record auth collections by specifying their names.
//
// Example:
//
-// apis.RequireRecordAuth()
-//
-// Or:
-//
-// apis.RequireRecordAuth("users", "supervisors")
-//
-// To restrict the auth record only to the loaded context collection,
-// use [apis.RequireSameContextRecordAuth()] instead.
-func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record == nil {
- return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
- }
+// apis.RequireAuth() // any auth collection
+// apis.RequireAuth("_superusers", "users") // only the listed auth collections
+func RequireAuth(optCollectionNames ...string) *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRequireAuthMiddlewareId,
+ Func: requireAuth(optCollectionNames...),
+ }
+}
- // check record collection name
- if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
- return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
- }
+func requireAuth(optCollectionNames ...string) func(*core.RequestEvent) error {
+ return func(e *core.RequestEvent) error {
+ if e.Auth == nil {
+ return e.UnauthorizedError("The request requires valid record authorization token.", nil)
+ }
- return next(c)
+ // check record collection name
+ if len(optCollectionNames) > 0 && !slices.Contains(optCollectionNames, e.Auth.Collection().Name) {
+ return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
}
+
+ return e.Next()
+ }
+}
+
+// RequireSuperuserAuth middleware requires a request to have
+// a valid superuser Authorization header.
+func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRequireSuperuserAuthMiddlewareId,
+ Func: requireAuth(core.CollectionNameSuperusers),
}
}
-// RequireSameContextRecordAuth middleware requires a request to have
-// a valid record Authorization header.
+// RequireSuperuserOrOwnerAuth middleware requires a request to have
+// a valid superuser or regular record owner Authorization header set.
//
-// The auth record must be from the same collection already loaded in the context.
-func RequireSameContextRecordAuth() echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record == nil {
- return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
+// This middleware is similar to [apis.RequireAuth()] but
+// for the auth record token expects to have the same id as the path
+// parameter ownerIdPathParam (default to "id" if empty).
+func RequireSuperuserOrOwnerAuth(ownerIdPathParam string) *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRequireSuperuserOrOwnerAuthMiddlewareId,
+ Func: func(e *core.RequestEvent) error {
+ if e.Auth == nil {
+ return e.UnauthorizedError("The request requires superuser or record authorization token.", nil)
}
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil || record.Collection().Id != collection.Id {
- return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
+ if e.Auth.IsSuperuser() {
+ return e.Next()
}
- return next(c)
- }
- }
-}
+ if ownerIdPathParam == "" {
+ ownerIdPathParam = "id"
+ }
+ ownerId := e.Request.PathValue(ownerIdPathParam)
-// RequireAdminAuth middleware requires a request to have
-// a valid admin Authorization header.
-func RequireAdminAuth() echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin == nil {
- return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
+ // note: it is considered "safe" to compare only the record id
+ // since the auth record ids are treated as unique across all auth collections
+ if e.Auth.Id != ownerId {
+ return e.ForbiddenError("You are not allowed to perform this request.", nil)
}
- return next(c)
- }
+ return e.Next()
+ },
}
}
-// RequireAdminAuthOnlyIfAny middleware requires a request to have
-// a valid admin Authorization header ONLY if the application has
-// at least 1 existing Admin model.
-func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return next(c)
+// RequireSameCollectionContextAuth middleware requires a request to have
+// a valid record Authorization header and the auth record's collection to
+// match the one from the route path parameter (default to "collection" if collectionParam is empty).
+func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRequireSameCollectionContextAuthMiddlewareId,
+ Func: func(e *core.RequestEvent) error {
+ if e.Auth == nil {
+ return e.UnauthorizedError("The request requires valid record authorization token.", nil)
}
- totalAdmins, err := app.Dao().TotalAdmins()
- if err != nil {
- return NewBadRequestError("Failed to fetch admins info.", err)
+ if collectionPathParam == "" {
+ collectionPathParam = "collection"
}
- if totalAdmins == 0 {
- return next(c)
+ collection, _ := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue(collectionPathParam))
+ if collection == nil || e.Auth.Collection().Id != collection.Id {
+ return e.ForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", e.Auth.Collection().Name), nil)
}
- return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
- }
+ return e.Next()
+ },
}
}
-// RequireAdminOrRecordAuth middleware requires a request to have
-// a valid admin or record Authorization header set.
+// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
//
-// You can further filter the allowed auth record collections by providing their names.
+// This middleware does nothing in case of:
+// - missing, invalid or expired token
+// - e.Auth is already loaded by another middleware
//
-// This middleware is the opposite of [apis.RequireGuestOnly()].
-func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
-
- if admin == nil && record == nil {
- return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
+// This middleware is registered by default for all routes.
+//
+// Note: We don't throw an error on invalid or expired token to allow
+// users to extend with their own custom handling in external middleware(s).
+func loadAuthToken() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultLoadAuthTokenMiddlewareId,
+ Priority: DefaultLoadAuthTokenMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ // already loaded by another middleware
+ if e.Auth != nil {
+ return e.Next()
}
- if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
- return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
+ token := getAuthTokenFromRequest(e)
+ if token == "" {
+ return e.Next()
}
- return next(c)
- }
+ record, err := e.App.FindAuthRecordByToken(token, core.TokenTypeAuth)
+ if err != nil {
+ e.App.Logger().Debug("loadAuthToken failure", "error", err)
+ } else if record != nil {
+ e.Auth = record
+ }
+
+ return e.Next()
+ },
}
}
-// RequireAdminOrOwnerAuth middleware requires a request to have
-// a valid admin or auth record owner Authorization header set.
-//
-// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
-// for the auth record token expects to have the same id as the path
-// parameter ownerIdParam (default to "id" if empty).
-func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return next(c)
- }
+func getAuthTokenFromRequest(e *core.RequestEvent) string {
+ token := e.Request.Header.Get("Authorization")
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record == nil {
- return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
- }
+ // the "Bearer" schema prefix is not required by PocketBase and it is
+ // supported only for compatibility with the defaults of some HTTP clients
+ if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
+ return token[7:]
+ }
- if ownerIdParam == "" {
- ownerIdParam = "id"
- }
- ownerId := c.PathParam(ownerIdParam)
+ return token
+}
- // note: it is "safe" to compare only the record id since the auth
- // record ids are treated as unique across all auth collections
- if record.Id != ownerId {
- return NewForbiddenError("You are not allowed to perform this request.", nil)
+// wwwRedirect performs www->non-www redirect(s) if the request host
+// matches with one of the values in redirectHosts.
+//
+// This middleware is registered by default on Serve for all routes.
+func wwwRedirect(redirectHosts []string) *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultWWWRedirectMiddlewareId,
+ Priority: DefaultWWWRedirectMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ host := e.Request.Host
+
+ if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, redirectHosts) {
+ // note: e.Request.URL.Scheme would be empty
+ schema := "http://"
+ if e.IsTLS() {
+ schema = "https://"
+ }
+
+ return e.Redirect(
+ http.StatusTemporaryRedirect,
+ (schema + host[4:] + e.Request.RequestURI),
+ )
}
- return next(c)
- }
+ return e.Next()
+ },
}
}
-// LoadAuthContext middleware reads the Authorization request header
-// and loads the token related record or admin instance into the
-// request's context.
-//
-// This middleware is expected to be already registered by default for all routes.
-func LoadAuthContext(app core.App) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- token := c.Request().Header.Get("Authorization")
- if token == "" {
- return next(c)
- }
+// panicRecover returns a default panic-recover handler.
+func panicRecover() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultPanicRecoverMiddlewareId,
+ Priority: DefaultPanicRecoverMiddlewarePriority,
+ Func: func(e *core.RequestEvent) (err error) {
+ // panic-recover
+ defer func() {
+ recoverResult := recover()
+ if recoverResult == nil {
+ return
+ }
- // the schema is not required and it is only for
- // compatibility with the defaults of some HTTP clients
- token = strings.TrimPrefix(token, "Bearer ")
+ recoverErr, ok := recoverResult.(error)
+ if !ok {
+ recoverErr = fmt.Errorf("%v", recoverResult)
+ } else if errors.Is(recoverErr, http.ErrAbortHandler) {
+ // don't recover ErrAbortHandler so the response to the client can be aborted
+ panic(recoverResult)
+ }
- claims, _ := security.ParseUnverifiedJWT(token)
- tokenType := cast.ToString(claims["type"])
+ stack := make([]byte, 2<<10) // 2 KB
+ length := runtime.Stack(stack, true)
+ err = e.InternalServerError("", fmt.Errorf("[PANIC RECOVER] %w %s", recoverErr, stack[:length]))
+ }()
- switch tokenType {
- case tokens.TypeAdmin:
- admin, err := app.Dao().FindAdminByToken(
- token,
- app.Settings().AdminAuthToken.Secret,
- )
- if err == nil && admin != nil {
- c.Set(ContextAdminKey, admin)
- }
- case tokens.TypeAuthRecord:
- record, err := app.Dao().FindAuthRecordByToken(
- token,
- app.Settings().RecordAuthToken.Secret,
- )
- if err == nil && record != nil {
- c.Set(ContextAuthRecordKey, record)
- }
- }
+ err = e.Next()
- return next(c)
- }
+ return err
+ },
}
}
-// LoadCollectionContext middleware finds the collection with related
-// path identifier and loads it into the request context.
+// securityHeaders middleware adds common security headers to the response.
//
-// Set optCollectionTypes to further filter the found collection by its type.
-func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- if param := c.PathParam("collection"); param != "" {
- collection, err := app.Dao().FindCollectionByNameOrId(param)
- if err != nil || collection == nil {
- return NewNotFoundError("", err)
- }
+// This middleware is registered by default for all routes.
+func securityHeaders() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultSecurityHeadersMiddlewareId,
+ Priority: DefaultSecurityHeadersMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ e.Response.Header().Set("X-XSS-Protection", "1; mode=block")
+ e.Response.Header().Set("X-Content-Type-Options", "nosniff")
+ e.Response.Header().Set("X-Frame-Options", "SAMEORIGIN")
+ e.Response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+
+ // @todo consider a default HSTS?
+ // (see also https://webkit.org/blog/8146/protecting-against-hsts-abuse/)
+
+ return e.Next()
+ },
+ }
+}
- if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
- return NewBadRequestError("Unsupported collection type.", nil)
+// 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"))
}
-
- c.Set(ContextCollectionKey, collection)
}
- return next(c)
- }
+ return e.Next()
+ },
}
}
-// ActivityLogger middleware takes care to save the request information
+// SkipSuccessActivityLog is a helper middleware that instructs the global
+// activity logger to log only requests that have failed/returned an error.
+func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultSkipSuccessActivityLogMiddlewareId,
+ Func: func(e *core.RequestEvent) error {
+ e.Set(requestEventKeySkipSuccessActivityLog, true)
+ return e.Next()
+ },
+ }
+}
+
+// activityLogger middleware takes care to save the request information
// into the logs database.
//
+// This middleware is registered by default for all routes.
+//
// The middleware does nothing if the app logs retention period is zero
// (aka. app.Settings().Logs.MaxDays = 0).
-func ActivityLogger(app core.App) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- err := next(c)
-
- logsMaxDays := app.Settings().Logs.MaxDays
+//
+// Users can attach the [apis.SkipSuccessActivityLog()] middleware if
+// you want to log only the failed requests.
+func activityLogger() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultActivityLoggerMiddlewareId,
+ Priority: DefaultActivityLoggerMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ e.Set(requestEventKeyExecStart, time.Now())
- // no logs retention
- if logsMaxDays == 0 {
- return err
- }
+ err := e.Next()
- httpRequest := c.Request()
- httpResponse := c.Response()
- status := httpResponse.Status
- meta := types.JsonMap{}
+ logRequest(e, err)
- if err != nil {
- switch v := err.(type) {
- case *echo.HTTPError:
- status = v.Code
- meta["errorMessage"] = v.Message
- meta["errorDetails"] = fmt.Sprint(v.Internal)
- case *ApiError:
- status = v.Code
- meta["errorMessage"] = v.Message
- meta["errorDetails"] = fmt.Sprint(v.RawData())
- default:
- status = http.StatusBadRequest
- meta["errorMessage"] = v.Error()
- }
- }
+ return err
+ },
+ }
+}
- requestAuth := models.RequestAuthGuest
- if c.Get(ContextAuthRecordKey) != nil {
- requestAuth = models.RequestAuthRecord
- } else if c.Get(ContextAdminKey) != nil {
- requestAuth = models.RequestAuthAdmin
- }
+func logRequest(event *core.RequestEvent, err error) {
+ // no logs retention
+ if event.App.Settings().Logs.MaxDays == 0 {
+ return
+ }
- ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
-
- model := &models.Request{
- Url: httpRequest.URL.RequestURI(),
- Method: strings.ToUpper(httpRequest.Method),
- Status: status,
- Auth: requestAuth,
- UserIp: realUserIp(httpRequest, ip),
- RemoteIp: ip,
- Referer: httpRequest.Referer(),
- UserAgent: httpRequest.UserAgent(),
- Meta: meta,
- }
- // set timestamp fields before firing a new go routine
- model.RefreshCreated()
- model.RefreshUpdated()
+ // the non-error route has explicitly disabled the activity logger
+ if err == nil && event.Get(requestEventKeySkipSuccessActivityLog) != nil {
+ return
+ }
- routine.FireAndForget(func() {
- if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
- log.Println("Log save failed:", err)
- }
+ attrs := make([]any, 0, 15)
- // Delete old request logs
- // ---
- now := time.Now()
- lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
- daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
-
- if daysDiff > float64(logsMaxDays) {
- deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*logsMaxDays))
- if deleteErr == nil {
- app.Cache().Set("lastLogsDeletedAt", now)
- } else if app.IsDebug() {
- log.Println("Logs delete failed:", deleteErr)
- }
- }
- })
+ attrs = append(attrs, slog.String("type", "request"))
- return err
- }
+ started := cast.ToTime(event.Get(requestEventKeyExecStart))
+ if !started.IsZero() {
+ attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
}
-}
-// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
-//
-// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
-func realUserIp(r *http.Request, fallbackIp string) string {
- if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
- return ip
+ if meta := event.Get(RequestEventKeyLogMeta); meta != nil {
+ attrs = append(attrs, slog.Any("meta", meta))
}
- if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
- return ip
- }
+ status := event.Status()
+ method := cutStr(strings.ToUpper(event.Request.Method), 50)
+ requestUri := cutStr(event.Request.URL.RequestURI(), 3000)
+
+ // parse the request error
+ if err != nil {
+ apiErr, isPlainApiError := err.(*router.ApiError)
+ if isPlainApiError || errors.As(err, &apiErr) {
+ // the status header wasn't written yet
+ if status == 0 {
+ status = apiErr.Status
+ }
- if ip := r.Header.Get("X-Real-IP"); ip != "" {
- return ip
+ var errMsg string
+ if isPlainApiError {
+ errMsg = apiErr.Message
+ } else {
+ // wrapped ApiError -> add the full serialized version
+ // of the original error since it could contain more information
+ errMsg = err.Error()
+ }
+
+ attrs = append(
+ attrs,
+ slog.String("error", errMsg),
+ slog.Any("details", apiErr.RawData()),
+ )
+ } else {
+ attrs = append(attrs, slog.String("error", err.Error()))
+ }
}
- if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
- // extract the first non-empty leftmost-ish ip
- ips := strings.Split(ipsList, ",")
- for _, ip := range ips {
- ip = strings.TrimSpace(ip)
- if ip != "" {
- return ip
- }
+ attrs = append(
+ attrs,
+ slog.String("url", requestUri),
+ slog.String("method", method),
+ slog.Int("status", status),
+ slog.String("referer", cutStr(event.Request.Referer(), 2000)),
+ slog.String("userAgent", cutStr(event.Request.UserAgent(), 2000)),
+ )
+
+ if event.Auth != nil {
+ attrs = append(attrs, slog.String("auth", event.Auth.Collection().Name))
+
+ if event.App.Settings().Logs.LogAuthId {
+ attrs = append(attrs, slog.String("authId", event.Auth.Id))
}
+ } else {
+ attrs = append(attrs, slog.String("auth", ""))
}
- return fallbackIp
-}
+ if event.App.Settings().Logs.LogIP {
+ attrs = append(
+ attrs,
+ slog.String("userIP", event.RealIP()),
+ slog.String("remoteIP", event.RemoteIP()),
+ )
+ }
-// eagerRequestInfoCache ensures that the request data is cached in the request
-// context to allow reading for example the json request body data more than once.
-func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- switch c.Request().Method {
- // currently we are eagerly caching only the requests with body
- case "POST", "PUT", "PATCH", "DELETE":
- RequestInfo(c)
- }
+ // don't block on logs write
+ routine.FireAndForget(func() {
+ message := method + " "
+
+ if escaped, unescapeErr := url.PathUnescape(requestUri); unescapeErr == nil {
+ message += escaped
+ } else {
+ message += requestUri
+ }
- return next(c)
+ if err != nil {
+ event.App.Logger().Error(message, attrs...)
+ } else {
+ event.App.Logger().Info(message, attrs...)
}
+ })
+}
+
+func cutStr(str string, max int) string {
+ if len(str) > max {
+ return str[:max] + "..."
}
+ return str
}
diff --git a/apis/middlewares_body_limit.go b/apis/middlewares_body_limit.go
new file mode 100644
index 0000000000..1c456156b3
--- /dev/null
+++ b/apis/middlewares_body_limit.go
@@ -0,0 +1,156 @@
+package apis
+
+import (
+ "io"
+ "net/http"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil)
+
+const DefaultMaxBodySize int64 = 32 << 20 // @todo consider replacing with router.DefaultMaxMemory
+
+const (
+ DefaultBodyLimitMiddlewareId = "pbBodyLimit"
+ DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
+)
+
+// BodyLimit returns a middleware handler that changes the default request body size limit.
+//
+// If limitBytes <= 0, no limit is applied.
+//
+// Otherwise, if the request body size exceeds the configured limitBytes,
+// it sends 413 error response.
+func BodyLimit(limitBytes int64) *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultBodyLimitMiddlewareId,
+ Priority: DefaultBodyLimitMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ err := applyBodyLimit(e, limitBytes)
+ if err != nil {
+ return err
+ }
+
+ return e.Next()
+ },
+ }
+}
+
+func dynamicCollectionBodyLimit(collectionPathParam string) *hook.Handler[*core.RequestEvent] {
+ if collectionPathParam == "" {
+ collectionPathParam = "collection"
+ }
+
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultBodyLimitMiddlewareId,
+ Priority: DefaultBodyLimitMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue(collectionPathParam))
+ if err != nil {
+ return e.NotFoundError("Missing or invalid collection context.", err)
+ }
+
+ limitBytes := DefaultMaxBodySize
+ if !collection.IsView() {
+ for _, f := range collection.Fields {
+ if calc, ok := f.(core.MaxBodySizeCalculator); ok {
+ limitBytes += calc.CalculateMaxBodySize()
+ }
+ }
+ }
+
+ err = applyBodyLimit(e, limitBytes)
+ if err != nil {
+ return err
+ }
+
+ return e.Next()
+ },
+ }
+}
+
+func applyBodyLimit(e *core.RequestEvent, limitBytes int64) error {
+ // no limit
+ if limitBytes <= 0 {
+ return nil
+ }
+
+ // optimistically check the submitted request content length
+ if e.Request.ContentLength > limitBytes {
+ return ErrRequestEntityTooLarge
+ }
+
+ // replace the request body
+ e.Request.Body = newMaxBytesReader(e.Request.Body, limitBytes)
+
+ return nil
+}
+
+func newMaxBytesReader(body io.ReadCloser, limitBytes int64) *maxBytesReader {
+ return &maxBytesReader{
+ ReadCloser: body,
+ limit: limitBytes,
+ remaining: limitBytes,
+ }
+}
+
+// maxBytesReader is very similar to the http.MaxBytesReader but support
+// rereads and doesn't try to prematurely close the related response
+// to allow consequent middlewares to operate correctly.
+type maxBytesReader struct {
+ io.ReadCloser
+ limit int64
+ remaining int64
+ stickyErr error
+}
+
+func (r *maxBytesReader) Read(b []byte) (int, error) {
+ if r.stickyErr != nil {
+ return 0, r.stickyErr
+ }
+
+ if len(b) == 0 {
+ return 0, nil
+ }
+
+ // if possible no need to read the entire chunk since
+ // remaining+1 is enough to determine whether it exceed the limit
+ if int64(len(b))-1 > r.remaining {
+ b = b[:r.remaining+1]
+ }
+
+ n, err := r.ReadCloser.Read(b)
+
+ if int64(n) <= r.remaining {
+ r.remaining -= int64(n)
+ r.stickyErr = err
+ return n, err
+ }
+
+ n = int(r.remaining)
+
+ r.remaining = 0
+ r.stickyErr = ErrRequestEntityTooLarge
+
+ return n, r.stickyErr
+}
+
+// explicit casts to ensure that the main struct methods will be invoked
+// (extra precautions in case of nested interface wrapping erasure)
+// ---
+
+func (r *maxBytesReader) Reread() {
+ rereader, ok := r.ReadCloser.(router.Rereader)
+ if ok {
+ rereader.Reread()
+ r.remaining = r.limit
+ r.stickyErr = nil
+ }
+}
+
+func (r *maxBytesReader) Close() error {
+ return r.ReadCloser.Close()
+}
diff --git a/apis/middlewares_body_limit_test.go b/apis/middlewares_body_limit_test.go
new file mode 100644
index 0000000000..f4ac9ae780
--- /dev/null
+++ b/apis/middlewares_body_limit_test.go
@@ -0,0 +1,153 @@
+package apis_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestBodyLimitMiddleware(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ pbRouter, err := apis.NewRouter(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testHandler := func(e *core.RequestEvent) error {
+ // read the body multiple times to ensure that the limited
+ // reader guards and rereads are invoked
+ var result any
+ if err := e.BindBody(&result); err != nil {
+ return err
+ }
+
+ if err := e.BindBody(&result); err != nil {
+ return err
+ }
+
+ return e.JSON(200, result)
+ }
+
+ const customLimit = 20
+
+ pbRouter.POST("/a", testHandler) // default global BodyLimit check
+ pbRouter.POST("/b", testHandler).Bind(apis.BodyLimit(customLimit))
+ pbRouter.POST("/iof", func(e *core.RequestEvent) error {
+ // ensure that normal io methods still operate correctly
+ b, err := io.ReadAll(e.Request.Body)
+ if err != nil {
+ return err
+ }
+
+ return e.String(http.StatusOK, string(b))
+ }).Bind(apis.BodyLimit(customLimit))
+
+ mux, err := pbRouter.BuildMux()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ url string
+ body string
+ lazyContentLength bool
+ expectedStatus int
+ }{
+ {
+ "(eager content-length check) with body = default limit",
+ "/a",
+ `"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
+ false,
+ http.StatusOK,
+ },
+ {
+ "(eager content-length check) with body > default limit",
+ "/a",
+ `"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
+ false,
+ http.StatusRequestEntityTooLarge,
+ },
+ {
+ "(lazy content-length check) with body = default limit",
+ "/a",
+ `"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
+ true,
+ http.StatusOK,
+ },
+ {
+ "(lazy content-length check) with body > default limit",
+ "/a",
+ `"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
+ true,
+ http.StatusRequestEntityTooLarge,
+ },
+ // ---
+ {
+ "(eager content-length check) with body = custom limit",
+ "/b",
+ `"` + strings.Repeat("a", customLimit-2) + `"`,
+ false,
+ http.StatusOK,
+ },
+ {
+ "(eager content-length check) with body > custom limit",
+ "/b",
+ `"` + strings.Repeat("a", customLimit) + `"`,
+ false,
+ http.StatusRequestEntityTooLarge,
+ },
+ {
+ "(lazy content-length check) with body = custom limit",
+ "/b",
+ `"` + strings.Repeat("a", customLimit-2) + `"`,
+ true,
+ http.StatusOK,
+ },
+ {
+ "(lazy content-length check) with body > custom limit",
+ "/b",
+ `"` + strings.Repeat("a", customLimit) + `"`,
+ true,
+ http.StatusRequestEntityTooLarge,
+ },
+ // ---
+ {
+ "io.ReadAll io.EOF exact limit check",
+ "/iof",
+ `"` + strings.Repeat("a", customLimit-2) + `"`,
+ true,
+ http.StatusOK,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+
+ req := httptest.NewRequest("POST", s.url, strings.NewReader(s.body))
+ req.Header.Set("Content-Type", "application/json")
+
+ if s.lazyContentLength {
+ req.ContentLength = -1
+ }
+
+ mux.ServeHTTP(rec, req)
+
+ result := rec.Result()
+ defer result.Body.Close()
+
+ if result.StatusCode != s.expectedStatus {
+ t.Fatalf("Expected response status %d, got %d", s.expectedStatus, result.StatusCode)
+ }
+ })
+ }
+}
diff --git a/apis/middlewares_cors.go b/apis/middlewares_cors.go
new file mode 100644
index 0000000000..e48fa69a98
--- /dev/null
+++ b/apis/middlewares_cors.go
@@ -0,0 +1,332 @@
+package apis
+
+// -------------------------------------------------------------------
+// This middleware is ported from echo/middleware to minimize the breaking
+// changes and differences in the API behavior from earlier PocketBase versions
+// (https://github.com/labstack/echo/blob/ec5b858dab6105ab4c3ed2627d1ebdfb6ae1ecb8/middleware/cors.go).
+//
+// I doubt that this would matter for most cases, but the only major difference
+// is that for non-supported routes this middleware doesn't return 405 and fallbacks
+// to the default catch-all PocketBase route (aka. returns 404) to avoid
+// the extra overhead of further hijacking and wrapping the Go default mux
+// (https://github.com/golang/go/issues/65648#issuecomment-1955328807).
+// -------------------------------------------------------------------
+
+import (
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/hook"
+)
+
+const (
+ DefaultCorsMiddlewareId = "pbCors"
+ DefaultCorsMiddlewarePriority = DefaultActivityLoggerMiddlewarePriority - 1 // before the activity logger and rate limit so that OPTIONS preflight requests are not counted
+)
+
+// CORSConfig defines the config for CORS middleware.
+type CORSConfig struct {
+ // AllowOrigins determines the value of the Access-Control-Allow-Origin
+ // response header. This header defines a list of origins that may access the
+ // resource.
+ //
+ // The wildcard characters '*' and '?' are supported as subdomain segments
+ // and are converted to regex fragments '.*' and '.' accordingly.
+ //
+ // Security: use extreme caution when handling the origin, and carefully
+ // validate any logic. Remember that attackers may register hostile domain names.
+ // See https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+ //
+ // Optional. Default value []string{"*"}.
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
+ AllowOrigins []string
+
+ // AllowOriginFunc is a custom function to validate the origin. It takes the
+ // origin as an argument and returns true if allowed or false otherwise. If
+ // an error is returned, it is returned by the handler. If this option is
+ // set, AllowOrigins is ignored.
+ //
+ // Security: use extreme caution when handling the origin, and carefully
+ // validate any logic. Remember that attackers may register hostile domain names.
+ // See https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+ //
+ // Optional.
+ AllowOriginFunc func(origin string) (bool, error)
+
+ // AllowMethods determines the value of the Access-Control-Allow-Methods
+ // response header. This header specified the list of methods allowed when
+ // accessing the resource. This is used in response to a preflight request.
+ //
+ // Optional. Default value DefaultCORSConfig.AllowMethods.
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
+ AllowMethods []string
+
+ // AllowHeaders determines the value of the Access-Control-Allow-Headers
+ // response header. This header is used in response to a preflight request to
+ // indicate which HTTP headers can be used when making the actual request.
+ //
+ // Optional. Default value []string{}.
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
+ AllowHeaders []string
+
+ // AllowCredentials determines the value of the
+ // Access-Control-Allow-Credentials response header. This header indicates
+ // whether or not the response to the request can be exposed when the
+ // credentials mode (Request.credentials) is true. When used as part of a
+ // response to a preflight request, this indicates whether or not the actual
+ // request can be made using credentials. See also
+ // [MDN: Access-Control-Allow-Credentials].
+ //
+ // Optional. Default value false, in which case the header is not set.
+ //
+ // Security: avoid using `AllowCredentials = true` with `AllowOrigins = *`.
+ // See "Exploiting CORS misconfigurations for Bitcoins and bounties",
+ // https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
+ AllowCredentials bool
+
+ // UnsafeWildcardOriginWithAllowCredentials UNSAFE/INSECURE: allows wildcard '*' origin to be used with AllowCredentials
+ // flag. In that case we consider any origin allowed and send it back to the client with `Access-Control-Allow-Origin` header.
+ //
+ // This is INSECURE and potentially leads to [cross-origin](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties)
+ // attacks. See: https://github.com/labstack/echo/issues/2400 for discussion on the subject.
+ //
+ // Optional. Default value is false.
+ UnsafeWildcardOriginWithAllowCredentials bool
+
+ // ExposeHeaders determines the value of Access-Control-Expose-Headers, which
+ // defines a list of headers that clients are allowed to access.
+ //
+ // Optional. Default value []string{}, in which case the header is not set.
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Header
+ ExposeHeaders []string
+
+ // MaxAge determines the value of the Access-Control-Max-Age response header.
+ // This header indicates how long (in seconds) the results of a preflight
+ // request can be cached.
+ // The header is set only if MaxAge != 0, negative value sends "0" which instructs browsers not to cache that response.
+ //
+ // Optional. Default value 0 - meaning header is not sent.
+ //
+ // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
+ MaxAge int
+}
+
+// DefaultCORSConfig is the default CORS middleware config.
+var DefaultCORSConfig = CORSConfig{
+ AllowOrigins: []string{"*"},
+ AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
+}
+
+// CORS returns a CORS middleware.
+func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
+ // Defaults
+ if len(config.AllowOrigins) == 0 {
+ config.AllowOrigins = DefaultCORSConfig.AllowOrigins
+ }
+ if len(config.AllowMethods) == 0 {
+ config.AllowMethods = DefaultCORSConfig.AllowMethods
+ }
+
+ allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
+ for _, origin := range config.AllowOrigins {
+ if origin == "*" {
+ continue // "*" is handled differently and does not need regexp
+ }
+
+ pattern := regexp.QuoteMeta(origin)
+ pattern = strings.ReplaceAll(pattern, "\\*", ".*")
+ pattern = strings.ReplaceAll(pattern, "\\?", ".")
+ pattern = "^" + pattern + "$"
+
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ // This is to preserve previous behaviour - invalid patterns were just ignored.
+ // If we would turn this to panic, users with invalid patterns
+ // would have applications crashing in production due unrecovered panic.
+ log.Println("invalid AllowOrigins pattern", origin)
+ continue
+ }
+ allowOriginPatterns = append(allowOriginPatterns, re)
+ }
+
+ allowMethods := strings.Join(config.AllowMethods, ",")
+ allowHeaders := strings.Join(config.AllowHeaders, ",")
+ exposeHeaders := strings.Join(config.ExposeHeaders, ",")
+
+ maxAge := "0"
+ if config.MaxAge > 0 {
+ maxAge = strconv.Itoa(config.MaxAge)
+ }
+
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultCorsMiddlewareId,
+ Priority: DefaultCorsMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ req := e.Request
+ res := e.Response
+ origin := req.Header.Get("Origin")
+ allowOrigin := ""
+
+ res.Header().Add("Vary", "Origin")
+
+ // Preflight request is an OPTIONS request, using three HTTP request headers: Access-Control-Request-Method,
+ // Access-Control-Request-Headers, and the Origin header. See: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
+ // For simplicity we just consider method type and later `Origin` header.
+ preflight := req.Method == http.MethodOptions
+
+ // No Origin provided. This is (probably) not request from actual browser - proceed executing middleware chain
+ if origin == "" {
+ if !preflight {
+ return e.Next()
+ }
+ return e.NoContent(http.StatusNoContent)
+ }
+
+ if config.AllowOriginFunc != nil {
+ allowed, err := config.AllowOriginFunc(origin)
+ if err != nil {
+ return err
+ }
+ if allowed {
+ allowOrigin = origin
+ }
+ } else {
+ // Check allowed origins
+ for _, o := range config.AllowOrigins {
+ if o == "*" && config.AllowCredentials && config.UnsafeWildcardOriginWithAllowCredentials {
+ allowOrigin = origin
+ break
+ }
+ if o == "*" || o == origin {
+ allowOrigin = o
+ break
+ }
+ if exactWildcardMatch(origin, o) {
+ allowOrigin = origin
+ break
+ }
+ }
+
+ checkPatterns := false
+ if allowOrigin == "" {
+ // to avoid regex cost by invalid (long) domains (253 is domain name max limit)
+ if len(origin) <= (253+3+5) && strings.Contains(origin, "://") {
+ checkPatterns = true
+ }
+ }
+ if checkPatterns {
+ for _, re := range allowOriginPatterns {
+ if match := re.MatchString(origin); match {
+ allowOrigin = origin
+ break
+ }
+ }
+ }
+ }
+
+ // Origin not allowed
+ if allowOrigin == "" {
+ if !preflight {
+ return e.Next()
+ }
+ return e.NoContent(http.StatusNoContent)
+ }
+
+ res.Header().Set("Access-Control-Allow-Origin", allowOrigin)
+ if config.AllowCredentials {
+ res.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+
+ // Simple request
+ if !preflight {
+ if exposeHeaders != "" {
+ res.Header().Set("Access-Control-Expose-Headers", exposeHeaders)
+ }
+ return e.Next()
+ }
+
+ // Preflight request
+ res.Header().Add("Vary", "Access-Control-Request-Method")
+ res.Header().Add("Vary", "Access-Control-Request-Headers")
+ res.Header().Set("Access-Control-Allow-Methods", allowMethods)
+
+ if allowHeaders != "" {
+ res.Header().Set("Access-Control-Allow-Headers", allowHeaders)
+ } else {
+ h := req.Header.Get("Access-Control-Request-Headers")
+ if h != "" {
+ res.Header().Set("Access-Control-Allow-Headers", h)
+ }
+ }
+ if config.MaxAge != 0 {
+ res.Header().Set("Access-Control-Max-Age", maxAge)
+ }
+
+ return e.NoContent(http.StatusNoContent)
+ },
+ }
+}
+
+func matchScheme(domain, pattern string) bool {
+ didx := strings.Index(domain, ":")
+ pidx := strings.Index(pattern, ":")
+ return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx]
+}
+
+// exactWildcardMatch compares domain with a * wildcard pattern
+func exactWildcardMatch(domain, pattern string) bool {
+ if !matchScheme(domain, pattern) {
+ return false
+ }
+
+ didx := strings.Index(domain, "://")
+ pidx := strings.Index(pattern, "://")
+ if didx == -1 || pidx == -1 {
+ return false
+ }
+ domAuth := domain[didx+3:]
+ // to avoid long loop by invalid long domain
+ if len(domAuth) > 253 {
+ return false
+ }
+ patAuth := pattern[pidx+3:]
+
+ domComp := strings.Split(domAuth, ".")
+ patComp := strings.Split(patAuth, ".")
+ for i := len(domComp)/2 - 1; i >= 0; i-- {
+ opp := len(domComp) - 1 - i
+ domComp[i], domComp[opp] = domComp[opp], domComp[i]
+ }
+ for i := len(patComp)/2 - 1; i >= 0; i-- {
+ opp := len(patComp) - 1 - i
+ patComp[i], patComp[opp] = patComp[opp], patComp[i]
+ }
+
+ if len(patComp) != len(domComp) {
+ return false
+ }
+
+ for i, v := range domComp {
+ p := patComp[i]
+
+ if p == "*" {
+ continue
+ }
+
+ if p != v {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/apis/middlewares_cors_test.go b/apis/middlewares_cors_test.go
new file mode 100644
index 0000000000..5627afd739
--- /dev/null
+++ b/apis/middlewares_cors_test.go
@@ -0,0 +1,39 @@
+package apis
+
+import "testing"
+
+func TestCorsExactWildcardMatch(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ domain string
+ pattern string
+ expected bool
+ }{
+ {"", "", false},
+ {"http://example.com", "", false},
+ {"", "http://example.com", false},
+ {"http://example.com", "https://example.com", false},
+ {"abc://example.com", "abc://example.com", true},
+ {"https://example.com", "https://example.com", true},
+ {"https://a.example.com", "https://example.com", false},
+ {"https://example.com", "https://a.example.com", false},
+ {"https://example.com", "https://*.example.com", false},
+ {"https://a.example.com", "https://*.example.com", true},
+ {"https://a.example.com", "https://a.*.example.com", false},
+ {"https://a.b.example.com", "https://a.*.example.com", true},
+ {"https://a.b.example.com", "https://a2.*.example.com", false},
+ {"https://a.b.example.com", "https://a.*", false},
+ {"https://a.b.example", "https://a.*", false},
+ {"https://a.b", "https://a.*", true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.domain+":"+s.pattern, func(t *testing.T) {
+ result := exactWildcardMatch(s.domain, s.pattern)
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/apis/middlewares_gzip.go b/apis/middlewares_gzip.go
new file mode 100644
index 0000000000..91faf1a674
--- /dev/null
+++ b/apis/middlewares_gzip.go
@@ -0,0 +1,247 @@
+package apis
+
+// -------------------------------------------------------------------
+// This middleware is ported from echo/middleware to minimize the breaking
+// changes and differences in the API behavior from earlier PocketBase versions
+// (https://github.com/labstack/echo/blob/ec5b858dab6105ab4c3ed2627d1ebdfb6ae1ecb8/middleware/compress.go).
+// -------------------------------------------------------------------
+
+import (
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+const (
+ gzipScheme = "gzip"
+)
+
+const (
+ DefaultGzipMiddlewareId = "pbGzip"
+)
+
+// GzipConfig defines the config for Gzip middleware.
+type GzipConfig struct {
+ // Gzip compression level.
+ // Optional. Default value -1.
+ Level int
+
+ // Length threshold before gzip compression is applied.
+ // Optional. Default value 0.
+ //
+ // Most of the time you will not need to change the default. Compressing
+ // a short response might increase the transmitted data because of the
+ // gzip format overhead. Compressing the response will also consume CPU
+ // and time on the server and the client (for decompressing). Depending on
+ // your use case such a threshold might be useful.
+ //
+ // See also:
+ // https://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits
+ MinLength int
+}
+
+// Gzip returns a middleware which compresses HTTP response using Gzip compression scheme.
+func Gzip() *hook.Handler[*core.RequestEvent] {
+ return GzipWithConfig(GzipConfig{})
+}
+
+// GzipWithConfig returns a middleware which compresses HTTP response using gzip compression scheme.
+func GzipWithConfig(config GzipConfig) *hook.Handler[*core.RequestEvent] {
+ if config.Level < -2 || config.Level > 9 { // these are consts: gzip.HuffmanOnly and gzip.BestCompression
+ panic(errors.New("invalid gzip level"))
+ }
+ if config.Level == 0 {
+ config.Level = -1
+ }
+ if config.MinLength < 0 {
+ config.MinLength = 0
+ }
+
+ pool := sync.Pool{
+ New: func() interface{} {
+ w, err := gzip.NewWriterLevel(io.Discard, config.Level)
+ if err != nil {
+ return err
+ }
+ return w
+ },
+ }
+
+ bpool := sync.Pool{
+ New: func() interface{} {
+ b := &bytes.Buffer{}
+ return b
+ },
+ }
+
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultGzipMiddlewareId,
+ Func: func(e *core.RequestEvent) error {
+ e.Response.Header().Add("Vary", "Accept-Encoding")
+ if strings.Contains(e.Request.Header.Get("Accept-Encoding"), gzipScheme) {
+ w, ok := pool.Get().(*gzip.Writer)
+ if !ok {
+ return e.InternalServerError("", errors.New("failed to get gzip.Writer"))
+ }
+
+ rw := e.Response
+ w.Reset(rw)
+
+ buf := bpool.Get().(*bytes.Buffer)
+ buf.Reset()
+
+ grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw, minLength: config.MinLength, buffer: buf}
+ defer func() {
+ // There are different reasons for cases when we have not yet written response to the client and now need to do so.
+ // a) handler response had only response code and no response body (ala 404 or redirects etc). Response code need to be written now.
+ // b) body is shorter than our minimum length threshold and being buffered currently and needs to be written
+ if !grw.wroteBody {
+ if rw.Header().Get("Content-Encoding") == gzipScheme {
+ rw.Header().Del("Content-Encoding")
+ }
+ if grw.wroteHeader {
+ rw.WriteHeader(grw.code)
+ }
+ // We have to reset response to it's pristine state when
+ // nothing is written to body or error is returned.
+ // See issue echo#424, echo#407.
+ e.Response = rw
+ w.Reset(io.Discard)
+ } else if !grw.minLengthExceeded {
+ // Write uncompressed response
+ e.Response = rw
+ if grw.wroteHeader {
+ rw.WriteHeader(grw.code)
+ }
+ grw.buffer.WriteTo(rw)
+ w.Reset(io.Discard)
+ }
+ w.Close()
+ bpool.Put(buf)
+ pool.Put(w)
+ }()
+ e.Response = grw
+ }
+
+ return e.Next()
+ },
+ }
+}
+
+type gzipResponseWriter struct {
+ http.ResponseWriter
+ io.Writer
+ buffer *bytes.Buffer
+ minLength int
+ code int
+ wroteHeader bool
+ wroteBody bool
+ minLengthExceeded bool
+}
+
+func (w *gzipResponseWriter) WriteHeader(code int) {
+ w.Header().Del("Content-Length") // Issue echo#444
+
+ w.wroteHeader = true
+
+ // Delay writing of the header until we know if we'll actually compress the response
+ w.code = code
+}
+
+func (w *gzipResponseWriter) Write(b []byte) (int, error) {
+ if w.Header().Get("Content-Type") == "" {
+ w.Header().Set("Content-Type", http.DetectContentType(b))
+ }
+
+ w.wroteBody = true
+
+ if !w.minLengthExceeded {
+ n, err := w.buffer.Write(b)
+
+ if w.buffer.Len() >= w.minLength {
+ w.minLengthExceeded = true
+
+ // The minimum length is exceeded, add Content-Encoding header and write the header
+ w.Header().Set("Content-Encoding", gzipScheme)
+ if w.wroteHeader {
+ w.ResponseWriter.WriteHeader(w.code)
+ }
+
+ return w.Writer.Write(w.buffer.Bytes())
+ }
+
+ return n, err
+ }
+
+ return w.Writer.Write(b)
+}
+
+func (w *gzipResponseWriter) Flush() {
+ if !w.minLengthExceeded {
+ // Enforce compression because we will not know how much more data will come
+ w.minLengthExceeded = true
+ w.Header().Set("Content-Encoding", gzipScheme)
+ if w.wroteHeader {
+ w.ResponseWriter.WriteHeader(w.code)
+ }
+
+ _, _ = w.Writer.Write(w.buffer.Bytes())
+ }
+
+ _ = w.Writer.(*gzip.Writer).Flush()
+
+ _ = http.NewResponseController(w.ResponseWriter).Flush()
+}
+
+func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ return http.NewResponseController(w.ResponseWriter).Hijack()
+}
+
+func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
+ rw := w.ResponseWriter
+ for {
+ switch p := rw.(type) {
+ case http.Pusher:
+ return p.Push(target, opts)
+ case router.RWUnwrapper:
+ rw = p.Unwrap()
+ default:
+ return http.ErrNotSupported
+ }
+ }
+}
+
+// Note: Disable the implementation for now because in case the platform
+// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
+// preventing compression on the fly.
+//
+// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
+// if w.wroteHeader {
+// w.ResponseWriter.WriteHeader(w.code)
+// }
+// rw := w.ResponseWriter
+// for {
+// switch rf := rw.(type) {
+// case io.ReaderFrom:
+// return rf.ReadFrom(r)
+// case router.RWUnwrapper:
+// rw = rf.Unwrap()
+// default:
+// return io.Copy(w.ResponseWriter, r)
+// }
+// }
+// }
+
+func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
+ return w.ResponseWriter
+}
diff --git a/apis/middlewares_rate_limit.go b/apis/middlewares_rate_limit.go
new file mode 100644
index 0000000000..55ade1e895
--- /dev/null
+++ b/apis/middlewares_rate_limit.go
@@ -0,0 +1,375 @@
+package apis
+
+import (
+ "errors"
+ "net/netip"
+ "sync"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/store"
+)
+
+const (
+ DefaultRateLimitMiddlewareId = "pbRateLimit"
+ DefaultRateLimitMiddlewarePriority = -1000
+)
+
+const (
+ rateLimitersStoreKey = "__pbRateLimiters__"
+ rateLimitersCronKey = "__pbRateLimitersCleanup__"
+ rateLimitersSettingsHookId = "__pbRateLimitersSettingsHook__"
+)
+
+// rateLimit defines the global rate limit middleware.
+//
+// This middleware is registered by default for all routes.
+func rateLimit() *hook.Handler[*core.RequestEvent] {
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRateLimitMiddlewareId,
+ Priority: DefaultRateLimitMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ if skipRateLimit(e) {
+ return e.Next()
+ }
+
+ rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
+ defaultRateLimitLabels(e),
+ defaultRateLimitAudience(e)...,
+ )
+ if ok {
+ err := checkRateLimit(e, rule.Label+rule.Audience, rule)
+ if err != nil {
+ return err
+ }
+ }
+
+ return e.Next()
+ },
+ }
+}
+
+// collectionPathRateLimit defines a rate limit middleware for the internal collection handlers.
+func collectionPathRateLimit(collectionPathParam string, baseTags ...string) *hook.Handler[*core.RequestEvent] {
+ if collectionPathParam == "" {
+ collectionPathParam = "collection"
+ }
+
+ return &hook.Handler[*core.RequestEvent]{
+ Id: DefaultRateLimitMiddlewareId,
+ Priority: DefaultRateLimitMiddlewarePriority,
+ Func: func(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue(collectionPathParam))
+ if err != nil {
+ return e.NotFoundError("Missing or invalid collection context.", err)
+ }
+
+ if err := checkCollectionRateLimit(e, collection, baseTags...); err != nil {
+ return err
+ }
+
+ return e.Next()
+ },
+ }
+}
+
+// checkCollectionRateLimit checks whether the current request satisfy the
+// rate limit configuration for the specific collection.
+//
+// Each baseTags entry will be prefixed with the collection name and its wildcard variant.
+func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection, baseTags ...string) error {
+ if skipRateLimit(e) {
+ return nil
+ }
+
+ labels := make([]string, 0, 2+len(baseTags)*2)
+
+ rtId := collection.Id + e.Request.Pattern
+
+ // add first the primary labels (aka. ["collectionName:action1", "collectionName:action2"])
+ for _, baseTag := range baseTags {
+ rtId += baseTag
+ labels = append(labels, collection.Name+":"+baseTag)
+ }
+
+ // add the wildcard labels (aka. [..., "*:action1","*:action2", "*"])
+ for _, baseTag := range baseTags {
+ labels = append(labels, "*:"+baseTag)
+ }
+ labels = append(labels, defaultRateLimitLabels(e)...)
+
+ rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
+ if ok {
+ return checkRateLimit(e, rtId+rule.Audience, rule)
+ }
+
+ 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?
+func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
+ switch rule.Audience {
+ case core.RateLimitRuleAudienceAll:
+ // valid for both guest and regular users
+ case core.RateLimitRuleAudienceGuest:
+ if e.Auth != nil {
+ return nil
+ }
+ case core.RateLimitRuleAudienceAuth:
+ if e.Auth == nil {
+ return nil
+ }
+ }
+
+ rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
+ return initRateLimitersStore(e.App)
+ }).(*store.Store[string, *rateLimiter])
+ if rateLimiters == nil {
+ e.App.Logger().Warn("Failed to retrieve app rate limiters store")
+ return nil
+ }
+
+ rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter {
+ return newRateLimiter(rule.MaxRequests, rule.Duration, 1800)
+ })
+ if rt == nil {
+ e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
+ return nil
+ }
+
+ key := e.RealIP()
+ if key == "" {
+ e.App.Logger().Warn("Empty rate limit client key")
+ return nil
+ }
+
+ if !rt.isAllowed(key) {
+ return e.TooManyRequestsError("", errors.New("triggered rate limit rule: "+rule.String()))
+ }
+
+ return nil
+}
+
+func skipRateLimit(e *core.RequestEvent) bool {
+ return !e.App.Settings().RateLimits.Enabled ||
+ e.HasSuperuserAuth() ||
+ isIPInList(e.App.Settings().RateLimits.ExcludedIPs, e.RealIP())
+}
+
+var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
+var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
+
+func defaultRateLimitAudience(e *core.RequestEvent) []string {
+ if e.Auth != nil {
+ return defaultAuthAudience
+ }
+
+ return defaultGuestAudience
+}
+
+func defaultRateLimitLabels(e *core.RequestEvent) []string {
+ return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
+}
+
+func destroyRateLimitersStore(app core.App) {
+ app.OnSettingsReload().Unbind(rateLimitersSettingsHookId)
+ app.Cron().Remove(rateLimitersCronKey)
+ app.Store().Remove(rateLimitersStoreKey)
+}
+
+func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
+ app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
+ limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
+ if !ok {
+ return
+ }
+ limiters := limitersStore.GetAll()
+ for _, limiter := range limiters {
+ limiter.clean()
+ }
+ })
+
+ app.OnSettingsReload().Bind(&hook.Handler[*core.SettingsReloadEvent]{
+ Id: rateLimitersSettingsHookId,
+ Func: func(e *core.SettingsReloadEvent) error {
+ err := e.Next()
+ if err != nil {
+ return err
+ }
+
+ // reset
+ destroyRateLimitersStore(e.App)
+
+ return nil
+ },
+ })
+
+ return store.New[string, *rateLimiter](nil)
+}
+
+func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
+ return &rateLimiter{
+ maxAllowed: maxAllowed,
+ interval: intervalInSec,
+ minDeleteInterval: minDeleteIntervalInSec,
+ clients: map[string]*rateClient{},
+ }
+}
+
+type rateLimiter struct {
+ clients map[string]*rateClient
+
+ maxAllowed int
+ interval int64
+ minDeleteInterval int64
+ totalDeleted int64
+
+ sync.RWMutex
+}
+
+//nolint:unused
+func (rt *rateLimiter) getClient(key string) (*rateClient, bool) {
+ rt.RLock()
+ client, ok := rt.clients[key]
+ rt.RUnlock()
+
+ return client, ok
+}
+
+func (rt *rateLimiter) isAllowed(key string) bool {
+ // lock only reads to minimize locks contention
+ rt.RLock()
+ client, ok := rt.clients[key]
+ rt.RUnlock()
+
+ if !ok {
+ rt.Lock()
+ // check again in case the client was added by another request
+ client, ok = rt.clients[key]
+ if !ok {
+ client = newRateClient(rt.maxAllowed, rt.interval)
+ rt.clients[key] = client
+ }
+ rt.Unlock()
+ }
+
+ return client.consume()
+}
+
+func (rt *rateLimiter) clean() {
+ rt.Lock()
+ defer rt.Unlock()
+
+ nowUnix := time.Now().Unix()
+
+ for k, client := range rt.clients {
+ if client.hasExpired(nowUnix, rt.minDeleteInterval) {
+ delete(rt.clients, k)
+ rt.totalDeleted++
+ }
+ }
+
+ // "shrink" the map if too may items were deleted
+ //
+ // @todo remove after https://github.com/golang/go/issues/20135
+ if rt.totalDeleted >= 300 {
+ shrunk := make(map[string]*rateClient, len(rt.clients))
+ for k, v := range rt.clients {
+ shrunk[k] = v
+ }
+ rt.clients = shrunk
+ rt.totalDeleted = 0
+ }
+}
+
+func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
+ return &rateClient{
+ maxAllowed: maxAllowed,
+ interval: intervalInSec,
+ }
+}
+
+// @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
+//
+// rateClient implements fixed window rate limit strategy.
+type rateClient struct {
+ // use plain Mutex instead of RWMutex since the operations are expected
+ // to be mostly writes (e.g. consume()) and it should perform better
+ sync.Mutex
+
+ maxAllowed int // the max allowed tokens per interval
+ available int // the total available tokens
+ start int64 // the start time of the current window
+ interval int64 // in seconds
+}
+
+// hasExpired checks whether it has been at least minElapsed seconds after the last active window.
+// (usually used to perform periodic cleanup of staled instances).
+func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
+ l.Lock()
+ defer l.Unlock()
+
+ return relativeNow-(l.start+l.interval) > minElapsed
+}
+
+// consume decreases the current allowance with 1 (if not exhausted already).
+//
+// It returns false if the allowance has been already exhausted and the user
+// has to wait until it resets back to its maxAllowed value.
+func (l *rateClient) consume() bool {
+ l.Lock()
+ defer l.Unlock()
+
+ nowUnix := time.Now().Unix()
+
+ // reset
+ if nowUnix-l.start >= l.interval {
+ l.available = l.maxAllowed
+ l.start = nowUnix
+ }
+
+ if l.available > 0 {
+ l.available--
+ return true
+ }
+
+ return false
+}
diff --git a/apis/middlewares_rate_limit_test.go b/apis/middlewares_rate_limit_test.go
new file mode 100644
index 0000000000..af19d1e19f
--- /dev/null
+++ b/apis/middlewares_rate_limit_test.go
@@ -0,0 +1,326 @@
+package apis_test
+
+import (
+ "fmt"
+ "net/http/httptest"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/hook"
+)
+
+func TestDefaultRateLimitMiddleware(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {
+ Label: "/rate/",
+ MaxRequests: 2,
+ Duration: 1,
+ },
+ {
+ Label: "/rate/b",
+ MaxRequests: 3,
+ Duration: 1,
+ },
+ {
+ Label: "POST /rate/b",
+ MaxRequests: 1,
+ Duration: 1,
+ },
+ {
+ Label: "/rate/guest",
+ MaxRequests: 1,
+ Duration: 1,
+ Audience: core.RateLimitRuleAudienceGuest,
+ },
+ {
+ Label: "/rate/auth",
+ MaxRequests: 1,
+ Duration: 1,
+ Audience: core.RateLimitRuleAudienceAuth,
+ },
+ }
+
+ pbRouter, err := apis.NewRouter(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pbRouter.GET("/norate", func(e *core.RequestEvent) error {
+ return e.String(200, "norate")
+ }).BindFunc(func(e *core.RequestEvent) error {
+ return e.Next()
+ })
+ pbRouter.GET("/rate/a", func(e *core.RequestEvent) error {
+ return e.String(200, "a")
+ })
+ pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
+ return e.String(200, "b")
+ })
+ pbRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
+ return e.String(200, "guest")
+ })
+ pbRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
+ return e.String(200, "auth")
+ })
+
+ mux, err := pbRouter.BuildMux()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ url string
+ wait float64 // ms
+ authenticated bool
+ expectedStatus int
+ }{
+ {"/norate", 0, false, 200},
+ {"/norate", 0, false, 200},
+ {"/norate", 0, false, 200},
+ {"/norate", 0, false, 200},
+ {"/norate", 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", 900, false, 200},
+ {"/rate/a", 0, false, 200},
+ {"/rate/a", 0, false, 429},
+ {"/rate/a", 0, false, 429},
+ {"/rate/a", 1000, false, 200},
+ {"/rate/a", 0, false, 200},
+ {"/rate/a", 0, false, 429},
+
+ {"/rate/b", 0, false, 200},
+ {"/rate/b", 0, false, 200},
+ {"/rate/b", 0, false, 200},
+ {"/rate/b", 0, false, 429},
+ {"/rate/b", 1000, false, 200},
+ {"/rate/b", 0, false, 200},
+ {"/rate/b", 0, false, 200},
+ {"/rate/b", 0, false, 429},
+
+ // "auth" with guest (should fallback to the /rate/ rule)
+ {"/rate/auth", 0, false, 200},
+ {"/rate/auth", 0, false, 200},
+ {"/rate/auth", 0, false, 429},
+ {"/rate/auth", 0, false, 429},
+
+ // "auth" rule with regular user (should match the /rate/auth rule)
+ {"/rate/auth", 0, true, 200},
+ {"/rate/auth", 0, true, 429},
+ {"/rate/auth", 0, true, 429},
+
+ // "guest" with guest (should match the /rate/guest rule)
+ {"/rate/guest", 0, false, 200},
+ {"/rate/guest", 0, false, 429},
+ {"/rate/guest", 0, false, 429},
+
+ // "guest" rule with regular user (should fallback to the /rate/ rule)
+ {"/rate/guest", 1000, true, 200},
+ {"/rate/guest", 0, true, 200},
+ {"/rate/guest", 0, true, 429},
+ {"/rate/guest", 0, true, 429},
+ }
+
+ synctest.Test(t, func(t *testing.T) {
+ for i, s := range scenarios {
+ prefix := fmt.Sprintf("[%s:%d] ", s.url, i+1)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", s.url, nil)
+
+ if s.authenticated {
+ auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf(prefix+"%v", err)
+ }
+
+ token, err := auth.NewAuthToken()
+ if err != nil {
+ t.Fatalf(prefix+"%v", err)
+ }
+
+ req.Header.Add("Authorization", token)
+ }
+
+ if s.wait > 0 {
+ synctest.Sleep(time.Duration(s.wait) * time.Millisecond)
+ }
+
+ mux.ServeHTTP(rec, req)
+
+ result := rec.Result()
+
+ if result.StatusCode != s.expectedStatus {
+ t.Fatalf(prefix+"Expected response status %d, got %d", s.expectedStatus, result.StatusCode)
+ }
+ }
+ })
+}
+
+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})
+ })
+}
diff --git a/apis/middlewares_test.go b/apis/middlewares_test.go
index 6dd81fdd6a..19302f32f6 100644
--- a/apis/middlewares_test.go
+++ b/apis/middlewares_test.go
@@ -4,97 +4,42 @@ import (
"net/http"
"testing"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
-func TestRequireGuestOnly(t *testing.T) {
+func TestPanicRecover(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
- Name: "valid record token",
- Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireGuestOnly(),
- },
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid admin token",
+ Name: "panic from route",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireGuestOnly(),
- },
+ URL: "/my/test",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ panic("123")
})
},
- ExpectedStatus: 400,
+ ExpectedStatus: 500,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "expired/invalid token",
- Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireGuestOnly(),
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
- },
- {
- Name: "guest",
+ Name: "panic from middleware",
Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireGuestOnly(),
- },
+ URL: "/my/test",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(http.StatusOK, "test")
+ }).BindFunc(func(e *core.RequestEvent) error {
+ panic(123)
})
},
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedStatus: 500,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -103,136 +48,60 @@ func TestRequireGuestOnly(t *testing.T) {
}
}
-func TestRequireRecordAuth(t *testing.T) {
+func TestRequireGuestOnly(t *testing.T) {
+ t.Parallel()
+
+ beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireGuestOnly())
+ }
+
scenarios := []tests.ApiScenario{
{
- Name: "guest",
- Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth(),
- },
- })
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired/invalid token",
+ Name: "valid regular user token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth(),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ BeforeTestFunc: beforeTestFunc,
+ ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid admin token",
+ Name: "valid superuser auth token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth(),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 401,
+ BeforeTestFunc: beforeTestFunc,
+ ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token",
+ Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth(),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.2D3tmqPn3vc5LoqqCz8V-iCDVXo9soYiH0d32G7FQT4",
},
+ BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token with collection not in the restricted list",
- Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth("demo1", "demo2"),
- },
- })
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "valid record token with collection in the restricted list",
- Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth("demo1", "demo2", "users"),
- },
- })
- },
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/my/test",
+ BeforeTestFunc: beforeTestFunc,
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -241,205 +110,132 @@ func TestRequireRecordAuth(t *testing.T) {
}
}
-func TestRequireSameContextRecordAuth(t *testing.T) {
+func TestRequireAuth(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
- Url: "/my/users/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireSameContextRecordAuth(),
- },
- })
+ URL: "/my/test",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "expired/invalid token",
+ Name: "expired token",
Method: http.MethodGet,
- Url: "/my/users/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireSameContextRecordAuth(),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.2D3tmqPn3vc5LoqqCz8V-iCDVXo9soYiH0d32G7FQT4",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid admin token",
+ Name: "invalid token",
Method: http.MethodGet,
- Url: "/my/users/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireSameContextRecordAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token but from different collection",
+ Name: "valid record auth token with no collection restrictions",
Method: http.MethodGet,
- Url: "/my/users/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ URL: "/my/test",
+ Headers: map[string]string{
+ // regular user
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireSameContextRecordAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
},
{
- Name: "valid record token",
+ Name: "valid record static auth token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test",
+ Headers: map[string]string{
+ // regular user
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireRecordAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRequireAdminAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
{
- Name: "guest",
+ Name: "valid record auth token with collection not in the restricted list",
Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuth(),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ // superuser
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired/invalid token",
- Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth("users", "demo1"))
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token",
+ Name: "valid record auth token with collection in the restricted list",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test",
+ Headers: map[string]string{
+ // superuser
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth("users", core.CollectionNameSuperusers))
},
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
},
{
- Name: "valid admin token",
+ Name: "valid record auth token with Bearer case-insensitive prefix",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test",
+ Headers: map[string]string{
+ // regular user
+ "Authorization": "BeArEr eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
@@ -451,114 +247,66 @@ func TestRequireAdminAuth(t *testing.T) {
}
}
-func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
+func TestRequireSuperuserAuth(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
- Name: "guest (while having at least 1 existing admin)",
- Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuthOnlyIfAny(app),
- },
- })
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/my/test",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest (while having 0 existing admins)",
- Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // delete all admins
- _, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
- if err != nil {
- t.Fatal(err)
- }
-
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuthOnlyIfAny(app),
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuthOnlyIfAny(app),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token",
+ Name: "valid regular user auth token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuthOnlyIfAny(app),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserAuth())
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid admin token",
+ Name: "valid superuser auth token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminAuthOnlyIfAny(app),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserAuth())
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
@@ -570,155 +318,112 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
}
-func TestRequireAdminOrRecordAuth(t *testing.T) {
+func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
- Url: "/my/test",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth(),
- },
- })
+ URL: "/my/test/4q1xlclmfloku33",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth(),
- },
- })
+ URL: "/my/test/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token",
+ Name: "valid record auth token (different user)",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test/oap640cot4yru2s",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token with collection not in the restricted list",
+ Name: "valid record auth token (owner)",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
},
{
- Name: "valid record token with collection in the restricted list",
+ Name: "valid record auth token (owner + non-matching custom owner param)",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid admin token",
+ Name: "valid record auth token (owner + matching custom owner param)",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth(),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{test}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
- Name: "valid admin token + restricted collections list (should be ignored)",
+ Name: "valid superuser auth token",
Method: http.MethodGet,
- Url: "/my/test",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrRecordAuth("demo1", "demo2"),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
@@ -730,136 +435,117 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
}
}
-func TestRequireAdminOrOwnerAuth(t *testing.T) {
+func TestRequireSameCollectionContextAuth(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:id",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth(""),
- },
- })
+ URL: "/my/test/_pb_users_auth_",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:id",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth(""),
- },
- })
+ URL: "/my/test/_pb_users_auth_",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.2D3tmqPn3vc5LoqqCz8V-iCDVXo9soYiH0d32G7FQT4",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token (different user)",
+ Name: "valid record auth token (different collection)",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
+ URL: "/my/test/clients",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:id",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth(""),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSameCollectionContextAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token (different collection)",
+ Name: "valid record auth token (same collection)",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ URL: "/my/test/_pb_users_auth_",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:id",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth(""),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSameCollectionContextAuth(""))
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ },
+ {
+ Name: "valid record auth token (non-matching/missing collection param)",
+ Method: http.MethodGet,
+ URL: "/my/test/_pb_users_auth_",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth(""))
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid record token (owner)",
+ Name: "valid record auth token (matching custom collection param)",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/my/test/_pb_users_auth_",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:id",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth(""),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{test}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSuperuserOrOwnerAuth("test"))
},
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "valid admin token",
+ Name: "superuser no exception check",
Method: http.MethodGet,
- Url: "/my/test/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test/_pb_users_auth_",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/test/:custom",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.RequireAdminOrOwnerAuth("custom"),
- },
- })
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
+ return e.String(200, "test123")
+ }).Bind(apis.RequireSameCollectionContextAuth(""))
},
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -868,127 +554,91 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
}
}
-func TestLoadCollectionContext(t *testing.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: "missing collection",
- Method: http.MethodGet,
- Url: "/my/missing",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app),
- },
- })
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "guest",
- Method: http.MethodGet,
- Url: "/my/demo1",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app),
- },
- })
- },
+ 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: "valid record token",
+ Name: "regular user with non-matching IP",
Method: http.MethodGet,
- Url: "/my/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app),
- },
- })
+ 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: "valid admin token",
+ Name: "superuser with non-matching IP",
Method: http.MethodGet,
- Url: "/my/demo1",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/my/test",
+ Headers: map[string]string{
+ "x-test-ip": "127.0.0.1",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app),
- },
- })
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{"test123"},
+ BeforeTestFunc: setupWhitelist("0.0.0.0"),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "mismatched type",
+ Name: "superuser with matching IP",
Method: http.MethodGet,
- Url: "/my/demo1",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app, "auth"),
- },
- })
+ URL: "/my/test",
+ Headers: map[string]string{
+ "x-test-ip": "127.0.0.1",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
+ BeforeTestFunc: setupWhitelist("0.0.0.0", "127.0.0.1"),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{"test123"},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "matched type",
+ Name: "superuser with no whitelisted IPs",
Method: http.MethodGet,
- Url: "/my/users",
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- e.AddRoute(echo.Route{
- Method: http.MethodGet,
- Path: "/my/:collection",
- Handler: func(c echo.Context) error {
- return c.String(200, "test123")
- },
- Middlewares: []echo.MiddlewareFunc{
- apis.LoadCollectionContext(app, "auth"),
- },
- })
+ 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},
},
}
diff --git a/apis/realtime.go b/apis/realtime.go
index 9825a9c8f7..d6d8b58bcf 100644
--- a/apis/realtime.go
+++ b/apis/realtime.go
@@ -2,196 +2,233 @@ package apis
import (
"context"
- "encoding/json"
+ "encoding/json/v2"
"errors"
- "log"
+ "fmt"
+ "log/slog"
"net/http"
"strings"
"time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/resolvers"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/picker"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
+ "golang.org/x/sync/errgroup"
)
-// bindRealtimeApi registers the realtime api endpoints.
-func bindRealtimeApi(app core.App, rg *echo.Group) {
- api := realtimeApi{app: app}
+// note: the chunk size is arbitrary chosen and may change in the future
+const clientsChunkSize = 150
- subGroup := rg.Group("/realtime", ActivityLogger(app))
- subGroup.GET("", api.connect)
- subGroup.POST("", api.setSubscriptions)
+// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
+const RealtimeClientAuthKey = "auth"
- api.bindEvents()
-}
+// RealtimeClientIPKey is the name of the realtime client store key that holds the IP of the connected client.
+const RealtimeClientIPKey = "pbRealtimeClientIP"
+
+// bindRealtimeApi registers the realtime api endpoints.
+func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ sub := rg.Group("/realtime")
+ sub.GET("", realtimeConnect).Bind(SkipSuccessActivityLog())
+ sub.POST("", realtimeSetSubscriptions)
-type realtimeApi struct {
- app core.App
+ bindRealtimeEvents(app)
}
-func (api *realtimeApi) connect(c echo.Context) error {
- cancelCtx, cancelRequest := context.WithCancel(c.Request().Context())
- defer cancelRequest()
- c.SetRequest(c.Request().Clone(cancelCtx))
-
- // register new subscription client
- client := subscriptions.NewDefaultClient()
- api.app.SubscriptionsBroker().Register(client)
- defer func() {
- disconnectEvent := &core.RealtimeDisconnectEvent{
- HttpContext: c,
- Client: client,
+func realtimeConnect(e *core.RequestEvent) error {
+ // disable global write deadline for the SSE connection
+ rc := http.NewResponseController(e.Response)
+ writeDeadlineErr := rc.SetWriteDeadline(time.Time{})
+ if writeDeadlineErr != nil {
+ if !errors.Is(writeDeadlineErr, http.ErrNotSupported) {
+ return e.InternalServerError("Failed to initialize SSE connection.", writeDeadlineErr)
}
- if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
+ // only log since there are valid cases where it may not be implement (e.g. httptest.ResponseRecorder)
+ e.App.Logger().Warn("SetWriteDeadline is not supported, fallback to the default server WriteTimeout")
+ }
- api.app.SubscriptionsBroker().Unregister(client.Id())
- }()
+ // create cancellable request
+ cancelCtx, cancelRequest := context.WithCancel(e.Request.Context())
+ defer cancelRequest()
+ e.Request = e.Request.Clone(cancelCtx)
- c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
- c.Response().Header().Set("Cache-Control", "no-store")
- c.Response().Header().Set("Connection", "keep-alive")
+ e.Response.Header().Set("Content-Type", "text/event-stream")
+ e.Response.Header().Set("Cache-Control", "no-store")
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
- c.Response().Header().Set("X-Accel-Buffering", "no")
-
- connectEvent := &core.RealtimeConnectEvent{
- HttpContext: c,
- Client: client,
- IdleTimeout: 5 * time.Minute,
- }
-
- if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
- return err
- }
-
- if api.app.IsDebug() {
- log.Printf("Realtime connection established: %s\n", client.Id())
- }
-
- // signalize established connection (aka. fire "connect" message)
- connectMsgEvent := &core.RealtimeMessageEvent{
- HttpContext: c,
- Client: client,
- Message: &subscriptions.Message{
+ e.Response.Header().Set("X-Accel-Buffering", "no")
+
+ connectEvent := new(core.RealtimeConnectRequestEvent)
+ connectEvent.RequestEvent = e
+ connectEvent.IdleTimeout = 5 * time.Minute
+ connectEvent.MaxTimeout = 30 * time.Minute
+ connectEvent.Client = subscriptions.NewDefaultClient()
+
+ // could be used as an optional cross-reference check in other API endpoints
+ connectEvent.Client.Set(RealtimeClientIPKey, e.RealIP())
+
+ return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
+ // register new subscription client
+ ce.App.SubscriptionsBroker().Register(ce.Client)
+ defer func() {
+ e.App.SubscriptionsBroker().Unregister(ce.Client.Id())
+ }()
+
+ ce.App.Logger().Debug("Realtime connection established", slog.String("clientId", ce.Client.Id()))
+
+ // signalize established connection (aka. fire "connect" message)
+ connectMsgEvent := new(core.RealtimeMessageEvent)
+ connectMsgEvent.RequestEvent = ce.RequestEvent
+ connectMsgEvent.Client = ce.Client
+ connectMsgEvent.Message = &subscriptions.Message{
Name: "PB_CONNECT",
- Data: []byte(`{"clientId":"` + client.Id() + `"}`),
- },
- }
- connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
- w := e.HttpContext.Response()
- w.Write([]byte("id:" + client.Id() + "\n"))
- w.Write([]byte("event:" + e.Message.Name + "\n"))
- w.Write([]byte("data:"))
- w.Write(e.Message.Data)
- w.Write([]byte("\n\n"))
- w.Flush()
- return api.app.OnRealtimeAfterMessageSend().Trigger(e)
- })
- if connectMsgErr != nil {
- if api.app.IsDebug() {
- log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
+ Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
+ }
+ connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
+ err := me.Message.WriteSSE(me.Response, me.Client.Id())
+ if err != nil {
+ return err
+ }
+ return me.Flush()
+ })
+ if connectMsgErr != nil {
+ ce.App.Logger().Debug(
+ "Realtime connection closed (failed to deliver PB_CONNECT)",
+ slog.String("clientId", ce.Client.Id()),
+ slog.String("error", connectMsgErr.Error()),
+ )
+ return nil
}
- return nil
- }
- // start an idle timer to keep track of inactive/forgotten connections
- idleTimeout := connectEvent.IdleTimeout
- idleTimer := time.NewTimer(idleTimeout)
- defer idleTimer.Stop()
-
- for {
- select {
- case <-idleTimer.C:
- cancelRequest()
- case msg, ok := <-client.Channel():
- if !ok {
- // channel is closed
- if api.app.IsDebug() {
- log.Println("Realtime connection closed (closed channel):", client.Id())
+ // start a max lifetime timer to prevent accumulating too much
+ // connection resources and to allow the GC to run more regularly
+ maxTimer := time.NewTimer(ce.MaxTimeout)
+ defer maxTimer.Stop()
+
+ // start an idle timer to keep track of inactive/forgotten connections
+ idleTimer := time.NewTimer(ce.IdleTimeout)
+ defer idleTimer.Stop()
+
+ for {
+ select {
+ case <-maxTimer.C:
+ cancelRequest()
+ case <-idleTimer.C:
+ cancelRequest()
+ case msg, ok := <-ce.Client.Channel():
+ if !ok {
+ // channel is closed
+ ce.App.Logger().Debug(
+ "Realtime connection closed (closed channel)",
+ slog.String("clientId", ce.Client.Id()),
+ )
+ return nil
}
- return nil
- }
- msgEvent := &core.RealtimeMessageEvent{
- HttpContext: c,
- Client: client,
- Message: &msg,
- }
- msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
- w := e.HttpContext.Response()
- w.Write([]byte("id:" + e.Client.Id() + "\n"))
- w.Write([]byte("event:" + e.Message.Name + "\n"))
- w.Write([]byte("data:"))
- w.Write(e.Message.Data)
- w.Write([]byte("\n\n"))
- w.Flush()
- return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
- })
- if msgErr != nil {
- if api.app.IsDebug() {
- log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
+ msgEvent := new(core.RealtimeMessageEvent)
+ msgEvent.RequestEvent = ce.RequestEvent
+ msgEvent.Client = ce.Client
+ msgEvent.Message = &msg
+ msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
+ err := me.Message.WriteSSE(me.Response, me.Client.Id())
+ if err != nil {
+ return err
+ }
+ return me.Flush()
+ })
+ if msgErr != nil {
+ ce.App.Logger().Debug(
+ "Realtime connection closed (failed to deliver message)",
+ slog.String("clientId", ce.Client.Id()),
+ slog.String("error", msgErr.Error()),
+ )
+ return nil
}
- return nil
- }
- idleTimer.Stop()
- idleTimer.Reset(idleTimeout)
- case <-c.Request().Context().Done():
- // connection is closed
- if api.app.IsDebug() {
- log.Println("Realtime connection closed (cancelled request):", client.Id())
+ idleTimer.Stop()
+ idleTimer.Reset(ce.IdleTimeout)
+ case <-ce.Request.Context().Done():
+ // connection is closed
+ ce.App.Logger().Debug(
+ "Realtime connection closed (cancelled request)",
+ slog.String("clientId", ce.Client.Id()),
+ )
+ return nil
}
- return nil
}
- }
+ })
+}
+
+type realtimeSubscribeForm struct {
+ ClientId string `form:"clientId" json:"clientId"`
+ Subscriptions []string `form:"subscriptions" json:"subscriptions"`
+}
+
+func (form *realtimeSubscribeForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
+ validation.Field(&form.Subscriptions,
+ validation.Length(0, 1000),
+ validation.Each(validation.Length(0, 2500)),
+ ),
+ )
}
// note: in case of reconnect, clients will have to resubmit all subscriptions again
-func (api *realtimeApi) setSubscriptions(c echo.Context) error {
- form := forms.NewRealtimeSubscribe()
+func realtimeSetSubscriptions(e *core.RequestEvent) error {
+ form := new(realtimeSubscribeForm)
- // read request data
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("", err)
+ err := e.BindBody(form)
+ if err != nil {
+ return e.BadRequestError("", err)
}
- // validate request data
- if err := form.Validate(); err != nil {
- return NewBadRequestError("", err)
+ err = form.validate()
+ if err != nil {
+ return e.BadRequestError("", err)
}
// find subscription client
- client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
+ client, err := e.App.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
- return NewNotFoundError("Missing or invalid client id.", err)
+ return e.NotFoundError("Missing or invalid client id.", err)
}
- // check if the previous request was authorized
- oldAuthId := extractAuthIdFromGetter(client)
- newAuthId := extractAuthIdFromGetter(c)
- if oldAuthId != "" && oldAuthId != newAuthId {
- return NewForbiddenError("The current and the previous request authorization don't match.", nil)
+ // 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"),
+ )
}
- event := &core.RealtimeSubscribeEvent{
- HttpContext: c,
- Client: client,
- Subscriptions: form.Subscriptions,
+ // for now allow only guest->auth upgrades and any other auth change is forbidden
+ clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
+ if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
+ return e.ForbiddenError("The current and the previous request authorization don't match.", nil)
}
- return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
+ event := new(core.RealtimeSubscribeRequestEvent)
+ event.RequestEvent = e
+ event.Client = client
+ event.Subscriptions = form.Subscriptions
+
+ return e.App.OnRealtimeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeRequestEvent) error {
// update auth state
- e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
- e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
+ e.Client.Set(RealtimeClientAuthKey, e.Auth)
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
@@ -199,298 +236,667 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// subscribe to the new subscriptions
e.Client.Subscribe(e.Subscriptions...)
- return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ e.App.Logger().Debug(
+ "Realtime subscriptions updated",
+ slog.String("clientId", e.Client.Id()),
+ slog.Any("subscriptions", e.Subscriptions),
+ )
- return e.HttpContext.NoContent(http.StatusNoContent)
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
})
})
}
-// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
-func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- clientModel, _ := client.Get(contextKey).(models.Model)
- if clientModel != nil &&
- clientModel.TableName() == newModel.TableName() &&
- clientModel.GetId() == newModel.GetId() {
- client.Set(contextKey, newModel)
- }
+// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
+//
+// Realtime connections has short lifetime by design, but to minimize abuse
+// if the new record has a different tokenKey (e.g. in case of password reset)
+// the auth state of the related realtime connections is also cleared
+// (aka. they remain active but unauthenticated, allowing to reauthenicate with the next subscription).
+func realtimeUpdateClientsAuth(app core.App, authRecord *core.Record) error {
+ chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
+
+ group := new(errgroup.Group)
+
+ for _, chunk := range chunks {
+ group.Go(routine.SafeWrap(func() error {
+ for _, client := range chunk {
+ clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
+ if clientAuth != nil &&
+ clientAuth.Id == authRecord.Id &&
+ clientAuth.Collection().Name == authRecord.Collection().Name {
+ if clientAuth.TokenKey() != authRecord.TokenKey() {
+ client.Unset(RealtimeClientAuthKey)
+ } else {
+ client.Set(RealtimeClientAuthKey, authRecord)
+ }
+ }
+ }
+
+ return nil
+ }))
}
- return nil
+ return group.Wait()
}
-// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
-func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
- for _, client := range api.app.SubscriptionsBroker().Clients() {
- clientModel, _ := client.Get(contextKey).(models.Model)
- if clientModel != nil &&
- clientModel.TableName() == model.TableName() &&
- clientModel.GetId() == model.GetId() {
- api.app.SubscriptionsBroker().Unregister(client.Id())
- }
+// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
+func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
+ chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
+
+ group := new(errgroup.Group)
+
+ for _, chunk := range chunks {
+ group.Go(routine.SafeWrap(func() error {
+ for _, client := range chunk {
+ clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
+ if clientAuth != nil &&
+ clientAuth.Id == authModel.PK() &&
+ clientAuth.Collection().Name == authModel.TableName() {
+ client.Unset(RealtimeClientAuthKey)
+ }
+ }
+
+ return nil
+ }))
}
- return nil
+ return group.Wait()
}
-func (api *realtimeApi) bindEvents() {
- // update the clients that has admin or auth record association
- api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
- if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
- return api.updateClientsAuthModel(ContextAuthRecordKey, record)
- }
+// 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)
- if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
- return api.updateClientsAuthModel(ContextAdminKey, admin)
- }
+ group := new(errgroup.Group)
- return nil
+ 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()
+}
+
+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,
})
- // remove the client(s) associated to the deleted admin or auth record
- api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
- if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
- return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
- }
+ // 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()),
+ )
+ }
+ }
- if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
- return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
- }
+ return e.Next()
+ },
+ Priority: -99,
+ })
- return nil
+ // update the clients that has auth record association
+ app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ authRecord := realtimeResolveRecord(e.App, e.Model, core.CollectionTypeAuth)
+ if authRecord != nil {
+ if err := realtimeUpdateClientsAuth(e.App, authRecord); err != nil {
+ app.Logger().Warn(
+ "Failed to update client(s) associated to the updated auth record",
+ slog.Any("id", authRecord.Id),
+ slog.String("collectionName", authRecord.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ }
+ }
+
+ return e.Next()
+ },
+ Priority: -99,
})
- api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
- if record := api.resolveRecord(e.Model); record != nil {
- if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() {
- log.Println(err)
+ // remove the client(s) associated to the deleted auth model
+ // (note: works also with custom model for backward compatibility)
+ app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ collection := realtimeResolveRecordCollection(e.App, e.Model)
+ if collection != nil && collection.IsAuth() {
+ if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil {
+ app.Logger().Warn(
+ "Failed to remove client(s) associated to the deleted auth model",
+ slog.Any("id", e.Model.PK()),
+ slog.String("collectionName", e.Model.TableName()),
+ slog.String("error", err.Error()),
+ )
+ }
}
- }
- return nil
+
+ return e.Next()
+ },
+ Priority: -99,
})
- api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
- if record := api.resolveRecord(e.Model); record != nil {
- if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() {
- log.Println(err)
+ app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ record := realtimeResolveRecord(e.App, e.Model, "")
+ if record != nil {
+ err := realtimeBroadcastRecord(e.App, "create", record, false)
+ if err != nil {
+ app.Logger().Debug(
+ "Failed to broadcast record create",
+ slog.String("id", record.Id),
+ slog.String("collectionName", record.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ }
}
- }
- return nil
+
+ return e.Next()
+ },
+ Priority: -99,
})
- api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
- if record := api.resolveRecord(e.Model); record != nil {
- if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() {
- log.Println(err)
+ app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ record := realtimeResolveRecord(e.App, e.Model, "")
+ if record != nil {
+ err := realtimeBroadcastRecord(e.App, "update", record, false)
+ if err != nil {
+ app.Logger().Debug(
+ "Failed to broadcast record update",
+ slog.String("id", record.Id),
+ slog.String("collectionName", record.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ }
}
- }
- return nil
+
+ return e.Next()
+ },
+ Priority: -99,
})
- api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
- if record := api.resolveRecord(e.Model); record != nil {
- if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() {
- log.Println(err)
+ // delete: dry cache
+ app.OnModelDelete().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ record := realtimeResolveRecord(e.App, e.Model, "")
+ if record != nil {
+ // note: use the outside scoped app instance for the access checks so that the API rules
+ // are performed out of the delete transaction ensuring that they would still work even if
+ // a cascade-deleted record's API rule relies on an already deleted parent record
+ err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
+ if err != nil {
+ app.Logger().Debug(
+ "Failed to dry cache record delete",
+ slog.String("id", record.Id),
+ slog.String("collectionName", record.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ }
}
- }
- return nil
+
+ return e.Next()
+ },
+ Priority: 99, // execute as later as possible
})
-}
-// resolveRecord converts *if possible* the provided model interface to a Record.
-// This is usually helpful if the provided model is a custom Record model struct.
-func (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
- record, _ = model.(*models.Record)
+ // delete: broadcast
+ app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
+ Func: func(e *core.ModelEvent) error {
+ // note: only ensure that it is a collection record
+ // and don't use realtimeResolveRecord because in case of a
+ // custom model it'll fail to resolve since the record is already deleted
+ collection := realtimeResolveRecordCollection(e.App, e.Model)
+ if collection != nil {
+ err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
+ if err != nil {
+ app.Logger().Debug(
+ "Failed to broadcast record delete",
+ slog.Any("id", e.Model.PK()),
+ slog.String("collectionName", collection.Name),
+ slog.String("error", err.Error()),
+ )
+ }
+ }
- // check if it is custom Record model struct (ignore "private" tables)
- if record == nil && !strings.HasPrefix(model.TableName(), "_") {
- record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
- }
+ return e.Next()
+ },
+ Priority: -99,
+ })
- return record
+ // delete: failure
+ app.OnModelAfterDeleteError().Bind(&hook.Handler[*core.ModelErrorEvent]{
+ Func: func(e *core.ModelErrorEvent) error {
+ record := realtimeResolveRecord(e.App, e.Model, "")
+ if record != nil {
+ err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
+ if err != nil {
+ app.Logger().Debug(
+ "Failed to cleanup after broadcast record delete failure",
+ slog.String("id", record.Id),
+ slog.String("collectionName", record.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ }
+ }
+
+ return e.Next()
+ },
+ Priority: -99,
+ })
}
-// resolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
+// resolveRecord converts *if possible* the provided model interface to a Record.
// This is usually helpful if the provided model is a custom Record model struct.
-func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
- if record, ok := model.(*models.Record); ok {
- collection = record.Collection()
- } else if !strings.HasPrefix(model.TableName(), "_") {
- // check if it is custom Record model struct (ignore "private" tables)
- collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
+func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
+ var record *core.Record
+ switch m := model.(type) {
+ case *core.Record:
+ record = m
+ case core.RecordProxy:
+ record = m.ProxyRecord()
}
- return collection
-}
-
-// canAccessRecord checks if the subscription client has access to the specified record model.
-func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
- admin, _ := client.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- // admins can access everything
- return true
+ if record != nil {
+ if optCollectionType == "" || record.Collection().Type == optCollectionType {
+ return record
+ }
+ return nil
}
- if accessRule == nil {
- // only admins can access this record
- return false
+ tblName := model.TableName()
+
+ // skip Log model checks
+ if tblName == core.LogsTableName {
+ return nil
}
- ruleFunc := func(q *dbx.SelectQuery) error {
- if *accessRule == "" {
- return nil // empty public rule
+ // check if it is custom Record model struct
+ collection, _ := app.FindCachedCollectionByNameOrId(tblName)
+ if collection != nil && (optCollectionType == "" || collection.Type == optCollectionType) {
+ if id, ok := model.PK().(string); ok {
+ record, _ = app.FindRecordById(collection, id)
}
+ }
- // mock request data
- requestInfo := &models.RequestInfo{
- Method: "GET",
- }
- requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
+ return record
+}
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
- expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
- if err != nil {
- return err
+// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
+// This is usually helpful if the provided model is a custom Record model struct.
+func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
+ switch m := model.(type) {
+ case *core.Record:
+ return m.Collection()
+ case core.RecordProxy:
+ return m.ProxyRecord().Collection()
+ default:
+ // check if it is custom Record model struct
+ collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
+ if err == nil {
+ return collection
}
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
-
- return nil
}
- foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
- if err == nil && foundRecord != nil {
- return true
- }
-
- return false
+ return nil
}
+// recordData represents the broadcasted record subscrition message data.
type recordData struct {
- Action string `json:"action"`
- Record *models.Record `json:"record"`
+ Record any `json:"record"` /* map or core.Record */
+ Action string `json:"action"`
}
-func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
+// Note: the optAccessCheckApp is there in case you want the access check
+// to be performed against different db app context (e.g. out of a transaction).
+// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
+// If not set, it fallbacks to app.
+func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
collection := record.Collection()
if collection == nil {
- return errors.New("Record collection not set.")
+ return errors.New("[broadcastRecord] Record collection not set")
}
- clients := api.app.SubscriptionsBroker().Clients()
- if len(clients) == 0 {
+ chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
+ if len(chunks) == 0 {
return nil // no subscribers
}
- // create a clean record copy without expand and unknown fields
- // because we don't know if the clients have permissions to view them
- cleanRecord := record.CleanCopy()
-
subscriptionRuleMap := map[string]*string{
- (collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
- (collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
- (collection.Name + "/*"): collection.ListRule,
- (collection.Id + "/*"): collection.ListRule,
+ (collection.Name + "/" + record.Id + "?"): collection.ViewRule,
+ (collection.Id + "/" + record.Id + "?"): collection.ViewRule,
+ (collection.Name + "/*?"): collection.ListRule,
+ (collection.Id + "/*?"): collection.ListRule,
+
// @deprecated: the same as the wildcard topic but kept for backward compatibility
- collection.Name: collection.ListRule,
- collection.Id: collection.ListRule,
+ (collection.Name + "?"): collection.ListRule,
+ (collection.Id + "?"): collection.ListRule,
}
- data := &recordData{
- Action: action,
- Record: cleanRecord,
- }
+ dryCacheKey := getDryCacheKey(action, record)
- dataBytes, err := json.Marshal(data)
- if err != nil {
- return err
+ group := new(errgroup.Group)
+
+ accessCheckApp := app
+ if len(optAccessCheckApp) > 0 {
+ accessCheckApp = optAccessCheckApp[0]
}
- for _, client := range clients {
- client := client
+ for _, chunk := range chunks {
+ group.Go(routine.SafeWrap(func() error {
+ var clientAuth *core.Record
+
+ for _, client := range chunk {
+ // note: not executed concurrently to avoid races and to ensure
+ // that the access checks are applied for the current record db state
+ for prefix, rule := range subscriptionRuleMap {
+ subs := client.Subscriptions(prefix)
+ if len(subs) == 0 {
+ continue
+ }
- for subscription, rule := range subscriptionRuleMap {
- if !client.HasSubscription(subscription) {
- continue
+ clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
+
+ for sub, options := range subs {
+ // mock request data
+ requestInfo := &core.RequestInfo{
+ Context: core.RequestInfoContextRealtime,
+ Method: "GET",
+ Query: options.Query,
+ Headers: options.Headers,
+ Auth: clientAuth,
+ }
+
+ if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
+ continue
+ }
+
+ // create a clean record copy without expand and unknown fields because we don't know yet
+ // which exact fields the client subscription requested or has permissions to access
+ cleanRecord := record.Fresh()
+
+ // -------------------------------------------
+ // @todo consider with the refactoring whether
+ // the default enriching used by the regular APIs
+ // can be reused here too to avoid eventual future
+ // discrepencies in the record event data
+ //
+ // https://github.com/pocketbase/pocketbase/issues/7721
+ // -------------------------------------------
+
+ // enable hidden fields for superuser subscribers
+ if requestInfo.HasSuperuserAuth() {
+ cleanRecord.Unhide(collection.Fields.FieldNames()...)
+ }
+
+ // trigger the enrich hooks
+ enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
+ // apply expand
+ rawExpand := options.Query[expandQueryParam]
+ if rawExpand != "" {
+ expandErrs := app.ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(app, requestInfo))
+ if len(expandErrs) > 0 {
+ app.Logger().Debug(
+ "[broadcastRecord] expand errors",
+ slog.String("id", cleanRecord.Id),
+ slog.String("collectionName", cleanRecord.Collection().Name),
+ slog.String("sub", sub),
+ slog.String("expand", rawExpand),
+ slog.Any("errors", expandErrs),
+ )
+ }
+ }
+
+ // ignore the auth record email visibility checks
+ // for auth owner, superuser or manager
+ if collection.IsAuth() {
+ if isSameAuth(clientAuth, cleanRecord) ||
+ realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
+ cleanRecord.IgnoreEmailVisibility(true)
+ }
+ }
+
+ return nil
+ })
+ if enrichErr != nil {
+ app.Logger().Debug(
+ "[broadcastRecord] record enrich error",
+ slog.String("id", cleanRecord.Id),
+ slog.String("collectionName", cleanRecord.Collection().Name),
+ slog.String("sub", sub),
+ slog.Any("error", enrichErr),
+ )
+ continue
+ }
+
+ data := &recordData{
+ Action: action,
+ Record: cleanRecord,
+ }
+
+ // check fields
+ rawFields := options.Query[fieldsQueryParam]
+ if rawFields != "" {
+ decoded, err := picker.Pick(cleanRecord, rawFields)
+ if err == nil {
+ data.Record = decoded
+ } else {
+ app.Logger().Debug(
+ "[broadcastRecord] pick fields error",
+ slog.String("id", cleanRecord.Id),
+ slog.String("collectionName", cleanRecord.Collection().Name),
+ slog.String("sub", sub),
+ slog.String("fields", rawFields),
+ slog.String("error", err.Error()),
+ )
+ }
+ }
+
+ dataBytes, err := json.Marshal(data)
+ if err != nil {
+ app.Logger().Debug(
+ "[broadcastRecord] data marshal error",
+ slog.String("id", cleanRecord.Id),
+ slog.String("collectionName", cleanRecord.Collection().Name),
+ slog.String("error", err.Error()),
+ )
+ continue
+ }
+
+ msg := subscriptions.Message{
+ Name: sub,
+ Data: dataBytes,
+ }
+
+ if dryCache {
+ messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
+ if !ok {
+ messages = []subscriptions.Message{msg}
+ } else {
+ messages = append(messages, msg)
+ }
+ client.Set(dryCacheKey, messages)
+ } else {
+ routine.FireAndForget(func() {
+ client.Send(msg)
+ })
+ }
+ }
+ }
}
- if !api.canAccessRecord(client, data.Record, rule) {
- continue
- }
+ return nil
+ }))
+ }
- msg := subscriptions.Message{
- Name: subscription,
- Data: dataBytes,
- }
+ return group.Wait()
+}
- // ignore the auth record email visibility checks for
- // auth owner, admin or manager
- if collection.IsAuth() {
- authId := extractAuthIdFromGetter(client)
- if authId == data.Record.Id ||
- api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
- data.Record.IgnoreEmailVisibility(true) // ignore
- if newData, err := json.Marshal(data); err == nil {
- msg.Data = newData
- }
- data.Record.IgnoreEmailVisibility(false) // restore
+// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
+func realtimeBroadcastDryCacheKey(app core.App, key string) error {
+ chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
+ if len(chunks) == 0 {
+ return nil // no subscribers
+ }
+
+ group := new(errgroup.Group)
+
+ for _, chunk := range chunks {
+ group.Go(routine.SafeWrap(func() error {
+ for _, client := range chunk {
+ messages, ok := client.Get(key).([]subscriptions.Message)
+ if !ok {
+ continue
}
- }
- if dryCache {
- client.Set(action+"/"+data.Record.Id, msg)
- } else {
+ client.Unset(key)
+
+ client := client
+
routine.FireAndForget(func() {
- client.Send(msg)
+ for _, msg := range messages {
+ client.Send(msg)
+ }
})
}
- }
+
+ return nil
+ }))
}
- return nil
+ return group.Wait()
}
-// broadcastDryCachedRecord broadcasts record if it is cached in the client context.
-func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
- clients := api.app.SubscriptionsBroker().Clients()
+// realtimeUnsetDryCacheKey removes the dry cached key related messages.
+func realtimeUnsetDryCacheKey(app core.App, key string) error {
+ chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
+ if len(chunks) == 0 {
+ return nil // no subscribers
+ }
- for _, client := range clients {
- key := action + "/" + record.Id
+ group := new(errgroup.Group)
- msg, ok := client.Get(key).(subscriptions.Message)
- if !ok {
- continue
- }
+ for _, chunk := range chunks {
+ group.Go(routine.SafeWrap(func() error {
+ for _, client := range chunk {
+ if client.Get(key) != nil {
+ client.Unset(key)
+ }
+ }
- client.Unset(key)
+ return nil
+ }))
+ }
- client := client
+ return group.Wait()
+}
- routine.FireAndForget(func() {
- client.Send(msg)
- })
+func getDryCacheKey(action string, model core.Model) string {
+ pkStr, ok := model.PK().(string)
+ if !ok {
+ pkStr = fmt.Sprintf("%v", model.PK())
}
- return nil
+
+ return action + "/" + model.TableName() + "/" + pkStr
}
-type getter interface {
- Get(string) any
+func isSameAuth(authA, authB *core.Record) bool {
+ if authA == nil {
+ return authB == nil
+ }
+
+ if authB == nil {
+ return false
+ }
+
+ return authA.Id == authB.Id && authA.Collection().Id == authB.Collection().Id
}
-func extractAuthIdFromGetter(val getter) string {
- record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
- if record != nil {
- return record.Id
+// realtimeCanAccessRecord checks if the subscription client has access to the specified record model.
+func realtimeCanAccessRecord(
+ app core.App,
+ record *core.Record,
+ requestInfo *core.RequestInfo,
+ accessRule *string,
+) bool {
+ // check the access rule
+ // ---
+ if ok, _ := app.CanAccessRecord(record, requestInfo, accessRule); !ok {
+ return false
+ }
+
+ // check the subscription client-side filter (if any)
+ // ---
+ filter := requestInfo.Query[search.FilterQueryParam]
+ if filter == "" {
+ return true // no further checks needed
}
- admin, _ := val.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return admin.Id
+ err := checkForSuperuserOnlyRuleFields(requestInfo)
+ if err != nil {
+ return false
}
- return ""
+ var exists int
+
+ q := app.ConcurrentDB().Select("(1)").
+ From(record.Collection().Name).
+ AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
+
+ resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, false)
+ expr, err := search.FilterData(filter).BuildExpr(resolver)
+ if err != nil {
+ return false
+ }
+
+ q.AndWhere(expr)
+
+ err = resolver.UpdateQuery(q)
+ if err != nil {
+ return false
+ }
+
+ err = q.Limit(1).Row(&exists)
+
+ return err == nil && exists > 0
}
diff --git a/apis/realtime_test.go b/apis/realtime_test.go
index ef4be345ff..fa3094bcd5 100644
--- a/apis/realtime_test.go
+++ b/apis/realtime_test.go
@@ -1,27 +1,32 @@
package apis_test
import (
+ "context"
+ "encoding/json/v2"
"errors"
+ "fmt"
"net/http"
+ "slices"
"strings"
+ "sync"
"testing"
+ "time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/subscriptions"
+ "github.com/pocketbase/pocketbase/tools/types"
)
func TestRealtimeConnect(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Method: http.MethodGet,
- Url: "/api/realtime",
+ URL: "/api/realtime",
+ Timeout: 100 * time.Millisecond,
+ Headers: map[string]string{"x-test-ip": "127.0.0.2"},
ExpectedStatus: 200,
ExpectedContent: []string{
`id:`,
@@ -29,12 +34,22 @@ func TestRealtimeConnect(t *testing.T) {
`data:{"clientId":`,
},
ExpectedEvents: map[string]int{
- "OnRealtimeConnectRequest": 1,
- "OnRealtimeBeforeMessageSend": 1,
- "OnRealtimeAfterMessageSend": 1,
- "OnRealtimeDisconnectRequest": 1,
+ "*": 0,
+ "OnRealtimeConnectRequest": 1,
+ "OnRealtimeMessageSend": 1,
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
+
+ app.OnRealtimeConnectRequest().BindFunc(func(e *core.RealtimeConnectRequestEvent) error {
+ if ip, _ := e.Client.Get(apis.RealtimeClientIPKey).(string); ip != "127.0.0.2" {
+ t.Fatalf("Expected IP %q, got %q", "127.0.0.2", ip)
+ }
+
+ return e.Next()
+ })
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -43,22 +58,23 @@ func TestRealtimeConnect(t *testing.T) {
{
Name: "PB_CONNECT interrupt",
Method: http.MethodGet,
- Url: "/api/realtime",
+ URL: "/api/realtime",
+ Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
- "OnRealtimeConnectRequest": 1,
- "OnRealtimeBeforeMessageSend": 1,
- "OnRealtimeDisconnectRequest": 1,
+ "*": 0,
+ "OnRealtimeConnectRequest": 1,
+ "OnRealtimeMessageSend": 1,
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRealtimeMessageSend().BindFunc(func(e *core.RealtimeMessageEvent) error {
if e.Message.Name == "PB_CONNECT" {
return errors.New("PB_CONNECT error")
}
- return nil
+ return e.Next()
})
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -67,19 +83,20 @@ func TestRealtimeConnect(t *testing.T) {
{
Name: "Skipping/ignoring messages",
Method: http.MethodGet,
- Url: "/api/realtime",
+ URL: "/api/realtime",
+ Timeout: 100 * time.Millisecond,
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
- "OnRealtimeConnectRequest": 1,
- "OnRealtimeBeforeMessageSend": 1,
- "OnRealtimeDisconnectRequest": 1,
+ "*": 0,
+ "OnRealtimeConnectRequest": 1,
+ "OnRealtimeMessageSend": 1,
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
- return hook.StopPropagation
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRealtimeMessageSend().BindFunc(func(e *core.RealtimeMessageEvent) error {
+ return nil
})
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
@@ -97,36 +114,177 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
- client.Set(apis.ContextAdminKey, nil)
- client.Set(apis.ContextAuthRecordKey, nil)
+ client.Unset(apis.RealtimeClientAuthKey)
+ client.Unset(apis.RealtimeClientIPKey)
+ }
+
+ validSubscriptionsLimit := make([]string, 1000)
+ for i := 0; i < len(validSubscriptionsLimit); i++ {
+ validSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
+ }
+ invalidSubscriptionsLimit := make([]string, 1001)
+ for i := 0; i < len(invalidSubscriptionsLimit); i++ {
+ invalidSubscriptionsLimit[i] = fmt.Sprintf(`"%d"`, i)
}
scenarios := []tests.ApiScenario{
{
Name: "missing client",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"missing","subscriptions":["test1", "test2"]}`),
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"clientId":{"code":"validation_required`,
+ },
+ NotExpectedContent: []string{
+ `"subscriptions"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "existing client with invalid subscriptions limit",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{
+ "clientId": "` + client.Id() + `",
+ "subscriptions": [` + strings.Join(invalidSubscriptionsLimit, ",") + `]
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ resetClient()
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"subscriptions":{"code":"validation_length_too_long"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "existing client with valid subscriptions limit",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{
+ "clientId": "` + client.Id() + `",
+ "subscriptions": [` + strings.Join(validSubscriptionsLimit, ",") + `]
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ client.Subscribe("test0") // should be replaced
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if len(client.Subscriptions()) != len(validSubscriptionsLimit) {
+ t.Errorf("Expected %d subscriptions, got %d", len(validSubscriptionsLimit), len(client.Subscriptions()))
+ }
+ if client.HasSubscription("test0") {
+ t.Errorf("Expected old subscriptions to be replaced")
+ }
+ resetClient()
+ },
+ },
+ {
+ Name: "existing client with invalid topic length",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{
+ "clientId": "` + client.Id() + `",
+ "subscriptions": ["abc", "` + strings.Repeat("a", 2501) + `"]
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ resetClient()
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"subscriptions":{"1":{"code":"validation_length_too_long"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "existing client with different IP",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test"]}`),
+ Headers: map[string]string{"x-test-ip": "127.0.0.2"},
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
+
+ client.Set(apis.RealtimeClientIPKey, "127.0.0.1")
+
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ resetClient()
+ },
+ },
+ {
+ Name: "existing client with valid topic length",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{
+ "clientId": "` + client.Id() + `",
+ "subscriptions": ["abc", "` + strings.Repeat("a", 2500) + `"]
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ client.Subscribe("test0")
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if len(client.Subscriptions()) != 2 {
+ t.Errorf("Expected %d subscriptions, got %d", 2, len(client.Subscriptions()))
+ }
+ if client.HasSubscription("test0") {
+ t.Errorf("Expected old subscriptions to be replaced")
+ }
+ resetClient()
+ },
},
{
Name: "existing client - empty subscriptions",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":[]}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnRealtimeBeforeSubscribeRequest": 1,
- "OnRealtimeAfterSubscribeRequest": 1,
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if len(client.Subscriptions()) != 0 {
- t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
+ t.Errorf("Expected no subscriptions, got %d", len(client.Subscriptions()))
}
resetClient()
},
@@ -134,18 +292,18 @@ func TestRealtimeSubscribe(t *testing.T) {
{
Name: "existing client - 2 new subscriptions",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnRealtimeBeforeSubscribeRequest": 1,
- "OnRealtimeAfterSubscribeRequest": 1,
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
client.Subscribe("test0")
app.SubscriptionsBroker().Register(client)
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
expectedSubs := []string{"test1", "test2"}
if len(expectedSubs) != len(client.Subscriptions()) {
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
@@ -160,47 +318,79 @@ func TestRealtimeSubscribe(t *testing.T) {
},
},
{
- Name: "existing client - authorized admin",
+ Name: "existing client - guest -> authorized superuser",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnRealtimeBeforeSubscribeRequest": 1,
- "OnRealtimeAfterSubscribeRequest": 1,
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
app.SubscriptionsBroker().Register(client)
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
- if admin == nil {
- t.Errorf("Expected admin auth model, got nil")
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ authRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
+ if authRecord == nil || !authRecord.IsSuperuser() {
+ t.Errorf("Expected superuser auth record, got %v", authRecord)
}
resetClient()
},
},
{
- Name: "existing client - authorized record",
+ Name: "existing client - guest -> authorized regular auth record",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnRealtimeBeforeSubscribeRequest": 1,
- "OnRealtimeAfterSubscribeRequest": 1,
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.SubscriptionsBroker().Register(client)
},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ authRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
+ if authRecord == nil {
+ t.Errorf("Expected regular user auth record, got %v", authRecord)
+ }
+ resetClient()
+ },
+ },
+ {
+ Name: "existing client - same auth",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRealtimeSubscribeRequest": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // the same user as the auth token
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client.Set(apis.RealtimeClientAuthKey, user)
+
app.SubscriptionsBroker().Register(client)
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ authRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
@@ -210,22 +400,50 @@ func TestRealtimeSubscribe(t *testing.T) {
{
Name: "existing client - mismatched auth",
Method: http.MethodPost,
- Url: "/api/realtime",
+ URL: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- initialAuth := &models.Record{}
- initialAuth.RefreshId()
- client.Set(apis.ContextAuthRecordKey, initialAuth)
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client.Set(apis.RealtimeClientAuthKey, user)
+
+ app.SubscriptionsBroker().Register(client)
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ authRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
+ if authRecord == nil {
+ t.Errorf("Expected auth record model, got nil")
+ }
+ resetClient()
+ },
+ },
+ {
+ Name: "existing client - unauthorized client",
+ Method: http.MethodPost,
+ URL: "/api/realtime",
+ Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client.Set(apis.RealtimeClientAuthKey, user)
app.SubscriptionsBroker().Register(client)
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ authRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
@@ -243,24 +461,60 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // init realtime handlers
+ _, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
- authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
- client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAuthRecordKey, authRecord)
- testApp.SubscriptionsBroker().Register(client)
+ authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1 := subscriptions.NewDefaultClient()
+ client1.Set(apis.RealtimeClientAuthKey, authRecord1)
+ testApp.SubscriptionsBroker().Register(client1)
+
+ client2 := subscriptions.NewDefaultClient()
+ client2.Set(apis.RealtimeClientAuthKey, authRecord1)
+ testApp.SubscriptionsBroker().Register(client2)
+
+ client3 := subscriptions.NewDefaultClient()
+ client3.Set(apis.RealtimeClientAuthKey, authRecord2)
+ testApp.SubscriptionsBroker().Register(client3)
+ // mock delete event
e := new(core.ModelEvent)
- e.Dao = testApp.Dao()
- e.Model = authRecord
- testApp.OnModelAfterDelete().Trigger(e)
+ e.App = testApp
+ e.Type = core.ModelEventTypeDelete
+ e.Context = context.Background()
+ e.Model = authRecord1
- if len(testApp.SubscriptionsBroker().Clients()) != 0 {
- t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
+ err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
+ t.Fatalf("Expected %d subscription clients, found %d", 3, total)
+ }
+
+ if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
+ t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
+ }
+
+ if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
+ t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
+ }
+
+ if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
+ t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
@@ -268,111 +522,367 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // init realtime handlers
+ _, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
- authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAuthRecordKey, authRecord1)
+ client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the authRecord and change its email
- authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ // refetch the authRecord and change its name
+ authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
- authRecord2.SetEmail("new@example.com")
- e := new(core.ModelEvent)
- e.Dao = testApp.Dao()
- e.Model = authRecord2
- testApp.OnModelAfterUpdate().Trigger(e)
+ newName := "test_new_name"
+ authRecord2.Set("name", newName)
+
+ err = testApp.Save(authRecord2)
+ if err != nil {
+ t.Fatal(err)
+ }
- clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
- if clientAuthRecord.Email() != authRecord2.Email() {
- t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
+ 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 TestRealtimeAdminDeleteEvent(t *testing.T) {
+func TestRealtimeRecordHiddenFields(t *testing.T) {
+ t.Parallel()
+
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // 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 + "/*"
- admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
+ // 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)
- client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAdminKey, admin)
- testApp.SubscriptionsBroker().Register(client)
+ // 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)
- e := new(core.ModelEvent)
- e.Dao = testApp.Dao()
- e.Model = admin
- testApp.OnModelAfterDelete().Trigger(e)
+ 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{}{}
+ }()
- if len(testApp.SubscriptionsBroker().Clients()) != 0 {
- t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
+ // 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 TestRealtimeAdminUpdateEvent(t *testing.T) {
+func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // init realtime handlers
+ _, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
- admin1, err := testApp.Dao().FindAdminByEmail("test@example.com")
+ authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAdminKey, admin1)
+ client.Set(apis.RealtimeClientAuthKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
- // refetch the authRecord and change its email
- admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
+ // 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)
}
- admin2.Email = "new@example.com"
+ 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.Dao = testApp.Dao()
- e.Model = admin2
- testApp.OnModelAfterUpdate().Trigger(e)
+ e.App = testApp
+ e.Type = core.ModelEventTypeDelete
+ e.Context = context.Background()
+ e.Model = usersCollection
- clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
- if clientAdmin.Email != admin2.Email {
- t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
+ err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectations := map[*core.Record]bool{
+ // record -> unset
+ authRecord1: true,
+ authRecord2: true,
+ authRecord3: false,
+ }
+ for record, expectedUnset := range expectations {
+ clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
+ unset := clientAuthRecord == nil
+ if unset != expectedUnset {
+ t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
+ }
}
}
// Custom auth record model struct
// -------------------------------------------------------------------
-var _ models.Model = (*CustomUser)(nil)
+var _ core.Model = (*CustomUser)(nil)
type CustomUser struct {
- models.BaseModel
+ core.BaseModel
Email string `db:"email" json:"email"`
}
func (m *CustomUser) TableName() string {
- return "users" // the name of your collection
+ return "users"
}
-func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
+func findCustomUserByEmail(app core.App, email string) (*CustomUser, error) {
model := &CustomUser{}
- err := dao.ModelQuery(model).
+ err := app.ModelQuery(model).
AndWhere(dbx.HashExp{"email": email}).
Limit(1).
One(model)
@@ -388,30 +898,59 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // init realtime handlers
+ _, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
- authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
- client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAuthRecordKey, authRecord)
- testApp.SubscriptionsBroker().Register(client)
+ authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1 := subscriptions.NewDefaultClient()
+ client1.Set(apis.RealtimeClientAuthKey, authRecord1)
+ testApp.SubscriptionsBroker().Register(client1)
+
+ client2 := subscriptions.NewDefaultClient()
+ client2.Set(apis.RealtimeClientAuthKey, authRecord1)
+ testApp.SubscriptionsBroker().Register(client2)
+
+ client3 := subscriptions.NewDefaultClient()
+ client3.Set(apis.RealtimeClientAuthKey, authRecord2)
+ testApp.SubscriptionsBroker().Register(client3)
// refetch the authRecord as CustomUser
- customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
+ customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
if err != nil {
t.Fatal(err)
}
// delete the custom user (should unset the client auth record)
- if err := testApp.Dao().Delete(customUser); err != nil {
+ if err := testApp.Delete(customUser); err != nil {
t.Fatal(err)
}
- if len(testApp.SubscriptionsBroker().Clients()) != 0 {
- t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
+ if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
+ t.Fatalf("Expected %d subscription clients, found %d", 3, total)
+ }
+
+ if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
+ t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
+ }
+
+ if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
+ t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
+ }
+
+ if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
+ t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
}
}
@@ -419,31 +958,284 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- apis.InitApi(testApp)
+ // init realtime handlers
+ _, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
- authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
- client.Set(apis.ContextAuthRecordKey, authRecord)
+ client.Set(apis.RealtimeClientAuthKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
// refetch the authRecord as CustomUser
- customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
+ customUser, err := findCustomUserByEmail(testApp, "test@example.com")
if err != nil {
t.Fatal(err)
}
// change its email
customUser.Email = "new@example.com"
- if err := testApp.Dao().Save(customUser); err != nil {
+ if err := testApp.Save(customUser); err != nil {
t.Fatal(err)
}
- clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
+ clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if clientAuthRecord.Email() != customUser.Email {
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
}
}
+
+// -------------------------------------------------------------------
+
+var _ core.Model = (*CustomModelResolve)(nil)
+
+type CustomModelResolve struct {
+ core.BaseModel
+ tableName string
+
+ Created string `db:"created"`
+}
+
+func (m *CustomModelResolve) TableName() string {
+ return m.tableName
+}
+
+func TestRealtimeRecordResolve(t *testing.T) {
+ t.Parallel()
+
+ const testCollectionName = "realtime_test_collection"
+
+ testRecordId := core.GenerateDefaultRandomId()
+
+ client0 := subscriptions.NewDefaultClient()
+ client0.Subscribe(testCollectionName + "/*")
+ client0.Discard()
+ // ---
+ client1 := subscriptions.NewDefaultClient()
+ client1.Subscribe(testCollectionName + "/*")
+ // ---
+ client2 := subscriptions.NewDefaultClient()
+ client2.Subscribe(testCollectionName + "/" + testRecordId)
+ // ---
+ client3 := subscriptions.NewDefaultClient()
+ client3.Subscribe("demo1/*")
+
+ scenarios := []struct {
+ name string
+ op func(testApp core.App) error
+ expected map[string][]string // clientId -> [events]
+ }{
+ {
+ "core.Record",
+ func(testApp core.App) error {
+ c, err := testApp.FindCollectionByNameOrId(testCollectionName)
+ if err != nil {
+ return err
+ }
+
+ r := core.NewRecord(c)
+ r.Id = testRecordId
+
+ // create
+ err = testApp.Save(r)
+ if err != nil {
+ return err
+ }
+
+ // update
+ err = testApp.Save(r)
+ if err != nil {
+ return err
+ }
+
+ // delete
+ err = testApp.Delete(r)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ },
+ map[string][]string{
+ client1.Id(): {"create", "update", "delete"},
+ client2.Id(): {"create", "update", "delete"},
+ },
+ },
+ {
+ "core.RecordProxy",
+ func(testApp core.App) error {
+ c, err := testApp.FindCollectionByNameOrId(testCollectionName)
+ if err != nil {
+ return err
+ }
+
+ r := core.NewRecord(c)
+
+ proxy := &struct {
+ core.BaseRecordProxy
+ }{}
+ proxy.SetProxyRecord(r)
+ proxy.Id = testRecordId
+
+ // create
+ err = testApp.Save(proxy)
+ if err != nil {
+ return err
+ }
+
+ // update
+ err = testApp.Save(proxy)
+ if err != nil {
+ return err
+ }
+
+ // delete
+ err = testApp.Delete(proxy)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ },
+ map[string][]string{
+ client1.Id(): {"create", "update", "delete"},
+ client2.Id(): {"create", "update", "delete"},
+ },
+ },
+ {
+ "custom model struct",
+ func(testApp core.App) error {
+ m := &CustomModelResolve{tableName: testCollectionName}
+ m.Id = testRecordId
+
+ // create
+ err := testApp.Save(m)
+ if err != nil {
+ return err
+ }
+
+ // update
+ m.Created = "123"
+ err = testApp.Save(m)
+ if err != nil {
+ return err
+ }
+
+ // delete
+ err = testApp.Delete(m)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ },
+ map[string][]string{
+ client1.Id(): {"create", "update", "delete"},
+ client2.Id(): {"create", "update", "delete"},
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ // init realtime handlers
+ apis.NewRouter(testApp)
+
+ // create new test collection with public read access
+ testCollection := core.NewBaseCollection(testCollectionName)
+ testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
+ testCollection.ListRule = types.Pointer("")
+ testCollection.ViewRule = types.Pointer("")
+ err := testApp.Save(testCollection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testApp.SubscriptionsBroker().Register(client0)
+ testApp.SubscriptionsBroker().Register(client1)
+ testApp.SubscriptionsBroker().Register(client2)
+ testApp.SubscriptionsBroker().Register(client3)
+
+ var wg sync.WaitGroup
+
+ var notifications = map[string][]string{}
+
+ var mu sync.Mutex
+ notify := func(clientId string, eventData []byte) {
+ data := struct {
+ Action string `json:"action"`
+ }{}
+ _ = json.Unmarshal(eventData, &data)
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ if notifications[clientId] == nil {
+ notifications[clientId] = []string{}
+ }
+ notifications[clientId] = append(notifications[clientId], data.Action)
+ }
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ timeout := time.After(250 * time.Millisecond)
+
+ for {
+ select {
+ case e, ok := <-client0.Channel():
+ if ok {
+ notify(client0.Id(), e.Data)
+ }
+ case e, ok := <-client1.Channel():
+ if ok {
+ notify(client1.Id(), e.Data)
+ }
+ case e, ok := <-client2.Channel():
+ if ok {
+ notify(client2.Id(), e.Data)
+ }
+ case e, ok := <-client3.Channel():
+ if ok {
+ notify(client3.Id(), e.Data)
+ }
+ case <-timeout:
+ return
+ }
+ }
+ }()
+
+ err = s.op(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ wg.Wait()
+
+ if len(s.expected) != len(notifications) {
+ t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
+ }
+
+ for id, events := range s.expected {
+ if len(events) != len(notifications[id]) {
+ t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
+ }
+ for _, event := range events {
+ if !slices.Contains(notifications[id], event) {
+ t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/apis/record_auth.go b/apis/record_auth.go
index c2b7b84e46..fe960f779a 100644
--- a/apis/record_auth.go
+++ b/apis/record_auth.go
@@ -1,670 +1,79 @@
package apis
import (
- "encoding/json"
- "errors"
- "fmt"
- "log"
- "net/http"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/routine"
- "github.com/pocketbase/pocketbase/tools/search"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/subscriptions"
- "golang.org/x/oauth2"
+ "github.com/pocketbase/pocketbase/tools/router"
)
// bindRecordAuthApi registers the auth record api endpoints and
// the corresponding handlers.
-func bindRecordAuthApi(app core.App, rg *echo.Group) {
- api := recordAuthApi{app: app}
-
+func bindRecordAuthApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
// global oauth2 subscription redirect handler
- rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
-
- // common collection record related routes
- subGroup := rg.Group(
- "/collections/:collection",
- ActivityLogger(app),
- LoadCollectionContext(app, models.CollectionTypeAuth),
+ rg.GET("/oauth2-redirect", oauth2SubscriptionRedirect).Bind(
+ SkipSuccessActivityLog(), // skip success log as it could contain sensitive information in the url
+ )
+ // add again as POST in case of response_mode=form_post
+ rg.POST("/oauth2-redirect", oauth2SubscriptionRedirect).Bind(
+ SkipSuccessActivityLog(), // skip success log as it could contain sensitive information in the url
)
- subGroup.GET("/auth-methods", api.authMethods)
- subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
- subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
- subGroup.POST("/auth-with-password", api.authWithPassword)
- subGroup.POST("/request-password-reset", api.requestPasswordReset)
- subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
- subGroup.POST("/request-verification", api.requestVerification)
- subGroup.POST("/confirm-verification", api.confirmVerification)
- subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth())
- subGroup.POST("/confirm-email-change", api.confirmEmailChange)
- subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
- subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
-}
-
-type recordAuthApi struct {
- app core.App
-}
-
-func (api *recordAuthApi) authRefresh(c echo.Context) error {
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record == nil {
- return NewNotFoundError("Missing auth record context.", nil)
- }
-
- event := new(core.RecordAuthRefreshEvent)
- event.HttpContext = c
- event.Collection = record.Collection()
- event.Record = record
-
- return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
- return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
- return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
- })
- })
-}
-
-type providerInfo struct {
- Name string `json:"name"`
- State string `json:"state"`
- CodeVerifier string `json:"codeVerifier"`
- CodeChallenge string `json:"codeChallenge"`
- CodeChallengeMethod string `json:"codeChallengeMethod"`
- AuthUrl string `json:"authUrl"`
-}
-
-func (api *recordAuthApi) authMethods(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- authOptions := collection.AuthOptions()
-
- result := struct {
- UsernamePassword bool `json:"usernamePassword"`
- EmailPassword bool `json:"emailPassword"`
- AuthProviders []providerInfo `json:"authProviders"`
- }{
- UsernamePassword: authOptions.AllowUsernameAuth,
- EmailPassword: authOptions.AllowEmailAuth,
- AuthProviders: []providerInfo{},
- }
-
- if !authOptions.AllowOAuth2Auth {
- return c.JSON(http.StatusOK, result)
- }
-
- nameConfigMap := api.app.Settings().NamedAuthProviderConfigs()
- for name, config := range nameConfigMap {
- if !config.Enabled {
- continue
- }
-
- provider, err := auth.NewProviderByName(name)
- if err != nil {
- if api.app.IsDebug() {
- log.Println(err)
- }
- continue // skip provider
- }
-
- if err := config.SetupProvider(provider); err != nil {
- if api.app.IsDebug() {
- log.Println(err)
- }
- continue // skip provider
- }
-
- state := security.RandomString(30)
- codeVerifier := security.RandomString(43)
- codeChallenge := security.S256Challenge(codeVerifier)
- codeChallengeMethod := "S256"
- urlOpts := []oauth2.AuthCodeOption{
- oauth2.SetAuthURLParam("code_challenge", codeChallenge),
- oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
- }
-
- // custom providers url options
- switch name {
- case auth.NameApple:
- // see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
- urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
- case auth.NameVK:
- // vk currently doesn't support PKCE for server-side authorization
- urlOpts = []oauth2.AuthCodeOption{}
- }
-
- result.AuthProviders = append(result.AuthProviders, providerInfo{
- Name: name,
- State: state,
- CodeVerifier: codeVerifier,
- CodeChallenge: codeChallenge,
- CodeChallengeMethod: codeChallengeMethod,
- AuthUrl: provider.BuildAuthUrl(
- state,
- urlOpts...,
- ) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
- })
- }
-
- return c.JSON(http.StatusOK, result)
-}
-
-func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- if !collection.AuthOptions().AllowOAuth2Auth {
- return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
- }
-
- var fallbackAuthRecord *models.Record
-
- loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
- fallbackAuthRecord = loggedAuthRecord
- }
-
- form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.RecordAuthWithOAuth2Event)
- event.HttpContext = c
- event.Collection = collection
- event.ProviderName = form.Provider
- event.IsNewRecord = false
-
- form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
- return createForm.DrySubmit(func(txDao *daos.Dao) error {
- event.IsNewRecord = true
- // clone the current request data and assign the form create data as its body data
- requestInfo := *RequestInfo(c)
- requestInfo.Data = form.CreateData
-
- createRuleFunc := func(q *dbx.SelectQuery) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return nil // either admin or the rule is empty
- }
-
- if collection.CreateRule == nil {
- return errors.New("Only admins can create new accounts with OAuth2")
- }
-
- if *collection.CreateRule != "" {
- resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
- expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
- if err != nil {
- return err
- }
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
- }
-
- return nil
- }
-
- if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
- return fmt.Errorf("Failed create rule constraint: %w", err)
- }
-
- return nil
- })
- })
-
- _, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
- return func(data *forms.RecordOAuth2LoginData) error {
- event.Record = data.Record
- event.OAuth2User = data.OAuth2User
- event.ProviderClient = data.ProviderClient
-
- return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
- data.Record = e.Record
- data.OAuth2User = e.OAuth2User
-
- if err := next(data); err != nil {
- return NewBadRequestError("Failed to authenticate.", err)
- }
-
- e.Record = data.Record
- e.OAuth2User = data.OAuth2User
-
- meta := struct {
- *auth.AuthUser
- IsNew bool `json:"isNew"`
- }{
- AuthUser: e.OAuth2User,
- IsNew: event.IsNewRecord,
- }
-
- return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
- return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *recordAuthApi) authWithPassword(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- form := forms.NewRecordPasswordLogin(api.app, collection)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.RecordAuthWithPasswordEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Password = form.Password
- event.Identity = form.Identity
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to authenticate.", err)
- }
-
- return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
- return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- authOptions := collection.AuthOptions()
- if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
- return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
- }
-
- form := forms.NewRecordPasswordResetRequest(api.app, collection)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- event := new(core.RecordRequestPasswordResetEvent)
- event.HttpContext = c
- event.Collection = collection
-
- submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
- // run in background because we don't need to show the result to the client
- routine.FireAndForget(func() {
- if err := next(e.Record); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- // eagerly write 204 response and skip submit errors
- // as a measure against emails enumeration
- if !c.Response().Committed {
- c.NoContent(http.StatusNoContent)
- }
-
- return submitErr
-}
-
-func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- form := forms.NewRecordPasswordResetConfirm(api.app, collection)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.RecordConfirmPasswordResetEvent)
- event.HttpContext = c
- event.Collection = collection
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to set new password.", err)
- }
-
- return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *recordAuthApi) requestVerification(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- form := forms.NewRecordVerificationRequest(api.app, collection)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- if err := form.Validate(); err != nil {
- return NewBadRequestError("An error occurred while validating the form.", err)
- }
-
- event := new(core.RecordRequestVerificationEvent)
- event.HttpContext = c
- event.Collection = collection
-
- submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
- // run in background because we don't need to show the result to the client
- routine.FireAndForget(func() {
- if err := next(e.Record); err != nil && api.app.IsDebug() {
- log.Println(err)
- }
- })
-
- return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- // eagerly write 204 response and skip submit errors
- // as a measure against users enumeration
- if !c.Response().Committed {
- c.NoContent(http.StatusNoContent)
- }
-
- return submitErr
-}
-
-func (api *recordAuthApi) confirmVerification(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- form := forms.NewRecordVerificationConfirm(api.app, collection)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.RecordConfirmVerificationEvent)
- event.HttpContext = c
- event.Collection = collection
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("An error occurred while submitting the form.", err)
- }
-
- return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
- if record == nil {
- return NewUnauthorizedError("The request requires valid auth record.", nil)
- }
-
- form := forms.NewRecordEmailChangeRequest(api.app, record)
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
- }
-
- event := new(core.RecordRequestEmailChangeEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
-
- return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to request email change.", err)
- }
-
- return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-}
-
-func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- form := forms.NewRecordEmailChangeConfirm(api.app, collection)
- if readErr := c.Bind(form); readErr != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
- }
-
- event := new(core.RecordConfirmEmailChangeEvent)
- event.HttpContext = c
- event.Collection = collection
-
- _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- event.Record = record
-
- return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to confirm email change.", err)
- }
-
- return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
- }
- })
-
- return submitErr
-}
-
-func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
-
- id := c.PathParam("id")
- if id == "" {
- return NewNotFoundError("", nil)
- }
-
- record, err := api.app.Dao().FindRecordById(collection.Id, id)
- if err != nil || record == nil {
- return NewNotFoundError("", err)
- }
-
- externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
- if err != nil {
- return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
- }
- event := new(core.RecordListExternalAuthsEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
- event.ExternalAuths = externalAuths
+ sub := rg.Group("/collections/{collection}")
- return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
- return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
- })
-}
+ sub.GET("/auth-methods", recordAuthMethods).Bind(
+ collectionPathRateLimit("", "listAuthMethods"),
+ )
-func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("Missing collection context.", nil)
- }
+ sub.POST("/auth-refresh", recordAuthRefresh).Bind(
+ collectionPathRateLimit("", "authRefresh"),
+ RequireSameCollectionContextAuth(""),
+ )
- id := c.PathParam("id")
- provider := c.PathParam("provider")
- if id == "" || provider == "" {
- return NewNotFoundError("", nil)
- }
+ sub.POST("/auth-with-password", recordAuthWithPassword).Bind(
+ collectionPathRateLimit("", "authWithPassword", "auth"),
+ )
- record, err := api.app.Dao().FindRecordById(collection.Id, id)
- if err != nil || record == nil {
- return NewNotFoundError("", err)
- }
+ sub.POST("/auth-with-oauth2", recordAuthWithOAuth2).Bind(
+ collectionPathRateLimit("", "authWithOAuth2", "auth"),
+ )
- externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
- if err != nil {
- return NewNotFoundError("Missing external auth provider relation.", err)
- }
+ sub.POST("/request-otp", recordRequestOTP).Bind(
+ collectionPathRateLimit("", "requestOTP"),
+ )
+ sub.POST("/auth-with-otp", recordAuthWithOTP).Bind(
+ collectionPathRateLimit("", "authWithOTP", "auth"),
+ )
- event := new(core.RecordUnlinkExternalAuthEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
- event.ExternalAuth = externalAuth
+ sub.POST("/request-password-reset", recordRequestPasswordReset).Bind(
+ collectionPathRateLimit("", "requestPasswordReset"),
+ )
+ sub.POST("/confirm-password-reset", recordConfirmPasswordReset).Bind(
+ collectionPathRateLimit("", "confirmPasswordReset"),
+ )
- return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
- if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
- return NewBadRequestError("Cannot unlink the external auth provider.", err)
- }
+ sub.POST("/request-verification", recordRequestVerification).Bind(
+ collectionPathRateLimit("", "requestVerification"),
+ )
+ sub.POST("/confirm-verification", recordConfirmVerification).Bind(
+ collectionPathRateLimit("", "confirmVerification"),
+ )
- return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ sub.POST("/request-email-change", recordRequestEmailChange).Bind(
+ collectionPathRateLimit("", "requestEmailChange"),
+ RequireSameCollectionContextAuth(""),
+ )
+ sub.POST("/confirm-email-change", recordConfirmEmailChange).Bind(
+ collectionPathRateLimit("", "confirmEmailChange"),
+ )
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
+ sub.POST("/impersonate/{id}", recordAuthImpersonate).Bind(RequireSuperuserAuth())
}
-// -------------------------------------------------------------------
+func findAuthCollection(e *core.RequestEvent) (*core.Collection, error) {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
-const oauth2SubscriptionTopic = "@oauth2"
-
-func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
- state := c.QueryParam("state")
- code := c.QueryParam("code")
-
- if code == "" || state == "" {
- return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
- }
-
- client, err := api.app.SubscriptionsBroker().ClientById(state)
- if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
- return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
- }
-
- data := map[string]string{
- "state": state,
- "code": code,
+ if err != nil || !collection.IsAuth() {
+ return nil, e.NotFoundError("Missing or invalid auth collection context.", err)
}
- encodedData, err := json.Marshal(data)
- if err != nil {
- return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
- }
-
- msg := subscriptions.Message{
- Name: oauth2SubscriptionTopic,
- Data: encodedData,
- }
-
- client.Send(msg)
-
- return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
+ return collection, nil
}
diff --git a/apis/record_auth_email_change_confirm.go b/apis/record_auth_email_change_confirm.go
new file mode 100644
index 0000000000..59641dc399
--- /dev/null
+++ b/apis/record_auth_email_change_confirm.go
@@ -0,0 +1,122 @@
+package apis
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func recordConfirmEmailChange(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if collection.Name == core.CollectionNameSuperusers {
+ return e.BadRequestError("All superusers can change their emails directly.", nil)
+ }
+
+ form := newEmailChangeConfirmForm(e.App, collection)
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ authRecord, newEmail, err := form.parseToken()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Invalid or expired token.", err))
+ }
+
+ event := new(core.RecordConfirmEmailChangeRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = authRecord
+ event.NewEmail = newEmail
+
+ return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
+ e.Record.SetEmail(e.NewEmail)
+ e.Record.SetVerified(true)
+
+ if err := e.App.Save(e.Record); err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
+ }
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+func newEmailChangeConfirmForm(app core.App, collection *core.Collection) *EmailChangeConfirmForm {
+ return &EmailChangeConfirmForm{
+ app: app,
+ collection: collection,
+ }
+}
+
+type EmailChangeConfirmForm struct {
+ app core.App
+ collection *core.Collection
+
+ Token string `form:"token" json:"token"`
+ Password string `form:"password" json:"password"`
+}
+
+func (form *EmailChangeConfirmForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
+ validation.Field(&form.Password, validation.Required, validation.Length(1, 100), validation.By(form.checkPassword)),
+ )
+}
+
+func (form *EmailChangeConfirmForm) checkToken(value any) error {
+ _, _, err := form.parseToken()
+ return err
+}
+
+func (form *EmailChangeConfirmForm) checkPassword(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ authRecord, _, _ := form.parseToken()
+ if authRecord == nil || !authRecord.ValidatePassword(v) {
+ return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
+ }
+
+ return nil
+}
+
+func (form *EmailChangeConfirmForm) parseToken() (*core.Record, string, error) {
+ // check token payload
+ claims, _ := security.ParseUnverifiedJWT(form.Token)
+ newEmail, _ := claims[core.TokenClaimNewEmail].(string)
+ if newEmail == "" {
+ return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
+ }
+
+ // verify that the token is not expired and its signature is valid
+ authRecord, err := form.app.FindAuthRecordByToken(form.Token, core.TokenTypeEmailChange)
+ if err != nil {
+ return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ if authRecord.Collection().Id != form.collection.Id {
+ return nil, "", validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ // check if there are other users with the new email
+ _, err = form.app.FindAuthRecordByEmail(form.collection, newEmail)
+ if err == nil {
+ return nil, "", validation.NewError("validation_invalid_token_email", "The new email address is invalid.")
+ }
+
+ return authRecord, newEmail, nil
+}
diff --git a/apis/record_auth_email_change_confirm_test.go b/apis/record_auth_email_change_confirm_test.go
new file mode 100644
index 0000000000..2744dddf26
--- /dev/null
+++ b/apis/record_auth_email_change_confirm_test.go
@@ -0,0 +1,250 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordConfirmEmailChange(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/confirm-email-change",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"token":{"code":"validation_required"`,
+ `"password":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{"token`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "expired token and correct password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.dff842MO0mgRTHY8dktp0dqG9-7LGQOgRuiAbQpYBls",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{`,
+ `"code":"validation_invalid_token"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-email change token",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{`,
+ `"code":"validation_invalid_token_payload"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid token and incorrect password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567891"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"password":{`,
+ `"code":"validation_invalid_password"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid token and correct password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmEmailChangeRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ // unverified->verified external auths removal
+ "OnModelDelete": 2,
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected the user to be unverified before the confirmation")
+ }
+
+ // ensure that there is at least one pre-existing OAuth2 link
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) == 0 {
+ t.Fatal("Expected at least one external auths")
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "change@example.com")
+ if err != nil {
+ t.Fatalf("Expected to find user with email %q, got error: %v", "change@example.com", err)
+ }
+
+ if !user.Verified() {
+ t.Fatalf("Expected the user to be verified after the confirmation")
+ }
+
+ // ensure that all pre-existing OAuth2 links are cleared
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) > 0 {
+ t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid token in different auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "OnRecordConfirmEmailChangeRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:confirmEmailChange",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:confirmEmailChange"},
+ {MaxRequests: 0, Label: "users:confirmEmailChange"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:confirmEmailChange",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-email-change",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJlbWFpbENoYW5nZSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoyNTI0NjA0NDYxfQ.Y7mVlaEPhJiNPoIvIqbIosZU4c4lEhwysOrRR8c95iU",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:confirmEmailChange"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_email_change_request.go b/apis/record_auth_email_change_request.go
new file mode 100644
index 0000000000..1e4c5e0cff
--- /dev/null
+++ b/apis/record_auth_email_change_request.go
@@ -0,0 +1,92 @@
+package apis
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/mails"
+)
+
+func recordRequestEmailChange(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if collection.Name == core.CollectionNameSuperusers {
+ return e.BadRequestError("All superusers can change their emails directly.", nil)
+ }
+
+ record := e.Auth
+ if record == nil {
+ return e.UnauthorizedError("The request requires valid auth record.", nil)
+ }
+
+ form := newEmailChangeRequestForm(e.App, record)
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ event := new(core.RecordRequestEmailChangeRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+ event.NewEmail = form.NewEmail
+
+ return e.App.OnRecordRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeRequestEvent) error {
+ if err := mails.SendRecordChangeEmail(e.App, e.Record, e.NewEmail); err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
+ }
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+func newEmailChangeRequestForm(app core.App, record *core.Record) *emailChangeRequestForm {
+ return &emailChangeRequestForm{
+ app: app,
+ record: record,
+ }
+}
+
+type emailChangeRequestForm struct {
+ app core.App
+ record *core.Record
+
+ NewEmail string `form:"newEmail" json:"newEmail"`
+}
+
+func (form *emailChangeRequestForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.NewEmail,
+ validation.Required,
+ validation.Length(1, 255),
+ is.EmailFormat,
+ validation.NotIn(form.record.Email()),
+ validation.By(form.checkUniqueEmail),
+ ),
+ )
+}
+
+func (form *emailChangeRequestForm) checkUniqueEmail(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil
+ }
+
+ found, _ := form.app.FindAuthRecordByEmail(form.record.Collection(), v)
+ if found != nil && found.Id != form.record.Id {
+ return validation.NewError("validation_invalid_new_email", "Invalid new email address.")
+ }
+
+ return nil
+}
diff --git a/apis/record_auth_email_change_request_test.go b/apis/record_auth_email_change_request_test.go
new file mode 100644
index 0000000000..ef39e0cd14
--- /dev/null
+++ b/apis/record_auth_email_change_request_test.go
@@ -0,0 +1,195 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordRequestEmailChange(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "record authentication but from different auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superuser authentication",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"newEmail":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid data (existing email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":`,
+ `"newEmail":{"code":"validation_invalid_new_email"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid data (new email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestEmailChangeRequest": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordEmailChangeSend": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "/auth/confirm-email-change") {
+ t.Fatalf("Expected email change email, got\n%v", app.TestMailer.LastMessage().HTML)
+ }
+ },
+ },
+ {
+ Name: "OnRecordRequestEmailChangeRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:requestEmailChange",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:requestEmailChange"},
+ {MaxRequests: 0, Label: "users:requestEmailChange"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:requestEmailChange",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-email-change",
+ Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:requestEmailChange"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_impersonate.go b/apis/record_auth_impersonate.go
new file mode 100644
index 0000000000..2e6874b9a5
--- /dev/null
+++ b/apis/record_auth_impersonate.go
@@ -0,0 +1,54 @@
+package apis
+
+import (
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// note: for now allow superusers but it may change in the future to allow access
+// also to users with "Manage API" rule access depending on the use cases that will arise
+func recordAuthImpersonate(e *core.RequestEvent) error {
+ if !e.HasSuperuserAuth() {
+ return e.ForbiddenError("", nil)
+ }
+
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ record, err := e.App.FindRecordById(collection, e.Request.PathValue("id"))
+ if err != nil {
+ return e.NotFoundError("", err)
+ }
+
+ form := &impersonateForm{}
+ if err = e.BindBody(form); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
+ }
+ if err = form.validate(); err != nil {
+ return e.BadRequestError("An error occurred while validating the submitted data.", err)
+ }
+
+ token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
+ if err != nil {
+ return e.InternalServerError("Failed to generate static auth token", err)
+ }
+
+ return recordAuthResponse(e, record, token, "", nil)
+}
+
+// -------------------------------------------------------------------
+
+type impersonateForm struct {
+ // Duration is the optional custom token duration in seconds.
+ Duration int64 `form:"duration" json:"duration"`
+}
+
+func (form *impersonateForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Duration, validation.Min(0)),
+ )
+}
diff --git a/apis/record_auth_impersonate_test.go b/apis/record_auth_impersonate_test.go
new file mode 100644
index 0000000000..a5f2dc1975
--- /dev/null
+++ b/apis/record_auth_impersonate_test.go
@@ -0,0 +1,109 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordAuthImpersonate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as different user",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as the same user",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as superuser",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"record":{`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields should remain hidden even though we are authenticated as superuser
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "authorized as superuser with custom invalid duration",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{"duration":-1}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"duration":{`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "authorized as superuser with custom valid duration",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: strings.NewReader(`{"duration":100}`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"record":{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_methods.go b/apis/record_auth_methods.go
new file mode 100644
index 0000000000..1cb59cfd45
--- /dev/null
+++ b/apis/record_auth_methods.go
@@ -0,0 +1,179 @@
+package apis
+
+import (
+ "log/slog"
+ "net/http"
+ "slices"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "golang.org/x/oauth2"
+)
+
+type otpResponse struct {
+ Enabled bool `json:"enabled"`
+ Duration int64 `json:"duration"` // in seconds
+}
+
+type mfaResponse struct {
+ Enabled bool `json:"enabled"`
+ Duration int64 `json:"duration"` // in seconds
+}
+
+type passwordResponse struct {
+ IdentityFields []string `json:"identityFields"`
+ Enabled bool `json:"enabled"`
+}
+
+type oauth2Response struct {
+ Providers []providerInfo `json:"providers"`
+ Enabled bool `json:"enabled"`
+}
+
+type providerInfo struct {
+ Name string `json:"name"`
+ DisplayName string `json:"displayName"`
+ Logo string `json:"logo"`
+ State string `json:"state"`
+ AuthURL string `json:"authURL"`
+
+ // @todo
+ // deprecated: use AuthURL instead
+ // AuthUrl will be removed after dropping v0.22 support
+ AuthUrl string `json:"authUrl"`
+
+ // technically could be omitted if the provider doesn't support PKCE,
+ // but to avoid breaking existing typed clients we'll return them as empty string
+ CodeVerifier string `json:"codeVerifier"`
+ CodeChallenge string `json:"codeChallenge"`
+ CodeChallengeMethod string `json:"codeChallengeMethod"`
+}
+
+type authMethodsResponse struct {
+ Password passwordResponse `json:"password"`
+ OAuth2 oauth2Response `json:"oauth2"`
+ MFA mfaResponse `json:"mfa"`
+ OTP otpResponse `json:"otp"`
+
+ // legacy fields
+ // @todo remove after dropping v0.22 support
+ AuthProviders []providerInfo `json:"authProviders"`
+ UsernamePassword bool `json:"usernamePassword"`
+ EmailPassword bool `json:"emailPassword"`
+}
+
+func (amr *authMethodsResponse) fillLegacyFields() {
+ amr.EmailPassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "email")
+
+ amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
+
+ if amr.OAuth2.Enabled {
+ // clone without the logo
+ legacyProviders := make([]providerInfo, len(amr.OAuth2.Providers))
+ for i, p := range amr.OAuth2.Providers {
+ legacyProviders[i] = p
+ legacyProviders[i].Logo = ""
+ }
+
+ amr.AuthProviders = legacyProviders
+ }
+}
+
+func recordAuthMethods(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ result := authMethodsResponse{
+ Password: passwordResponse{
+ IdentityFields: make([]string, 0, len(collection.PasswordAuth.IdentityFields)),
+ },
+ OAuth2: oauth2Response{
+ Providers: make([]providerInfo, 0, len(collection.OAuth2.Providers)),
+ },
+ OTP: otpResponse{
+ Enabled: collection.OTP.Enabled,
+ },
+ MFA: mfaResponse{
+ Enabled: collection.MFA.Enabled,
+ },
+ }
+
+ if collection.PasswordAuth.Enabled {
+ result.Password.Enabled = true
+ result.Password.IdentityFields = collection.PasswordAuth.IdentityFields
+ }
+
+ if collection.OTP.Enabled {
+ result.OTP.Duration = collection.OTP.Duration
+ }
+
+ if collection.MFA.Enabled {
+ result.MFA.Duration = collection.MFA.Duration
+ }
+
+ if !collection.OAuth2.Enabled {
+ result.fillLegacyFields()
+
+ return e.JSON(http.StatusOK, result)
+ }
+
+ result.OAuth2.Enabled = true
+
+ for _, config := range collection.OAuth2.Providers {
+ provider, err := config.InitProvider()
+ if err != nil {
+ e.App.Logger().Debug(
+ "Failed to setup OAuth2 provider",
+ slog.String("name", config.Name),
+ slog.String("error", err.Error()),
+ )
+ continue // skip provider
+ }
+
+ info := providerInfo{
+ Name: config.Name,
+ DisplayName: provider.DisplayName(),
+ Logo: provider.Logo(),
+ State: security.RandomString(30),
+ }
+
+ if info.DisplayName == "" {
+ info.DisplayName = config.Name
+ }
+
+ urlOpts := []oauth2.AuthCodeOption{}
+
+ // custom providers url options
+ switch config.Name {
+ case auth.NameApple:
+ // see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
+ urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "form_post"))
+ }
+
+ if provider.PKCE() {
+ info.CodeVerifier = security.RandomString(43)
+ info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
+ info.CodeChallengeMethod = "S256"
+ urlOpts = append(urlOpts,
+ oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
+ oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
+ )
+ }
+
+ info.AuthURL = provider.BuildAuthURL(
+ info.State,
+ urlOpts...,
+ ) + "&redirect_uri=" // empty redirect_uri so that users can append their redirect url
+
+ info.AuthUrl = info.AuthURL
+
+ result.OAuth2.Providers = append(result.OAuth2.Providers, info)
+ }
+
+ result.fillLegacyFields()
+
+ return e.JSON(http.StatusOK, result)
+}
diff --git a/apis/record_auth_methods_test.go b/apis/record_auth_methods_test.go
new file mode 100644
index 0000000000..5ae9d8cfaa
--- /dev/null
+++ b/apis/record_auth_methods_test.go
@@ -0,0 +1,108 @@
+package apis_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordAuthMethodsList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "missing collection",
+ Method: http.MethodGet,
+ URL: "/api/collections/missing/auth-methods",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo1/auth-methods",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth collection with none auth methods allowed",
+ Method: http.MethodGet,
+ URL: "/api/collections/nologin/auth-methods",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"password":{"identityFields":[],"enabled":false}`,
+ `"oauth2":{"providers":[],"enabled":false}`,
+ `"mfa":{"enabled":false,"duration":0}`,
+ `"otp":{"enabled":false,"duration":0}`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth collection with all auth methods allowed",
+ Method: http.MethodGet,
+ URL: "/api/collections/users/auth-methods",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"password":{"identityFields":["email","username"],"enabled":true}`,
+ `"mfa":{"enabled":true,"duration":1800}`,
+ `"otp":{"enabled":true,"duration":300}`,
+ `"oauth2":{`,
+ `"providers":[{`,
+ `"name":"google"`,
+ `"name":"gitlab"`,
+ `"logo":" 9 {
+ otp = otps[0] // otps are DESC sorted
+ e.App.Logger().Warn(
+ "Too many OTP requests - reusing the last issued",
+ "email", form.Email,
+ "recordId", e.Record.Id,
+ "otpId", existingOTP.Id,
+ )
+ break
+ }
+ }
+ }
+
+ if otp == nil {
+ // create new OTP
+ // ---
+ otp = core.NewOTP(e.App)
+ otp.SetCollectionRef(e.Record.Collection().Id)
+ otp.SetRecordRef(e.Record.Id)
+ otp.SetPassword(e.Password)
+ err = e.App.Save(otp)
+ if err != nil {
+ return err
+ }
+
+ // send OTP email
+ // (in the background as a very basic timing attacks and emails enumeration protection)
+ // ---
+ routine.FireAndForget(func() {
+ err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
+ if err != nil {
+ originalApp.Logger().Error("Failed to send OTP email", "error", errors.Join(err, originalApp.Delete(otp)))
+ }
+ })
+ }
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+type createOTPForm struct {
+ Email string `form:"email" json:"email"`
+}
+
+func (form createOTPForm) validate() error {
+ return validation.ValidateStruct(&form,
+ validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
+ )
+}
diff --git a/apis/record_auth_otp_request_test.go b/apis/record_auth_otp_request_test.go
new file mode 100644
index 0000000000..5a262d721b
--- /dev/null
+++ b/apis/record_auth_otp_request_test.go
@@ -0,0 +1,316 @@
+package apis_test
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRecordRequestOTP(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth collection with disabled otp",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ usersCol.OTP.Enabled = false
+
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty body",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid body",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid request data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"invalid"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"email":{"code":"validation_is_email`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "missing auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"otpId":"`, // some fake random generated string
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestOTPRequest": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "existing auth record (with < 9 non-expired)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // insert 8 non-expired and 2 expired
+ for i := 0; i < 10; i++ {
+ otp := core.NewOTP(app)
+ otp.Id = "otp_" + strconv.Itoa(i)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if i >= 8 {
+ expiredDate := types.NowDateTime().AddDate(-3, 0, 0)
+ otp.SetRaw("created", expiredDate)
+ otp.SetRaw("updated", expiredDate)
+ }
+ if err := app.SaveNoValidate(otp); err != nil {
+ t.Fatal(err)
+ }
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"otpId":"`,
+ },
+ NotExpectedContent: []string{
+ `"otpId":"otp_`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestOTPRequest": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordOTPSend": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 2, // + 1 for the OTP update after the email send
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 2,
+ // OTP update
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
+ }
+
+ // ensure that sentTo is set
+ otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
+ if err != nil || len(otps) != 1 {
+ t.Fatalf("Expected to find 1 OTP with sentTo %q, found %d", "test@example.com", len(otps))
+ }
+ },
+ },
+ {
+ Name: "existing auth record with intercepted email (with < 9 non-expired)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // prevent email sent
+ app.OnMailerRecordOTPSend("users").BindFunc(func(e *core.MailerRecordEvent) error {
+ return nil
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"otpId":"`,
+ },
+ NotExpectedContent: []string{
+ `"otpId":"otp_`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestOTPRequest": 1,
+ "OnMailerRecordOTPSend": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected 0 emails, got %d", app.TestMailer.TotalSend())
+ }
+
+ // ensure that there is no OTP with user email as sentTo
+ otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
+ if err != nil || len(otps) != 0 {
+ t.Fatalf("Expected to find 0 OTPs with sentTo %q, found %d", "test@example.com", len(otps))
+ }
+ },
+ },
+ {
+ Name: "existing auth record (with > 9 non-expired)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // insert 10 non-expired
+ for i := 0; i < 10; i++ {
+ otp := core.NewOTP(app)
+ otp.Id = "otp_" + strconv.Itoa(i)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.SaveNoValidate(otp); err != nil {
+ t.Fatal(err)
+ }
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"otpId":"otp_9"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestOTPRequest": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected 0 sent emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "OnRecordRequestOTPRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:requestOTP",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:requestOTP"},
+ {MaxRequests: 0, Label: "users:requestOTP"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:requestOTP",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-otp",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:requestOTP"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_password_reset_confirm.go b/apis/record_auth_password_reset_confirm.go
new file mode 100644
index 0000000000..e656f117de
--- /dev/null
+++ b/apis/record_auth_password_reset_confirm.go
@@ -0,0 +1,104 @@
+package apis
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+func recordConfirmPasswordReset(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ form := new(recordConfirmPasswordResetForm)
+ form.app = e.App
+ form.collection = collection
+ if err = e.BindBody(form); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ authRecord, err := e.App.FindAuthRecordByToken(form.Token, core.TokenTypePasswordReset)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Invalid or expired password reset token.", err))
+ }
+
+ event := new(core.RecordConfirmPasswordResetRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = authRecord
+
+ return e.App.OnRecordConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetRequestEvent) error {
+ authRecord.SetPassword(form.Password)
+
+ if !authRecord.Verified() {
+ payload, err := security.ParseUnverifiedJWT(form.Token)
+ if err == nil && authRecord.Email() == cast.ToString(payload[core.TokenClaimEmail]) {
+ // mark as verified if the email hasn't changed
+ authRecord.SetVerified(true)
+ }
+ }
+
+ err = e.App.Save(authRecord)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
+ }
+
+ e.App.Store().Remove(getPasswordResetResendKey(authRecord))
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+type recordConfirmPasswordResetForm struct {
+ app core.App
+ collection *core.Collection
+
+ Token string `form:"token" json:"token"`
+ Password string `form:"password" json:"password"`
+ PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
+}
+
+func (form *recordConfirmPasswordResetForm) validate() error {
+ min := 1
+ passField, ok := form.collection.Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
+ if ok && passField != nil && passField.Min > 0 {
+ min = passField.Min
+ }
+
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
+ validation.Field(&form.Password, validation.Required, validation.Length(min, 255)), // the FieldPassword validator will check further the specicic length constraints
+ validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Equal(form.Password))),
+ )
+}
+
+func (form *recordConfirmPasswordResetForm) checkToken(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil
+ }
+
+ record, err := form.app.FindAuthRecordByToken(v, core.TokenTypePasswordReset)
+ if err != nil || record == nil {
+ return validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ if record.Collection().Id != form.collection.Id {
+ return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ return nil
+}
diff --git a/apis/record_auth_password_reset_confirm_test.go b/apis/record_auth_password_reset_confirm_test.go
new file mode 100644
index 0000000000..8b87e6d90a
--- /dev/null
+++ b/apis/record_auth_password_reset_confirm_test.go
@@ -0,0 +1,394 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordConfirmPasswordReset(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"password":{"code":"validation_required"`,
+ `"passwordConfirm":{"code":"validation_required"`,
+ `"token":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data format",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{"password`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "expired token and invalid password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.5Tm6_6amQqOlX3urAnXlEdmxwG5qQJfiTg6U0hHR1hk",
+ "password":"1234567",
+ "passwordConfirm":"7654321"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ `"passwordConfirm":{"code":"validation_values_mismatch"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-password reset token",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/confirm-password-reset?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "different auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/confirm-password-reset?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid token and data (unverified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmPasswordResetRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ // ---
+ "OnModelDelete": 2, // pre-existing OAuth2 links
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected the user to be unverified")
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err := app.FindAuthRecordByToken(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ core.TokenTypePasswordReset,
+ )
+ if err == nil {
+ t.Fatal("Expected the password reset token to be invalidated")
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ if !user.Verified() {
+ t.Fatal("Expected the user to be marked as verified")
+ }
+
+ if !user.ValidatePassword("1234567!") {
+ t.Fatal("Password wasn't changed")
+ }
+
+ // ensure that all pre-existing OAuth2 links are cleared
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) > 0 {
+ t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid token and data (unverified user with different email from the one in the token)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmPasswordResetRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected the user to be unverified")
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ // manually change the email to check whether the verified state will be updated
+ user.SetEmail("test_update@example.com")
+ if err = app.Save(user); err != nil {
+ t.Fatalf("Failed to update user test email: %v", err)
+ }
+
+ // resave with the old token key since the email change above
+ // would change it and will make the password token invalid
+ user.SetTokenKey(oldTokenKey)
+ if err = app.Save(user); err != nil {
+ t.Fatalf("Failed to restore original user tokenKey: %v", err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err := app.FindAuthRecordByToken(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ core.TokenTypePasswordReset,
+ )
+ if err == nil {
+ t.Fatalf("Expected the password reset token to be invalidated")
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test_update@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected the user to remain unverified")
+ }
+
+ if !user.ValidatePassword("1234567!") {
+ t.Fatal("Password wasn't changed")
+ }
+
+ // ensure that all pre-existing OAuth2 were NOT deleted
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) != 2 {
+ t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid token and data (verified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmPasswordResetRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ // ensure that the user is already verified
+ user.SetVerified(true)
+ if err := app.Save(user); err != nil {
+ t.Fatalf("Failed to update user verified state")
+ }
+
+ // resave with the old token key since the verified change above
+ // would refresh it and will make the password token invalid
+ user.SetTokenKey(oldTokenKey)
+ if err = app.Save(user); err != nil {
+ t.Fatalf("Failed to restore original user tokenKey: %v", err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ _, err := app.FindAuthRecordByToken(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ core.TokenTypePasswordReset,
+ )
+ if err == nil {
+ t.Fatal("Expected the password reset token to be invalidated")
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatalf("Failed to fetch confirm password user: %v", err)
+ }
+
+ if !user.Verified() {
+ t.Fatal("Expected the user to remain verified")
+ }
+
+ if !user.ValidatePassword("1234567!") {
+ t.Fatal("Password wasn't changed")
+ }
+ },
+ },
+ {
+ Name: "OnRecordConfirmPasswordResetRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:confirmPasswordReset",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:confirmPasswordReset"},
+ {MaxRequests: 0, Label: "users:confirmPasswordReset"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:confirmPasswordReset",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-password-reset",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY",
+ "password":"1234567!",
+ "passwordConfirm":"1234567!"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:confirmPasswordReset"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_password_reset_request.go b/apis/record_auth_password_reset_request.go
new file mode 100644
index 0000000000..14f9addb53
--- /dev/null
+++ b/apis/record_auth_password_reset_request.go
@@ -0,0 +1,88 @@
+package apis
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/tools/routine"
+)
+
+func recordRequestPasswordReset(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if !collection.PasswordAuth.Enabled {
+ return e.BadRequestError("The collection is not configured to allow password authentication.", nil)
+ }
+
+ form := new(recordRequestPasswordResetForm)
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
+ if err != nil {
+ // eagerly write 204 response as a very basic measure against emails enumeration
+ e.NoContent(http.StatusNoContent)
+ return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
+ }
+
+ resendKey := getPasswordResetResendKey(record)
+ if e.App.Store().Has(resendKey) {
+ // eagerly write 204 response as a very basic measure against emails enumeration
+ e.NoContent(http.StatusNoContent)
+ return errors.New("try again later - you've already requested a password reset email")
+ }
+
+ event := new(core.RecordRequestPasswordResetRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+
+ return e.App.OnRecordRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetRequestEvent) error {
+ // run in background because we don't need to show the result to the client
+ app := e.App
+ routine.FireAndForget(func() {
+ if err := mails.SendRecordPasswordReset(app, e.Record); err != nil {
+ app.Logger().Error("Failed to send password reset email", "error", err)
+ return
+ }
+
+ app.Store().Set(resendKey, struct{}{})
+ time.AfterFunc(2*time.Minute, func() {
+ app.Store().Remove(resendKey)
+ })
+ })
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+type recordRequestPasswordResetForm struct {
+ Email string `form:"email" json:"email"`
+}
+
+func (form *recordRequestPasswordResetForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
+ )
+}
+
+func getPasswordResetResendKey(record *core.Record) string {
+ return "@limitPasswordResetEmail_" + record.Collection().Id + record.Id
+}
diff --git a/apis/record_auth_password_reset_request_test.go b/apis/record_auth_password_reset_request_test.go
new file mode 100644
index 0000000000..0b4a34853f
--- /dev/null
+++ b/apis/record_auth_password_reset_request_test.go
@@ -0,0 +1,169 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordRequestPasswordReset(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/request-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "existing auth record in a collection with disabled password login",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "missing auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "existing auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestPasswordResetRequest": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordPasswordResetSend": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "/auth/confirm-password-reset") {
+ t.Fatalf("Expected password reset email, got\n%v", app.TestMailer.LastMessage().HTML)
+ }
+ },
+ },
+ {
+ Name: "existing auth record (after already sent)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // simulate recent verification sent
+ authRecord, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resendKey := "@limitPasswordResetEmail_" + authRecord.Collection().Id + authRecord.Id
+ app.Store().Set(resendKey, struct{}{})
+ },
+ },
+ {
+ Name: "OnRecordRequestPasswordResetRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:requestPasswordReset",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:requestPasswordReset"},
+ {MaxRequests: 0, Label: "users:requestPasswordReset"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:requestPasswordReset",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-password-reset",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:requestPasswordReset"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_refresh.go b/apis/record_auth_refresh.go
new file mode 100644
index 0000000000..8d42962f3a
--- /dev/null
+++ b/apis/record_auth_refresh.go
@@ -0,0 +1,35 @@
+package apis
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+func recordAuthRefresh(e *core.RequestEvent) error {
+ record := e.Auth
+ if record == nil {
+ return e.NotFoundError("Missing auth record context.", nil)
+ }
+
+ event := new(core.RecordAuthRefreshRequestEvent)
+ event.RequestEvent = e
+ event.Collection = record.Collection()
+ event.Record = record
+
+ return e.App.OnRecordAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshRequestEvent) error {
+ token := getAuthTokenFromRequest(e.RequestEvent)
+
+ // skip token renewal if the token's payload doesn't explicitly allow it (e.g. impersonate tokens)
+ claims, _ := security.ParseUnverifiedJWT(token) //
+ if v, ok := claims[core.TokenClaimRefreshable]; ok && cast.ToBool(v) {
+ var tokenErr error
+ token, tokenErr = e.Record.NewAuthToken()
+ if tokenErr != nil {
+ return e.InternalServerError("Failed to refresh auth token.", tokenErr)
+ }
+ }
+
+ return recordAuthResponse(e.RequestEvent, e.Record, token, "", nil)
+ })
+}
diff --git a/apis/record_auth_refresh_test.go b/apis/record_auth_refresh_test.go
new file mode 100644
index 0000000000..ffca2efd77
--- /dev/null
+++ b/apis/record_auth_refresh_test.go
@@ -0,0 +1,216 @@
+package apis_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordAuthRefresh(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "unauthorized",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superuser trying to refresh the auth of another auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth record + not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth record + different auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-refresh?expand=rel,missing",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth record + same auth collection as the token",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh?expand=rel,missing",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"emailVisibility":false`,
+ `"email":"test@example.com"`, // the owner can always view their email address
+ `"expand":`,
+ `"rel":`,
+ `"id":"llvuca81nly1qls"`,
+ },
+ NotExpectedContent: []string{
+ `"missing":`,
+ // should return a different token
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRefreshRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 2,
+ },
+ },
+ {
+ Name: "auth record + same auth collection as the token but static/unrefreshable",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ // should return the same token
+ `"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak"`,
+ `"record":`,
+ `"id":"4q1xlclmfloku33"`,
+ `"emailVisibility":false`,
+ `"email":"test@example.com"`, // the owner can always view their email address
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRefreshRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "unverified auth record in onlyVerified collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im8xeTBkZDBzcGQ3ODZtZCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.Zi0yXE-CNmnbTdVaQEzYZVuECqRdn3LgEM6pmB3XWBE",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRefreshRequest": 1,
+ },
+ },
+ {
+ Name: "verified auth record in onlyVerified collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":`,
+ `"record":`,
+ `"id":"gk390qegs4y47wn"`,
+ `"verified":true`,
+ `"email":"test@example.com"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthRefreshRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "OnRecordAuthRefreshRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:authRefresh",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authRefresh"},
+ {MaxRequests: 0, Label: "users:authRefresh"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:authRefresh",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-refresh",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:authRefresh"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go
deleted file mode 100644
index 8cdad322c4..0000000000
--- a/apis/record_auth_test.go
+++ /dev/null
@@ -1,1391 +0,0 @@
-package apis_test
-
-import (
- "context"
- "errors"
- "net/http"
- "strings"
- "testing"
- "time"
-
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/subscriptions"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestRecordAuthMethodsList(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "missing collection",
- Method: http.MethodGet,
- Url: "/api/collections/missing/auth-methods",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "non auth collection",
- Method: http.MethodGet,
- Url: "/api/collections/demo1/auth-methods",
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth collection with all auth methods allowed",
- Method: http.MethodGet,
- Url: "/api/collections/users/auth-methods",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"usernamePassword":true`,
- `"emailPassword":true`,
- `"authProviders":[{`,
- `"name":"gitlab"`,
- `"state":`,
- `"codeVerifier":`,
- `"codeChallenge":`,
- `"codeChallengeMethod":`,
- `"authUrl":`,
- `redirect_uri="`, // ensures that the redirect_uri is the last url param
- },
- },
- {
- Name: "auth collection with only email/password auth allowed",
- Method: http.MethodGet,
- Url: "/api/collections/clients/auth-methods",
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"usernamePassword":false`,
- `"emailPassword":true`,
- `"authProviders":[]`,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthWithPassword(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "invalid body format",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{"identity`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "empty body params",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{"identity":"","password":""}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"identity":{`,
- `"password":{`,
- },
- },
-
- // username
- {
- Name: "invalid username and valid password",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"invalid",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid username and invalid password",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test2_username",
- "password":"invalid"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid username and valid password in restricted collection",
- Method: http.MethodPost,
- Url: "/api/collections/nologin/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test_username",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid username and valid password in allowed collection",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test2_username",
- "password":"1234567890"
- }`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"record":{`,
- `"token":"`,
- `"id":"oap640cot4yru2s"`,
- `"email":"test2@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- "OnRecordAfterAuthWithPasswordRequest": 1,
- "OnRecordAuthRequest": 1,
- },
- },
-
- // email
- {
- Name: "invalid email and valid password",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"missing@example.com",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid email and invalid password",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test@example.com",
- "password":"invalid"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid email and valid password in restricted collection",
- Method: http.MethodPost,
- Url: "/api/collections/nologin/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test@example.com",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- },
- },
- {
- Name: "valid email and valid password in allowed collection",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test@example.com",
- "password":"1234567890"
- }`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"record":{`,
- `"token":"`,
- `"id":"4q1xlclmfloku33"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- "OnRecordAfterAuthWithPasswordRequest": 1,
- "OnRecordAuthRequest": 1,
- },
- },
-
- // with already authenticated record or admin
- {
- Name: "authenticated record",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- Body: strings.NewReader(`{
- "identity":"test@example.com",
- "password":"1234567890"
- }`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"record":{`,
- `"token":"`,
- `"id":"4q1xlclmfloku33"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- "OnRecordAfterAuthWithPasswordRequest": 1,
- "OnRecordAuthRequest": 1,
- },
- },
- {
- Name: "authenticated admin",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- Body: strings.NewReader(`{
- "identity":"test@example.com",
- "password":"1234567890"
- }`),
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"record":{`,
- `"token":"`,
- `"id":"4q1xlclmfloku33"`,
- `"email":"test@example.com"`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- "OnRecordAfterAuthWithPasswordRequest": 1,
- "OnRecordAuthRequest": 1,
- },
- },
-
- // after hooks error checks
- {
- Name: "OnRecordAfterAuthWithPasswordRequest error response",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-with-password",
- Body: strings.NewReader(`{
- "identity":"test2_username",
- "password":"1234567890"
- }`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthWithPasswordRequest": 1,
- "OnRecordAfterAuthWithPasswordRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthRefresh(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-refresh",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record + not an auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/auth-refresh",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record + different auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/clients/auth-refresh?expand=rel,missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record + same auth collection as the token",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-refresh?expand=rel,missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"token":`,
- `"record":`,
- `"id":"4q1xlclmfloku33"`,
- `"emailVisibility":false`,
- `"email":"test@example.com"`, // the owner can always view their email address
- `"expand":`,
- `"rel":`,
- `"id":"llvuca81nly1qls"`,
- },
- NotExpectedContent: []string{
- `"missing":`,
- },
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthRefreshRequest": 1,
- "OnRecordAuthRequest": 1,
- "OnRecordAfterAuthRefreshRequest": 1,
- },
- },
- {
- Name: "OnRecordAfterAuthRefreshRequest error response",
- Method: http.MethodPost,
- Url: "/api/collections/users/auth-refresh?expand=rel,missing",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeAuthRefreshRequest": 1,
- "OnRecordAfterAuthRefreshRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthRequestPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "not an auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/request-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-password-reset",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing auth record",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-password-reset",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "existing auth record",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnRecordBeforeRequestPasswordResetRequest": 1,
- "OnRecordAfterRequestPasswordResetRequest": 1,
- "OnMailerBeforeRecordResetPasswordSend": 1,
- "OnMailerAfterRecordResetPasswordSend": 1,
- },
- },
- {
- Name: "existing auth record (after already sent)",
- Method: http.MethodPost,
- Url: "/api/collections/clients/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent password request sent
- authRecord, err := app.Dao().FindFirstRecordByData("clients", "email", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- authRecord.SetLastResetSentAt(types.NowDateTime())
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(authRecord); err != nil {
- t.Fatal(err)
- }
- },
- },
- {
- Name: "existing auth record in a collection with disabled password login",
- Method: http.MethodPost,
- Url: "/api/collections/nologin/request-password-reset",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthConfirmPasswordReset(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-password-reset",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"password":{"code":"validation_required"`,
- `"passwordConfirm":{"code":"validation_required"`,
- `"token":{"code":"validation_required"`,
- },
- },
- {
- Name: "invalid data format",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-password-reset",
- Body: strings.NewReader(`{"password`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token and invalid password",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
- "password":"1234567",
- "passwordConfirm":"7654321"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{"code":"validation_invalid_token"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
- },
- },
- {
- Name: "non auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/confirm-password-reset?expand=rel,missing",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "different auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/clients/confirm-password-reset?expand=rel,missing",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{"token":{"code":"validation_token_collection_mismatch"`,
- },
- },
- {
- Name: "valid token and data",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`),
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmPasswordResetRequest": 1,
- "OnRecordAfterConfirmPasswordResetRequest": 1,
- },
- },
- {
- Name: "OnRecordAfterConfirmPasswordResetRequest error response",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-password-reset",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmPasswordResetRequest": 1,
- "OnRecordAfterConfirmPasswordResetRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthRequestVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "not an auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/request-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(`{"email`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing auth record",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(`{"email":"missing@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- },
- {
- Name: "already verified auth record",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(`{"email":"test2@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeRequestVerificationRequest": 1,
- "OnRecordAfterRequestVerificationRequest": 1,
- },
- },
- {
- Name: "existing auth record",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnRecordBeforeRequestVerificationRequest": 1,
- "OnRecordAfterRequestVerificationRequest": 1,
- "OnMailerBeforeRecordVerificationSend": 1,
- "OnMailerAfterRecordVerificationSend": 1,
- },
- },
- {
- Name: "existing auth record (after already sent)",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-verification",
- Body: strings.NewReader(`{"email":"test@example.com"}`),
- Delay: 100 * time.Millisecond,
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- // "OnRecordBeforeRequestVerificationRequest": 1,
- // "OnRecordAfterRequestVerificationRequest": 1,
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- // simulate recent verification sent
- authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- authRecord.SetLastVerificationSentAt(types.NowDateTime())
- dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
- if err := dao.Save(authRecord); err != nil {
- t.Fatal(err)
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthConfirmVerification(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{"code":"validation_required"`,
- },
- },
- {
- Name: "invalid data format",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(`{"password`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{"code":"validation_invalid_token"`,
- },
- },
- {
- Name: "non auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/confirm-verification?expand=rel,missing",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "different auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/clients/confirm-verification?expand=rel,missing",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{"token":{"code":"validation_token_collection_mismatch"`,
- },
- },
- {
- Name: "valid token",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
- }`),
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmVerificationRequest": 1,
- "OnRecordAfterConfirmVerificationRequest": 1,
- },
- },
- {
- Name: "valid token (already verified)",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
- }`),
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnRecordBeforeConfirmVerificationRequest": 1,
- "OnRecordAfterConfirmVerificationRequest": 1,
- },
- },
- {
- Name: "valid verification token from a collection without allowed login",
- Method: http.MethodPost,
- Url: "/api/collections/nologin/confirm-verification",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.coREjeTDS3_Go7DP1nxHtevIX5rujwHU-_mRB6oOm3w"
- }`),
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmVerificationRequest": 1,
- "OnRecordAfterConfirmVerificationRequest": 1,
- },
- },
- {
- Name: "OnRecordAfterConfirmVerificationRequest error response",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-verification",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
- }`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmVerificationRequest": 1,
- "OnRecordAfterConfirmVerificationRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthRequestEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "not an auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin authentication",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "record authentication but from different auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/clients/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{"newEmail`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_required"`,
- },
- },
- {
- Name: "valid data (existing email)",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"test2@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"newEmail":{"code":"validation_record_email_exists"`,
- },
- },
- {
- Name: "valid data (new email)",
- Method: http.MethodPost,
- Url: "/api/collections/users/request-email-change",
- Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnMailerBeforeRecordChangeEmailSend": 1,
- "OnMailerAfterRecordChangeEmailSend": 1,
- "OnRecordBeforeRequestEmailChangeRequest": 1,
- "OnRecordAfterRequestEmailChangeRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthConfirmEmailChange(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "not an auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/demo1/confirm-email-change",
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
- },
- {
- Name: "empty data",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(``),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":`,
- `"token":{"code":"validation_required"`,
- `"password":{"code":"validation_required"`,
- },
- },
- {
- Name: "invalid data",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(`{"token`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "expired token and correct password",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjE2NDA5OTE2NjF9.D20jh5Ss7SZyXRUXjjEyLCYo9Ky0N5cE5dKB_MGJ8G8",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{`,
- `"code":"validation_invalid_token"`,
- },
- },
- {
- Name: "valid token and incorrect password",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
- "password":"1234567891"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"password":{`,
- `"code":"validation_invalid_password"`,
- },
- },
- {
- Name: "valid token and correct password",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
- "password":"1234567890"
- }`),
- ExpectedStatus: 204,
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmEmailChangeRequest": 1,
- "OnRecordAfterConfirmEmailChangeRequest": 1,
- },
- },
- {
- Name: "valid token and correct password in different auth collection",
- Method: http.MethodPost,
- Url: "/api/collections/clients/confirm-email-change",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
- "password":"1234567890"
- }`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{`,
- `"token":{"code":"validation_token_collection_mismatch"`,
- },
- },
- {
- Name: "OnRecordAfterConfirmEmailChangeRequest error response",
- Method: http.MethodPost,
- Url: "/api/collections/users/confirm-email-change",
- Body: strings.NewReader(`{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
- "password":"1234567890"
- }`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordBeforeConfirmEmailChangeRequest": 1,
- "OnRecordAfterConfirmEmailChangeRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthListExternalsAuths(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin + nonexisting record id",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/missing/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin + existing record id and no external auths",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/oap640cot4yru2s/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{`[]`},
- ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
- },
- {
- Name: "admin + existing user id and 2 external auths",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"clmflokuq1xl341"`,
- `"id":"dlmflokuq1xl342"`,
- `"recordId":"4q1xlclmfloku33"`,
- `"collectionId":"_pb_users_auth_"`,
- },
- ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
- },
- {
- Name: "auth record + trying to list another user external auths",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record + trying to list another user external auths from different collection",
- Method: http.MethodGet,
- Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record + owner without external auths",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/oap640cot4yru2s/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{`[]`},
- ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
- },
- {
- Name: "authorized as user - owner with 2 external auths",
- Method: http.MethodGet,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 200,
- ExpectedContent: []string{
- `"id":"clmflokuq1xl341"`,
- `"id":"dlmflokuq1xl342"`,
- `"recordId":"4q1xlclmfloku33"`,
- `"collectionId":"_pb_users_auth_"`,
- },
- ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
- scenarios := []tests.ApiScenario{
- {
- Name: "unauthorized",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
- ExpectedStatus: 401,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin - nonexisting recod id",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/missing/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin - nonlinked provider",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/facebook",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "admin - linked provider",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterUnlinkExternalAuthRequest": 1,
- "OnRecordBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
- if err != nil {
- t.Fatal(err)
- }
- auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google")
- if auth != nil {
- t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
- }
- },
- },
- {
- Name: "auth record - trying to unlink another user external auth",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.uatnTBFqMnF0p4FkmwEpA9R-uGFu0Putwyk6NJCKBno",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record - trying to unlink another user external auth from different collection",
- Method: http.MethodDelete,
- Url: "/api/collections/clients/records/o1y0dd0spd786md/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 403,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "auth record - owner with existing external auth",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- },
- ExpectedStatus: 204,
- ExpectedContent: []string{},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterUnlinkExternalAuthRequest": 1,
- "OnRecordBeforeUnlinkExternalAuthRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
- if err != nil {
- t.Fatal(err)
- }
- auth, _ := app.Dao().FindExternalAuthByRecordAndProvider(record, "google")
- if auth != nil {
- t.Fatalf("Expected the google ExternalAuth to be deleted, got got \n%v", auth)
- }
- },
- },
- {
- Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
- Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
- return errors.New("error")
- })
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterUnlinkExternalAuthRequest": 1,
- "OnRecordBeforeUnlinkExternalAuthRequest": 1,
- },
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
-
-func TestRecordAuthOAuth2Redirect(t *testing.T) {
- c1 := subscriptions.NewDefaultClient()
-
- c2 := subscriptions.NewDefaultClient()
- c2.Subscribe("@oauth2")
-
- c3 := subscriptions.NewDefaultClient()
- c3.Subscribe("test1", "@oauth2")
-
- c4 := subscriptions.NewDefaultClient()
- c4.Subscribe("test1", "test2")
-
- c5 := subscriptions.NewDefaultClient()
- c5.Subscribe("@oauth2")
- c5.Discard()
-
- beforeTestFunc := func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.SubscriptionsBroker().Register(c1)
- app.SubscriptionsBroker().Register(c2)
- app.SubscriptionsBroker().Register(c3)
- app.SubscriptionsBroker().Register(c4)
- app.SubscriptionsBroker().Register(c5)
- }
-
- scenarios := []tests.ApiScenario{
- {
- Name: "no state query param",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?code=123",
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "no code query param",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?state=" + c3.Id(),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "missing client",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?code=123&state=missing",
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "discarded client with @oauth2 subscription",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
- BeforeTestFunc: beforeTestFunc,
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "client without @oauth2 subscription",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
- BeforeTestFunc: beforeTestFunc,
- ExpectedStatus: 404,
- ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "client with @oauth2 subscription",
- Method: http.MethodGet,
- Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- beforeTestFunc(t, app, e)
-
- ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
-
- go func() {
- defer cancelFunc()
- L:
- for {
- select {
- case <-c1.Channel():
- t.Error("Unexpected c1 message")
- break L
- case <-c2.Channel():
- t.Error("Unexpected c2 message")
- break L
- case msg := <-c3.Channel():
- if msg.Name != "@oauth2" {
- t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
- }
-
- expectedParams := []string{`"state"`, `"code"`}
- for _, p := range expectedParams {
- if !strings.Contains(string(msg.Data), p) {
- t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
- }
- }
-
- break L
- case <-c4.Channel():
- t.Error("Unexpected c4 message")
- break L
- case <-c5.Channel():
- t.Error("Unexpected c5 message")
- break L
- case <-ctx.Done():
- t.Error("Context timeout reached")
- break L
- }
- }
- }()
- },
- ExpectedStatus: http.StatusTemporaryRedirect,
- },
- }
-
- for _, scenario := range scenarios {
- scenario.Test(t)
- }
-}
diff --git a/apis/record_auth_verification_confirm.go b/apis/record_auth_verification_confirm.go
new file mode 100644
index 0000000000..b64182bd8c
--- /dev/null
+++ b/apis/record_auth_verification_confirm.go
@@ -0,0 +1,109 @@
+package apis
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+func recordConfirmVerification(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if collection.Name == core.CollectionNameSuperusers {
+ return e.BadRequestError("All superusers are verified by default.", nil)
+ }
+
+ form := new(recordConfirmVerificationForm)
+ form.app = e.App
+ form.collection = collection
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ record, err := form.app.FindAuthRecordByToken(form.Token, core.TokenTypeVerification)
+ if err != nil {
+ return e.BadRequestError("Invalid or expired verification token.", err)
+ }
+
+ wasVerified := record.Verified()
+
+ event := new(core.RecordConfirmVerificationRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+
+ return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
+ if !wasVerified {
+ e.Record.SetVerified(true)
+
+ // similar to the OTP auth, we enforce an extra password reset
+ // guard as this way is less prone to pre-hijacking attacks
+ // in case the password auth is eventually enabled later
+ if !e.Record.Collection().PasswordAuth.Enabled {
+ e.Record.SetRandomPassword()
+ }
+
+ if err := e.App.Save(e.Record); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
+ }
+ }
+
+ e.App.Store().Remove(getVerificationResendKey(e.Record))
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+type recordConfirmVerificationForm struct {
+ app core.App
+ collection *core.Collection
+
+ Token string `form:"token" json:"token"`
+}
+
+func (form *recordConfirmVerificationForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
+ )
+}
+
+func (form *recordConfirmVerificationForm) checkToken(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ claims, _ := security.ParseUnverifiedJWT(v)
+ email := cast.ToString(claims["email"])
+ if email == "" {
+ return validation.NewError("validation_invalid_token_claims", "Missing email token claim.")
+ }
+
+ record, err := form.app.FindAuthRecordByToken(v, core.TokenTypeVerification)
+ if err != nil || record == nil {
+ return validation.NewError("validation_invalid_token", "Invalid or expired token.")
+ }
+
+ if record.Collection().Id != form.collection.Id {
+ return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
+ }
+
+ if record.Email() != email {
+ return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.")
+ }
+
+ return nil
+}
diff --git a/apis/record_auth_verification_confirm_test.go b/apis/record_auth_verification_confirm_test.go
new file mode 100644
index 0000000000..e24ee8f245
--- /dev/null
+++ b/apis/record_auth_verification_confirm_test.go
@@ -0,0 +1,340 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordConfirmVerification(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data format",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{"password`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "expired token",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.qqelNNL2Udl6K_TJ282sNHYCpASgA6SIuSVKGfBHMZU"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-verification token",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InBhc3N3b3JkUmVzZXQiLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.xR-xq1oHDy0D8Q4NDOAEyYKGHWd_swzoiSoL8FLFBHY"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"token":{"code":"validation_invalid_token"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
+ }`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "different auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/confirm-verification?expand=rel,missing",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{"token":{"code":"validation_token_collection_mismatch"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid token",
+ 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)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected the user to be unverified before the confirmation")
+ }
+
+ // ensure that there is at least one pre-existing OAuth2 link
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) == 0 {
+ t.Fatal("Expected at least one external auths")
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.Verified() {
+ t.Fatalf("Expected the user to be verified after the confirmation")
+ }
+
+ // ensure that all pre-existing OAuth2 links are cleared
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) > 0 {
+ t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid token (disabled password auth)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmVerificationRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelValidate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // unverified->verified external auths removal
+ "OnModelDelete": 2,
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user.Collection().PasswordAuth.Enabled = false
+ if err = app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected the user to be unverified before the confirmation")
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatal("Expected password to be valid")
+ }
+
+ // ensure that there is at least one pre-existing OAuth2 link
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) == 0 {
+ t.Fatal("Expected at least one external auths")
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.Verified() {
+ t.Fatalf("Expected the user to be verified after the confirmation")
+ }
+
+ if user.ValidatePassword("1234567890") {
+ t.Fatal("Expected the user password to be reset")
+ }
+
+ // ensure that all pre-existing OAuth2 links are cleared
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) > 0 {
+ t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid token (already verified)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20ifQ.QQmM3odNFVk6u4J4-5H8IBM3dfk9YCD7mPW-8PhBAI8"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmVerificationRequest": 1,
+ },
+ },
+ {
+ Name: "valid verification token from a collection without allowed login",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.5GmuZr4vmwk3Cb_3ZZWNxwbE75KZC-j71xxIPR9AsVw"
+ }`),
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordConfirmVerificationRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelValidate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ },
+ },
+ {
+ Name: "OnRecordConfirmVerificationRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - nologin:confirmVerification",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.5GmuZr4vmwk3Cb_3ZZWNxwbE75KZC-j71xxIPR9AsVw"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:confirmVerification"},
+ {MaxRequests: 0, Label: "nologin:confirmVerification"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:confirmVerification",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/confirm-verification",
+ Body: strings.NewReader(`{
+ "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.5GmuZr4vmwk3Cb_3ZZWNxwbE75KZC-j71xxIPR9AsVw"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:confirmVerification"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_verification_request.go b/apis/record_auth_verification_request.go
new file mode 100644
index 0000000000..3755a8f5a7
--- /dev/null
+++ b/apis/record_auth_verification_request.go
@@ -0,0 +1,91 @@
+package apis
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/tools/routine"
+)
+
+func recordRequestVerification(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if collection.Name == core.CollectionNameSuperusers {
+ return e.BadRequestError("All superusers are verified by default.", nil)
+ }
+
+ form := new(recordRequestVerificationForm)
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
+ if err != nil {
+ // eagerly write 204 response as a very basic measure against emails enumeration
+ e.NoContent(http.StatusNoContent)
+ return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
+ }
+
+ resendKey := getVerificationResendKey(record)
+ if !record.Verified() && e.App.Store().Has(resendKey) {
+ // eagerly write 204 response as a very basic measure against emails enumeration
+ e.NoContent(http.StatusNoContent)
+ return errors.New("try again later - you've already requested a verification email")
+ }
+
+ event := new(core.RecordRequestVerificationRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+
+ return e.App.OnRecordRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationRequestEvent) error {
+ if e.Record.Verified() {
+ return e.NoContent(http.StatusNoContent)
+ }
+
+ // run in background because we don't need to show the result to the client
+ app := e.App
+ routine.FireAndForget(func() {
+ if err := mails.SendRecordVerification(app, e.Record); err != nil {
+ app.Logger().Error("Failed to send verification email", "error", err)
+ }
+
+ app.Store().Set(resendKey, struct{}{})
+ time.AfterFunc(2*time.Minute, func() {
+ app.Store().Remove(resendKey)
+ })
+ })
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
+ })
+ })
+}
+
+// -------------------------------------------------------------------
+
+type recordRequestVerificationForm struct {
+ Email string `form:"email" json:"email"`
+}
+
+func (form *recordRequestVerificationForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
+ )
+}
+
+func getVerificationResendKey(record *core.Record) string {
+ return "@limitVerificationEmail_" + record.Collection().Id + record.Id
+}
diff --git a/apis/record_auth_verification_request_test.go b/apis/record_auth_verification_request_test.go
new file mode 100644
index 0000000000..edbc7ca720
--- /dev/null
+++ b/apis/record_auth_verification_request_test.go
@@ -0,0 +1,186 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordRequestVerification(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/request-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "missing auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"missing@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "already verified auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test2@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestVerificationRequest": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "existing auth record",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordRequestVerificationRequest": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordVerificationSend": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "/auth/confirm-verification") {
+ t.Fatalf("Expected verification email, got\n%v", app.TestMailer.LastMessage().HTML)
+ }
+ },
+ },
+ {
+ Name: "existing auth record (after already sent)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ Delay: 100 * time.Millisecond,
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ // terminated before firing the event
+ // "OnRecordRequestVerificationRequest": 1,
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // simulate recent verification sent
+ authRecord, err := app.FindFirstRecordByData("users", "email", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resendKey := "@limitVerificationEmail_" + authRecord.Collection().Id + authRecord.Id
+ app.Store().Set(resendKey, struct{}{})
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
+ }
+ },
+ },
+ {
+ Name: "OnRecordRequestVerificationRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:requestVerification",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:requestVerification"},
+ {MaxRequests: 0, Label: "users:requestVerification"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:requestVerification",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/request-verification",
+ Body: strings.NewReader(`{"email":"test@example.com"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:requestVerification"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_with_oauth2.go b/apis/record_auth_with_oauth2.go
new file mode 100644
index 0000000000..814e873711
--- /dev/null
+++ b/apis/record_auth_with_oauth2.go
@@ -0,0 +1,510 @@
+package apis
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "maps"
+ "net"
+ "net/http"
+ "path"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "golang.org/x/oauth2"
+)
+
+func recordAuthWithOAuth2(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if !collection.OAuth2.Enabled {
+ return e.ForbiddenError("The collection is not configured to allow OAuth2 authentication.", nil)
+ }
+
+ var fallbackAuthRecord *core.Record
+ if e.Auth != nil && e.Auth.Collection().Id == collection.Id {
+ fallbackAuthRecord = e.Auth
+ }
+
+ e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
+
+ form := new(recordOAuth2LoginForm)
+ form.collection = collection
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+
+ if form.RedirectUrl != "" && form.RedirectURL == "" {
+ e.App.Logger().Warn("[recordAuthWithOAuth2] redirectUrl body param is deprecated and will be removed in the future. Please replace it with redirectURL.")
+ form.RedirectURL = form.RedirectUrl
+ }
+
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+
+ // exchange token for OAuth2 user info and locate existing ExternalAuth rel
+ // ---------------------------------------------------------------
+
+ // load provider configuration
+ providerConfig, ok := collection.OAuth2.GetProviderConfig(form.Provider)
+ if !ok {
+ return e.InternalServerError("Missing or invalid provider config.", nil)
+ }
+
+ provider, err := providerConfig.InitProvider()
+ if err != nil {
+ return firstApiError(err, e.InternalServerError("Failed to init provider "+form.Provider, err))
+ }
+
+ ctx, cancel := context.WithTimeout(e.Request.Context(), 30*time.Second)
+ defer cancel()
+
+ provider.SetContext(ctx)
+ provider.SetRedirectURL(form.RedirectURL)
+
+ var opts []oauth2.AuthCodeOption
+
+ if provider.PKCE() {
+ opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
+ }
+
+ // fetch token
+ token, err := provider.FetchToken(form.Code, opts...)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 token.", err))
+ }
+
+ // fetch external auth user
+ authUser, err := provider.FetchAuthUser(token)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
+ }
+
+ // Apple currently returns the user's name only as part of the first redirect data response
+ // so we try to assign the [apis.oauth2SubscriptionRedirect] forwarded name.
+ if form.Provider == auth.NameApple && authUser.Name == "" {
+ nameKey := oauth2RedirectAppleNameStoreKeyPrefix + form.Code
+ name, ok := e.App.Store().Get(nameKey).(string)
+ if ok {
+ e.App.Store().Remove(nameKey)
+ authUser.Name = name
+ } else {
+ e.App.Logger().Debug("Missing or already removed Apple user's name")
+ }
+ }
+
+ var authRecord *core.Record
+
+ // check for existing relation with the auth collection
+ externalAuthRel, err := e.App.FindFirstExternalAuthByExpr(dbx.HashExp{
+ "collectionRef": form.collection.Id,
+ "provider": form.Provider,
+ "providerId": authUser.Id,
+ })
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return e.InternalServerError("Failed OAuth2 relation check.", err)
+ }
+
+ switch {
+ case err == nil && externalAuthRel != nil:
+ authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
+ if err != nil {
+ return err
+ }
+ case fallbackAuthRecord != nil && fallbackAuthRecord.Collection().Id == form.collection.Id:
+ // fallback to the logged auth record (if any)
+ authRecord = fallbackAuthRecord
+ case authUser.Email != "":
+ // look for an existing auth record by the external auth record's email
+ authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return e.InternalServerError("Failed OAuth2 auth record check.", err)
+ }
+ }
+
+ // ---------------------------------------------------------------
+
+ event := new(core.RecordAuthWithOAuth2RequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.ProviderName = form.Provider
+ event.ProviderClient = provider
+ event.OAuth2User = authUser
+ event.CreateData = form.CreateData
+ event.Record = authRecord
+ event.IsNewRecord = authRecord == nil
+
+ return e.App.OnRecordAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2RequestEvent) error {
+ if err := oauth2Submit(e, externalAuthRel); err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
+ }
+
+ // @todo revert back to struct after removing the custom auth.AuthUser marshalization
+ meta := map[string]any{}
+ rawOAuth2User, err := json.Marshal(e.OAuth2User)
+ if err != nil {
+ return err
+ }
+ err = json.Unmarshal(rawOAuth2User, &meta)
+ if err != nil {
+ return err
+ }
+ meta["isNew"] = e.IsNewRecord
+
+ return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
+ })
+}
+
+// -------------------------------------------------------------------
+
+type recordOAuth2LoginForm struct {
+ collection *core.Collection
+
+ // Additional data that will be used for creating a new auth record
+ // if an existing OAuth2 account doesn't exist.
+ CreateData map[string]any `form:"createData" json:"createData"`
+
+ // The name of the OAuth2 client provider (eg. "google")
+ Provider string `form:"provider" json:"provider"`
+
+ // The authorization code returned from the initial request.
+ Code string `form:"code" json:"code"`
+
+ // The optional PKCE code verifier as part of the code_challenge sent with the initial request.
+ CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
+
+ // The redirect url sent with the initial request.
+ RedirectURL string `form:"redirectURL" json:"redirectURL"`
+
+ // @todo
+ // deprecated: use RedirectURL instead
+ // RedirectUrl will be removed after dropping v0.22 support
+ RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
+}
+
+func (form *recordOAuth2LoginForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
+ validation.Field(&form.Code, validation.Required),
+ )
+}
+
+func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
+ name, _ := value.(string)
+
+ _, ok := form.collection.OAuth2.GetProviderConfig(name)
+ if !ok {
+ return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
+ SetParams(map[string]any{"name": name})
+ }
+
+ return nil
+}
+
+// @todo evaluate if it is still worth keeping as this exists only for backward-compatibility with pre v0.23 versions
+func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
+ field := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username)
+ if field == nil {
+ return false
+ }
+
+ // ensure that the value matches the pattern of the username field (if text)
+ if txtField, ok := field.(*core.TextField); ok && txtField.ValidatePlainValue(username) != nil {
+ return false
+ }
+
+ // ensure that username is unique
+ index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field.GetName())
+ if hasUniqueue {
+ // it is not required because collection fields are already sanitized
+ // but normalize as an extra precaution in case of a custom validator
+ colName := inflector.Columnify(field.GetName())
+
+ var expr dbx.Expression
+ if strings.EqualFold(index.Columns[0].Collate, "nocase") {
+ // case-insensitive search
+ expr = dbx.NewExp("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
+ } else {
+ expr = dbx.HashExp{colName: username}
+ }
+
+ var exists int
+ _ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
+ if exists > 0 {
+ return false
+ }
+ }
+
+ return true
+}
+
+func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ if e.Record == nil {
+ // extra check to prevent creating a superuser record via
+ // OAuth2 in case the method is used by another action
+ if e.Collection.Name == core.CollectionNameSuperusers {
+ return errors.New("superusers are not allowed to sign-up with OAuth2")
+ }
+
+ payload := maps.Clone(e.CreateData)
+ if payload == nil {
+ payload = map[string]any{}
+ }
+
+ // assign the OAuth2 user email only if the user hasn't submitted one
+ // (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
+ if v, _ := payload[core.FieldNameEmail].(string); v == "" {
+ payload[core.FieldNameEmail] = e.OAuth2User.Email
+ }
+
+ // map known fields (unless the field was explicitly submitted as part of CreateData)
+ if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
+ payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
+ }
+ if _, ok := payload[e.Collection.OAuth2.MappedFields.Name]; !ok && e.Collection.OAuth2.MappedFields.Name != "" {
+ payload[e.Collection.OAuth2.MappedFields.Name] = e.OAuth2User.Name
+ }
+ if _, ok := payload[e.Collection.OAuth2.MappedFields.Username]; !ok &&
+ // no explicit username payload value and existing OAuth2 mapping
+ e.Collection.OAuth2.MappedFields.Username != "" &&
+ // extra checks for backward compatibility with earlier versions
+ oldCanAssignUsername(txApp, e.Collection, e.OAuth2User.Username) {
+ payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
+ }
+ if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
+ // no explicit avatar payload value and existing OAuth2 mapping
+ e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
+ // non-empty OAuth2 avatar url
+ e.OAuth2User.AvatarURL != "" {
+ mappedField := e.Collection.Fields.GetByName(e.Collection.OAuth2.MappedFields.AvatarURL)
+ if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
+ // download the avatar if the mapped field is a file
+ avatarFile, err := func() (*filesystem.File, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ // the extra checks are not required because the OAuth2 APIs are trusted vendor
+ // but are here to minimize the impact in case the provider is vulnerable
+ return safeFileFromURL(ctx, e.OAuth2User.AvatarURL)
+ }()
+ if err != nil {
+ txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
+ } else {
+ payload[e.Collection.OAuth2.MappedFields.AvatarURL] = avatarFile
+ }
+ } else {
+ // otherwise - assign the url string
+ payload[e.Collection.OAuth2.MappedFields.AvatarURL] = e.OAuth2User.AvatarURL
+ }
+ }
+
+ createdRecord, err := sendOAuth2RecordCreateRequest(txApp, e, payload)
+ if err != nil {
+ return err
+ }
+
+ e.Record = createdRecord
+
+ if e.Record.Email() == e.OAuth2User.Email && !e.Record.Verified() {
+ // mark as verified as long as it matches the OAuth2 data (even if the email is empty)
+ e.Record.SetVerified(true)
+ if err := txApp.Save(e.Record); err != nil {
+ return err
+ }
+ }
+ } else {
+ var needUpdate bool
+
+ isLoggedAuthRecord := e.Auth != nil &&
+ e.Auth.Id == e.Record.Id &&
+ e.Auth.Collection().Id == e.Record.Collection().Id
+
+ // prevent pre-hijacking with password auth
+ //
+ // reset the unverified user password in case the record was precreated by a malicious actor
+ if !isLoggedAuthRecord && !e.Record.Verified() {
+ needUpdate = true
+ e.Record.SetRandomPassword()
+ }
+
+ // prevent pre-hijacking with different OAuth2 provider
+ //
+ // delete all other previous OAuth2 record links for the cases
+ // when the user was precreated by malicious OAuth2 auth with custom payload data
+ //
+ // while this would be also done automatically on unverified -> verified upgrade,
+ // doing it manually here ensures that a single unverified record could have
+ // max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
+ if !e.Record.Verified() {
+ err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
+ if err != nil {
+ return err
+ }
+ optExternalAuth = nil // clear to allow recreate below
+ }
+
+ // update the existing auth record empty email if the data.OAuth2User has one
+ // (this is in case previously the auth record was created
+ // with an OAuth2 provider that didn't return an email address)
+ if e.Record.Email() == "" && e.OAuth2User.Email != "" {
+ needUpdate = true
+ e.Record.SetEmail(e.OAuth2User.Email)
+ }
+
+ // update the existing auth record verified state
+ // (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
+ if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
+ needUpdate = true
+ e.Record.SetVerified(true)
+ }
+
+ if needUpdate {
+ if err := txApp.Save(e.Record); err != nil {
+ return err
+ }
+ }
+ }
+
+ // create ExternalAuth relation if missing
+ if optExternalAuth == nil {
+ optExternalAuth = core.NewExternalAuth(txApp)
+ optExternalAuth.SetCollectionRef(e.Record.Collection().Id)
+ optExternalAuth.SetRecordRef(e.Record.Id)
+ optExternalAuth.SetProvider(e.ProviderName)
+ optExternalAuth.SetProviderId(e.OAuth2User.Id)
+
+ if err := txApp.Save(optExternalAuth); err != nil {
+ return fmt.Errorf("failed to save linked rel: %w", err)
+ }
+ }
+
+ return nil
+ })
+}
+
+func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2RequestEvent, payload map[string]any) (*core.Record, error) {
+ ir := &core.InternalRequest{
+ Method: http.MethodPost,
+ URL: "/api/collections/" + e.Collection.Name + "/records",
+ Body: payload,
+ }
+
+ var createdRecord *core.Record
+ response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, func(data any) error {
+ createdRecord, _ = data.(*core.Record)
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if response.Status != http.StatusOK || createdRecord == nil {
+ return nil, errors.New("failed to create OAuth2 auth record")
+ }
+
+ return createdRecord, nil
+}
+
+// -------------------------------------------------------------------
+
+// safeHTTPClient initializes a custom http.Client with extra host checks
+// to prevent internal network probing requests
+// (aka. disallow loopback, private, multicast, etc. requests).
+//
+// NB! The host checks are not perfect and there are probably edge cases that are not covered,
+// so if you plan using with untrusted user URL, consider performing additional whitelist checks.
+//
+// @todo Evaluate with the refactoring if worth exporting(+tests) and moving under the security package.
+func safeHTTPClient() *http.Client {
+ dialer := &net.Dialer{
+ // the same options as in http.DefaultTransport.DialContext
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+
+ // check the address right after estrablishing the connection to prevent dns rebinding
+ Control: func(network, address string, c syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return err
+ }
+
+ ip := net.ParseIP(host)
+
+ if ip == nil ||
+ ip.IsLoopback() ||
+ ip.IsUnspecified() ||
+ ip.IsPrivate() ||
+ ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() ||
+ ip.IsMulticast() {
+ return fmt.Errorf("address %q is invalid or resolve to disallowed IP", address)
+ }
+
+ return nil
+ },
+ }
+
+ return &http.Client{
+ Timeout: 180 * time.Second, // can be still cancelled with the request context
+ Transport: &http.Transport{
+ DialContext: dialer.DialContext,
+ // the same options as in http.DefaultTransport
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 100,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ },
+ }
+}
+
+// safeFileFromURL downloads the file from the specified url (using safeHTTPClient)
+// and creates a new filesystem.File value from its content (limited to DefaultMaxBodySize).
+//
+// @todo Evaluate with the refactoring if worth exporting/replacing filesystem.NewFileFromURL (or redefine as NewUnsafeFileFromURL).
+func safeFileFromURL(ctx context.Context, url string) (*filesystem.File, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ client := safeHTTPClient()
+
+ res, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode < 200 || res.StatusCode > 399 {
+ return nil, fmt.Errorf("failed to download url %s (%d)", url, res.StatusCode)
+ }
+
+ body := io.LimitReader(res.Body, DefaultMaxBodySize)
+
+ var buf bytes.Buffer
+ if _, err = io.Copy(&buf, body); err != nil {
+ return nil, err
+ }
+
+ return filesystem.NewFileFromBytes(buf.Bytes(), path.Base(url))
+}
diff --git a/apis/record_auth_with_oauth2_redirect.go b/apis/record_auth_with_oauth2_redirect.go
new file mode 100644
index 0000000000..2c2e9964f5
--- /dev/null
+++ b/apis/record_auth_with_oauth2_redirect.go
@@ -0,0 +1,180 @@
+package apis
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/subscriptions"
+ "github.com/pocketbase/pocketbase/ui"
+)
+
+const (
+ oauth2SubscriptionTopic string = "@oauth2"
+ oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
+ oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
+ oauth2RedirectAppleNameStoreKeyPrefix string = "@redirect_name_"
+)
+
+type oauth2RedirectData struct {
+ State string `form:"state" json:"state"`
+ Code string `form:"code" json:"code"`
+ Error string `form:"error" json:"error,omitempty"`
+
+ // returned by Apple only
+ AppleUser string `form:"user" json:"-"`
+}
+
+func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
+ data := oauth2RedirectData{}
+
+ if e.Request.Method == http.MethodPost {
+ if err := e.BindBody(&data); err != nil {
+ e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
+ return failureRedirect(e)
+ }
+ } else {
+ query := e.Request.URL.Query()
+ data.State = query.Get("state")
+ data.Code = query.Get("code")
+ data.Error = query.Get("error")
+ }
+
+ if data.State == "" {
+ e.App.Logger().Debug("Missing OAuth2 state parameter")
+ return failureRedirect(e)
+ }
+
+ client, err := e.App.SubscriptionsBroker().ClientById(data.State)
+ if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
+ e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
+ return failureRedirect(e)
+ }
+ defer client.Unsubscribe(oauth2SubscriptionTopic)
+
+ // additional check to minimize the risk of XSRF attack vectors
+ //
+ // note: custom registered clients (aka. those without IP in the store)
+ // are excluded from the check for backward compatibility
+ clientIP, _ := client.Get(RealtimeClientIPKey).(string)
+ if clientIP != "" && clientIP != e.RealIP() {
+ e.App.Logger().Debug("The client IP that completed the authentication is different from the one that initialized the OAuth2 realtime connection")
+ return failureRedirect(e)
+ }
+
+ // temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
+ // (see https://github.com/pocketbase/pocketbase/issues/7090)
+ if data.AppleUser != "" && data.Error == "" && data.Code != "" {
+ nameErr := parseAndStoreAppleRedirectName(
+ e.App,
+ oauth2RedirectAppleNameStoreKeyPrefix+data.Code,
+ data.AppleUser,
+ )
+ if nameErr != nil {
+ // non-critical error
+ e.App.Logger().Debug("Failed to parse and load Apple Redirect name data", "error", nameErr)
+ }
+ }
+
+ encodedData, err := json.Marshal(data)
+ if err != nil {
+ e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
+ return failureRedirect(e)
+ }
+
+ msg := subscriptions.Message{
+ Name: oauth2SubscriptionTopic,
+ Data: encodedData,
+ }
+
+ client.Send(msg)
+
+ if data.Error != "" || data.Code == "" {
+ e.App.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
+ return failureRedirect(e)
+ }
+
+ return successRedirect(e)
+}
+
+func redirectStatusCode(e *core.RequestEvent) int {
+ if e.Request.Method != http.MethodGet {
+ return http.StatusSeeOther
+ }
+
+ return http.StatusTemporaryRedirect
+}
+
+func failureRedirect(e *core.RequestEvent) error {
+ // fallback if UI is not bundled
+ if ui.DistDirFS == nil {
+ return e.String(http.StatusOK, "Failed to authenticate. You can close this window and go back to the app to try again.")
+ }
+
+ return e.Redirect(redirectStatusCode(e), oauth2RedirectFailurePath)
+}
+
+func successRedirect(e *core.RequestEvent) error {
+ // fallback if UI is not bundled
+ if ui.DistDirFS == nil {
+ return e.HTML(http.StatusOK, "Auth completed. You can close this window and go back to the app.")
+ }
+
+ return e.Redirect(redirectStatusCode(e), oauth2RedirectSuccessPath)
+}
+
+// parseAndStoreAppleRedirectName extracts the first and last name
+// from serializedNameData and temporary store them in the app.Store.
+//
+// This is hacky workaround to forward safely and seamlessly the Apple
+// redirect user's name back to the OAuth2 auth handler.
+//
+// Note that currently Apple is the only provider that behaves like this and
+// for now it is unnecessary to check whether the redirect is coming from Apple or not.
+//
+// Ideally this shouldn't be needed and will be removed in the future
+// once Apple adds a dedicated userinfo endpoint.
+func parseAndStoreAppleRedirectName(app core.App, nameKey string, serializedNameData string) error {
+ if serializedNameData == "" {
+ return nil
+ }
+
+ // just in case to prevent storing large strings in memory
+ if len(nameKey) > 1000 {
+ return errors.New("nameKey is too large")
+ }
+
+ // https://developer.apple.com/documentation/signinwithapple/incorporating-sign-in-with-apple-into-other-platforms#Handle-the-response
+ extracted := struct {
+ Name struct {
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
+ } `json:"name"`
+ }{}
+ if err := json.Unmarshal([]byte(serializedNameData), &extracted); err != nil {
+ return err
+ }
+
+ fullName := extracted.Name.FirstName + " " + extracted.Name.LastName
+
+ // truncate just in case to prevent storing large strings in memory
+ if len(fullName) > 150 {
+ fullName = fullName[:150]
+ }
+
+ fullName = strings.TrimSpace(fullName)
+ if fullName == "" {
+ return nil
+ }
+
+ // store (and remove)
+ app.Store().Set(nameKey, fullName)
+ time.AfterFunc(1*time.Minute, func() {
+ app.Store().Remove(nameKey)
+ })
+
+ return nil
+}
diff --git a/apis/record_auth_with_oauth2_redirect_test.go b/apis/record_auth_with_oauth2_redirect_test.go
new file mode 100644
index 0000000000..9063253387
--- /dev/null
+++ b/apis/record_auth_with_oauth2_redirect_test.go
@@ -0,0 +1,366 @@
+package apis_test
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/subscriptions"
+)
+
+func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
+ t.Parallel()
+
+ clientStubs := make([]map[string]subscriptions.Client, 0, 11)
+
+ for i := 0; i < 11; i++ {
+ c1 := subscriptions.NewDefaultClient()
+
+ c2 := subscriptions.NewDefaultClient()
+ c2.Subscribe("@oauth2")
+
+ c3 := subscriptions.NewDefaultClient()
+ c3.Subscribe("test1", "@oauth2")
+
+ c4 := subscriptions.NewDefaultClient()
+ c4.Subscribe("test1", "test2")
+
+ c5 := subscriptions.NewDefaultClient()
+ c5.Subscribe("@oauth2")
+ c5.Discard()
+
+ clientStubs = append(clientStubs, map[string]subscriptions.Client{
+ "c1": c1,
+ "c2": c2,
+ "c3": c3,
+ "c4": c4,
+ "c5": c5,
+ })
+ }
+
+ checkFailureRedirect := func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ loc := res.Header.Get("Location")
+ if !strings.Contains(loc, "/oauth2-redirect-failure") {
+ t.Fatalf("Expected failure redirect, got %q", loc)
+ }
+ }
+
+ checkSuccessRedirect := func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ loc := res.Header.Get("Location")
+ if !strings.Contains(loc, "/oauth2-redirect-success") {
+ t.Fatalf("Expected success redirect, got %q", loc)
+ }
+ }
+
+ // note: don't exit because it is usually called as part of a separate goroutine
+ checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
+ if len(expectedMessages[clientId]) == 0 {
+ t.Errorf("Unexpected client %q message, got %q:\n%q", clientId, msg.Name, msg.Data)
+ return
+ }
+
+ if msg.Name != "@oauth2" {
+ t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
+ return
+ }
+
+ for _, txt := range expectedMessages[clientId] {
+ if !strings.Contains(string(msg.Data), txt) {
+ t.Errorf("Failed to find %q in \n%s", txt, msg.Data)
+ return
+ }
+ }
+ }
+
+ beforeTestFunc := func(
+ clients map[string]subscriptions.Client,
+ expectedMessages map[string][]string,
+ ) func(testing.TB, *tests.TestApp, *core.ServeEvent) {
+ return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ for _, client := range clients {
+ app.SubscriptionsBroker().Register(client)
+ }
+
+ ctx, cancelFunc := context.WithTimeout(context.Background(), 100*time.Millisecond)
+
+ // add to the app store so that it can be cancelled manually after test completion
+ app.Store().Set("cancelFunc", cancelFunc)
+
+ go func() {
+ defer cancelFunc()
+
+ for {
+ select {
+ case msg, ok := <-clients["c1"].Channel():
+ if ok {
+ checkClientMessages(t, "c1", msg, expectedMessages)
+ } else {
+ t.Errorf("Unexpected c1 closed channel")
+ }
+ case msg, ok := <-clients["c2"].Channel():
+ if ok {
+ checkClientMessages(t, "c2", msg, expectedMessages)
+ } else {
+ t.Errorf("Unexpected c2 closed channel")
+ }
+ case msg, ok := <-clients["c3"].Channel():
+ if ok {
+ checkClientMessages(t, "c3", msg, expectedMessages)
+ } else {
+ t.Errorf("Unexpected c3 closed channel")
+ }
+ case msg, ok := <-clients["c4"].Channel():
+ if ok {
+ checkClientMessages(t, "c4", msg, expectedMessages)
+ } else {
+ t.Errorf("Unexpected c4 closed channel")
+ }
+ case _, ok := <-clients["c5"].Channel():
+ if ok {
+ t.Errorf("Expected c5 channel to be closed")
+ }
+ case <-ctx.Done():
+ for _, c := range clients {
+ c.Discard()
+ }
+ return
+ }
+ }
+ }()
+ }
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "no state query param",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?code=123",
+ BeforeTestFunc: beforeTestFunc(clientStubs[0], nil),
+ 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)
+ },
+ },
+ {
+ Name: "invalid or missing client",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?code=123&state=missing",
+ BeforeTestFunc: beforeTestFunc(clientStubs[1], nil),
+ 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)
+ },
+ },
+ {
+ Name: "no code query param",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?state=" + clientStubs[2]["c3"].Id(),
+ BeforeTestFunc: beforeTestFunc(clientStubs[2], map[string][]string{
+ "c3": {`"state":"` + clientStubs[2]["c3"].Id(), `"code":""`},
+ }),
+ 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)
+
+ if clientStubs[2]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+ },
+ },
+ {
+ Name: "error query param",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?error=example&code=123&state=" + clientStubs[3]["c3"].Id(),
+ BeforeTestFunc: beforeTestFunc(clientStubs[3], map[string][]string{
+ "c3": {`"state":"` + clientStubs[3]["c3"].Id(), `"code":"123"`, `"error":"example"`},
+ }),
+ 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)
+
+ if clientStubs[3]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+ },
+ },
+ {
+ Name: "discarded client with @oauth2 subscription",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c5"].Id(),
+ BeforeTestFunc: beforeTestFunc(clientStubs[4], nil),
+ 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)
+ },
+ },
+ {
+ Name: "client without @oauth2 subscription",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c4"].Id(),
+ BeforeTestFunc: beforeTestFunc(clientStubs[5], nil),
+ ExpectedStatus: http.StatusTemporaryRedirect,
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ app.Store().Get("cancelFunc").(context.CancelFunc)()
+
+ checkFailureRedirect(t, app, res)
+ },
+ },
+ {
+ Name: "client with @oauth2 subscription",
+ Method: http.MethodGet,
+ URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[6]["c3"].Id(),
+ BeforeTestFunc: beforeTestFunc(clientStubs[6], map[string][]string{
+ "c3": {`"state":"` + clientStubs[6]["c3"].Id(), `"code":"123"`},
+ }),
+ 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)()
+
+ checkSuccessRedirect(t, app, res)
+
+ if clientStubs[6]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+ },
+ },
+ {
+ Name: "(POST) client with @oauth2 subscription",
+ Method: http.MethodPost,
+ URL: "/api/oauth2-redirect",
+ Body: strings.NewReader("code=123&state=" + clientStubs[7]["c3"].Id()),
+ Headers: map[string]string{
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ BeforeTestFunc: beforeTestFunc(clientStubs[7], map[string][]string{
+ "c3": {`"state":"` + clientStubs[7]["c3"].Id(), `"code":"123"`},
+ }),
+ ExpectedStatus: http.StatusSeeOther,
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ app.Store().Get("cancelFunc").(context.CancelFunc)()
+
+ checkSuccessRedirect(t, app, res)
+
+ if clientStubs[7]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+ },
+ },
+ {
+ Name: "(POST) Apple user's name json (nameKey error)",
+ Method: http.MethodPost,
+ URL: "/api/oauth2-redirect",
+ Body: strings.NewReader(url.Values{
+ "code": []string{strings.Repeat("a", 986)},
+ "state": []string{clientStubs[8]["c3"].Id()},
+ "user": []string{
+ `{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
+ },
+ }.Encode()),
+ Headers: map[string]string{
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ BeforeTestFunc: beforeTestFunc(clientStubs[8], map[string][]string{
+ "c3": {`"state":"` + clientStubs[8]["c3"].Id(), `"code":"` + strings.Repeat("a", 986) + `"`},
+ }),
+ ExpectedStatus: http.StatusSeeOther,
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ app.Store().Get("cancelFunc").(context.CancelFunc)()
+
+ checkSuccessRedirect(t, app, res)
+
+ if clientStubs[8]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+
+ if storedName := app.Store().Get("@redirect_name_" + strings.Repeat("a", 986)); storedName != nil {
+ t.Fatalf("Didn't expect stored name, got %q", storedName)
+ }
+ },
+ },
+ {
+ Name: "(POST) Apple user's name json",
+ Method: http.MethodPost,
+ URL: "/api/oauth2-redirect",
+ Body: strings.NewReader(url.Values{
+ "code": []string{strings.Repeat("a", 985)},
+ "state": []string{clientStubs[9]["c3"].Id()},
+ "user": []string{
+ `{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
+ },
+ }.Encode()),
+ Headers: map[string]string{
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ BeforeTestFunc: beforeTestFunc(clientStubs[9], map[string][]string{
+ "c3": {`"state":"` + clientStubs[9]["c3"].Id(), `"code":"` + strings.Repeat("a", 985) + `"`},
+ }),
+ ExpectedStatus: http.StatusSeeOther,
+ ExpectedEvents: map[string]int{"*": 0},
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ app.Store().Get("cancelFunc").(context.CancelFunc)()
+
+ checkSuccessRedirect(t, app, res)
+
+ if clientStubs[9]["c3"].HasSubscription("@oauth2") {
+ t.Fatalf("Expected oauth2 subscription to be removed")
+ }
+
+ storedName, _ := app.Store().Get("@redirect_name_" + strings.Repeat("a", 985)).(string)
+ expectedName := "aaa " + strings.Repeat("b", 146)
+ if storedName != expectedName {
+ t.Fatalf("Expected stored name\n%q\ngot\n%q", expectedName, storedName)
+ }
+ },
+ },
+ {
+ 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 {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_with_oauth2_test.go b/apis/record_auth_with_oauth2_test.go
new file mode 100644
index 0000000000..f6b1fd3a95
--- /dev/null
+++ b/apis/record_auth_with_oauth2_test.go
@@ -0,0 +1,2128 @@
+package apis_test
+
+import (
+ "bytes"
+ "errors"
+ "image"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "golang.org/x/oauth2"
+)
+
+var _ auth.Provider = (*oauth2MockProvider)(nil)
+
+type oauth2MockProvider struct {
+ auth.BaseProvider
+
+ AuthUser *auth.AuthUser
+ Token *oauth2.Token
+}
+
+func (p *oauth2MockProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
+ if p.Token == nil {
+ return nil, errors.New("failed to fetch OAuth2 token")
+ }
+ return p.Token, nil
+}
+
+func (p *oauth2MockProvider) FetchAuthUser(token *oauth2.Token) (*auth.AuthUser, error) {
+ if p.AuthUser == nil {
+ return nil, errors.New("failed to fetch OAuth2 user")
+ }
+ return p.AuthUser, nil
+}
+
+func TestRecordAuthWithOAuth2(t *testing.T) {
+ t.Parallel()
+
+ // start a test server
+ localServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ buf := new(bytes.Buffer)
+ png.Encode(buf, image.Rect(0, 0, 1, 1)) // tiny 1x1 png
+ http.ServeContent(res, req, "test_avatar.png", time.Now(), bytes.NewReader(buf.Bytes()))
+ }))
+ defer localServer.Close()
+
+ externalImageURL := "https://pocketbase.io/images/logo.svg"
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "disabled OAuth2 auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code": "123",
+ "codeVerifier": "456",
+ "redirectURL": "https://example.com"
+ }`),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid body",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{"provider"`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "trigger form validations (missing provider)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "missing"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"provider":`,
+ `"code":`,
+ },
+ NotExpectedContent: []string{
+ // should be optional
+ `"codeVerifier":`,
+ `"redirectURL":`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "trigger form validations (existing but disabled provider)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "apple"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"provider":`,
+ `"code":`,
+ },
+ NotExpectedContent: []string{
+ // should be optional
+ `"codeVerifier":`,
+ `"redirectURL":`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "existing linked OAuth2 (unverified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "name": "test_new"
+ }
+ }`),
+ Headers: map[string]string{
+ // users, test2@example.com
+ // (auth with some other user from the same collection to ensure that it is ignored)
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected user %q to be unverified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ // stub linked provider
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef(user.Id)
+ ea.SetProvider("test")
+ ea.SetProviderId("test_id")
+ if err := app.Save(ea); err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that there is at least one other external auth different than test
+ // so that later we can verify that it was deleted
+ var hasAtLeastOneOtherEA = false
+ externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
+ for _, rel := range externalAuths {
+ if rel.Id != ea.Id {
+ hasAtLeastOneOtherEA = true
+ break
+ }
+ }
+ if !hasAtLeastOneOtherEA {
+ t.Fatal("Expected at least one non-test external auth linked")
+ }
+
+ // test at least once that the correct request info context is properly loaded
+ app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
+ info, err := e.RequestInfo()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if info.Context != core.RequestInfoContextOAuth2 {
+ t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
+ }
+
+ return e.Next()
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"email":"test@example.com"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"test_id"`,
+ `"verified":false`, // shouldn't change
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // user + recreated external auth
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelDelete": 3, // pre-existing external auths
+ "OnModelDeleteExecute": 3,
+ "OnModelAfterDeleteSuccess": 3,
+ "OnRecordDelete": 3,
+ "OnRecordDeleteExecute": 3,
+ "OnRecordAfterDeleteSuccess": 3,
+ // ---
+ "OnModelValidate": 3, // user create/update + recreated external auth
+ "OnRecordValidate": 3,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if name := user.GetString("name"); name != "test1" {
+ t.Fatalf("Expected name to not change, got %q", name)
+ }
+
+ if user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be changed", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+
+ // ensure that other linked external auths have been deleted
+ externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
+ if len(externalAuths) != 1 {
+ t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
+ }
+ if provider := externalAuths[0].Provider(); provider != "test" {
+ t.Fatalf("Expected %q external auth, got %q", "test", provider)
+ }
+ if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
+ t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
+ }
+ if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
+ t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
+ }
+ if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
+ t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
+ }
+ },
+ },
+ {
+ Name: "existing linked OAuth2 (verified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.Verified() {
+ t.Fatalf("Expected user %q to be verified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ // stub linked provider
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef(user.Id)
+ ea.SetProvider("test")
+ ea.SetProviderId("test_id")
+ if err := app.Save(ea); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test2@example.com"`,
+ `"id":"oap640cot4yru2s"`,
+ `"id":"test_id"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ // ---
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected old password %q to remain valid", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+ },
+ },
+ {
+ Name: "link by email (unverified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected user %q to be unverified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // ensure that there is at least one other external auth different than test
+ // so that later we can verify that it was deleted
+ var hasAtLeastOneOtherEA = false
+ externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
+ for _, rel := range externalAuths {
+ if rel.Provider() != "test" {
+ hasAtLeastOneOtherEA = true
+ break
+ }
+ }
+ if !hasAtLeastOneOtherEA {
+ t.Fatal("Expected at least one non-test external auth linked")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id", Email: "test@example.com"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test@example.com"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"test_id"`,
+ `"verified":true`, // should be updated
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // authOrigins + externalAuths
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelUpdate": 1, // record password and verified states
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelDelete": 2, // pre-existing external auths
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ // ---
+ "OnModelValidate": 3, // record + authOrigins + externalAuths
+ "OnRecordValidate": 3,
+ },
+ 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.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be changed", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+
+ // ensure that other linked external auths have been deleted
+ externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
+ if len(externalAuths) != 1 {
+ t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
+ }
+ if provider := externalAuths[0].Provider(); provider != "test" {
+ t.Fatalf("Expected %q external auth, got %q", "test", provider)
+ }
+ if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
+ t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
+ }
+ if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
+ t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
+ }
+ if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
+ t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
+ }
+ },
+ },
+ {
+ Name: "link by email (verified user)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.Verified() {
+ t.Fatalf("Expected user %q to be verified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id", Email: "test3@example.com"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // ensure that there is at least one other external auth different than test
+ // so that later we can verify that they are not deleted
+ var hasAtLeastOneOtherEA = false
+ externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
+ for _, rel := range externalAuths {
+ if rel.Provider() != "test" {
+ hasAtLeastOneOtherEA = true
+ break
+ }
+ }
+ if !hasAtLeastOneOtherEA {
+ t.Fatal("Expected at least one non-test external auth linked")
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test3@example.com"`,
+ `"id":"bgs820n361vj1qd"`,
+ `"id":"test_id"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // authOrigins + externalAuths
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelValidate": 2, // authOrigins + externalAuths
+ "OnRecordValidate": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected old password %q to remain valid", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+
+ var hasTestEA = false
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if len(externalAuths) <= 1 {
+ t.Fatalf("Expected to have 2+ ExternalAuth records, got %d (%v)", len(externalAuths), err)
+ }
+ for _, rel := range externalAuths {
+ if rel.Provider() == "test" {
+ hasTestEA = true
+ break
+ }
+ }
+ if !hasTestEA {
+ t.Fatal("Expected test external auth to be linked")
+ }
+ },
+ },
+ {
+ Name: "link by fallback user (OAuth2 user with different email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected user %q to be unverified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "test_id",
+ Email: "test2@example.com", // different email -> should be ignored
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test@example.com"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"test_id"`,
+ `"verified":false`, // shouldn't change because the OAuth2 user email is different
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // authOrigins + externalAuths
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelDelete": 2, // pre-existing external auths
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ // ---
+ "OnModelValidate": 2,
+ "OnRecordValidate": 2,
+ },
+ 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.ValidatePassword("1234567890") {
+ t.Fatalf("Expected old password %q to remain valid", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+ },
+ },
+ {
+ Name: "link by fallback user (user without email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected user %q to be unverified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ // manually unset the user email
+ user.SetEmail("")
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ // resave with the old token key since the email change above
+ // would change it and will make the password token invalid
+ user.SetTokenKey(oldTokenKey)
+ if err = app.Save(user); err != nil {
+ t.Fatalf("Failed to restore original user tokenKey: %v", err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "test_id",
+ Email: "test_oauth2@example.com",
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test_oauth2@example.com"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"test_id"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // authOrigins + externalAuths
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelUpdate": 1, // record email set
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelDelete": 2, // pre-existing external auths
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ // ---
+ "OnModelValidate": 3, // record + authOrigins + externalAuths
+ "OnRecordValidate": 3,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindAuthRecordByEmail("users", "test_oauth2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected old password %q to remain valid", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+ },
+ },
+ {
+ Name: "link by fallback user (unverified user with matching email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatalf("Expected user %q to be unverified", user.Email())
+ }
+
+ // ensure that the old password works
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "test_id",
+ Email: "test@example.com", // matching email -> should be marked as verified
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":false`,
+ `"email":"test@example.com"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"test_id"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelCreate": 2, // authOrigins + externalAuths
+ "OnModelCreateExecute": 2,
+ "OnModelAfterCreateSuccess": 2,
+ "OnRecordCreate": 2,
+ "OnRecordCreateExecute": 2,
+ "OnRecordAfterCreateSuccess": 2,
+ // ---
+ "OnModelUpdate": 1, // record verified update
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelDelete": 2, // pre-existing external auths
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ // ---
+ "OnModelValidate": 3, // record + authOrigins + externalAuths
+ "OnRecordValidate": 3,
+ },
+ 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.ValidatePassword("1234567890") {
+ t.Fatalf("Expected old password %q to remain valid", "1234567890")
+ }
+
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if len(devices) != 1 {
+ t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
+ }
+ },
+ },
+ {
+ Name: "creating user (no extra create data or custom fields mapping)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"record":{`,
+ `"token":"`,
+ `"meta":{`,
+ `"isNew":true`,
+ `"email":""`,
+ `"id":"test_id"`,
+ `"verified":true`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (submit failure - form auth fields validator)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "verified": true,
+ "email": "invalid",
+ "rel": "invalid",
+ "file": "invalid"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"verified":{"code":"validation_values_mismatch"`,
+ },
+ NotExpectedContent: []string{
+ `"email":`, // ignored because the record validator never ran
+ `"rel":`, // ignored because the record validator never ran
+ `"file":`, // ignored because the record validator never ran
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordCreateRequest": 1,
+ },
+ },
+ {
+ Name: "creating user (submit failure - record fields validator)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "email": "invalid",
+ "rel": "invalid",
+ "file": "invalid"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"email":{"code":"validation_is_email"`,
+ `"rel":{"code":"validation_missing_rel_records"`,
+ `"file":{"code":"validation_invalid_file"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordCreateRequest": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnModelCreate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordAfterCreateError": 1,
+ },
+ },
+ {
+ Name: "creating user (valid create data with empty submitted email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "email": "",
+ "emailVisibility": true,
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ "name": "test_name",
+ "username": "test_username",
+ "rel": "0yxhwia2amd8gec"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":""`,
+ `"emailVisibility":true`,
+ `"name":"test_name"`,
+ `"username":"test_username"`,
+ `"verified":true`,
+ `"rel":"0yxhwia2amd8gec"`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindFirstRecordByData("users", "username", "test_username")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+ },
+ },
+ {
+ Name: "creating user (valid create data with non-empty valid submitted email)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "email": "test_create@example.com",
+ "emailVisibility": true,
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ "name": "test_name",
+ "username": "test_username",
+ "rel": "0yxhwia2amd8gec"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "test_id",
+ Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test_create@example.com"`,
+ `"emailVisibility":true`,
+ `"name":"test_name"`,
+ `"username":"test_username"`,
+ `"verified":false`,
+ `"rel":"0yxhwia2amd8gec"`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelValidate": 3,
+ "OnRecordValidate": 3,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ user, err := app.FindFirstRecordByData("users", "username", "test_username")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.ValidatePassword("1234567890") {
+ t.Fatalf("Expected password %q to be valid", "1234567890")
+ }
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "name": "test_name",
+ "emailVisibility": true,
+ "rel": "0yxhwia2amd8gec"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ Username: "oauth2_username",
+ AvatarURL: externalImageURL,
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Username: "name", // should be ignored because of the explicit submitted value
+ Id: "username",
+ AvatarURL: "avatar",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":true`,
+ `"name":"test_name"`,
+ `"username":"oauth2_username"`,
+ `"verified":true`,
+ `"rel":"0yxhwia2amd8gec"`,
+ `"avatar":"logo_`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 fields and local avatarURL->file field; ensures that safeHTTPClient is being used)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com",
+ "createData": {
+ "name": "test_name",
+ "emailVisibility": true,
+ "rel": "0yxhwia2amd8gec"
+ }
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ Username: "oauth2_username",
+ AvatarURL: localServer.URL + "/oauth2_avatar.png", // local/private file download is not allowed
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Username: "name", // should be ignored because of the explicit submitted value
+ Id: "username",
+ AvatarURL: "avatar",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":true`,
+ `"name":"test_name"`,
+ `"username":"oauth2_username"`,
+ `"verified":true`,
+ `"rel":"0yxhwia2amd8gec"`,
+ `"avatar":"`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 avatarURL field but empty OAuth2User.avatarURL value)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ AvatarURL: "",
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ AvatarURL: "avatar",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":false`,
+ `"verified":true`,
+ `"avatar":""`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
+ Name: "oauth2_name",
+ AvatarURL: localServer.URL + "/oauth2_avatar.png", // allowed because it is not being downloaded
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Username: "username",
+ AvatarURL: "name",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":false`,
+ `"username":"tESt2_username"`,
+ `"name":"http://127.`,
+ `"verified":true`,
+ `"avatar":""`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ Username: "tESt2_username",
+ Name: "oauth2_name",
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // make the username index case-insensitive to ensure that case-insensitive match is used
+ index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
+ if ok {
+ index.Columns[0].Collate = "nocase"
+ usersCol.RemoveIndex(index.IndexName)
+ usersCol.Indexes = append(usersCol.Indexes, index.Build())
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Username: "username",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":false`,
+ `"verified":true`,
+ `"avatar":""`,
+ `"username":"users`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "creating user (with mapped OAuth2 fields and username that doesn't match the field validations)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{
+ Id: "oauth2_id",
+ Email: "oauth2@example.com",
+ Username: "!@invalid",
+ Name: "oauth2_name",
+ },
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ usersCol.MFA.Enabled = false
+ usersCol.OAuth2.Enabled = true
+ usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Username: "username",
+ }
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"isNew":true`,
+ `"email":"oauth2@example.com"`,
+ `"emailVisibility":false`,
+ `"verified":true`,
+ `"avatar":""`,
+ `"username":"users`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOAuth2Request": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 2, // the auth response and from the create request
+ // ---
+ "OnModelCreate": 3, // record + authOrigins + externalAuths
+ "OnModelCreateExecute": 3,
+ "OnModelAfterCreateSuccess": 3,
+ "OnRecordCreate": 3,
+ "OnRecordCreateExecute": 3,
+ "OnRecordAfterCreateSuccess": 3,
+ // ---
+ "OnModelUpdate": 1, // created record verified state change
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ // ---
+ "OnModelValidate": 4,
+ "OnRecordValidate": 4,
+ },
+ },
+ {
+ Name: "OnRecordAuthWithOAuth2Request tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"123",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ // add the test provider in the collection
+ user.Collection().MFA.Enabled = false
+ user.Collection().OAuth2.Enabled = true
+ user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ // stub linked provider
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef(user.Id)
+ ea.SetProvider("test")
+ ea.SetProviderId("test_id")
+ if err := app.Save(ea); err != nil {
+ t.Fatal(err)
+ }
+
+ app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // Apple AuthUser.Name assign checks
+ // -----------------------------------------------------------
+ {
+ Name: "store name with Apple provider",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "apple",
+ "code":"test_code",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers[auth.NameApple] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ app.Store().Set("@redirect_name_test_code", "test_store_name")
+
+ // add the test provider in the collection
+ users.MFA.Enabled = false
+ users.OAuth2.Enabled = true
+ users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: auth.NameApple,
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(users); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"meta":{`,
+ `"name":"test_store_name"`,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.Store().Has("@redirect_name_test_code") {
+ t.Fatal("Expected @redirect_name_test_code store key to be removed")
+ }
+ },
+ },
+ {
+ Name: "store name with non-Apple provider",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ Body: strings.NewReader(`{
+ "provider": "test",
+ "code":"test_code",
+ "redirectURL": "https://example.com"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // register the test provider
+ auth.Providers["test"] = func() auth.Provider {
+ return &oauth2MockProvider{
+ AuthUser: &auth.AuthUser{Id: "test_id"},
+ Token: &oauth2.Token{AccessToken: "abc"},
+ }
+ }
+
+ app.Store().Set("@redirect_name_test_code", "test_store_name")
+
+ // add the test provider in the collection
+ users.MFA.Enabled = false
+ users.OAuth2.Enabled = true
+ users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
+ Name: "test",
+ ClientId: "123",
+ ClientSecret: "456",
+ }}
+ if err := app.Save(users); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ NotExpectedContent: []string{
+ `"name":"test_store_name"`,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if !app.Store().Has("@redirect_name_test_code") {
+ t.Fatal("Expected @redirect_name_test_code store key to NOT be deleted")
+ }
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:authWithOAuth2",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithOAuth2"},
+ {MaxRequests: 100, Label: "users:auth"},
+ {MaxRequests: 0, Label: "users:authWithOAuth2"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:authWithOAuth2",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:auth"},
+ {MaxRequests: 0, Label: "*:authWithOAuth2"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit tag - users:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithOAuth2"},
+ {MaxRequests: 0, Label: "users:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit tag - *:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-oauth2",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_auth_with_otp.go b/apis/record_auth_with_otp.go
new file mode 100644
index 0000000000..a9903e6015
--- /dev/null
+++ b/apis/record_auth_with_otp.go
@@ -0,0 +1,116 @@
+package apis
+
+import (
+ "errors"
+ "fmt"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func recordAuthWithOTP(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if !collection.OTP.Enabled {
+ return e.ForbiddenError("The collection is not configured to allow OTP authentication.", nil)
+ }
+
+ form := &authWithOTPForm{}
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
+
+ event := new(core.RecordAuthWithOTPRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+
+ // extra validations
+ // (note: returns a generic 400 as a very basic OTPs enumeration protection)
+ // ---
+ event.OTP, err = e.App.FindOTPById(form.OTPId)
+ if err != nil {
+ return e.BadRequestError("Invalid or expired OTP", err)
+ }
+
+ if event.OTP.CollectionRef() != collection.Id {
+ return e.BadRequestError("Invalid or expired OTP", errors.New("the OTP is for a different collection"))
+ }
+
+ if event.OTP.HasExpired(collection.OTP.DurationTime()) {
+ return e.BadRequestError("Invalid or expired OTP", errors.New("the OTP is expired"))
+ }
+
+ event.Record, err = e.App.FindRecordById(event.OTP.CollectionRef(), event.OTP.RecordRef())
+ if err != nil {
+ return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
+ }
+
+ // since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumeration protection
+ err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
+ if err != nil {
+ return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
+ }
+
+ if !event.OTP.ValidatePassword(form.Password) {
+ return e.BadRequestError("Invalid or expired OTP", errors.New("incorrect password"))
+ }
+ // ---
+
+ return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
+ otpId := e.OTP.Id
+ otpSentTo := e.OTP.SentTo()
+
+ // eagerly delete the OTP to avoid unnecessary double delete model hook calls
+ // triggered by the password change below
+ err := e.App.Delete(e.OTP)
+ if err != nil {
+ e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
+ }
+
+ // update the user email verified state in case the OTP originate from an email address matching the current record one
+ //
+ // note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
+ if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
+ e.Record.SetVerified(true)
+
+ // this is technically not required but we enforce password
+ // reset on verified upgrades in case the OTP is used on its own
+ // since this makes it less error prone to pre-hijacking attacks
+ if !e.Record.Collection().MFA.Enabled {
+ e.Record.SetRandomPassword()
+ }
+
+ if err := e.App.Save(e.Record); err != nil {
+ e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
+ "error", err,
+ "otpId", otpId,
+ "recordId", e.Record.Id,
+ )
+ }
+ }
+
+ return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
+ })
+}
+
+// -------------------------------------------------------------------
+
+type authWithOTPForm struct {
+ OTPId string `form:"otpId" json:"otpId"`
+ Password string `form:"password" json:"password"`
+}
+
+func (form *authWithOTPForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.OTPId, validation.Required, validation.Length(1, 255)),
+ validation.Field(&form.Password, validation.Required, validation.Length(1, 71)),
+ )
+}
diff --git a/apis/record_auth_with_otp_test.go b/apis/record_auth_with_otp_test.go
new file mode 100644
index 0000000000..a0d950bef4
--- /dev/null
+++ b/apis/record_auth_with_otp_test.go
@@ -0,0 +1,644 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRecordAuthWithOTP(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "not an auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/auth-with-otp",
+ Body: strings.NewReader(`{"otpId":"test","password":"123456"}`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "auth collection with disabled otp",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{"otpId":"test","password":"123456"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ usersCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ usersCol.OTP.Enabled = false
+
+ if err := app.Save(usersCol); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid body",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{"email`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty body",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(``),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"otpId":{"code":"validation_required"`,
+ `"password":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid request data",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 256) + `",
+ "password":"` + strings.Repeat("a", 72) + `"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"otpId":{"code":"validation_length_out_of_range"`,
+ `"password":{"code":"validation_length_out_of_range"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "missing otp",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"missing",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "otp for different collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ client, err := app.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(client.Collection().Id)
+ otp.SetRecordRef(client.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "otp with wrong password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("1234567890")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "expired otp with valid password",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ expiredDate := types.NowDateTime().AddDate(-3, 0, 0)
+ otp.SetRaw("created", expiredDate)
+ otp.SetRaw("updated", expiredDate)
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid otp with valid password (enabled MFA)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"mfaId":"`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOTPRequest": 1,
+ "OnRecordAuthRequest": 1,
+ // ---
+ "OnModelValidate": 1,
+ "OnModelCreate": 1, // mfa record
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelDelete": 1, // otp delete
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ // ---
+ "OnRecordValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ {
+ Name: "valid otp with valid password and empty sentTo (disabled MFA)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the user is unverified
+ user.SetVerified(false)
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ // disable MFA
+ user.Collection().MFA.Enabled = false
+ if err = app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+
+ // test at least once that the correct request info context is properly loaded
+ app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
+ info, err := e.RequestInfo()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if info.Context != core.RequestInfoContextOTP {
+ t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
+ }
+
+ return e.Next()
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":"`,
+ `"record":{`,
+ `"email":"test@example.com"`,
+ },
+ NotExpectedContent: []string{
+ `"meta":`,
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOTPRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelValidate": 1,
+ "OnModelCreate": 1, // authOrigin
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelDelete": 1, // otp delete
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ // ---
+ "OnRecordValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ 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.Fatal("Expected the user to remain unverified because sentTo != email")
+ }
+
+ // ensure that all pre-existing OAuth2 were NOT deleted
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) != 2 {
+ t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "valid otp with valid password and nonempty sentTo=email (disabled MFA)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the user is unverified
+ user.SetVerified(false)
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ // disable MFA
+ user.Collection().MFA.Enabled = false
+ if err = app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ otp.SetSentTo(user.Email())
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+
+ // verify that there are at least one pre-existing OAuth2 link
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) == 0 {
+ t.Fatal("Expected at least one external auth")
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"token":"`,
+ `"record":{`,
+ `"email":"test@example.com"`,
+ },
+ NotExpectedContent: []string{
+ `"meta":`,
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithOTPRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // ---
+ "OnModelValidate": 2, // +1 because of the verified user update
+ // authOrigin create
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ // record OTP + 2 ExternalAuths delete
+ "OnModelDelete": 3,
+ "OnModelDeleteExecute": 3,
+ "OnModelAfterDeleteSuccess": 3,
+ // user verified update
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ // ---
+ "OnRecordValidate": 2,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordDelete": 3,
+ "OnRecordDeleteExecute": 3,
+ "OnRecordAfterDeleteSuccess": 3,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ },
+ 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.Fatal("Expected the user to be marked as verified")
+ }
+
+ // ensure that all pre-existing OTPs are cleared
+ otps, err := app.FindAllOTPsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(otps) > 0 {
+ t.Fatalf("Expected all OTPs to be cleared, found %d", len(otps))
+ }
+
+ // ensure that all pre-existing OAuth2 links are cleared
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(externalAuths) > 0 {
+ t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
+ }
+ },
+ },
+ {
+ Name: "OnRecordAuthWithOTPRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + strings.Repeat("a", 15) + `",
+ "password":"123456"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // disable MFA
+ user.Collection().MFA.Enabled = false
+ if err = app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ otp := core.NewOTP(app)
+ otp.Id = strings.Repeat("a", 15)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+
+ app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:authWithOTP",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithOTP"},
+ {MaxRequests: 100, Label: "users:auth"},
+ {MaxRequests: 0, Label: "users:authWithOTP"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:authWithOTP",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:auth"},
+ {MaxRequests: 0, Label: "*:authWithOTP"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - users:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithOTP"},
+ {MaxRequests: 0, Label: "users:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
+ t.Parallel()
+
+ var storeCache map[string]any
+
+ otpAId := strings.Repeat("a", 15)
+ otpBId := strings.Repeat("b", 15)
+
+ scenarios := []struct {
+ otpId string
+ password string
+ expectedStatus int
+ }{
+ {otpAId, "12345", 400},
+ {otpAId, "12345", 400},
+ {otpBId, "12345", 400},
+ {otpBId, "12345", 400},
+ {otpBId, "12345", 400},
+ {otpAId, "12345", 429},
+ {otpAId, "123456", 429}, // reject even if it is correct
+ {otpAId, "123456", 429},
+ {otpBId, "123456", 429},
+ }
+
+ for _, s := range scenarios {
+ (&tests.ApiScenario{
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-otp",
+ Body: strings.NewReader(`{
+ "otpId":"` + s.otpId + `",
+ "password":"` + s.password + `"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ for k, v := range storeCache {
+ app.Store().Set(k, v)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user.Collection().MFA.Enabled = false
+ if err := app.Save(user.Collection()); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, id := range []string{otpAId, otpBId} {
+ otp := core.NewOTP(app)
+ otp.Id = id
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ }
+ },
+ ExpectedStatus: s.expectedStatus,
+ ExpectedContent: []string{`"`}, // it doesn't matter anything non-empty
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ storeCache = app.Store().GetAll()
+ },
+ }).Test(t)
+ }
+}
diff --git a/apis/record_auth_with_password.go b/apis/record_auth_with_password.go
new file mode 100644
index 0000000000..180783cfd0
--- /dev/null
+++ b/apis/record_auth_with_password.go
@@ -0,0 +1,164 @@
+package apis
+
+import (
+ "database/sql"
+ "errors"
+ "slices"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func recordAuthWithPassword(e *core.RequestEvent) error {
+ collection, err := findAuthCollection(e)
+ if err != nil {
+ return err
+ }
+
+ if !collection.PasswordAuth.Enabled {
+ return e.ForbiddenError("The collection is not configured to allow password authentication.", nil)
+ }
+
+ form := &authWithPasswordForm{}
+ if err = e.BindBody(form); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+ if err = form.validate(collection); err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
+
+ var foundRecord *core.Record
+ var foundErr error
+
+ if form.IdentityField != "" {
+ foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
+ } else {
+ identityFields := collection.PasswordAuth.IdentityFields
+
+ // @todo consider removing with the stable release or moving it in the collection save
+ //
+ // prioritize email lookup to minimize breaking changes with earlier versions
+ if len(identityFields) > 1 && identityFields[0] != core.FieldNameEmail {
+ identityFields = slices.Clone(identityFields)
+ slices.SortStableFunc(identityFields, func(a, b string) int {
+ if a == "email" {
+ return -1
+ }
+ if b == "email" {
+ return 1
+ }
+ return 0
+ })
+ }
+
+ for _, name := range identityFields {
+ if name == core.FieldNameEmail && is.EmailFormat.Validate(form.Identity) != nil {
+ continue // no need to query the database if we know that the submitted value is not an email
+ }
+
+ foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
+ if foundErr == nil {
+ break
+ }
+ }
+ }
+
+ // ignore not found errors to allow custom record find implementations
+ if foundErr != nil && !errors.Is(foundErr, sql.ErrNoRows) {
+ return e.InternalServerError("", foundErr)
+ }
+
+ event := new(core.RecordAuthWithPasswordRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = foundRecord
+ event.Identity = form.Identity
+ event.Password = form.Password
+ event.IdentityField = form.IdentityField
+
+ return e.App.OnRecordAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordRequestEvent) error {
+ if e.Record == nil || !e.Record.ValidatePassword(e.Password) {
+ // dummy password check to minimize enumeration side-channel attacks
+ if e.Record == nil {
+ dummyPasswordCheck(e.App, e.Collection)
+ }
+
+ return e.BadRequestError("Failed to authenticate.", errors.New("invalid login credentials"))
+ }
+
+ return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodPassword, nil)
+ })
+}
+
+// -------------------------------------------------------------------
+
+type authWithPasswordForm struct {
+ Identity string `form:"identity" json:"identity"`
+ Password string `form:"password" json:"password"`
+
+ // IdentityField specifies the field to use to search for the identity
+ // (leave it empty for "auto" detection).
+ IdentityField string `form:"identityField" json:"identityField"`
+}
+
+func (form *authWithPasswordForm) validate(collection *core.Collection) error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
+ validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
+ validation.Field(
+ &form.IdentityField,
+ validation.Length(1, 255),
+ validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
+ ),
+ )
+}
+
+// dummy password check to minimize side-channel attacks
+// (performed with the collection configured field cost)
+func dummyPasswordCheck(app core.App, collection *core.Collection) {
+ record := &core.Record{}
+
+ // find any random existing record
+ err := app.RecordQuery(collection).Limit(1).One(record)
+ if err != nil {
+ return
+ }
+
+ // the value and result doesn't matter, we just need a constant-time check
+ _ = record.ValidatePassword("")
+}
+
+func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
+ if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
+ return nil, errors.New("invalid identity field " + field)
+ }
+
+ index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
+ if !ok {
+ return nil, errors.New("missing " + field + " unique index constraint")
+ }
+
+ var expr dbx.Expression
+ if strings.EqualFold(index.Columns[0].Collate, "nocase") {
+ // case-insensitive search
+ expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
+ } else {
+ expr = dbx.HashExp{field: value}
+ }
+
+ record := &core.Record{}
+
+ err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
diff --git a/apis/record_auth_with_password_test.go b/apis/record_auth_with_password_test.go
new file mode 100644
index 0000000000..37f7ce62de
--- /dev/null
+++ b/apis/record_auth_with_password_test.go
@@ -0,0 +1,764 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+)
+
+func TestRecordAuthWithPassword(t *testing.T) {
+ t.Parallel()
+
+ updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for column, collate := range fieldCollateMap {
+ index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
+ if !ok {
+ t.Fatalf("Missing unique identityField index for column %q", column)
+ }
+
+ index.Columns[0].Collate = collate
+
+ collection.RemoveIndex(index.IndexName)
+ collection.Indexes = append(collection.Indexes, index.Build())
+ }
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatalf("Failed to update identityField index: %v", err)
+ }
+ }
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "disabled password auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/nologin/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-auth collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/auth-with-password",
+ Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid body format",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{"identity`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty body params",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{"identity":"","password":""}`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"identity":{`,
+ `"password":{`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "OnRecordAuthWithPasswordRequest tx body write check",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
+ {
+ Name: "valid identity field and invalid password",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"invalid"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ },
+ },
+ {
+ Name: "valid identity field (email) and valid password",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // test at least once that the correct request info context is properly loaded
+ app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
+ info, err := e.RequestInfo()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if info.Context != core.RequestInfoContextPasswordAuth {
+ t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
+ }
+
+ return e.Next()
+ })
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "valid identity field (username) and valid password",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"clients57772",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"clients57772"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ // https://github.com/pocketbase/pocketbase/issues/7256
+ Name: "valid non-email identity field with a value that is a properly formatted email",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"username_as_email@example.com",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ record, err := app.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record.Set("username", "username_as_email@example.com")
+
+ err = app.SaveNoValidate(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"username_as_email@example.com"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "unknown explicit identityField",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identityField": "created",
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"identityField":{"code":"validation_in_invalid"`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "valid identity field and valid password with mismatched explicit identityField",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identityField": "username",
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ },
+ },
+ {
+ Name: "valid identity field and valid password with matched explicit identityField",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identityField": "username",
+ "identity":"clients57772",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"clients57772"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "valid identity (unverified) and valid password in onlyVerified collection",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test2@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ },
+ },
+ {
+ Name: "already authenticated record",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"gk390qegs4y47wn"`,
+ `"email":"test@example.com"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "with mfa first auth check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ ExpectedStatus: 401,
+ ExpectedContent: []string{
+ `"mfaId":"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ // mfa create
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ 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)
+ }
+
+ mfas, err := app.FindAllMFAsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v := len(mfas); v != 1 {
+ t.Fatalf("Expected 1 mfa record to be created, got %d", v)
+ }
+ },
+ },
+ {
+ Name: "with mfa second auth check",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "mfaId": "` + strings.Repeat("a", 15) + `",
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ 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)
+ }
+
+ // insert a dummy mfa record
+ mfa := core.NewMFA(app)
+ mfa.Id = strings.Repeat("a", 15)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("test")
+ if err := app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 0, // disabled auth email alerts
+ "OnMailerRecordAuthAlertSend": 0,
+ // mfa delete
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ {
+ Name: "with enabled mfa but unsatisfied mfa rule (aka. skip the mfa check)",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"test@example.com",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ users.MFA.Enabled = true
+ users.MFA.Rule = "1=2"
+
+ if err := app.Save(users); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 0, // disabled auth email alerts
+ "OnMailerRecordAuthAlertSend": 0,
+ },
+ 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)
+ }
+
+ mfas, err := app.FindAllMFAsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v := len(mfas); v != 0 {
+ t.Fatalf("Expected no mfa records to be created, got %d", v)
+ }
+ },
+ },
+
+ // case sensitivity checks
+ // -----------------------------------------------------------
+ {
+ Name: "with explicit identityField (case-sensitive)",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identityField": "username",
+ "identity":"Clients57772",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": ""}),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ },
+ },
+ {
+ Name: "with explicit identityField (case-insensitive)",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identityField": "username",
+ "identity":"Clients57772",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"clients57772"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "without explicit identityField and non-email field (case-insensitive)",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"Clients57772",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"clients57772"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+ {
+ Name: "without explicit identityField and email field (case-insensitive)",
+ Method: http.MethodPost,
+ URL: "/api/collections/clients/auth-with-password",
+ Body: strings.NewReader(`{
+ "identity":"tESt@example.com",
+ "password":"1234567890"
+ }`),
+ BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"email":"test@example.com"`,
+ `"username":"clients57772"`,
+ `"token":`,
+ },
+ NotExpectedContent: []string{
+ // hidden fields
+ `"tokenKey"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordAuthWithPasswordRequest": 1,
+ "OnRecordAuthRequest": 1,
+ "OnRecordEnrich": 1,
+ // authOrigin track
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ "OnMailerSend": 1,
+ "OnMailerRecordAuthAlertSend": 1,
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - users:authWithPassword",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithPassword"},
+ {MaxRequests: 100, Label: "users:auth"},
+ {MaxRequests: 0, Label: "users:authWithPassword"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:authWithPassword",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:auth"},
+ {MaxRequests: 0, Label: "*:authWithPassword"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - users:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:authWithPassword"},
+ {MaxRequests: 0, Label: "users:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/users/auth-with-password",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:auth"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud.go b/apis/record_crud.go
index 991e5abed1..3a35e93ab9 100644
--- a/apis/record_crud.go
+++ b/apis/record_crud.go
@@ -1,416 +1,766 @@
package apis
import (
+ cryptoRand "crypto/rand"
+ "errors"
"fmt"
- "log"
+ "math/big"
"net/http"
"strings"
+ "time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/resolvers"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
)
-const expandQueryParam = "expand"
-
// bindRecordCrudApi registers the record crud api endpoints and
// the corresponding handlers.
-func bindRecordCrudApi(app core.App, rg *echo.Group) {
- api := recordApi{app: app}
-
- subGroup := rg.Group(
- "/collections/:collection",
- ActivityLogger(app),
- )
-
- subGroup.GET("/records", api.list, LoadCollectionContext(app))
- subGroup.GET("/records/:id", api.view, LoadCollectionContext(app))
- subGroup.POST("/records", api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
- subGroup.PATCH("/records/:id", api.update, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
- subGroup.DELETE("/records/:id", api.delete, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth))
+//
+// note: the rate limiter is "inlined" because some of the crud actions are also used in the batch APIs
+func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
+ subGroup.GET("", recordsList)
+ subGroup.GET("/{id}", recordView)
+ subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
+ subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
+ subGroup.DELETE("/{id}", recordDelete(true, nil))
}
-type recordApi struct {
- app core.App
-}
+func recordsList(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("Missing collection context.", err)
+ }
+
+ err = checkCollectionRateLimit(e, collection, "list")
+ if err != nil {
+ return err
+ }
-func (api *recordApi) list(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("", "Missing collection context.")
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("", err))
+ }
+
+ if collection.ListRule == nil && !requestInfo.HasSuperuserAuth() {
+ return e.ForbiddenError("Only superusers can perform this action.", nil)
}
// forbid users and guests to query special filter/sort fields
- if err := api.checkForForbiddenQueryFields(c); err != nil {
+ err = checkForSuperuserOnlyRuleFields(requestInfo)
+ if err != nil {
return err
}
- requestInfo := RequestInfo(c)
+ query := e.App.RecordQuery(collection)
+
+ fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
- if requestInfo.Admin == nil && collection.ListRule == nil {
- // only admins can access if the rule is nil
- return NewForbiddenError("Only admins can perform this action.", nil)
+ if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
+ expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
+ if err != nil {
+ return err
+ }
+ query.AndWhere(expr)
+
+ // will be applied by the search provider right before executing the query
+ // fieldsResolver.UpdateQuery(query)
}
- fieldsResolver := resolvers.NewRecordFieldResolver(
- api.app.Dao(),
- collection,
- requestInfo,
- // hidden fields are searchable only by admins
- requestInfo.Admin != nil,
- )
+ // hidden fields are searchable only by superusers
+ fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
- searchProvider := search.NewProvider(fieldsResolver).
- Query(api.app.Dao().RecordQuery(collection))
+ searchProvider := search.NewProvider(fieldsResolver).Query(query)
- if requestInfo.Admin == nil && collection.ListRule != nil {
- searchProvider.AddFilter(search.FilterData(*collection.ListRule))
+ // use rowid when available to minimize the need of a covering index with the "id" field
+ if !collection.IsView() {
+ searchProvider.CountCol("_rowid_")
}
- records := []*models.Record{}
-
- result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
+ records := []*core.Record{}
+ result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
if err != nil {
- return NewBadRequestError("Invalid filter parameters.", err)
+ return firstApiError(err, e.BadRequestError("", err))
}
- event := new(core.RecordsListEvent)
- event.HttpContext = c
+ event := new(core.RecordsListRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
event.Records = records
event.Result = result
- return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
+ return e.App.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListRequestEvent) error {
+ if err := EnrichRecords(e.RequestEvent, e.Records); err != nil {
+ return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
}
- if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
- log.Println(err)
+ // Add a randomized throttle in case of too many empty search filter attempts.
+ //
+ // This is just for extra precaution since security researches raised concern regarding the possibility of eventual
+ // timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
+ // This is by design and it is an accepted trade off between performance, usability and correctness.
+ //
+ // While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
+ // A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
+ //
+ // In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
+ // are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
+ if !e.HasSuperuserAuth() &&
+ (collection.ListRule != nil && *collection.ListRule != "") &&
+ (requestInfo.Query["filter"] != "") &&
+ len(e.Records) == 0 &&
+ checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
+ e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
+ randomizedThrottle(500)
}
- return e.HttpContext.JSON(http.StatusOK, e.Result)
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Result)
+ })
})
}
-func (api *recordApi) view(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("", "Missing collection context.")
+var listTimingRateLimitRule = core.RateLimitRule{MaxRequests: 3, Duration: 3}
+
+func randomizedThrottle(softMax int64) {
+ var timeout int64
+ randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
+ if err == nil {
+ timeout = randRange.Int64()
+ } else {
+ timeout = softMax
+ }
+
+ time.Sleep(time.Duration(timeout) * time.Millisecond)
+}
+
+func recordView(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("Missing collection context.", err)
}
- recordId := c.PathParam("id")
+ err = checkCollectionRateLimit(e, collection, "view")
+ if err != nil {
+ return err
+ }
+
+ recordId := e.Request.PathValue("id")
if recordId == "" {
- return NewNotFoundError("", nil)
+ return e.NotFoundError("", nil)
}
- requestInfo := RequestInfo(c)
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("", err))
+ }
- if requestInfo.Admin == nil && collection.ViewRule == nil {
- // only admins can access if the rule is nil
- return NewForbiddenError("Only admins can perform this action.", nil)
+ if collection.ViewRule == nil && !requestInfo.HasSuperuserAuth() {
+ return e.ForbiddenError("Only superusers can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
- if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
+ if !requestInfo.HasSuperuserAuth() && collection.ViewRule != nil && *collection.ViewRule != "" {
+ resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
+
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
}
- resolver.UpdateQuery(q)
+
q.AndWhere(expr)
+
+ err = resolver.UpdateQuery(q)
+ if err != nil {
+ return err
+ }
}
return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
+ record, fetchErr := e.App.FindRecordById(collection, recordId, ruleFunc)
if fetchErr != nil || record == nil {
- return NewNotFoundError("", fetchErr)
+ return firstApiError(err, e.NotFoundError("", fetchErr))
}
- event := new(core.RecordViewEvent)
- event.HttpContext = c
+ event := new(core.RecordRequestEvent)
+ event.RequestEvent = e
event.Collection = collection
event.Record = record
- return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
- log.Println(err)
+ return e.App.OnRecordViewRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
+ if err := EnrichRecord(e.RequestEvent, e.Record); err != nil {
+ return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
}
- return e.HttpContext.JSON(http.StatusOK, e.Record)
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Record)
+ })
})
}
-func (api *recordApi) create(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("", "Missing collection context.")
- }
+func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
+ return func(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("Missing collection context.", err)
+ }
- requestInfo := RequestInfo(c)
+ if collection.IsView() {
+ return e.BadRequestError("Unsupported collection type.", nil)
+ }
- if requestInfo.Admin == nil && collection.CreateRule == nil {
- // only admins can access if the rule is nil
- return NewForbiddenError("Only admins can perform this action.", nil)
- }
+ err = checkCollectionRateLimit(e, collection, "create")
+ if err != nil {
+ return err
+ }
- hasFullManageAccess := requestInfo.Admin != nil
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("", err))
+ }
- // temporary save the record and check it against the create rule
- if requestInfo.Admin == nil && collection.CreateRule != nil {
- testRecord := models.NewRecord(collection)
+ hasSuperuserAuth := requestInfo.HasSuperuserAuth()
+ if !hasSuperuserAuth && collection.CreateRule == nil {
+ return e.ForbiddenError("Only superusers can perform this action.", nil)
+ }
+
+ record := core.NewRecord(collection)
+
+ data, err := recordDataFromRequest(e, record)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
+ }
+
+ // set a random password for the OAuth2 ignoring its plain password validators
+ var skipPlainPasswordRecordValidators bool
+ if requestInfo.Context == core.RequestInfoContextOAuth2 {
+ if _, ok := data[core.FieldNamePassword]; !ok {
+ data[core.FieldNamePassword] = security.RandomString(30)
+ data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
+ skipPlainPasswordRecordValidators = true
+ }
+ }
// replace modifiers fields so that the resolved value is always
- // available when accessing requestInfo.Data using just the field name
- if requestInfo.HasModifierDataKeys() {
- requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
+ // available when accessing requestInfo.Body
+ requestInfo.Body = data
+
+ form := forms.NewRecordUpsert(e.App, record)
+ if hasSuperuserAuth {
+ form.GrantSuperuserAccess()
}
+ form.Load(data)
- testForm := forms.NewRecordUpsert(api.app, testRecord)
- testForm.SetFullManageAccess(true)
- if err := testForm.LoadRequest(c.Request(), ""); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
+ if skipPlainPasswordRecordValidators {
+ // unset the plain value to skip the plain password field validators
+ if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
+ raw.Plain = ""
+ }
}
- createRuleFunc := func(q *dbx.SelectQuery) error {
- if *collection.CreateRule == "" {
- return nil // no create rule to resolve
+ // check the request and record data against the create and manage rules
+ if !hasSuperuserAuth && collection.CreateRule != nil {
+ dummyRecord := record.Clone()
+
+ dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
+
+ // set an id if it doesn't have already
+ // (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
+ if dummyRecord.Id == "" {
+ dummyRecord.Id = "__temp_id__" + dummyRandomPart
}
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
- expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
+ // unset the verified field to prevent manage API rule misuse in case the rule relies on it
+ dummyRecord.SetVerified(false)
+
+ // export the dummy record data into db params
+ dummyExport, err := dummyRecord.DBExport(e.App)
if err != nil {
- return err
+ return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
+ }
+
+ dummyParams := make(dbx.Params, len(dummyExport))
+ selects := make([]string, 0, len(dummyExport))
+ var param string
+ for k, v := range dummyExport {
+ k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
+ param = "__pb_create__" + k
+ dummyParams[param] = v
+ selects = append(selects, "{:"+param+"} AS [["+k+"]]")
+ }
+
+ // shallow clone the current collection
+ dummyCollection := *collection
+ dummyCollection.Id += dummyRandomPart
+ dummyCollection.Name += inflector.Columnify(dummyRandomPart)
+
+ withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
+
+ // check non-empty create rule
+ if *dummyCollection.CreateRule != "" {
+ ruleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
+
+ resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
+
+ expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
+ if err != nil {
+ return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
+ }
+ ruleQuery.AndWhere(expr)
+
+ err = resolver.UpdateQuery(ruleQuery)
+ if err != nil {
+ return e.BadRequestError("Failed to create record", fmt.Errorf("create rule update query failure: %w", err))
+ }
+
+ var exists int
+ err = ruleQuery.Limit(1).Row(&exists)
+ if err != nil || exists == 0 {
+ return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
+ }
+ }
+
+ // check for manage rule access
+ manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
+ if !form.HasManageAccess() &&
+ hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
+ form.GrantManagerAccess()
}
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
- return nil
}
- testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
- foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
+ var isOptFinalizerCalled bool
+
+ event := new(core.RecordRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+
+ hookErr := e.App.OnRecordCreateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
+ form.SetApp(e.App)
+ form.SetRecord(e.Record)
+
+ err := form.Submit()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to create record", err))
+ }
+
+ err = EnrichRecord(e.RequestEvent, e.Record)
if err != nil {
- return fmt.Errorf("DrySubmit create rule failure: %w", err)
+ return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
+ }
+
+ err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Record)
+ })
+ if err != nil {
+ return err
+ }
+
+ if optFinalizer != nil {
+ isOptFinalizerCalled = true
+ err = optFinalizer(e.Record)
+ if err != nil {
+ return firstApiError(err, e.InternalServerError("", err))
+ }
}
- hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
+
return nil
})
+ if hookErr != nil {
+ return hookErr
+ }
- if testErr != nil {
- return NewBadRequestError("Failed to create record.", testErr)
+ // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
+ if !isOptFinalizerCalled && optFinalizer != nil {
+ if err := optFinalizer(event.Record); err != nil {
+ return firstApiError(err, e.InternalServerError("", err))
+ }
}
+
+ return nil
}
+}
- record := models.NewRecord(collection)
- form := forms.NewRecordUpsert(api.app, record)
- form.SetFullManageAccess(hasFullManageAccess)
+func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
+ return func(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("Missing collection context.", err)
+ }
- // load request
- if err := form.LoadRequest(c.Request(), ""); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
+ if collection.IsView() {
+ return e.BadRequestError("Unsupported collection type.", nil)
+ }
- event := new(core.RecordCreateEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
- event.UploadedFiles = form.FilesToUpload()
+ err = checkCollectionRateLimit(e, collection, "update")
+ if err != nil {
+ return err
+ }
- // create the record
- return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(m *models.Record) error {
- event.Record = m
+ recordId := e.Request.PathValue("id")
+ if recordId == "" {
+ return e.NotFoundError("", nil)
+ }
- return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to create record.", err)
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("", err))
+ }
+
+ hasSuperuserAuth := requestInfo.HasSuperuserAuth()
+
+ if !hasSuperuserAuth && collection.UpdateRule == nil {
+ return firstApiError(err, e.ForbiddenError("Only superusers can perform this action.", nil))
+ }
+
+ // eager fetch the record so that the modifiers field values can be resolved
+ record, err := e.App.FindRecordById(collection, recordId)
+ if err != nil {
+ return firstApiError(err, e.NotFoundError("", err))
+ }
+
+ data, err := recordDataFromRequest(e, record)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
+ }
+
+ // replace modifiers fields so that the resolved value is always
+ // available when accessing requestInfo.Body
+ requestInfo.Body = data
+
+ ruleFunc := func(q *dbx.SelectQuery) error {
+ if !hasSuperuserAuth && collection.UpdateRule != nil && *collection.UpdateRule != "" {
+ resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
+
+ expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
+ if err != nil {
+ return err
}
- if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
- log.Println(err)
+ q.AndWhere(expr)
+
+ err = resolver.UpdateQuery(q)
+ if err != nil {
+ return err
}
+ }
+ return nil
+ }
- return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ // refetch with access checks
+ record, err = e.App.FindRecordById(collection, recordId, ruleFunc)
+ if err != nil {
+ return firstApiError(err, e.NotFoundError("", err))
+ }
- return e.HttpContext.JSON(http.StatusOK, e.Record)
- })
- })
+ form := forms.NewRecordUpsert(e.App, record)
+ if hasSuperuserAuth {
+ form.GrantSuperuserAccess()
}
- })
-}
+ form.Load(data)
-func (api *recordApi) update(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("", "Missing collection context.")
- }
+ manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
+ collection.Name + ".id": record.Id,
+ })
+ if !form.HasManageAccess() &&
+ hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
+ form.GrantManagerAccess()
+ }
- recordId := c.PathParam("id")
- if recordId == "" {
- return NewNotFoundError("", nil)
- }
+ var isOptFinalizerCalled bool
- requestInfo := RequestInfo(c)
+ event := new(core.RecordRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
- if requestInfo.Admin == nil && collection.UpdateRule == nil {
- // only admins can access if the rule is nil
- return NewForbiddenError("Only admins can perform this action.", nil)
- }
+ hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
+ form.SetApp(e.App)
+ form.SetRecord(e.Record)
- // eager fetch the record so that the modifier field values are replaced
- // and available when accessing requestInfo.Data using just the field name
- if requestInfo.HasModifierDataKeys() {
- record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
- if err != nil || record == nil {
- return NewNotFoundError("", err)
- }
- requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
- }
+ err := form.Submit()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to update record.", err))
+ }
- ruleFunc := func(q *dbx.SelectQuery) error {
- if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
- expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
+ err = EnrichRecord(e.RequestEvent, e.Record)
+ if err != nil {
+ return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
+ }
+
+ err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Record)
+ })
if err != nil {
return err
}
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
+
+ if optFinalizer != nil {
+ isOptFinalizerCalled = true
+ err = optFinalizer(e.Record)
+ if err != nil {
+ return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
+ }
+ }
+
+ return nil
+ })
+ if hookErr != nil {
+ return hookErr
+ }
+
+ // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
+ if !isOptFinalizerCalled && optFinalizer != nil {
+ if err := optFinalizer(event.Record); err != nil {
+ return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
+ }
}
+
return nil
}
+}
- // fetch record
- record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
- if fetchErr != nil || record == nil {
- return NewNotFoundError("", fetchErr)
- }
+func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
+ return func(e *core.RequestEvent) error {
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
+ if err != nil || collection == nil {
+ return e.NotFoundError("Missing collection context.", err)
+ }
- form := forms.NewRecordUpsert(api.app, record)
- form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
+ if collection.IsView() {
+ return e.BadRequestError("Unsupported collection type.", nil)
+ }
- // load request
- if err := form.LoadRequest(c.Request(), ""); err != nil {
- return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
- }
+ err = checkCollectionRateLimit(e, collection, "delete")
+ if err != nil {
+ return err
+ }
- event := new(core.RecordUpdateEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
- event.UploadedFiles = form.FilesToUpload()
+ recordId := e.Request.PathValue("id")
+ if recordId == "" {
+ return e.NotFoundError("", nil)
+ }
- // update the record
- return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(m *models.Record) error {
- event.Record = m
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("", err))
+ }
- return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
- if err := next(e.Record); err != nil {
- return NewBadRequestError("Failed to update record.", err)
+ if !requestInfo.HasSuperuserAuth() && collection.DeleteRule == nil {
+ return e.ForbiddenError("Only superusers can perform this action.", nil)
+ }
+
+ ruleFunc := func(q *dbx.SelectQuery) error {
+ if !requestInfo.HasSuperuserAuth() && collection.DeleteRule != nil && *collection.DeleteRule != "" {
+ resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
+
+ expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
+ if err != nil {
+ return err
}
- if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
- log.Println(err)
+ q.AndWhere(expr)
+
+ err = resolver.UpdateQuery(q)
+ if err != nil {
+ return err
}
+ }
+ return nil
+ }
+
+ record, err := e.App.FindRecordById(collection, recordId, ruleFunc)
+ if err != nil || record == nil {
+ return e.NotFoundError("", err)
+ }
- return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ var isOptFinalizerCalled bool
- return e.HttpContext.JSON(http.StatusOK, e.Record)
- })
+ event := new(core.RecordRequestEvent)
+ event.RequestEvent = e
+ event.Collection = collection
+ event.Record = record
+
+ hookErr := e.App.OnRecordDeleteRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
+ if err := e.App.Delete(e.Record); err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
+ }
+
+ err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
+ return e.NoContent(http.StatusNoContent)
})
+ if err != nil {
+ return err
+ }
+
+ if optFinalizer != nil {
+ isOptFinalizerCalled = true
+ err = optFinalizer(e.Record)
+ if err != nil {
+ return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
+ }
+ }
+
+ return nil
+ })
+ if hookErr != nil {
+ return hookErr
}
- })
+
+ // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
+ if !isOptFinalizerCalled && optFinalizer != nil {
+ if err := optFinalizer(event.Record); err != nil {
+ return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
+ }
+ }
+
+ return nil
+ }
}
-func (api *recordApi) delete(c echo.Context) error {
- collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
- if collection == nil {
- return NewNotFoundError("", "Missing collection context.")
+// -------------------------------------------------------------------
+
+func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[string]any, error) {
+ info, err := e.RequestInfo()
+ if err != nil {
+ return nil, err
}
- recordId := c.PathParam("id")
- if recordId == "" {
- return NewNotFoundError("", nil)
+ // resolve regular fields
+ result := record.ReplaceModifiers(info.Body)
+
+ // resolve uploaded files
+ uploadedFiles, err := extractUploadedFiles(e, record.Collection(), "")
+ if err != nil {
+ return nil, err
}
+ if len(uploadedFiles) > 0 {
+ for k, files := range uploadedFiles {
+ uploaded := make([]any, 0, len(files))
+
+ // if not remove/prepend/append -> merge with the submitted
+ // info.Body values to prevent accidental old files deletion
+ if info.Body[k] != nil &&
+ !strings.HasPrefix(k, "+") &&
+ !strings.HasSuffix(k, "+") &&
+ !strings.HasSuffix(k, "-") {
+ existing := list.ToUniqueStringSlice(info.Body[k])
+ for _, name := range existing {
+ uploaded = append(uploaded, name)
+ }
+ }
+
+ for _, file := range files {
+ uploaded = append(uploaded, file)
+ }
- requestInfo := RequestInfo(c)
+ result[k] = uploaded
+ }
- if requestInfo.Admin == nil && collection.DeleteRule == nil {
- // only admins can access if the rule is nil
- return NewForbiddenError("Only admins can perform this action.", nil)
+ result = record.ReplaceModifiers(result)
}
- ruleFunc := func(q *dbx.SelectQuery) error {
- if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
- resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
- expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
- if err != nil {
- return err
+ isAuth := record.Collection().IsAuth()
+
+ // unset hidden fields for non-superusers
+ if !info.HasSuperuserAuth() {
+ for _, f := range record.Collection().Fields {
+ if f.GetHidden() {
+ // exception for the auth collection "password" field
+ if isAuth && f.GetName() == core.FieldNamePassword {
+ continue
+ }
+
+ delete(result, f.GetName())
}
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
}
- return nil
}
- record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
- if fetchErr != nil || record == nil {
- return NewNotFoundError("", fetchErr)
+ return result, nil
+}
+
+func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, prefix string) (map[string][]*filesystem.File, error) {
+ contentType := re.Request.Header.Get("content-type")
+ if !strings.HasPrefix(contentType, "multipart/form-data") {
+ return nil, nil // not multipart/form-data request
}
- event := new(core.RecordDeleteEvent)
- event.HttpContext = c
- event.Collection = collection
- event.Record = record
+ result := map[string][]*filesystem.File{}
- return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
- // delete the record
- if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
- return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
+ for _, field := range collection.Fields {
+ if field.Type() != core.FieldTypeFile {
+ continue
}
- return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
+ baseKey := field.GetName()
+
+ keys := []string{
+ baseKey,
+ // prepend and append modifiers
+ "+" + baseKey,
+ baseKey + "+",
+ }
+
+ for _, k := range keys {
+ if prefix != "" {
+ k = prefix + "." + k
+ }
+ files, err := re.FindUploadedFiles(k)
+ if err != nil && !errors.Is(err, http.ErrMissingFile) {
+ return nil, err
}
+ if len(files) > 0 {
+ result[k] = files
+ }
+ }
+ }
- return e.HttpContext.NoContent(http.StatusNoContent)
- })
- })
+ return result, nil
}
-func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
- admin, _ := c.Get(ContextAdminKey).(*models.Admin)
- if admin != nil {
- return nil // admins are allowed to query everything
+// hasAuthManageAccess checks whether the client is allowed to have
+// [forms.RecordUpsert] auth management permissions
+// (e.g. allowing to change system auth fields without oldPassword).
+func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
+ if !collection.IsAuth() {
+ return false
}
- decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
- forbiddenFields := []string{"@collection.", "@request."}
+ manageRule := collection.ManageRule
- for _, field := range forbiddenFields {
- if strings.Contains(decodedQuery, field) {
- return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
- }
+ if manageRule == nil || *manageRule == "" {
+ return false // only for superusers (manageRule can't be empty)
+ }
+
+ if requestInfo == nil || requestInfo.Auth == nil {
+ return false // no auth record
+ }
+
+ resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
+
+ expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
+ if err != nil {
+ app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
+ return false
}
+ query.AndWhere(expr)
+
+ err = resolver.UpdateQuery(query)
+ if err != nil {
+ return false
+ }
+
+ var exists int
+
+ err = query.Limit(1).Row(&exists)
- return nil
+ return err == nil && exists > 0
}
diff --git a/apis/record_crud_auth_origin_test.go b/apis/record_crud_auth_origin_test.go
new file mode 100644
index 0000000000..98db76c1cc
--- /dev/null
+++ b/apis/record_crud_auth_origin_test.go
@@ -0,0 +1,314 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudAuthOriginList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ {
+ Name: "regular auth with authOrigins",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":1`,
+ `"totalPages":1`,
+ `"id":"9r2j0m74260ur8i"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "regular auth without authOrigins",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudAuthOriginView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`"id":"9r2j0m74260ur8i"`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudAuthOriginDelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudAuthOriginCreate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "recordRef": "4q1xlclmfloku33",
+ "collectionRef": "_pb_users_auth_",
+ "fingerprint": "abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"fingerprint":"abc"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudAuthOriginUpdate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "fingerprint":"abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"id":"9r2j0m74260ur8i"`,
+ `"fingerprint":"abc"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud_external_auth_test.go b/apis/record_crud_external_auth_test.go
new file mode 100644
index 0000000000..c4d1c4856d
--- /dev/null
+++ b/apis/record_crud_external_auth_test.go
@@ -0,0 +1,316 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudExternalAuthList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ {
+ Name: "regular auth with externalAuths",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":1`,
+ `"totalPages":1`,
+ `"id":"f1z5b3843pzc964"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "regular auth without externalAuths",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ Headers: map[string]string{
+ // users, test2@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudExternalAuthView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`"id":"dlmflokuq1xl342"`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudExternalAuthDelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudExternalAuthCreate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "recordRef": "4q1xlclmfloku33",
+ "collectionRef": "_pb_users_auth_",
+ "provider": "github",
+ "providerId": "abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"recordRef":"4q1xlclmfloku33"`,
+ `"providerId":"abc"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudExternalAuthUpdate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "providerId": "abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"id":"dlmflokuq1xl342"`,
+ `"providerId":"abc"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud_mfa_test.go b/apis/record_crud_mfa_test.go
new file mode 100644
index 0000000000..f82c446fca
--- /dev/null
+++ b/apis/record_crud_mfa_test.go
@@ -0,0 +1,405 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudMFAList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ {
+ Name: "regular auth with mfas",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":1`,
+ `"totalPages":1`,
+ `"id":"user1_0"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "regular auth without mfas",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudMFAView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`"id":"user1_0"`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudMFADelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudMFACreate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "recordRef": "4q1xlclmfloku33",
+ "collectionRef": "_pb_users_auth_",
+ "method": "abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedContent: []string{
+ `"recordRef":"4q1xlclmfloku33"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudMFAUpdate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "method":"abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedContent: []string{
+ `"id":"user1_0"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud_otp_test.go b/apis/record_crud_otp_test.go
new file mode 100644
index 0000000000..d7c44b82ed
--- /dev/null
+++ b/apis/record_crud_otp_test.go
@@ -0,0 +1,405 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudOTPList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ {
+ Name: "regular auth with otps",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":1`,
+ `"totalPages":1`,
+ `"id":"user1_0"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "regular auth without otps",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalItems":0`,
+ `"totalPages":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudOTPView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 404,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{`"id":"user1_0"`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudOTPDelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-owner auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudOTPCreate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "recordRef": "4q1xlclmfloku33",
+ "collectionRef": "_pb_users_auth_",
+ "password": "abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedContent: []string{
+ `"recordRef":"4q1xlclmfloku33"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudOTPUpdate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "password":"abc"
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "owner regular auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedContent: []string{
+ `"id":"user1_0"`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud_superuser_test.go b/apis/record_crud_superuser_test.go
new file mode 100644
index 0000000000..6c2ad58f24
--- /dev/null
+++ b/apis/record_crud_superuser_test.go
@@ -0,0 +1,336 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordCrudSuperuserList(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-superusers auth",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":4`,
+ `"items":[{`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 4,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudSuperuserView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-superusers auth",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodGet,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"sywbhecnh46rhm0"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudSuperuserDelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sbmbsdb40jyxf7h",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-superusers auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sbmbsdb40jyxf7h",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sbmbsdb40jyxf7h",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 204,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 4, // + 3 AuthOrigins
+ "OnModelDeleteExecute": 4,
+ "OnModelAfterDeleteSuccess": 4,
+ "OnRecordDelete": 4,
+ "OnRecordDeleteExecute": 4,
+ "OnRecordAfterDeleteSuccess": 4,
+ },
+ },
+ {
+ Name: "delete the last superuser",
+ Method: http.MethodDelete,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ // delete all other superusers
+ superusers, err := app.FindAllRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{"id": "sywbhecnh46rhm0"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, superuser := range superusers {
+ if err = app.Delete(superuser); err != nil {
+ t.Fatal(err)
+ }
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelAfterDeleteError": 1,
+ "OnRecordDelete": 1,
+ "OnRecordAfterDeleteError": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudSuperuserCreate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "email": "test_new@example.com",
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ "verified": false
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPost,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"collectionName":"_superusers"`,
+ `"email":"test_new@example.com"`,
+ `"verified":true`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
+
+func TestRecordCrudSuperuserUpdate(t *testing.T) {
+ t.Parallel()
+
+ body := func() *strings.Reader {
+ return strings.NewReader(`{
+ "email": "test_new@example.com",
+ "verified": true
+ }`)
+ }
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "non-superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Headers: map[string]string{
+ // users, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ },
+ Body: body(),
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superusers auth",
+ Method: http.MethodPatch,
+ URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
+ Headers: map[string]string{
+ // _superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ Body: body(),
+ ExpectedContent: []string{
+ `"collectionName":"_superusers"`,
+ `"id":"sywbhecnh46rhm0"`,
+ `"email":"test_new@example.com"`,
+ `"verified":true`,
+ },
+ ExpectedStatus: 200,
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnRecordEnrich": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordValidate": 1,
+ },
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/apis/record_crud_test.go b/apis/record_crud_test.go
index 0abeeaf115..c9110214bf 100644
--- a/apis/record_crud_test.go
+++ b/apis/record_crud_test.go
@@ -1,65 +1,82 @@
package apis_test
import (
- "errors"
+ "bytes"
"net/http"
"net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
"time"
- "github.com/labstack/echo/v5"
+ "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/types"
)
func TestRecordCrudList(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
- Url: "/api/collections/missing/records",
+ URL: "/api/collections/missing/records",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)",
+ Name: "unauthenticated trying to access nil rule collection (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records",
+ URL: "/api/collections/demo1/records",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authenticated record trying to access nil rule collection (aka. need admin auth)",
+ Name: "authenticated record trying to access nil rule collection (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/collections/demo1/records",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "public collection but with superuser only filter param (aka. @collection, @request, etc.)",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "public collection but with admin only filter/sort (aka. @collection)",
+ Name: "public collection but with superuser only sort param (aka. @collection, @request, etc.)",
Method: http.MethodGet,
- Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
+ URL: "/api/collections/demo2/records?sort=@request.auth.title",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)",
+ Name: "public collection but with ENCODED superuser only filter/sort (aka. @collection)",
Method: http.MethodGet,
- Url: "/api/collections/demo2/records?filter=%40collection.demo2.title%3D%27test1%27",
+ URL: "/api/collections/demo2/records?filter=%40collection.demo2.title%3D%27test1%27",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "public collection",
Method: http.MethodGet,
- Url: "/api/collections/demo2/records",
+ URL: "/api/collections/demo2/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -71,12 +88,16 @@ func TestRecordCrudList(t *testing.T) {
`"id":"achvryl401bhse3"`,
`"id":"llvuca81nly1qls"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
{
Name: "public collection (using the collection id)",
Method: http.MethodGet,
- Url: "/api/collections/sz5l5z67tg7gku0/records",
+ URL: "/api/collections/sz5l5z67tg7gku0/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -88,14 +109,18 @@ func TestRecordCrudList(t *testing.T) {
`"id":"achvryl401bhse3"`,
`"id":"llvuca81nly1qls"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
{
- Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)",
+ Name: "authorized as superuser trying to access nil rule collection (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -108,14 +133,18 @@ func TestRecordCrudList(t *testing.T) {
`"id":"84nmscqy84lsi1t"`,
`"id":"imy661ixudk5izi"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
{
Name: "valid query params",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records?filter=text~'test'&sort=-bool",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records?filter=text~'test'&sort=-bool",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -126,24 +155,29 @@ func TestRecordCrudList(t *testing.T) {
`"id":"al1h9ijdeojtsjy"`,
`"id":"84nmscqy84lsi1t"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 2,
+ },
},
{
Name: "invalid filter",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records?filter=invalid~'test'",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records?filter=invalid~'test'",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "expand relations",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -167,20 +201,24 @@ func TestRecordCrudList(t *testing.T) {
// subrel items
`"id":"0yxhwia2amd8gec"`,
`"id":"llvuca81nly1qls"`,
- // email visibility should be ignored for admins even in expanded rels
+ // email visibility should be ignored for superusers even in expanded rels
`"email":"test@example.com"`,
`"email":"test2@example.com"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 8,
+ },
},
{
Name: "authenticated record model that DOESN'T match the collection list rule",
Method: http.MethodGet,
- Url: "/api/collections/demo3/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo3/records",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -189,15 +227,122 @@ func TestRecordCrudList(t *testing.T) {
`"totalItems":0`,
`"items":[]`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
},
{
Name: "authenticated record that matches the collection list rule",
Method: http.MethodGet,
- Url: "/api/collections/demo3/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo3/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":4`,
+ `"items":[{`,
+ `"id":"1tmknxy2868d869"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"mk5fmymtx4wsprk"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 4,
+ },
+ },
+ {
+ Name: "authenticated regular record that matches the collection list rule with hidden field",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo3/records",
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ col.ListRule = types.Pointer("title ~ 'test'")
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":1`,
+ `"totalItems":4`,
+ `"items":[{`,
+ `"id":"1tmknxy2868d869"`,
+ `"id":"lcl9d87w22ml6jy"`,
+ `"id":"7nwo8tuiatetxdm"`,
+ `"id":"mk5fmymtx4wsprk"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 4,
+ },
+ },
+ {
+ Name: "authenticated regular record filtering with a hidden field",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo3/records?filter=title~'test'",
+ Headers: map[string]string{
// clients, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superuser filtering with a hidden field",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo3/records?filter=title~'test'",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -211,12 +356,16 @@ func TestRecordCrudList(t *testing.T) {
`"id":"7nwo8tuiatetxdm"`,
`"id":"mk5fmymtx4wsprk"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 4,
+ },
},
{
Name: ":rule modifer",
Method: http.MethodGet,
- Url: "/api/collections/demo5/records",
+ URL: "/api/collections/demo5/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -226,12 +375,38 @@ func TestRecordCrudList(t *testing.T) {
`"items":[{`,
`"id":"qjeql998mtp1azp"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
- Name: "multi-match - at least one of",
+ Name: "multi-match - at least one of (guest - non-satisfied relation filter API rule)",
Method: http.MethodGet,
- Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
+ URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"page":1`,
+ `"perPage":30`,
+ `"totalPages":0`,
+ `"totalItems":0`,
+ `"items":[]`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 0,
+ },
+ },
+ {
+ Name: "multi-match - at least one of (clients)",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -241,12 +416,20 @@ func TestRecordCrudList(t *testing.T) {
`"items":[{`,
`"id":"qzaqccwrmva4o1n"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
- Name: "multi-match - all",
- Method: http.MethodGet,
- Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
+ Name: "multi-match - all (clients)",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -255,7 +438,36 @@ func TestRecordCrudList(t *testing.T) {
`"totalItems":0`,
`"items":[]`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
+ },
+ {
+ Name: "OnRecordsListRequest tx body write check",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo4/records",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
@@ -263,7 +475,7 @@ func TestRecordCrudList(t *testing.T) {
{
Name: "check email visibility as guest",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records",
+ URL: "/api/collections/nologin/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -280,19 +492,23 @@ func TestRecordCrudList(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
`"email":"test@example.com"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
{
Name: "check email visibility as any authenticated record",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records",
+ Headers: map[string]string{
// clients, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -309,20 +525,24 @@ func TestRecordCrudList(t *testing.T) {
`"emailVisibility":false`,
},
NotExpectedContent: []string{
- `"tokenKey"`,
- `"passwordHash"`,
+ `"tokenKey":"`,
+ `"password":""`,
`"email":"test@example.com"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
{
Name: "check email visibility as manage auth record",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -342,16 +562,20 @@ func TestRecordCrudList(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
- Name: "check email visibility as admin",
+ Name: "check email visibility as superuser",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/nologin/records",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -371,17 +595,21 @@ func TestRecordCrudList(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
},
{
Name: "check self email visibility resolver",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records",
+ Headers: map[string]string{
// nologin, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.fdUPFLDx5b6RM_XFqnqsyiyNieyKA2HIIkRmUh9kIoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -400,10 +628,14 @@ func TestRecordCrudList(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 3,
+ },
},
// view collection
@@ -411,7 +643,7 @@ func TestRecordCrudList(t *testing.T) {
{
Name: "public view records",
Method: http.MethodGet,
- Url: "/api/collections/view2/records?filter=state=false",
+ URL: "/api/collections/view2/records?filter=state=false",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -426,12 +658,16 @@ func TestRecordCrudList(t *testing.T) {
`"created"`,
`"updated"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 2,
+ },
},
{
Name: "guest that doesn't match the view collection list rule",
Method: http.MethodGet,
- Url: "/api/collections/view1/records",
+ URL: "/api/collections/view1/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -440,15 +676,18 @@ func TestRecordCrudList(t *testing.T) {
`"totalItems":0`,
`"items":[]`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ },
},
{
Name: "authenticated record that matches the view collection list rule",
Method: http.MethodGet,
- Url: "/api/collections/view1/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/view1/records",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -460,12 +699,16 @@ func TestRecordCrudList(t *testing.T) {
`"id":"84nmscqy84lsi1t"`,
`"bool":true`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "view collection with numeric ids",
Method: http.MethodGet,
- Url: "/api/collections/numeric_id_view/records",
+ URL: "/api/collections/numeric_id_view/records",
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
@@ -476,7 +719,45 @@ func TestRecordCrudList(t *testing.T) {
`"id":"1"`,
`"id":"2"`,
},
- ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordsListRequest": 1,
+ "OnRecordEnrich": 2,
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - view2:list",
+ Method: http.MethodGet,
+ URL: "/api/collections/view2/records",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:list"},
+ {MaxRequests: 0, Label: "view2:list"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:list",
+ Method: http.MethodGet,
+ URL: "/api/collections/view2/records",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:list"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -486,93 +767,112 @@ func TestRecordCrudList(t *testing.T) {
}
func TestRecordCrudView(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
- Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ URL: "/api/collections/missing/records/0yxhwia2amd8gec",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "missing record",
Method: http.MethodGet,
- Url: "/api/collections/demo2/records/missing",
+ URL: "/api/collections/demo2/records/missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "unauthenticated trying to access nil rule collection (aka. need admin auth)",
+ Name: "unauthenticated trying to access nil rule collection (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authenticated record trying to access nil rule collection (aka. need admin auth)",
+ Name: "authenticated record trying to access nil rule collection (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authenticated record that doesn't match the collection view rule",
Method: http.MethodGet,
- Url: "/api/collections/users/records/bgs820n361vj1qd",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records/bgs820n361vj1qd",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "public collection view",
Method: http.MethodGet,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"0yxhwia2amd8gec"`,
`"collectionName":"demo2"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "public collection view (using the collection id)",
Method: http.MethodGet,
- Url: "/api/collections/sz5l5z67tg7gku0/records/0yxhwia2amd8gec",
+ URL: "/api/collections/sz5l5z67tg7gku0/records/0yxhwia2amd8gec",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"0yxhwia2amd8gec"`,
`"collectionName":"demo2"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
- Name: "authorized as admin trying to access nil rule collection view (aka. need admin auth)",
+ Name: "authorized as superuser trying to access nil rule collection view (aka. need superuser auth)",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"imy661ixudk5izi"`,
`"collectionName":"demo1"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "authenticated record that does match the collection view rule",
Method: http.MethodGet,
- Url: "/api/collections/users/records/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records/4q1xlclmfloku33",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -582,14 +882,18 @@ func TestRecordCrudView(t *testing.T) {
`"emailVisibility":false`,
`"email":"test@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "expand relations",
Method: http.MethodGet,
- Url: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -603,7 +907,37 @@ func TestRecordCrudView(t *testing.T) {
`"id":"0yxhwia2amd8gec"`,
`"collectionName":"demo2"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 7,
+ },
+ },
+ {
+ Name: "OnRecordViewRequest tx body write check",
+ Method: http.MethodGet,
+ URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
// auth collection
@@ -611,7 +945,7 @@ func TestRecordCrudView(t *testing.T) {
{
Name: "check email visibility as guest",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records/oos036e9xvqeexy",
+ URL: "/api/collections/nologin/records/oos036e9xvqeexy",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"oos036e9xvqeexy"`,
@@ -620,18 +954,22 @@ func TestRecordCrudView(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "check email visibility as any authenticated record",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records/oos036e9xvqeexy",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records/oos036e9xvqeexy",
+ Headers: map[string]string{
// clients, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -641,18 +979,22 @@ func TestRecordCrudView(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
`"email":"test3@example.com"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "check email visibility as manage auth record",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records/oos036e9xvqeexy",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records/oos036e9xvqeexy",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -661,14 +1003,18 @@ func TestRecordCrudView(t *testing.T) {
`"email":"test3@example.com"`,
`"verified":true`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
- Name: "check email visibility as admin",
+ Name: "check email visibility as superuser",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records/oos036e9xvqeexy",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/nologin/records/oos036e9xvqeexy",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -679,17 +1025,21 @@ func TestRecordCrudView(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
{
Name: "check self email visibility resolver",
Method: http.MethodGet,
- Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ Headers: map[string]string{
// nologin, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyMjA4OTg1MjYxfQ.DOYSon3x1-C0hJbwjEU6dp2-6oLeEa8bOlkyP1CinyM",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoia3B2NzA5c2sybHFicWs4IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.fdUPFLDx5b6RM_XFqnqsyiyNieyKA2HIIkRmUh9kIoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -700,9 +1050,13 @@ func TestRecordCrudView(t *testing.T) {
},
NotExpectedContent: []string{
`"tokenKey"`,
- `"passwordHash"`,
+ `"password"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
},
// view collection
@@ -710,7 +1064,7 @@ func TestRecordCrudView(t *testing.T) {
{
Name: "public view record",
Method: http.MethodGet,
- Url: "/api/collections/view2/records/84nmscqy84lsi1t",
+ URL: "/api/collections/view2/records/84nmscqy84lsi1t",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"84nmscqy84lsi1t"`,
@@ -722,22 +1076,27 @@ func TestRecordCrudView(t *testing.T) {
`"created"`,
`"updated"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "guest that doesn't match the view collection view rule",
Method: http.MethodGet,
- Url: "/api/collections/view1/records/84nmscqy84lsi1t",
+ URL: "/api/collections/view1/records/84nmscqy84lsi1t",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authenticated record that matches the view collection view rule",
Method: http.MethodGet,
- Url: "/api/collections/view1/records/84nmscqy84lsi1t",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/view1/records/84nmscqy84lsi1t",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -745,17 +1104,59 @@ func TestRecordCrudView(t *testing.T) {
`"bool":true`,
`"text":"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "view record with numeric id",
Method: http.MethodGet,
- Url: "/api/collections/numeric_id_view/records/1",
+ URL: "/api/collections/numeric_id_view/records/1",
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"1"`,
},
- ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordViewRequest": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - numeric_id_view:view",
+ Method: http.MethodGet,
+ URL: "/api/collections/numeric_id_view/records/1",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:view"},
+ {MaxRequests: 0, Label: "numeric_id_view:view"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:view",
+ Method: http.MethodGet,
+ URL: "/api/collections/numeric_id_view/records/1",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:view"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -765,12 +1166,14 @@ func TestRecordCrudView(t *testing.T) {
}
func TestRecordCrudDelete(t *testing.T) {
+ t.Parallel()
+
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
entries, _ := os.ReadDir(storageDir)
if len(entries) != 0 {
- t.Errorf("Expected empty/deleted dir, found %d", len(entries))
+ t.Errorf("Expected empty/deleted dir, found: %d\n%v", len(entries), entries)
}
}
@@ -778,139 +1181,170 @@ func TestRecordCrudDelete(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodDelete,
- Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ URL: "/api/collections/missing/records/0yxhwia2amd8gec",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "missing record",
Method: http.MethodDelete,
- Url: "/api/collections/demo2/records/missing",
+ URL: "/api/collections/demo2/records/missing",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "unauthenticated trying to delete nil rule collection (aka. need admin auth)",
+ Name: "unauthenticated trying to delete nil rule collection (aka. need superuser auth)",
Method: http.MethodDelete,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authenticated record trying to delete nil rule collection (aka. need admin auth)",
+ Name: "authenticated record trying to delete nil rule collection (aka. need superuser auth)",
Method: http.MethodDelete,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authenticated record that doesn't match the collection delete rule",
Method: http.MethodDelete,
- Url: "/api/collections/users/records/bgs820n361vj1qd",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records/bgs820n361vj1qd",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "trying to delete a view collection record",
Method: http.MethodDelete,
- Url: "/api/collections/view1/records/imy661ixudk5izi",
+ URL: "/api/collections/view1/records/imy661ixudk5izi",
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "public collection record delete",
Method: http.MethodDelete,
- Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ URL: "/api/collections/nologin/records/dc49k6jgejn40h3",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
},
},
{
Name: "public collection record delete (using the collection id as identifier)",
Method: http.MethodDelete,
- Url: "/api/collections/kpv709sk2lqbqk8/records/dc49k6jgejn40h3",
+ URL: "/api/collections/kpv709sk2lqbqk8/records/dc49k6jgejn40h3",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
},
},
{
- Name: "authorized as admin trying to delete nil rule collection view (aka. need admin auth)",
+ Name: "authorized as superuser trying to delete nil rule collection view (aka. need superuser auth)",
Method: http.MethodDelete,
- Url: "/api/collections/clients/records/o1y0dd0spd786md",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/clients/records/o1y0dd0spd786md",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
},
},
{
- Name: "OnRecordAfterDeleteRequest error response",
+ Name: "OnRecordDeleteRequest tx body write check",
Method: http.MethodDelete,
- Url: "/api/collections/clients/records/o1y0dd0spd786md",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
- return errors.New("error")
+ URL: "/api/collections/clients/records/o1y0dd0spd786md",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
- },
+ ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "authenticated record that match the collection delete rule",
Method: http.MethodDelete,
- Url: "/api/collections/users/records/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records/4q1xlclmfloku33",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 3, // +2 because of the external auths
- "OnModelBeforeDelete": 3, // +2 because of the external auths
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 3, // +2 for the externalAuths
+ "OnModelDeleteExecute": 3,
+ "OnModelAfterDeleteSuccess": 3,
+ "OnRecordDelete": 3,
+ "OnRecordDeleteExecute": 3,
+ "OnRecordAfterDeleteSuccess": 3,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnRecordUpdateExecute": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
// check if all the external auths records were deleted
- collection, _ := app.Dao().FindCollectionByNameOrId("users")
- record := models.NewRecord(collection)
- record.Id = "4q1xlclmfloku33"
- externalAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
+ collection, _ := app.FindCollectionByNameOrId("users")
+ record := core.NewRecord(collection)
+ record.Set("id", "4q1xlclmfloku33")
+ externalAuths, err := app.FindAllExternalAuthsByRecord(record)
if err != nil {
t.Errorf("Failed to fetch external auths: %v", err)
}
@@ -922,20 +1356,25 @@ func TestRecordCrudDelete(t *testing.T) {
{
Name: "@request :isset (rule failure check)",
Method: http.MethodDelete,
- Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "@request :isset (rule pass check)",
Method: http.MethodDelete,
- Url: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelAfterDelete": 1,
- "OnModelBeforeDelete": 1,
- "OnRecordAfterDeleteRequest": 1,
- "OnRecordBeforeDeleteRequest": 1,
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
},
},
@@ -944,55 +1383,82 @@ func TestRecordCrudDelete(t *testing.T) {
{
Name: "trying to delete a record while being part of a non-cascade required relation",
Method: http.MethodDelete,
- Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo3/records/7nwo8tuiatetxdm",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
- "OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 2, // the record itself + rel_one_cascade of test1 record
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteError": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteError": 2,
+ "OnModelUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
+ "OnModelUpdateExecute": 2,
+ "OnModelAfterUpdateError": 2,
+ "OnRecordUpdate": 2,
+ "OnRecordUpdateExecute": 2,
+ "OnRecordAfterUpdateError": 2,
},
},
{
Name: "delete a record with non-cascade references",
Method: http.MethodDelete,
- Url: "/api/collections/demo3/records/1tmknxy2868d869",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/demo3/records/1tmknxy2868d869",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 1,
- "OnModelAfterDelete": 1,
- "OnModelBeforeUpdate": 2,
- "OnModelAfterUpdate": 2,
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 1,
+ "OnModelDeleteExecute": 1,
+ "OnModelAfterDeleteSuccess": 1,
+ "OnRecordDelete": 1,
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ "OnModelUpdate": 2,
+ "OnModelUpdateExecute": 2,
+ "OnModelAfterUpdateSuccess": 2,
+ "OnRecordUpdate": 2,
+ "OnRecordUpdateExecute": 2,
+ "OnRecordAfterUpdateSuccess": 2,
},
},
{
Name: "delete a record with cascade references",
Method: http.MethodDelete,
- Url: "/api/collections/users/records/oap640cot4yru2s",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/collections/users/records/oap640cot4yru2s",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
- "OnModelBeforeDelete": 2,
- "OnModelAfterDelete": 2,
- "OnModelBeforeUpdate": 2,
- "OnModelAfterUpdate": 2,
- "OnRecordBeforeDeleteRequest": 1,
- "OnRecordAfterDeleteRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
+ "*": 0,
+ "OnRecordDeleteRequest": 1,
+ "OnModelDelete": 2,
+ "OnModelDeleteExecute": 2,
+ "OnModelAfterDeleteSuccess": 2,
+ "OnRecordDelete": 2,
+ "OnRecordDeleteExecute": 2,
+ "OnRecordAfterDeleteSuccess": 2,
+ "OnModelUpdate": 2,
+ "OnModelUpdateExecute": 2,
+ "OnModelAfterUpdateSuccess": 2,
+ "OnRecordUpdate": 2,
+ "OnRecordUpdateExecute": 2,
+ "OnRecordAfterUpdateSuccess": 2,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
recId := "84nmscqy84lsi1t"
- rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
+ rec, _ := app.FindRecordById("demo1", recId, nil)
if rec != nil {
t.Errorf("Expected record %s to be cascade deleted", recId)
}
@@ -1000,6 +1466,40 @@ func TestRecordCrudDelete(t *testing.T) {
ensureDeletedFiles(app, "_pb_users_auth_", "oap640cot4yru2s")
},
},
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - demo5:delete",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:delete"},
+ {MaxRequests: 0, Label: "demo5:delete"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:delete",
+ Method: http.MethodDelete,
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:delete"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
}
for _, scenario := range scenarios {
@@ -1008,6 +1508,8 @@ func TestRecordCrudDelete(t *testing.T) {
}
func TestRecordCrudCreate(t *testing.T) {
+ t.Parallel()
+
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test",
}, "files")
@@ -1015,73 +1517,108 @@ func TestRecordCrudCreate(t *testing.T) {
t.Fatal(err)
}
+ formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
+ router.JSONPayloadKey: `{"title": "title_test2", "testPayload": 123}`,
+ }, "files")
+ if err2 != nil {
+ t.Fatal(err2)
+ }
+
+ formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
+ router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123}`,
+ }, "files")
+ if err3 != nil {
+ t.Fatal(err3)
+ }
+
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodPost,
- Url: "/api/collections/missing/records",
+ URL: "/api/collections/missing/records",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest trying to access nil-rule collection",
Method: http.MethodPost,
- Url: "/api/collections/demo1/records",
+ URL: "/api/collections/demo1/records",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record trying to access nil-rule collection",
Method: http.MethodPost,
- Url: "/api/collections/demo1/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo1/records",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "trying to create a new view collection record",
Method: http.MethodPost,
- Url: "/api/collections/view1/records",
+ URL: "/api/collections/view1/records",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "submit nil body",
+ Name: "submit invalid body",
Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
- Body: nil,
+ URL: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "submit invalid format",
- Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
+ Name: "submit nil body",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo2/records",
+ Body: nil,
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"title":{"code":"validation_required"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterCreateError": 1,
+ },
},
{
Name: "submit empty json body",
Method: http.MethodPost,
- Url: "/api/collections/nologin/records",
+ URL: "/api/collections/nologin/records",
Body: strings.NewReader(`{}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"email":{"code":"validation_required"`,
`"password":{"code":"validation_required"`,
`"passwordConfirm":{"code":"validation_required"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ },
},
{
Name: "guest submit in public collection",
Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
+ URL: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1090,36 +1627,45 @@ func TestRecordCrudCreate(t *testing.T) {
`"active":false`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "guest trying to submit in restricted collection",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{"title":"test123"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule failure check)",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{"title":"test123"}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
Method: http.MethodPost,
- Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ URL: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
Body: strings.NewReader(`{
"title":"test123",
"rel_one_no_cascade":"mk5fmymtx4wsprk",
@@ -1129,14 +1675,15 @@ func TestRecordCrudCreate(t *testing.T) {
"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"title":"test123"`,
+ `"expand":{}`, // empty expand even because of the query param
`"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
`"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
`"rel_one_cascade":"mk5fmymtx4wsprk"`,
@@ -1146,23 +1693,29 @@ func TestRecordCrudCreate(t *testing.T) {
},
NotExpectedContent: []string{
// the users auth records don't have access to view the demo3 expands
- `"expand":{`,
`"missing"`,
`"id":"mk5fmymtx4wsprk"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"lcl9d87w22ml6jy"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
- Name: "admin submit in restricted collection (rule skip check) + expand relations",
+ Name: "superuser submit in restricted collection (rule skip check) + expand relations",
Method: http.MethodPost,
- Url: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ URL: "/api/collections/demo4/records?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
Body: strings.NewReader(`{
"title":"test123",
"rel_one_no_cascade":"mk5fmymtx4wsprk",
@@ -1172,8 +1725,8 @@ func TestRecordCrudCreate(t *testing.T) {
"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1194,20 +1747,27 @@ func TestRecordCrudCreate(t *testing.T) {
`"missing"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 4,
},
},
{
- Name: "submit via multipart form data",
+ Name: "superuser submit via multipart form data",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: formData,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": mp.FormDataContentType(),
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1216,44 +1776,131 @@ func TestRecordCrudCreate(t *testing.T) {
`"files":["`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
- Name: "unique field error check",
+ Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.body rule",
Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
- Body: strings.NewReader(`{
- "title":"test2"
- }`),
+ URL: "/api/collections/demo3/records",
+ Body: formData2,
+ Headers: map[string]string{
+ "Content-Type": mp2.FormDataContentType(),
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatalf("failed to find demo3 collection: %v", err)
+ }
+ collection.CreateRule = types.Pointer("@request.body.testPayload != 123")
+ if err := app.Save(collection); err != nil {
+ t.Fatalf("failed to update demo3 collection create rule: %v", err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo3/records",
+ Body: formData3,
+ Headers: map[string]string{
+ "Content-Type": mp3.FormDataContentType(),
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatalf("failed to find demo3 collection: %v", err)
+ }
+ collection.CreateRule = types.Pointer("@request.body.testPayload = 123")
+ if err := app.Save(collection); err != nil {
+ t.Fatalf("failed to update demo3 collection create rule: %v", err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"`,
+ `"title":"title_test3"`,
+ `"files":["`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "unique field error check",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{
+ "title":"test2"
+ }`),
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"title":{`,
`"code":"validation_not_unique"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateError": 1,
+ "OnModelValidate": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateError": 1,
+ "OnRecordValidate": 1,
+ },
},
{
- Name: "OnRecordAfterCreateRequest error response",
+ Name: "OnRecordCreateRequest tx body write check",
Method: http.MethodPost,
- Url: "/api/collections/demo2/records",
+ URL: "/api/collections/demo2/records",
Body: strings.NewReader(`{"title":"new"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
- return errors.New("error")
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- },
+ ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
// ID checks
@@ -1261,45 +1908,65 @@ func TestRecordCrudCreate(t *testing.T) {
{
Name: "invalid custom insertion id (less than 15 chars)",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "12345678901234",
"title": "test"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"id":{"code":"validation_length_invalid"`,
+ `"id":{"code":"validation_min_text_constraint"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterCreateError": 1,
},
},
{
Name: "invalid custom insertion id (more than 15 chars)",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "1234567890123456",
"title": "test"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
- `"id":{"code":"validation_length_invalid"`,
+ `"id":{"code":"validation_max_text_constraint"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterCreateError": 1,
},
},
{
Name: "valid custom insertion id (exactly 15 chars)",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "123456789012345",
"title": "test"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1307,22 +1974,29 @@ func TestRecordCrudCreate(t *testing.T) {
`"title":"test"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "valid custom insertion id existing in another non-auth collection",
Method: http.MethodPost,
- Url: "/api/collections/demo3/records",
+ URL: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "0yxhwia2amd8gec",
"title": "test"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1330,73 +2004,71 @@ func TestRecordCrudCreate(t *testing.T) {
`"title":"test"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "valid custom insertion auth id duplicating in another auth collection",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
+ URL: "/api/collections/users/records",
Body: strings.NewReader(`{
"id":"o1y0dd0spd786md",
"title":"test",
"password":"1234567890",
"passwordConfirm":"1234567890"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnRecordBeforeCreateRequest": 1,
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- },
-
- // fields modifier checks
- // -----------------------------------------------------------
- {
- Name: "trying to delete a record while being part of a non-cascade required relation",
- Method: http.MethodDelete,
- Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"id":{"code":"validation_invalid_auth_id"`,
},
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{
- "OnRecordBeforeDeleteRequest": 1,
- "OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
- "OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1, // unique constraints are handled on db level
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateError": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
},
},
- // check whether if @request.data modifer fields are properly resolved
+ // check whether if @request.body modifer fields are properly resolved
// -----------------------------------------------------------
{
- Name: "@request.data.field with compute modifers (rule failure check)",
+ Name: "@request.body.field with compute modifers (rule failure check)",
Method: http.MethodPost,
- Url: "/api/collections/demo5/records",
+ URL: "/api/collections/demo5/records",
Body: strings.NewReader(`{
- "total":1,
"total+":4,
- "total-":1
+ "total-":2
}`),
- ExpectedStatus: 400,
- ExpectedContent: []string{
- `"data":{}`,
- },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "@request.data.field with compute modifers (rule pass check)",
+ Name: "@request.body.field with compute modifers (rule pass check)",
Method: http.MethodPost,
- Url: "/api/collections/demo5/records",
+ URL: "/api/collections/demo5/records",
Body: strings.NewReader(`{
- "total":1,
- "total+":3,
+ "total+":4,
"total-":1
}`),
ExpectedStatus: 200,
@@ -1406,65 +2078,83 @@ func TestRecordCrudCreate(t *testing.T) {
`"total":3`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterCreate": 1,
- "OnModelBeforeCreate": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnRecordBeforeCreateRequest": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
// auth records
// -----------------------------------------------------------
{
- Name: "auth record with invalid data",
+ Name: "auth record with invalid form data",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
+ URL: "/api/collections/users/records",
Body: strings.NewReader(`{
- "id":"o1y0pd786mq",
- "username":"Users75657",
- "email":"invalid",
"password":"1234567",
- "passwordConfirm":"1234560"
+ "passwordConfirm":"1234560",
+ "email":"invalid",
+ "username":"Users75657"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"id":{"code":"validation_length_invalid"`,
- `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username
- `"email":{"code":"validation_is_email"`,
- `"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
NotExpectedContent: []string{
- // schema fields are not checked if the base fields has errors
- `"rel":{"code":`,
+ // record fields are not checked if the base auth form fields have errors
+ `"rel":`,
+ `"email":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
},
},
{
- Name: "auth record with valid base fields but invalid schema data",
+ Name: "auth record with valid form data but invalid record fields",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
+ URL: "/api/collections/users/records",
Body: strings.NewReader(`{
- "password":"12345678",
- "passwordConfirm":"12345678",
+ "password":"1234567",
+ "passwordConfirm":"1234567",
"rel":"invalid"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"rel":{"code":`,
+ `"password":{"code":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterCreateError": 1,
+ "OnRecordCreate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterCreateError": 1,
},
},
{
Name: "auth record with valid data and explicitly verified state by guest",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
+ URL: "/api/collections/users/records",
Body: strings.NewReader(`{
"password":"12345678",
"passwordConfirm":"12345678",
@@ -1475,14 +2165,19 @@ func TestRecordCrudCreate(t *testing.T) {
`"data":{`,
`"verified":{"code":`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ // no validation hooks because it should fail before save by the form auth fields validator
+ },
},
{
Name: "auth record with valid data and explicitly verified state by random user",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
Body: strings.NewReader(`{
"password":"12345678",
@@ -1498,11 +2193,16 @@ func TestRecordCrudCreate(t *testing.T) {
NotExpectedContent: []string{
`"emailVisibility":{"code":`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ // no validation hooks because it should fail before save by the form auth fields validator
+ },
},
{
- Name: "auth record with valid data by admin",
+ Name: "auth record with valid data by superuser",
Method: http.MethodPost,
- Url: "/api/collections/users/records",
+ URL: "/api/collections/users/records",
Body: strings.NewReader(`{
"id":"o1o1y0pd78686mq",
"username":"test.valid",
@@ -1513,8 +2213,8 @@ func TestRecordCrudCreate(t *testing.T) {
"emailVisibility":true,
"verified":true
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1529,19 +2229,25 @@ func TestRecordCrudCreate(t *testing.T) {
`"tokenKey"`,
`"password"`,
`"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterCreate": 1,
- "OnModelBeforeCreate": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnRecordBeforeCreateRequest": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "auth record with valid data by auth record with manage access",
Method: http.MethodPost,
- Url: "/api/collections/nologin/records",
+ URL: "/api/collections/nologin/records",
Body: strings.NewReader(`{
"email":"new@example.com",
"password":"12345678",
@@ -1550,9 +2256,9 @@ func TestRecordCrudCreate(t *testing.T) {
"emailVisibility":true,
"verified":true
}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1567,15 +2273,242 @@ func TestRecordCrudCreate(t *testing.T) {
`"tokenKey"`,
`"password"`,
`"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterCreate": 1,
- "OnModelBeforeCreate": 1,
- "OnRecordAfterCreateRequest": 1,
- "OnRecordBeforeCreateRequest": 1,
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
+
+ // ensure that hidden fields cannot be set by non-superusers
+ // -----------------------------------------------------------
+ {
+ Name: "create with hidden field as regular user",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "abcde1234567890",
+ "title": "test_create"
+ }`),
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, err := app.FindRecordById("demo3", "abcde1234567890")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the title wasn't saved
+ if v := record.GetString("title"); v != "" {
+ t.Fatalf("Expected empty title, got %q", v)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"abcde1234567890"`,
+ },
+ NotExpectedContent: []string{
+ `"title"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "create with hidden field as superuser",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo3/records",
+ Body: strings.NewReader(`{
+ "id": "abcde1234567890",
+ "title": "test_create"
+ }`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, err := app.FindRecordById("demo3", "abcde1234567890")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the title was saved
+ if v := record.GetString("title"); v != "test_create" {
+ t.Fatalf("Expected title %q, got %q", "test_create", v)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"abcde1234567890"`,
+ `"title":"test_create"`,
+ },
+ NotExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordCreateRequest": 1,
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnRecordCreate": 1,
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - demo2:create",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"title":"new"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:create"},
+ {MaxRequests: 0, Label: "demo2:create"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:create",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo2/records",
+ Body: strings.NewReader(`{"title":"new"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:create"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+
+ // dynamic body limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "body > collection BodyLimit",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/records",
+ // the exact body doesn't matter as long as it returns 413
+ Body: bytes.NewReader(make([]byte, apis.DefaultMaxBodySize+5+20+2+1)),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // adjust field sizes for the test
+ // ---
+ fileOneField := collection.Fields.GetByName("file_one").(*core.FileField)
+ fileOneField.MaxSize = 5
+
+ fileManyField := collection.Fields.GetByName("file_many").(*core.FileField)
+ fileManyField.MaxSize = 10
+ fileManyField.MaxSelect = 2
+
+ jsonField := collection.Fields.GetByName("json").(*core.JSONField)
+ jsonField.MaxSize = 2
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 413,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "body <= collection BodyLimit",
+ Method: http.MethodPost,
+ URL: "/api/collections/demo1/records",
+ // the exact body doesn't matter as long as it doesn't return 413
+ Body: bytes.NewReader(make([]byte, apis.DefaultMaxBodySize+5+20+2)),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // adjust field sizes for the test
+ // ---
+ fileOneField := collection.Fields.GetByName("file_one").(*core.FileField)
+ fileOneField.MaxSize = 5
+
+ fileManyField := collection.Fields.GetByName("file_many").(*core.FileField)
+ fileManyField.MaxSize = 10
+ fileManyField.MaxSelect = 2
+
+ jsonField := collection.Fields.GetByName("json").(*core.JSONField)
+ jsonField.MaxSize = 2
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
}
for _, scenario := range scenarios {
@@ -1584,6 +2517,8 @@ func TestRecordCrudCreate(t *testing.T) {
}
func TestRecordCrudUpdate(t *testing.T) {
+ t.Parallel()
+
formData, mp, err := tests.MockMultipartData(map[string]string{
"title": "title_test",
}, "files")
@@ -1591,60 +2526,95 @@ func TestRecordCrudUpdate(t *testing.T) {
t.Fatal(err)
}
+ formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
+ router.JSONPayloadKey: `{"title": "title_test2", "testPayload": 123}`,
+ }, "files")
+ if err2 != nil {
+ t.Fatal(err2)
+ }
+
+ formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
+ router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123, "files":"300_JdfBOieXAW.png"}`,
+ }, "files")
+ if err3 != nil {
+ t.Fatal(err3)
+ }
+
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodPatch,
- Url: "/api/collections/missing/records/0yxhwia2amd8gec",
+ URL: "/api/collections/missing/records/0yxhwia2amd8gec",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "guest trying to access nil-rule collection record",
Method: http.MethodPatch,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record trying to access nil-rule collection",
Method: http.MethodPatch,
- Url: "/api/collections/demo1/records/imy661ixudk5izi",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
- },
- {
- Name: "submit invalid body",
- Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
- Body: strings.NewReader(`{"`),
- ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "trying to update a view collection record",
Method: http.MethodPatch,
- Url: "/api/collections/view1/records/imy661ixudk5izi",
+ URL: "/api/collections/view1/records/imy661ixudk5izi",
Body: strings.NewReader(`{"text":"new"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "submit nil body",
+ Name: "submit invalid body",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
- Body: nil,
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "submit nil body (aka. no fields change)",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: nil,
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"collectionName":"demo2"`,
+ `"id":"0yxhwia2amd8gec"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
},
{
Name: "submit empty body (aka. no fields change)",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{}`),
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1652,27 +2622,44 @@ func TestRecordCrudUpdate(t *testing.T) {
`"id":"0yxhwia2amd8gec"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "trigger field validation",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"a"}`),
ExpectedStatus: 400,
ExpectedContent: []string{
`data":{`,
`"title":{"code":"validation_min_text_constraint"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterUpdateError": 1,
+ },
},
{
Name: "guest submit in public collection",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
Body: strings.NewReader(`{"title":"new"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1681,36 +2668,45 @@ func TestRecordCrudUpdate(t *testing.T) {
`"active":true`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
Name: "guest trying to submit in restricted collection",
Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: strings.NewReader(`{"title":"new"}`),
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule failure check)",
Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: strings.NewReader(`{"title":"new"}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
Method: http.MethodPatch,
- Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ URL: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
Body: strings.NewReader(`{
"title":"test123",
"rel_one_no_cascade":"mk5fmymtx4wsprk",
@@ -1720,14 +2716,15 @@ func TestRecordCrudUpdate(t *testing.T) {
"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"i9naidtvr6qsgb4"`,
`"title":"test123"`,
+ `"expand":{}`, // empty expand even because of the query param
`"rel_one_no_cascade":"mk5fmymtx4wsprk"`,
`"rel_one_no_cascade_required":"7nwo8tuiatetxdm"`,
`"rel_one_cascade":"mk5fmymtx4wsprk"`,
@@ -1737,23 +2734,29 @@ func TestRecordCrudUpdate(t *testing.T) {
},
NotExpectedContent: []string{
// the users auth records don't have access to view the demo3 expands
- `"expand":{`,
`"missing"`,
`"id":"mk5fmymtx4wsprk"`,
`"id":"7nwo8tuiatetxdm"`,
`"id":"lcl9d87w22ml6jy"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
- Name: "admin submit in restricted collection (rule skip check) + expand relations",
+ Name: "superuser submit in restricted collection (rule skip check) + expand relations",
Method: http.MethodPatch,
- Url: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
+ URL: "/api/collections/demo4/records/i9naidtvr6qsgb4?expand=missing,rel_one_no_cascade,rel_many_no_cascade_required",
Body: strings.NewReader(`{
"title":"test123",
"rel_one_no_cascade":"mk5fmymtx4wsprk",
@@ -1763,8 +2766,8 @@ func TestRecordCrudUpdate(t *testing.T) {
"rel_many_no_cascade_required":["7nwo8tuiatetxdm","lcl9d87w22ml6jy"],
"rel_many_cascade":"lcl9d87w22ml6jy"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1785,20 +2788,27 @@ func TestRecordCrudUpdate(t *testing.T) {
`"missing"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 4,
},
},
{
- Name: "submit via multipart form data",
+ Name: "superuser submit via multipart form data",
Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: formData,
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
"Content-Type": mp.FormDataContentType(),
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -1807,51 +2817,138 @@ func TestRecordCrudUpdate(t *testing.T) {
`"files":["`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
{
- Name: "OnRecordAfterUpdateRequest error response",
+ Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.body rule",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
- Body: strings.NewReader(`{"title":"new"}`),
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
- return errors.New("error")
- })
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: formData2,
+ Headers: map[string]string{
+ "Content-Type": mp2.FormDataContentType(),
},
- ExpectedStatus: 400,
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatalf("failed to find demo3 collection: %v", err)
+ }
+ collection.UpdateRule = types.Pointer("@request.body.testPayload != 123")
+ if err := app.Save(collection); err != nil {
+ t.Fatalf("failed to update demo3 collection update rule: %v", err)
+ }
+ },
+ ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ Body: formData3,
+ Headers: map[string]string{
+ "Content-Type": mp3.FormDataContentType(),
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatalf("failed to find demo3 collection: %v", err)
+ }
+ collection.UpdateRule = types.Pointer("@request.body.testPayload = 123")
+ if err := app.Save(collection); err != nil {
+ t.Fatalf("failed to update demo3 collection update rule: %v", err)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"mk5fmymtx4wsprk"`,
+ `"title":"title_test3"`,
+ `"files":["`,
+ `"300_JdfBOieXAW.png"`,
+ `"tmpfile_`,
+ },
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "OnRecordUpdateRequest tx body write check",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"title":"new"}`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
},
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
{
Name: "try to change the id of an existing record",
Method: http.MethodPatch,
- Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
+ URL: "/api/collections/demo3/records/mk5fmymtx4wsprk",
Body: strings.NewReader(`{
"id": "mk5fmymtx4wspra"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"id":{"code":"validation_in_invalid"`,
+ `"id":{"code":"validation_pk_change"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterUpdateError": 1,
},
},
{
Name: "unique field error check",
Method: http.MethodPatch,
- Url: "/api/collections/demo2/records/llvuca81nly1qls",
+ URL: "/api/collections/demo2/records/llvuca81nly1qls",
Body: strings.NewReader(`{
"title":"test2"
}`),
@@ -1862,17 +2959,25 @@ func TestRecordCrudUpdate(t *testing.T) {
`"code":"validation_not_unique"`,
},
ExpectedEvents: map[string]int{
- "OnRecordBeforeUpdateRequest": 1,
- "OnModelBeforeUpdate": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
},
},
- // check whether if @request.data modifer fields are properly resolved
+ // check whether if @request.body modifer fields are properly resolved
// -----------------------------------------------------------
{
- Name: "@request.data.field with compute modifers (rule failure check)",
+ Name: "@request.body.field with compute modifers (rule failure check)",
Method: http.MethodPatch,
- Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":3,
"total-":1
@@ -1881,11 +2986,12 @@ func TestRecordCrudUpdate(t *testing.T) {
ExpectedContent: []string{
`"data":{}`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "@request.data.field with compute modifers (rule pass check)",
+ Name: "@request.body.field with compute modifers (rule pass check)",
Method: http.MethodPatch,
- Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
+ URL: "/api/collections/demo5/records/la4y2w4o98acwuj",
Body: strings.NewReader(`{
"total+":2,
"total-":1
@@ -1897,66 +3003,84 @@ func TestRecordCrudUpdate(t *testing.T) {
`"total":3`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
},
},
// auth records
// -----------------------------------------------------------
{
- Name: "auth record with invalid data",
+ Name: "auth record with invalid form data",
Method: http.MethodPatch,
- Url: "/api/collections/users/records/bgs820n361vj1qd",
+ URL: "/api/collections/users/records/bgs820n361vj1qd",
Body: strings.NewReader(`{
- "username":"Users75657",
- "email":"invalid",
- "password":"1234567",
+ "password":"",
"passwordConfirm":"1234560",
+ "email":"invalid",
"verified":false
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
- `"username":{"code":"validation_invalid_username"`, // for duplicated case-insensitive username
- `"email":{"code":"validation_is_email"`,
- `"password":{"code":"validation_length_out_of_range"`,
- `"passwordConfirm":{"code":"validation_values_mismatch"`,
+ `"passwordConfirm":{`,
+ `"password":{`,
},
NotExpectedContent: []string{
- // admins are allowed to change the verified state
- `"verified"`,
- // schema fields are not checked if the base fields has errors
- `"rel":{"code":`,
+ // record fields are not checked if the base auth form fields have errors
+ `"email":`,
+ "verified", // superusers are allowed to change the verified state
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
},
},
{
- Name: "auth record with valid base fields but invalid schema data",
+ Name: "auth record with valid form data but invalid record fields",
Method: http.MethodPatch,
- Url: "/api/collections/users/records/bgs820n361vj1qd",
+ URL: "/api/collections/users/records/bgs820n361vj1qd",
Body: strings.NewReader(`{
- "password":"12345678",
- "passwordConfirm":"12345678",
+ "password":"1234567",
+ "passwordConfirm":"1234567",
"rel":"invalid"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"rel":{"code":`,
+ `"password":{"code":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelValidate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordAfterUpdateError": 1,
},
},
{
Name: "try to change account managing fields by guest",
Method: http.MethodPatch,
- Url: "/api/collections/nologin/records/phhq3wr65cap535",
+ URL: "/api/collections/nologin/records/phhq3wr65cap535",
Body: strings.NewReader(`{
"password":"12345678",
"passwordConfirm":"12345678",
@@ -1972,14 +3096,18 @@ func TestRecordCrudUpdate(t *testing.T) {
NotExpectedContent: []string{
`"emailVisibility":{"code":`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ },
},
{
Name: "try to change account managing fields by auth record (owner)",
Method: http.MethodPatch,
- Url: "/api/collections/users/records/4q1xlclmfloku33",
- RequestHeaders: map[string]string{
+ URL: "/api/collections/users/records/4q1xlclmfloku33",
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
Body: strings.NewReader(`{
"password":"12345678",
@@ -1996,11 +3124,38 @@ func TestRecordCrudUpdate(t *testing.T) {
NotExpectedContent: []string{
`"emailVisibility":{"code":`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ },
+ },
+ {
+ Name: "try to unset/downgrade email and verified fields (owner)",
+ Method: http.MethodPatch,
+ URL: "/api/collections/users/records/oap640cot4yru2s",
+ Headers: map[string]string{
+ // users, test2@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
+ },
+ Body: strings.NewReader(`{
+ "email":"",
+ "verified":false
+ }`),
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"email":{"code":`,
+ `"verified":{"code":`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ },
},
{
Name: "try to change account managing fields by auth record with managing rights",
Method: http.MethodPatch,
- Url: "/api/collections/nologin/records/phhq3wr65cap535",
+ URL: "/api/collections/nologin/records/phhq3wr65cap535",
Body: strings.NewReader(`{
"email":"new@example.com",
"password":"12345678",
@@ -2009,9 +3164,9 @@ func TestRecordCrudUpdate(t *testing.T) {
"emailVisibility":true,
"verified":true
}`),
- RequestHeaders: map[string]string{
+ Headers: map[string]string{
// users, test@example.com
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -2024,25 +3179,31 @@ func TestRecordCrudUpdate(t *testing.T) {
`"tokenKey"`,
`"password"`,
`"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, _ := app.FindRecordById("nologin", "phhq3wr65cap535")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
}
},
},
{
- Name: "update auth record with valid data by admin",
+ Name: "update auth record with valid data by superuser",
Method: http.MethodPatch,
- Url: "/api/collections/users/records/oap640cot4yru2s",
+ URL: "/api/collections/users/records/oap640cot4yru2s",
Body: strings.NewReader(`{
"username":"test.valid",
"email":"new@example.com",
@@ -2052,8 +3213,8 @@ func TestRecordCrudUpdate(t *testing.T) {
"emailVisibility":true,
"verified":false
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -2066,31 +3227,53 @@ func TestRecordCrudUpdate(t *testing.T) {
NotExpectedContent: []string{
`"tokenKey"`,
`"password"`,
- `"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, _ := app.FindRecordById("users", "oap640cot4yru2s")
if !record.ValidatePassword("12345678") {
t.Fatal("Password update failed.")
}
},
},
{
- Name: "update auth record with valid data by guest (empty update filter)",
+ Name: "update auth record with valid data by guest (empty update filter + auth origins check)",
Method: http.MethodPatch,
- Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ URL: "/api/collections/nologin/records/dc49k6jgejn40h3",
Body: strings.NewReader(`{
"username":"test_new",
"emailVisibility":true,
"name":"test"
}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ nologin, err := app.FindCollectionByNameOrId("nologin")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // add dummy auth origins for the record
+ for i := 0; i < 3; i++ {
+ d := core.NewAuthOrigin(app)
+ d.SetCollectionRef(nologin.Id)
+ d.SetRecordRef("dc49k6jgejn40h3")
+ d.SetFingerprint("abc_" + strconv.Itoa(i))
+ if err = app.Save(d); err != nil {
+ t.Fatalf("Failed to save dummy auth origin %d: %v", i, err)
+ }
+ }
+ },
ExpectedStatus: 200,
ExpectedContent: []string{
`"username":"test_new"`,
@@ -2103,24 +3286,59 @@ func TestRecordCrudUpdate(t *testing.T) {
`"tokenKey"`,
`"password"`,
`"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, _ := app.FindRecordById("nologin", "dc49k6jgejn40h3")
+
+ // the dummy auth origins should NOT have been removed since we didn't change the password
+ devices, err := app.FindAllAuthOriginsByRecord(record)
+ if err != nil {
+ t.Fatalf("Failed to retrieve dummy auth origins: %v", err)
+ }
+ if len(devices) != 3 {
+ t.Fatalf("Expected %d auth origins, got %d", 3, len(devices))
+ }
},
},
{
- Name: "success password change with oldPassword",
+ Name: "success password change with oldPassword (+authOrigins reset check)",
Method: http.MethodPatch,
- Url: "/api/collections/nologin/records/dc49k6jgejn40h3",
+ URL: "/api/collections/nologin/records/dc49k6jgejn40h3",
Body: strings.NewReader(`{
"password":"123456789",
"passwordConfirm":"123456789",
"oldPassword":"1234567890"
}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ nologin, err := app.FindCollectionByNameOrId("nologin")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // add dummy auth origins for the record
+ for i := 0; i < 3; i++ {
+ d := core.NewAuthOrigin(app)
+ d.SetCollectionRef(nologin.Id)
+ d.SetRecordRef("dc49k6jgejn40h3")
+ d.SetFingerprint("abc_" + strconv.Itoa(i))
+ if err = app.Save(d); err != nil {
+ t.Fatalf("Failed to save dummy auth origin %d: %v", i, err)
+ }
+ }
+ },
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"dc49k6jgejn40h3"`,
@@ -2129,21 +3347,261 @@ func TestRecordCrudUpdate(t *testing.T) {
`"tokenKey"`,
`"password"`,
`"passwordConfirm"`,
- `"passwordHash"`,
},
ExpectedEvents: map[string]int{
- "OnModelAfterUpdate": 1,
- "OnModelBeforeUpdate": 1,
- "OnRecordAfterUpdateRequest": 1,
- "OnRecordBeforeUpdateRequest": 1,
- },
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ // auth origins
+ "OnModelDelete": 3,
+ "OnModelDeleteExecute": 3,
+ "OnModelAfterDeleteSuccess": 3,
+ "OnRecordDelete": 3,
+ "OnRecordDeleteExecute": 3,
+ "OnRecordAfterDeleteSuccess": 3,
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, _ := app.FindRecordById("nologin", "dc49k6jgejn40h3")
if !record.ValidatePassword("123456789") {
t.Fatal("Password update failed.")
}
+
+ // the dummy auth origins should have been removed
+ devices, err := app.FindAllAuthOriginsByRecord(record)
+ if err != nil {
+ t.Fatalf("Failed to retrieve dummy auth origins: %v", err)
+ }
+ if len(devices) > 0 {
+ t.Fatalf("Expected auth origins to be removed, got %d", len(devices))
+ }
},
},
+
+ // ensure that hidden fields cannot be set by non-superusers
+ // -----------------------------------------------------------
+ {
+ Name: "update with hidden field as regular user",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo3/records/1tmknxy2868d869",
+ Body: strings.NewReader(`{
+ "title": "test_update"
+ }`),
+ Headers: map[string]string{
+ // clients, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, err := app.FindRecordById("demo3", "1tmknxy2868d869")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the title wasn't saved
+ if v := record.GetString("title"); v != "test1" {
+ t.Fatalf("Expected no title change, got %q", v)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"1tmknxy2868d869"`,
+ },
+ NotExpectedContent: []string{
+ `"title"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+ {
+ Name: "update with hidden field as superuser",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo3/records/1tmknxy2868d869",
+ Body: strings.NewReader(`{
+ "title": "test_update"
+ }`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock hidden field
+ col.Fields.GetByName("title").SetHidden(true)
+
+ if err = app.Save(col); err != nil {
+ t.Fatal(err)
+ }
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ record, err := app.FindRecordById("demo3", "1tmknxy2868d869")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the title has been updated
+ if v := record.GetString("title"); v != "test_update" {
+ t.Fatalf("Expected title %q, got %q", "test_update", v)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"id":"1tmknxy2868d869"`,
+ `"title":"test_update"`,
+ },
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnRecordUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnRecordUpdate": 1,
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnRecordValidate": 1,
+ "OnRecordEnrich": 1,
+ },
+ },
+
+ // rate limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "RateLimit rule - demo2:update",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"title":"new"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 100, Label: "*:update"},
+ {MaxRequests: 0, Label: "demo2:update"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "RateLimit rule - *:update",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
+ Body: strings.NewReader(`{"title":"new"}`),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.Settings().RateLimits.Enabled = true
+ app.Settings().RateLimits.Rules = []core.RateLimitRule{
+ {MaxRequests: 100, Label: "abc"},
+ {MaxRequests: 0, Label: "*:update"},
+ }
+ },
+ ExpectedStatus: 429,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+
+ // dynamic body limit checks
+ // -----------------------------------------------------------
+ {
+ Name: "body > collection BodyLimit",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ // the exact body doesn't matter as long as it returns 413
+ Body: bytes.NewReader(make([]byte, apis.DefaultMaxBodySize+5+20+2+1)),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // adjust field sizes for the test
+ // ---
+ fileOneField := collection.Fields.GetByName("file_one").(*core.FileField)
+ fileOneField.MaxSize = 5
+
+ fileManyField := collection.Fields.GetByName("file_many").(*core.FileField)
+ fileManyField.MaxSize = 10
+ fileManyField.MaxSelect = 2
+
+ jsonField := collection.Fields.GetByName("json").(*core.JSONField)
+ jsonField.MaxSize = 2
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 413,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "body <= collection BodyLimit",
+ Method: http.MethodPatch,
+ URL: "/api/collections/demo1/records/imy661ixudk5izi",
+ // the exact body doesn't matter as long as it doesn't return 413
+ Body: bytes.NewReader(make([]byte, apis.DefaultMaxBodySize+5+20+2)),
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // adjust field sizes for the test
+ // ---
+ fileOneField := collection.Fields.GetByName("file_one").(*core.FileField)
+ fileOneField.MaxSize = 5
+
+ fileManyField := collection.Fields.GetByName("file_many").(*core.FileField)
+ fileManyField.MaxSize = 10
+ fileManyField.MaxSelect = 2
+
+ jsonField := collection.Fields.GetByName("json").(*core.JSONField)
+ jsonField.MaxSize = 2
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
}
for _, scenario := range scenarios {
diff --git a/apis/record_helpers.go b/apis/record_helpers.go
index cb76901a04..27992d8dde 100644
--- a/apis/record_helpers.go
+++ b/apis/record_helpers.go
@@ -1,239 +1,472 @@
package apis
import (
+ "database/sql"
+ "errors"
"fmt"
- "log"
"net/http"
"strings"
+ "time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/mails"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
)
-const ContextRequestInfoKey = "requestInfo"
+const (
+ expandQueryParam = "expand"
+ fieldsQueryParam = "fields"
+)
-// Deprecated: Use RequestInfo instead.
-func RequestData(c echo.Context) *models.RequestInfo {
- log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
- return RequestInfo(c)
-}
+var ErrMFA = errors.New("mfa required")
-// RequestInfo exports cached common request data fields
-// (query, body, logged auth state, etc.) from the provided context.
-func RequestInfo(c echo.Context) *models.RequestInfo {
- // return cached to avoid copying the body multiple times
- if v := c.Get(ContextRequestInfoKey); v != nil {
- if data, ok := v.(*models.RequestInfo); ok {
- // refresh auth state
- data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
- data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
- return data
- }
+// RecordAuthResponse writes standardized json record auth response
+// into the specified request context.
+//
+// The authMethod argument specify the name of the current authentication method (eg. password, oauth2, etc.)
+// that it is used primarily as an auth identifier during MFA and for login alerts.
+//
+// Set authMethod to empty string if you want to ignore the MFA checks and the login alerts
+// (can be also adjusted additionally via the OnRecordAuthRequest hook).
+func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMethod string, meta any) error {
+ token, tokenErr := authRecord.NewAuthToken()
+ if tokenErr != nil {
+ return e.InternalServerError("Failed to create auth token.", tokenErr)
}
- result := &models.RequestInfo{
- Method: c.Request().Method,
- Query: map[string]any{},
- Data: map[string]any{},
- Headers: map[string]any{},
- }
+ return recordAuthResponse(e, authRecord, token, authMethod, meta)
+}
- // extract the first value of all headers and normalizes the keys
- // ("X-Token" is converted to "x_token")
- for k, v := range c.Request().Header {
- if len(v) > 0 {
- result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
+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"))
}
}
- result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
- result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
- echo.BindQueryParams(c, &result.Query)
- rest.BindBody(c, &result.Data)
-
- c.Set(ContextRequestInfoKey, result)
-
- return result
-}
+ originalRequestInfo, err := e.RequestInfo()
+ if err != nil {
+ return err
+ }
-// RecordAuthResponse writes standardised json record auth response
-// into the specified request context.
-func RecordAuthResponse(
- app core.App,
- c echo.Context,
- authRecord *models.Record,
- meta any,
- finalizers ...func(token string) error,
-) error {
- token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
- if tokenErr != nil {
- return NewBadRequestError("Failed to create auth token.", tokenErr)
+ ok, err := e.App.CanAccessRecord(authRecord, originalRequestInfo, authRecord.Collection().AuthRule)
+ if !ok {
+ return firstApiError(err, e.ForbiddenError("The request doesn't satisfy the collection requirements to authenticate.", err))
}
- event := new(core.RecordAuthEvent)
- event.HttpContext = c
+ event := new(core.RecordAuthRequestEvent)
+ event.RequestEvent = e
event.Collection = authRecord.Collection()
event.Record = authRecord
event.Token = token
event.Meta = meta
+ event.AuthMethod = authMethod
- return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
- if e.HttpContext.Response().Committed {
+ return e.App.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthRequestEvent) error {
+ if e.Written() {
return nil
}
- // allow always returning the email address of the authenticated account
- e.Record.IgnoreEmailVisibility(true)
-
- // expand record relations
- expands := strings.Split(c.QueryParam(expandQueryParam), ",")
- if len(expands) > 0 {
- // create a copy of the cached request data and adjust it to the current auth record
- requestInfo := *RequestInfo(e.HttpContext)
- requestInfo.Admin = nil
- requestInfo.AuthRecord = e.Record
- failed := app.Dao().ExpandRecord(
- e.Record,
- expands,
- expandFetch(app.Dao(), &requestInfo),
- )
- if len(failed) > 0 && app.IsDebug() {
- log.Println("Failed to expand relations: ", failed)
+ // MFA
+ // ---
+ mfaId, err := checkMFA(e.RequestEvent, e.Record, e.AuthMethod)
+ if err != nil {
+ return err
+ }
+
+ // require additional authentication
+ if mfaId != "" {
+ // eagerly write the mfa response and return an err so that
+ // external middlewars are aware that the auth response requires an extra step
+ e.JSON(http.StatusUnauthorized, map[string]string{
+ "mfaId": mfaId,
+ })
+ return ErrMFA
+ }
+ // ---
+
+ // create a shallow copy of the cached request data and adjust it to the current auth record
+ requestInfo := *originalRequestInfo
+ requestInfo.Auth = e.Record
+
+ err = triggerRecordEnrichHooks(e.App, &requestInfo, []*core.Record{e.Record}, func() error {
+ if e.Record.IsSuperuser() {
+ e.Record.Unhide(e.Record.Collection().Fields.FieldNames()...)
+ }
+
+ // allow always returning the email address of the authenticated model
+ e.Record.IgnoreEmailVisibility(true)
+
+ // expand record relations
+ expands := strings.Split(e.Request.URL.Query().Get(expandQueryParam), ",")
+ if len(expands) > 0 {
+ failed := e.App.ExpandRecord(e.Record, expands, expandFetch(e.App, &requestInfo))
+ if len(failed) > 0 {
+ e.App.Logger().Warn("[recordAuthResponse] Failed to expand relations", "error", failed)
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ if e.AuthMethod != "" && authRecord.Collection().AuthAlert.Enabled {
+ if err = authAlert(e.RequestEvent, e.Record); err != nil {
+ e.App.Logger().Warn("[recordAuthResponse] Failed to send login alert", "error", err)
}
}
- result := map[string]any{
- "token": e.Token,
- "record": e.Record,
+ result := struct {
+ Meta any `json:"meta,omitempty"`
+ Record *core.Record `json:"record"`
+ Token string `json:"token"`
+ }{
+ Token: e.Token,
+ Record: e.Record,
}
if e.Meta != nil {
- result["meta"] = e.Meta
+ result.Meta = e.Meta
}
- for _, f := range finalizers {
- if err := f(e.Token); err != nil {
- return err
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, result)
+ })
+ })
+}
+
+// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule
+// (note: returns true even in case of an error as a safer default).
+func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
+ rule := record.Collection().MFA.Rule
+ if rule == "" {
+ return true, nil
+ }
+
+ requestInfo, err := e.RequestInfo()
+ if err != nil {
+ return true, err
+ }
+
+ var exists int
+
+ query := e.App.RecordQuery(record.Collection()).
+ Select("(1)").
+ AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
+
+ // parse and apply the access rule filter
+ resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
+ expr, err := search.FilterData(rule).BuildExpr(resolver)
+ if err != nil {
+ return true, err
+ }
+
+ err = resolver.UpdateQuery(query)
+ if err != nil {
+ return true, err
+ }
+
+ err = query.AndWhere(expr).Limit(1).Row(&exists)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return true, err
+ }
+
+ return exists > 0, nil
+}
+
+// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
+// Returns the mfaId that needs to be written as response to the user.
+//
+// (note: all auth methods are treated as equal and there is no requirement for "pairing").
+func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod string) (string, error) {
+ if !authRecord.Collection().MFA.Enabled || currentAuthMethod == "" {
+ return "", nil
+ }
+
+ ok, err := wantsMFA(e, authRecord)
+ if err != nil {
+ return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
+ }
+ if !ok {
+ return "", nil // no mfa needed for this auth record
+ }
+
+ // read the mfaId either from the qyery params or request body
+ mfaId := e.Request.URL.Query().Get("mfaId")
+ if mfaId == "" {
+ // check the body
+ data := struct {
+ MfaId string `form:"mfaId" json:"mfaId" xml:"mfaId"`
+ }{}
+ if err := e.BindBody(&data); err != nil {
+ return "", firstApiError(err, e.BadRequestError("Failed to read MFA Id", err))
+ }
+ mfaId = data.MfaId
+ }
+
+ // first-time auth
+ // ---
+ if mfaId == "" {
+ mfa := core.NewMFA(e.App)
+ mfa.SetCollectionRef(authRecord.Collection().Id)
+ mfa.SetRecordRef(authRecord.Id)
+ mfa.SetMethod(currentAuthMethod)
+ if err := e.App.Save(mfa); err != nil {
+ return "", firstApiError(err, e.InternalServerError("Failed to create MFA record", err))
+ }
+
+ return mfa.Id, nil
+ }
+
+ // second-time auth
+ // ---
+ mfa, err := e.App.FindMFAById(mfaId)
+ deleteMFA := func() {
+ // try to delete the expired mfa
+ if mfa != nil {
+ if deleteErr := e.App.Delete(mfa); deleteErr != nil {
+ e.App.Logger().Warn("Failed to delete expired MFA record", "error", deleteErr, "mfaId", mfa.Id)
}
}
+ }
+ if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
+ deleteMFA()
+ return "", e.BadRequestError("Invalid or expired MFA session.", err)
+ }
- return e.HttpContext.JSON(http.StatusOK, result)
- })
+ if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
+ return "", e.BadRequestError("Invalid MFA session.", nil)
+ }
+
+ if mfa.Method() == currentAuthMethod {
+ return "", e.BadRequestError("A different authentication method is required.", nil)
+ }
+
+ deleteMFA()
+
+ return "", nil
}
// EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
-// are visibe only for the current logged admin, record owner or record with manage access
-func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
- return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
+// are visible only for the current logged superuser, record owner or record with manage access
+func EnrichRecord(e *core.RequestEvent, record *core.Record, defaultExpands ...string) error {
+ return EnrichRecords(e, []*core.Record{record}, defaultExpands...)
}
// EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations
-// are visibe only for the current logged admin, record owner or record with manage access
-func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
- requestInfo := RequestInfo(c)
+// are visible only for the current logged superuser, record owner or record with manage access
+//
+// Note: Expects all records to be from the same collection!
+func EnrichRecords(e *core.RequestEvent, records []*core.Record, defaultExpands ...string) error {
+ if len(records) == 0 {
+ return nil
+ }
+
+ info, err := e.RequestInfo()
+ if err != nil {
+ return err
+ }
+
+ return triggerRecordEnrichHooks(e.App, info, records, func() error {
+ expands := defaultExpands
+ if param := info.Query[expandQueryParam]; param != "" {
+ expands = append(expands, strings.Split(param, ",")...)
+ }
+
+ err := defaultEnrichRecords(e.App, info, records, expands...)
+ if err != nil {
+ // only log because it is not critical
+ e.App.Logger().Warn("failed to apply default enriching", "error", err)
+ }
+
+ return nil
+ })
+}
- if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
- return fmt.Errorf("Failed to resolve email visibility: %w", err)
+type iterator[T any] struct {
+ items []T
+ index int
+}
+
+func (ri *iterator[T]) next() T {
+ var item T
+
+ if ri.index < len(ri.items) {
+ item = ri.items[ri.index]
+ ri.index++
}
- expands := defaultExpands
- if param := c.QueryParam(expandQueryParam); param != "" {
- expands = append(expands, strings.Split(param, ",")...)
+ return item
+}
+
+func triggerRecordEnrichHooks(app core.App, requestInfo *core.RequestInfo, records []*core.Record, finalizer func() error) error {
+ it := iterator[*core.Record]{items: records}
+
+ enrichHook := app.OnRecordEnrich()
+
+ event := new(core.RecordEnrichEvent)
+ event.App = app
+ event.RequestInfo = requestInfo
+
+ var iterate func(record *core.Record) error
+ iterate = func(record *core.Record) error {
+ if record == nil {
+ return nil
+ }
+
+ event.Record = record
+
+ return enrichHook.Trigger(event, func(ee *core.RecordEnrichEvent) error {
+ next := it.next()
+ if next == nil {
+ if finalizer != nil {
+ return finalizer()
+ }
+ return nil
+ }
+
+ event.App = ee.App // in case it was replaced with a transaction
+ event.Record = next
+
+ err := iterate(next)
+
+ event.App = app
+ event.Record = record
+
+ return err
+ })
}
- if len(expands) == 0 {
- return nil // nothing to expand
+
+ return iterate(it.next())
+}
+
+func defaultEnrichRecords(app core.App, requestInfo *core.RequestInfo, records []*core.Record, expands ...string) error {
+ err := autoResolveRecordsFlags(app, records, requestInfo)
+ if err != nil {
+ return fmt.Errorf("failed to resolve records flags: %w", err)
}
- errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
- if len(errs) > 0 {
- return fmt.Errorf("Failed to expand: %v", errs)
+ if len(expands) > 0 {
+ expandErrs := app.ExpandRecords(records, expands, expandFetch(app, requestInfo))
+ if len(expandErrs) > 0 {
+ errsSlice := make([]error, 0, len(expandErrs))
+ for key, err := range expandErrs {
+ errsSlice = append(errsSlice, fmt.Errorf("failed to expand %q: %w", key, err))
+ }
+ return fmt.Errorf("failed to expand records: %w", errors.Join(errsSlice...))
+ }
}
return nil
}
// expandFetch is the records fetch function that is used to expand related records.
-func expandFetch(
- dao *daos.Dao,
- requestInfo *models.RequestInfo,
-) daos.ExpandFetchFunc {
- return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
- records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
- if requestInfo.Admin != nil {
- return nil // admins can access everything
+func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.ExpandFetchFunc {
+ // shallow clone the provided request info to set an "expand" context
+ requestInfoClone := *originalRequestInfo
+ requestInfoPtr := &requestInfoClone
+ requestInfoPtr.Context = core.RequestInfoContextExpand
+
+ return func(relCollection *core.Collection, relIds []string) ([]*core.Record, error) {
+ records, findErr := app.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
+ if requestInfoPtr.Auth != nil && requestInfoPtr.Auth.IsSuperuser() {
+ return nil // superusers can access everything
}
if relCollection.ViewRule == nil {
- return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
+ return fmt.Errorf("only superusers can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
- resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
+ resolver := core.NewRecordFieldResolver(app, relCollection, requestInfoPtr, true)
+
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
- resolver.UpdateQuery(q)
+
q.AndWhere(expr)
+
+ err = resolver.UpdateQuery(q)
+ if err != nil {
+ return err
+ }
}
return nil
})
+ if findErr != nil {
+ return nil, findErr
+ }
+
+ enrichErr := triggerRecordEnrichHooks(app, requestInfoPtr, records, func() error {
+ if err := autoResolveRecordsFlags(app, records, requestInfoPtr); err != nil {
+ // non-critical error
+ app.Logger().Warn("Failed to apply autoResolveRecordsFlags for the expanded records", "error", err)
+ }
- if err == nil && len(records) > 0 {
- autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
+ return nil
+ })
+ if enrichErr != nil {
+ return nil, enrichErr
}
- return records, err
+ return records, nil
}
}
-// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
-// the provided record if the current auth model is admin, owner or a "manager".
+// autoResolveRecordsFlags resolves various visibility flags of the provided records.
//
-// Note: Expects all records to be from the same auth collection!
-func autoIgnoreAuthRecordsEmailVisibility(
- dao *daos.Dao,
- records []*models.Record,
- requestInfo *models.RequestInfo,
-) error {
- if len(records) == 0 || !records[0].Collection().IsAuth() {
- return nil // nothing to check
+// Currently it enables:
+// - export of hidden fields if the current auth model is a superuser
+// - email export ignoring the emailVisibity checks if the current auth model is superuser, owner or a "manager".
+//
+// Note: Expects all records to be from the same collection!
+func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *core.RequestInfo) error {
+ if len(records) == 0 {
+ return nil // nothing to resolve
}
- if requestInfo.Admin != nil {
+ if requestInfo.HasSuperuserAuth() {
+ hiddenFields := records[0].Collection().Fields.FieldNames()
for _, rec := range records {
+ rec.Unhide(hiddenFields...)
rec.IgnoreEmailVisibility(true)
}
- return nil
+ }
+
+ // additional emailVisibility checks
+ // ---------------------------------------------------------------
+ if !records[0].Collection().IsAuth() {
+ return nil // not auth collection records
}
collection := records[0].Collection()
- mappedRecords := make(map[string]*models.Record, len(records))
+ mappedRecords := make(map[string]*core.Record, len(records))
recordIds := make([]any, len(records))
for i, rec := range records {
mappedRecords[rec.Id] = rec
recordIds[i] = rec.Id
}
- if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
- mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
+ if requestInfo.Auth != nil && mappedRecords[requestInfo.Auth.Id] != nil {
+ mappedRecords[requestInfo.Auth.Id].IgnoreEmailVisibility(true)
}
- authOptions := collection.AuthOptions()
- if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
+ if collection.ManageRule == nil || *collection.ManageRule == "" {
return nil // no manage rule to check
}
@@ -241,19 +474,25 @@ func autoIgnoreAuthRecordsEmailVisibility(
// ---
managedIds := []string{}
- query := dao.RecordQuery(collection).
- Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
- AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
+ query := app.RecordQuery(collection).
+ Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
+ AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
- resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
- expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
+ resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
+ expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
if err != nil {
return err
}
- resolver.UpdateQuery(query)
+
query.AndWhere(expr)
- if err := query.Column(&managedIds); err != nil {
+ err = resolver.UpdateQuery(query)
+ if err != nil {
+ return err
+ }
+
+ err = query.Column(&managedIds)
+ if err != nil {
return err
}
// ---
@@ -268,40 +507,158 @@ func autoIgnoreAuthRecordsEmailVisibility(
return nil
}
-// hasAuthManageAccess checks whether the client is allowed to have full
-// [forms.RecordUpsert] auth management permissions
-// (aka. allowing to change system auth fields without oldPassword).
-func hasAuthManageAccess(
- dao *daos.Dao,
- record *models.Record,
- requestInfo *models.RequestInfo,
-) bool {
- if !record.Collection().IsAuth() {
- return false
+var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
+var superuserOnlyRuleFields = []string{"@collection.", "@request."}
+
+// checkForSuperuserOnlyRuleFields loosely checks and returns an error if
+// the provided RequestInfo contains rule fields that only the superuser can use.
+func checkForSuperuserOnlyRuleFields(requestInfo *core.RequestInfo) error {
+ if len(requestInfo.Query) == 0 || requestInfo.HasSuperuserAuth() {
+ return nil // superuser or nothing to check
}
- manageRule := record.Collection().AuthOptions().ManageRule
+ for _, param := range ruleQueryParams {
+ v := requestInfo.Query[param]
+ if v == "" {
+ continue
+ }
- if manageRule == nil || *manageRule == "" {
- return false // only for admins (manageRule can't be empty)
+ for _, field := range superuserOnlyRuleFields {
+ if strings.Contains(v, field) {
+ return router.NewForbiddenError("Only superusers can filter by "+field, nil)
+ }
+ }
+ }
+
+ return nil
+}
+
+// firstApiError returns the first ApiError from the errors list
+// (this is used usually to prevent unnecessary wraping and to allow bubling ApiError from nested hooks)
+//
+// If no ApiError is found, returns a default "Internal server" error.
+func firstApiError(errs ...error) *router.ApiError {
+ var apiErr *router.ApiError
+ var ok bool
+
+ for _, err := range errs {
+ if err == nil {
+ continue
+ }
+
+ // quick assert to avoid the reflection checks
+ apiErr, ok = err.(*router.ApiError)
+ if ok {
+ return apiErr
+ }
+
+ // nested/wrapped errors
+ if errors.As(err, &apiErr) {
+ return apiErr
+ }
+ }
+
+ return router.NewInternalServerError("", errors.Join(errs...))
+}
+
+// execAfterSuccessTx ensures that fn is executed only after a successful transaction.
+//
+// If the current app instance is not a transactional or checkTx is false,
+// then fn is directly executed.
+//
+// It could be usually used to allow propagating an error or writing
+// custom response from within the wrapped transaction block.
+func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
+ if txInfo := app.TxInfo(); txInfo != nil && checkTx {
+ txInfo.OnComplete(func(txErr error) error {
+ if txErr == nil {
+ return fn()
+ }
+ return nil
+ })
+ return nil
+ }
+
+ return fn()
+}
+
+// -------------------------------------------------------------------
+
+const maxAuthOrigins = 5
+
+func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
+ // generate fingerprint
+ // ---
+ ip := e.RealIP()
+
+ userAgent := e.Request.UserAgent()
+ if len(userAgent) > 200 {
+ userAgent = userAgent[:200] + "..."
+ }
+
+ fingerprint := security.MD5(ip + userAgent)
+ alertInfo := fmt.Sprintf("%s - %s %s", types.NowDateTime().String(), ip, userAgent)
+ // ---
+
+ origins, err := e.App.FindAllAuthOriginsByRecord(authRecord)
+ if err != nil {
+ return err
}
- if requestInfo == nil || requestInfo.AuthRecord == nil {
- return false // no auth record
+ isFirstLogin := len(origins) == 0
+
+ var currentOrigin *core.AuthOrigin
+ for _, origin := range origins {
+ if origin.Fingerprint() == fingerprint {
+ currentOrigin = origin
+ break
+ }
}
+ if currentOrigin == nil {
+ currentOrigin = core.NewAuthOrigin(e.App)
+ currentOrigin.SetCollectionRef(authRecord.Collection().Id)
+ currentOrigin.SetRecordRef(authRecord.Id)
+ currentOrigin.SetFingerprint(fingerprint)
+ }
+
+ // send email alert for the new origin auth (skip first login)
+ //
+ // Note: The "fake" timeout is a temp solution to avoid blocking
+ // for too long when the SMTP server is not accessible, due
+ // to the lack of context cancellation support in the underlying
+ // mailer and net/smtp package.
+ // The goroutine technically "leaks" but we assume that the OS will
+ // terminate the connection after some time (usually after 3-4 mins).
+ if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
+ mailSent := make(chan error, 1)
+
+ timer := time.AfterFunc(15*time.Second, func() {
+ mailSent <- errors.New("auth alert mail send wait timeout reached")
+ })
- ruleFunc := func(q *dbx.SelectQuery) error {
- resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
- expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
+ routine.FireAndForget(func() {
+ err := mails.SendRecordAuthAlert(e.App, authRecord, alertInfo)
+ timer.Stop()
+ mailSent <- err
+ })
+
+ err = <-mailSent
if err != nil {
return err
}
- resolver.UpdateQuery(q)
- q.AndWhere(expr)
- return nil
}
- _, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
+ // try to keep only up to maxAuthOrigins
+ // (pop the last used ones; it is not executed in a transaction to avoid unnecessary locks)
+ if currentOrigin.IsNew() && len(origins) >= maxAuthOrigins {
+ for i := len(origins) - 1; i >= maxAuthOrigins-1; i-- {
+ if err := e.App.Delete(origins[i]); err != nil {
+ // treat as non-critical error, just log for now
+ e.App.Logger().Warn("Failed to delete old AuthOrigin record", "error", err, "authOriginId", origins[i].Id)
+ }
+ }
+ }
- return findErr == nil
+ // create/update the origin fingerprint
+ return e.App.Save(currentOrigin)
}
diff --git a/apis/record_helpers_test.go b/apis/record_helpers_test.go
index 70506c7872..43f0a98871 100644
--- a/apis/record_helpers_test.go
+++ b/apis/record_helpers_test.go
@@ -1,220 +1,797 @@
package apis_test
import (
- "encoding/json"
+ "encoding/json/v2"
+ "errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "time"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
- "github.com/pocketbase/pocketbase/models"
+ "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/types"
)
-func TestRequestInfo(t *testing.T) {
- e := echo.New()
- req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
- req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
- req.Header.Set("X-Token-Test", "123")
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
+func TestEnrichRecords(t *testing.T) {
+ t.Parallel()
- dummyRecord := &models.Record{}
- dummyRecord.Id = "id1"
- c.Set(apis.ContextAuthRecordKey, dummyRecord)
+ // mock test data
+ // ---
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ freshRecords := func(records []*core.Record) []*core.Record {
+ result := make([]*core.Record, len(records))
+ for i, r := range records {
+ result[i] = r.Fresh()
+ }
+ return result
+ }
- dummyAdmin := &models.Admin{}
- dummyAdmin.Id = "id2"
- c.Set(apis.ContextAdminKey, dummyAdmin)
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
- result := apis.RequestInfo(c)
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
- if result == nil {
- t.Fatal("Expected *models.RequestInfo instance, got nil")
+ usersRecords, err := app.FindRecordsByIds("users", []string{"4q1xlclmfloku33", "bgs820n361vj1qd"})
+ if err != nil {
+ t.Fatal(err)
}
- if result.Method != http.MethodPost {
- t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
+ nologinRecords, err := app.FindRecordsByIds("nologin", []string{"dc49k6jgejn40h3", "oos036e9xvqeexy"})
+ if err != nil {
+ t.Fatal(err)
}
- rawHeaders, _ := json.Marshal(result.Headers)
- expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
- if v := string(rawHeaders); v != expectedHeaders {
- t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
+ demo1Records, err := app.FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
+ if err != nil {
+ t.Fatal(err)
}
- rawQuery, _ := json.Marshal(result.Query)
- expectedQuery := `{"test":"123"}`
- if v := string(rawQuery); v != expectedQuery {
- t.Fatalf("Expected Query %v, got %v", expectedQuery, v)
+ demo5Records, err := app.FindRecordsByIds("demo5", []string{"la4y2w4o98acwuj", "qjeql998mtp1azp"})
+ if err != nil {
+ t.Fatal(err)
}
- rawData, _ := json.Marshal(result.Data)
- expectedData := `{"test":456}`
- if v := string(rawData); v != expectedData {
- t.Fatalf("Expected Data %v, got %v", expectedData, v)
+ // temp update the view rule to ensure that request context is set to "expand"
+ demo4, err := app.FindCollectionByNameOrId("demo4")
+ if err != nil {
+ t.Fatal(err)
+ }
+ demo4.ViewRule = types.Pointer("@request.context = 'expand'")
+ if err := app.Save(demo4); err != nil {
+ t.Fatal(err)
}
+ // ---
+
+ scenarios := []struct {
+ name string
+ auth *core.Record
+ records []*core.Record
+ queryExpand string
+ defaultExpands []string
+ expected []string
+ notExpected []string
+ }{
+ // email visibility checks
+ {
+ name: "[emailVisibility] guest",
+ auth: nil,
+ records: freshRecords(usersRecords),
+ queryExpand: "",
+ defaultExpands: nil,
+ expected: []string{
+ `"customField":"123"`,
+ `"test3@example.com"`, // emailVisibility=true
+ },
+ notExpected: []string{
+ `"test@example.com"`,
+ },
+ },
+ {
+ name: "[emailVisibility] owner",
+ auth: user,
+ records: freshRecords(usersRecords),
+ queryExpand: "",
+ defaultExpands: nil,
+ expected: []string{
+ `"customField":"123"`,
+ `"test3@example.com"`, // emailVisibility=true
+ `"test@example.com"`, // owner
+ },
+ },
+ {
+ name: "[emailVisibility] manager",
+ auth: user,
+ records: freshRecords(nologinRecords),
+ queryExpand: "",
+ defaultExpands: nil,
+ expected: []string{
+ `"customField":"123"`,
+ `"test3@example.com"`,
+ `"test@example.com"`,
+ },
+ },
+ {
+ name: "[emailVisibility] superuser",
+ auth: superuser,
+ records: freshRecords(nologinRecords),
+ queryExpand: "",
+ defaultExpands: nil,
+ expected: []string{
+ `"customField":"123"`,
+ `"test3@example.com"`,
+ `"test@example.com"`,
+ },
+ },
+ {
+ name: "[emailVisibility + expand] recursive auth rule checks (regular user)",
+ auth: user,
+ records: freshRecords(demo1Records),
+ queryExpand: "",
+ defaultExpands: []string{"rel_many"},
+ expected: []string{
+ `"customField":"123"`,
+ `"expand":{"rel_many"`,
+ `"expand":{}`,
+ `"test@example.com"`,
+ },
+ notExpected: []string{
+ `"id":"bgs820n361vj1qd"`,
+ `"id":"oap640cot4yru2s"`,
+ },
+ },
+ {
+ name: "[emailVisibility + expand] recursive auth rule checks (superuser)",
+ auth: superuser,
+ records: freshRecords(demo1Records),
+ queryExpand: "",
+ defaultExpands: []string{"rel_many"},
+ expected: []string{
+ `"customField":"123"`,
+ `"test@example.com"`,
+ `"expand":{"rel_many"`,
+ `"id":"bgs820n361vj1qd"`,
+ `"id":"4q1xlclmfloku33"`,
+ `"id":"oap640cot4yru2s"`,
+ },
+ notExpected: []string{
+ `"expand":{}`,
+ },
+ },
- if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id {
- t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord)
+ // expand checks
+ {
+ name: "[expand] guest (query)",
+ auth: nil,
+ records: freshRecords(usersRecords),
+ queryExpand: "rel",
+ defaultExpands: nil,
+ expected: []string{
+ `"customField":"123"`,
+ `"expand":{"rel"`,
+ `"id":"llvuca81nly1qls"`,
+ `"id":"0yxhwia2amd8gec"`,
+ },
+ notExpected: []string{
+ `"expand":{}`,
+ },
+ },
+ {
+ name: "[expand] guest (default expands)",
+ auth: nil,
+ records: freshRecords(usersRecords),
+ queryExpand: "",
+ defaultExpands: []string{"rel"},
+ expected: []string{
+ `"customField":"123"`,
+ `"expand":{"rel"`,
+ `"id":"llvuca81nly1qls"`,
+ `"id":"0yxhwia2amd8gec"`,
+ },
+ },
+ {
+ name: "[expand] @request.context=expand check",
+ auth: nil,
+ records: freshRecords(demo5Records),
+ queryExpand: "rel_one",
+ defaultExpands: []string{"rel_many"},
+ expected: []string{
+ `"customField":"123"`,
+ `"expand":{}`,
+ `"expand":{"`,
+ `"rel_many":[{`,
+ `"rel_one":{`,
+ `"id":"i9naidtvr6qsgb4"`,
+ `"id":"qzaqccwrmva4o1n"`,
+ },
+ },
}
- if result.Admin == nil || result.Admin.Id != dummyAdmin.Id {
- t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin)
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.OnRecordEnrich().BindFunc(func(e *core.RecordEnrichEvent) error {
+ e.Record.WithCustomData(true)
+ e.Record.Set("customField", "123")
+ return e.Next()
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/?expand="+s.queryExpand, nil)
+ rec := httptest.NewRecorder()
+
+ requestEvent := new(core.RequestEvent)
+ requestEvent.App = app
+ requestEvent.Request = req
+ requestEvent.Response = rec
+ requestEvent.Auth = s.auth
+
+ err := apis.EnrichRecords(requestEvent, s.records, s.defaultExpands...)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(s.records)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ for _, str := range s.expected {
+ if !strings.Contains(rawStr, str) {
+ t.Fatalf("Expected\n%q\nin\n%v", str, rawStr)
+ }
+ }
+
+ for _, str := range s.notExpected {
+ if strings.Contains(rawStr, str) {
+ t.Fatalf("Didn't expected\n%q\nin\n%v", str, rawStr)
+ }
+ }
+ })
}
}
-func TestRecordAuthResponse(t *testing.T) {
+func TestRecordAuthResponseAuthRuleCheck(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- dummyAdmin := &models.Admin{}
- dummyAdmin.Id = "id1"
+ event := new(core.RequestEvent)
+ event.App = app
+ event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
+ event.Response = httptest.NewRecorder()
- nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
- authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
- if err != nil {
- t.Fatal(err)
+ scenarios := []struct {
+ name string
+ rule *string
+ expectError bool
+ }{
+ {
+ "admin only rule",
+ nil,
+ true,
+ },
+ {
+ "empty rule",
+ types.Pointer(""),
+ false,
+ },
+ {
+ "false rule",
+ types.Pointer("1=2"),
+ true,
+ },
+ {
+ "true rule",
+ types.Pointer("1=1"),
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ user.Collection().AuthRule = s.rule
+
+ err := apis.RecordAuthResponse(event, user, "", nil)
+
+ hasErr := err != nil
+ if s.expectError != hasErr {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ // in all cases login alert shouldn't be send because of the empty auth method
+ if app.TestMailer.TotalSend() != 0 {
+ t.Fatalf("Expected no emails send, got %d:\n%v", app.TestMailer.TotalSend(), app.TestMailer.LastMessage().HTML)
+ }
+
+ if !hasErr {
+ return
+ }
+
+ apiErr, ok := err.(*router.ApiError)
+
+ if !ok || apiErr == nil {
+ t.Fatalf("Expected ApiError, got %v", apiErr)
+ }
+
+ if apiErr.Status != http.StatusForbidden {
+ t.Fatalf("Expected ApiError.Status %d, got %d", http.StatusForbidden, apiErr.Status)
+ }
+ })
}
+}
+
+func TestRecordAuthResponseAuthAlertCheck(t *testing.T) {
+ const testFingerprint = "d0f88d6c87767262ba8e93d6acccd784"
scenarios := []struct {
- name string
- record *models.Record
- meta any
- expectError bool
- expectedContent []string
- notExpectedContent []string
- expectedEvents map[string]int
+ name string
+ devices []string // mock existing device fingerprints
+ expectDevices []string
+ enabled bool
+ expectEmail bool
}{
{
- name: "non auth record",
- record: nonAuthRecord,
- expectError: true,
+ name: "first login",
+ devices: nil,
+ expectDevices: []string{testFingerprint},
+ enabled: true,
+ expectEmail: false,
},
{
- name: "valid auth record - without meta",
- record: authRecord,
- expectError: false,
- expectedContent: []string{
- `"token":"`,
- `"record":{`,
- `"id":"`,
- `"expand":{"rel":{`,
- },
- notExpectedContent: []string{
- `"meta":`,
- },
- expectedEvents: map[string]int{
- "OnRecordAuthRequest": 1,
- },
+ name: "existing device",
+ devices: []string{"1", testFingerprint},
+ expectDevices: []string{"1", testFingerprint},
+ enabled: true,
+ expectEmail: false,
},
{
- name: "valid auth record - with meta",
- record: authRecord,
- meta: map[string]any{"meta_test": 123},
- expectError: false,
- expectedContent: []string{
- `"token":"`,
- `"record":{`,
- `"id":"`,
- `"expand":{"rel":{`,
- `"meta":{"meta_test":123`,
- },
- expectedEvents: map[string]int{
- "OnRecordAuthRequest": 1,
- },
+ name: "new device (< 5)",
+ devices: []string{"1", "2"},
+ expectDevices: []string{"1", "2", testFingerprint},
+ enabled: true,
+ expectEmail: true,
+ },
+ {
+ name: "new device (>= 5)",
+ devices: []string{"1", "2", "3", "4", "5"},
+ expectDevices: []string{"2", "3", "4", "5", testFingerprint},
+ enabled: true,
+ expectEmail: true,
+ },
+ {
+ name: "with disabled auth alert collection flag",
+ devices: []string{"1", "2"},
+ expectDevices: []string{"1", "2"},
+ enabled: false,
+ expectEmail: false,
},
}
for _, s := range scenarios {
- app.ResetEventCalls()
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ event := new(core.RequestEvent)
+ event.App = app
+ event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
+ event.Response = httptest.NewRecorder()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
- e := echo.New()
- req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
- req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
- rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- c.Set(apis.ContextAdminKey, dummyAdmin)
+ user.Collection().MFA.Enabled = false
+ user.Collection().AuthRule = types.Pointer("")
+ user.Collection().AuthAlert.Enabled = s.enabled
- responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
+ // ensure that there are no other auth origins
+ err = app.DeleteAllAuthOriginsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
- hasErr := responseErr != nil
- if hasErr != s.expectError {
- t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
- }
+ mockCreated := types.NowDateTime().Add(-time.Duration(len(s.devices)+1) * time.Second)
+ // insert the mock devices
+ for _, fingerprint := range s.devices {
+ mockCreated = mockCreated.Add(1 * time.Second)
+ d := core.NewAuthOrigin(app)
+ d.SetCollectionRef(user.Collection().Id)
+ d.SetRecordRef(user.Id)
+ d.SetFingerprint(fingerprint)
+ d.SetRaw("created", mockCreated)
+ d.SetRaw("updated", mockCreated)
+ if err = app.Save(d); err != nil {
+ t.Fatal(err)
+ }
+ }
- if len(app.EventCalls) != len(s.expectedEvents) {
- t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
- }
- for k, v := range s.expectedEvents {
- if app.EventCalls[k] != v {
- t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if err != nil {
+ t.Fatalf("Failed to resolve auth response: %v", err)
}
- }
- if hasErr {
- continue
- }
+ var expectTotalSend int
+ if s.expectEmail {
+ expectTotalSend = 1
+ }
+ if total := app.TestMailer.TotalSend(); total != expectTotalSend {
+ t.Fatalf("Expected %d sent emails, got %d", expectTotalSend, total)
+ }
- response := rec.Body.String()
+ devices, err := app.FindAllAuthOriginsByRecord(user)
+ if err != nil {
+ t.Fatalf("Failed to retrieve auth origins: %v", err)
+ }
- for _, v := range s.expectedContent {
- if !strings.Contains(response, v) {
- t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
+ if len(devices) != len(s.expectDevices) {
+ t.Fatalf("Expected %d devices, got %d", len(s.expectDevices), len(devices))
}
- }
- for _, v := range s.notExpectedContent {
- if strings.Contains(response, v) {
- t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
+ for _, fingerprint := range s.expectDevices {
+ var exists bool
+ fingerprints := make([]string, 0, len(devices))
+ for _, d := range devices {
+ if d.Fingerprint() == fingerprint {
+ exists = true
+ break
+ }
+ fingerprints = append(fingerprints, d.Fingerprint())
+ }
+ if !exists {
+ t.Fatalf("Missing device with fingerprint %q:\n%v", fingerprint, fingerprints)
+ }
}
- }
+ })
}
}
-func TestEnrichRecords(t *testing.T) {
- e := echo.New()
- req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
- req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+func TestRecordAuthResponseMFACheck(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
rec := httptest.NewRecorder()
- c := e.NewContext(req, rec)
- dummyAdmin := &models.Admin{}
- dummyAdmin.Id = "test_id"
- c.Set(apis.ContextAdminKey, dummyAdmin)
+ event := new(core.RequestEvent)
+ event.App = app
+ event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
+ event.Response = rec
+
+ resetMFAs := func(authRecord *core.Record) {
+ // ensure that mfa is enabled
+ user.Collection().MFA.Enabled = true
+ user.Collection().MFA.Duration = 5
+ user.Collection().MFA.Rule = ""
+
+ mfas, err := app.FindAllMFAsByRecord(authRecord)
+ if err != nil {
+ t.Fatalf("Failed to retrieve mfas: %v", err)
+ }
+ for _, mfa := range mfas {
+ if err := app.Delete(mfa); err != nil {
+ t.Fatalf("Failed to delete mfa %q: %v", mfa.Id, err)
+ }
+ }
+
+ // reset response
+ rec = httptest.NewRecorder()
+ event.Response = rec
+ }
+
+ totalMFAs := func(authRecord *core.Record) int {
+ mfas, err := app.FindAllMFAsByRecord(authRecord)
+ if err != nil {
+ t.Fatalf("Failed to retrieve mfas: %v", err)
+ }
+ return len(mfas)
+ }
+
+ t.Run("no collection MFA enabled", func(t *testing.T) {
+ resetMFAs(user)
+ user.Collection().MFA.Enabled = false
+
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if err != nil {
+ t.Fatalf("Expected nil, got error: %v", err)
+ }
+
+ body := rec.Body.String()
+ if strings.Contains(body, "mfaId") {
+ t.Fatalf("Expected no mfaId in the response body, got\n%v", body)
+ }
+ if !strings.Contains(body, "token") {
+ t.Fatalf("Expected auth token in the response body, got\n%v", body)
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected no mfa records to be created, got %d", total)
+ }
+ })
+
+ t.Run("no explicit auth method", func(t *testing.T) {
+ resetMFAs(user)
+
+ err = apis.RecordAuthResponse(event, user, "", nil)
+ if err != nil {
+ t.Fatalf("Expected nil, got error: %v", err)
+ }
+
+ body := rec.Body.String()
+ if strings.Contains(body, "mfaId") {
+ t.Fatalf("Expected no mfaId in the response body, got\n%v", body)
+ }
+ if !strings.Contains(body, "token") {
+ t.Fatalf("Expected auth token in the response body, got\n%v", body)
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected no mfa records to be created, got %d", total)
+ }
+ })
+
+ t.Run("no mfa wanted (mfa rule check failure)", func(t *testing.T) {
+ resetMFAs(user)
+ user.Collection().MFA.Rule = "1=2"
+
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if err != nil {
+ t.Fatalf("Expected nil, got error: %v", err)
+ }
+
+ body := rec.Body.String()
+ if strings.Contains(body, "mfaId") {
+ t.Fatalf("Expected no mfaId in the response body, got\n%v", body)
+ }
+ if !strings.Contains(body, "token") {
+ t.Fatalf("Expected auth token in the response body, got\n%v", body)
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected no mfa records to be created, got %d", total)
+ }
+ })
+
+ t.Run("mfa wanted (mfa rule check success)", func(t *testing.T) {
+ resetMFAs(user)
+ user.Collection().MFA.Rule = "1=1"
+
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if !errors.Is(err, apis.ErrMFA) {
+ t.Fatalf("Expected ErrMFA, got: %v", err)
+ }
+
+ body := rec.Body.String()
+ if !strings.Contains(body, "mfaId") {
+ t.Fatalf("Expected the created mfaId to be returned in the response body, got\n%v", body)
+ }
+
+ if total := totalMFAs(user); total != 1 {
+ t.Fatalf("Expected a single mfa record to be created, got %d", total)
+ }
+ })
+
+ t.Run("mfa first-time", func(t *testing.T) {
+ resetMFAs(user)
+
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if !errors.Is(err, apis.ErrMFA) {
+ t.Fatalf("Expected ErrMFA, got: %v", err)
+ }
+
+ body := rec.Body.String()
+ if !strings.Contains(body, "mfaId") {
+ t.Fatalf("Expected the created mfaId to be returned in the response body, got\n%v", body)
+ }
+
+ if total := totalMFAs(user); total != 1 {
+ t.Fatalf("Expected a single mfa record to be created, got %d", total)
+ }
+ })
+
+ t.Run("mfa second-time with the same auth method", func(t *testing.T) {
+ resetMFAs(user)
+
+ // create a dummy mfa record
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("example")
+ if err = app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/?mfaId="+mfa.Id, nil)
+
+ err = apis.RecordAuthResponse(event, user, "example", nil)
+ if err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if total := totalMFAs(user); total != 1 {
+ t.Fatalf("Expected only 1 mfa record (the existing one), got %d", total)
+ }
+ })
+
+ t.Run("mfa second-time with the different auth method (query param)", func(t *testing.T) {
+ resetMFAs(user)
+
+ // create a dummy mfa record
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("example1")
+ if err = app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/?mfaId="+mfa.Id, nil)
+
+ err = apis.RecordAuthResponse(event, user, "example2", nil)
+ if err != nil {
+ t.Fatalf("Expected nil, got error: %v", err)
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected the dummy mfa record to be deleted, found %d", total)
+ }
+ })
+
+ t.Run("mfa second-time with the different auth method (body param)", func(t *testing.T) {
+ resetMFAs(user)
+
+ // create a dummy mfa record
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("example1")
+ if err = app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{"mfaId":"`+mfa.Id+`"}`))
+ event.Request.Header.Add("content-type", "application/json")
+
+ err = apis.RecordAuthResponse(event, user, "example2", nil)
+ if err != nil {
+ t.Fatalf("Expected nil, got error: %v", err)
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected the dummy mfa record to be deleted, found %d", total)
+ }
+ })
+
+ t.Run("missing mfa", func(t *testing.T) {
+ resetMFAs(user)
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/?mfaId=missing", nil)
+
+ err = apis.RecordAuthResponse(event, user, "example2", nil)
+ if err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected 0 mfa records, got %d", total)
+ }
+ })
+
+ t.Run("expired mfa", func(t *testing.T) {
+ resetMFAs(user)
+
+ // create a dummy expired mfa record
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("example1")
+ mfa.SetRaw("created", types.NowDateTime().Add(-1*time.Hour))
+ mfa.SetRaw("updated", types.NowDateTime().Add(-1*time.Hour))
+ if err = app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/?mfaId="+mfa.Id, nil)
+
+ err = apis.RecordAuthResponse(event, user, "example2", nil)
+ if err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if totalMFAs(user) != 0 {
+ t.Fatal("Expected the expired mfa record to be deleted")
+ }
+ })
+
+ t.Run("mfa for different auth record", func(t *testing.T) {
+ resetMFAs(user)
+
+ // create a dummy expired mfa record
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user2.Collection().Id)
+ mfa.SetRecordRef(user2.Id)
+ mfa.SetMethod("example1")
+ if err = app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+
+ event.Request = httptest.NewRequest(http.MethodGet, "/?mfaId="+mfa.Id, nil)
+
+ err = apis.RecordAuthResponse(event, user, "example2", nil)
+ if err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ if total := totalMFAs(user); total != 0 {
+ t.Fatalf("Expected no user mfas, got %d", total)
+ }
+
+ if total := totalMFAs(user2); total != 1 {
+ t.Fatalf("Expected only 1 user2 mfa, got %d", total)
+ }
+ })
+}
+
+func TestRecordAuthResponseSuperuserIPsWhitelistCheck(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
if err != nil {
t.Fatal(err)
}
- apis.EnrichRecords(c, app.Dao(), records, "rel_one")
+ app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
- for _, record := range records {
- expand := record.Expand()
- if len(expand) == 0 {
- t.Fatalf("Expected non-empty expand, got nil for record %v", record)
- }
+ 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()
- if len(record.GetStringSlice("rel_one")) != 0 {
- if _, ok := expand["rel_one"]; !ok {
- t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand)
- }
+ 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")
}
+ })
- if len(record.GetStringSlice("rel_many")) != 0 {
- if _, ok := expand["rel_many"]; !ok {
- t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand)
- }
+ 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)
}
- }
+ })
}
diff --git a/apis/serve.go b/apis/serve.go
index c5d6384d33..785e0c2125 100644
--- a/apis/serve.go
+++ b/apis/serve.go
@@ -3,35 +3,36 @@ package apis
import (
"context"
"crypto/tls"
+ "errors"
"log"
"net"
"net/http"
"path/filepath"
"strings"
+ "sync"
"time"
"github.com/fatih/color"
- "github.com/labstack/echo/v5"
- "github.com/labstack/echo/v5/middleware"
- "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/migrations"
- "github.com/pocketbase/pocketbase/migrations/logs"
+ "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/migrate"
+ "github.com/pocketbase/pocketbase/tools/routine"
+ "github.com/pocketbase/pocketbase/ui"
"golang.org/x/crypto/acme"
"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'"
+
// ServeConfig defines a configuration struct for apis.Serve().
type ServeConfig struct {
// ShowStartBanner indicates whether to show or hide the server start console message.
ShowStartBanner bool
- // HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
+ // HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
HttpAddr string
- // HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
+ // HttpsAddr is the TCP address to listen for the HTTPS server (eg. "127.0.0.1:443").
HttpsAddr string
// Optional domains list to use when issuing the TLS certificate.
@@ -52,42 +53,52 @@ type ServeConfig struct {
//
// Example:
//
-// app.Bootstrap()
-// apis.Serve(app, apis.ServeConfig{
-// HttpAddr: "127.0.0.1:8080",
-// ShowStartBanner: false,
-// })
-func Serve(app core.App, config ServeConfig) (*http.Server, error) {
+// app.Bootstrap()
+// apis.Serve(app, apis.ServeConfig{
+// HttpAddr: "127.0.0.1:8080",
+// ShowStartBanner: false,
+// })
+func Serve(app core.App, config ServeConfig) error {
if len(config.AllowedOrigins) == 0 {
config.AllowedOrigins = []string{"*"}
}
// ensure that the latest migrations are applied before starting the server
- if err := runMigrations(app); err != nil {
- return nil, err
- }
-
- // reload app settings in case a new default value was set with a migration
- // (or if this is the first time the init migration was executed)
- if err := app.RefreshSettings(); err != nil {
- color.Yellow("=====================================")
- color.Yellow("WARNING: Settings load error! \n%v", err)
- color.Yellow("Fallback to the application defaults.")
- color.Yellow("=====================================")
+ err := app.RunAllMigrations()
+ if err != nil {
+ return err
}
- router, err := InitApi(app)
+ pbRouter, err := NewRouter(app)
if err != nil {
- return nil, err
+ return err
}
- // configure cors
- router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
- Skipper: middleware.DefaultSkipper,
+ pbRouter.Bind(CORS(CORSConfig{
AllowOrigins: config.AllowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
+ // @todo consider moving in base
+ if ui.DistDirFS != nil {
+ pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
+ BindFunc(func(e *core.RequestEvent) error {
+ if !e.App.IsDev() &&
+ // exclude root path
+ e.Request.PathValue(StaticWildcardParam) != "" &&
+ e.Response.Header().Get("Cache-Control") == "" {
+ e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
+ }
+
+ if e.Response.Header().Get("Content-Security-Policy") == "" {
+ e.Response.Header().Set("Content-Security-Policy", defaultCSP)
+ }
+
+ return e.Next()
+ }).
+ Bind(Gzip())
+ }
+
// start http server
// ---
mainAddr := config.HttpAddr
@@ -117,63 +128,152 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
// implicit www->non-www redirect(s)
if len(wwwRedirects) > 0 {
- router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- host := c.Request().Host
-
- if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) {
- return c.Redirect(
- http.StatusTemporaryRedirect,
- (c.Scheme() + "://" + host[4:] + c.Request().RequestURI),
- )
- }
-
- return next(c)
- }
- })
+ pbRouter.Bind(wwwRedirect(wwwRedirects))
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
- Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
+ Cache: autocert.DirCache(filepath.Join(app.DataDir(), core.LocalAutocertCacheDirName)),
HostPolicy: autocert.HostWhitelist(hostNames...),
}
+ // base request context used for cancelling long running requests
+ // like the SSE connections
+ baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
+ defer cancelBaseCtx()
+
server := &http.Server{
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
- ReadTimeout: 10 * time.Minute,
- ReadHeaderTimeout: 30 * time.Second,
- // WriteTimeout: 60 * time.Second, // breaks sse!
- Handler: router,
- Addr: mainAddr,
+ // higher defaults to accommodate large file uploads/downloads
+ WriteTimeout: 5 * time.Minute,
+ ReadTimeout: 5 * time.Minute,
+ ReadHeaderTimeout: 1 * time.Minute,
+ Addr: mainAddr,
+ BaseContext: func(l net.Listener) context.Context {
+ return baseCtx
+ },
+ ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
}
- serveEvent := &core.ServeEvent{
- App: app,
- Router: router,
- Server: server,
- CertManager: certManager,
- }
- if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
- return nil, err
- }
+ var listener net.Listener
- if config.ShowStartBanner {
- schema := "http"
- addr := server.Addr
+ // graceful shutdown
+ // ---------------------------------------------------------------
+ // WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
+ // Note that the WaitGroup would do nothing if the app.OnTerminate() hook isn't triggered.
+ var wg sync.WaitGroup
+
+ // try to gracefully shutdown the server on app termination
+ app.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{
+ Id: "pbGracefulShutdown",
+ Func: func(te *core.TerminateEvent) error {
+ cancelBaseCtx()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ wg.Add(1)
+
+ _ = server.Shutdown(ctx)
+
+ if te.IsRestart {
+ // wait for execve and other handlers up to 3 seconds before exit
+ time.AfterFunc(3*time.Second, func() {
+ wg.Done()
+ })
+ } else {
+ wg.Done()
+ }
+
+ return te.Next()
+ },
+ Priority: -9999,
+ })
+
+ // wait for the graceful shutdown to complete before exit
+ defer func() {
+ wg.Wait()
+
+ if listener != nil {
+ _ = listener.Close()
+ }
+ }()
+ // ---------------------------------------------------------------
+
+ var baseURL string
+
+ serveEvent := new(core.ServeEvent)
+ serveEvent.App = app
+ serveEvent.Router = pbRouter
+ serveEvent.Server = server
+ serveEvent.CertManager = certManager
+ serveEvent.InstallerFunc = DefaultInstallerFunc
+
+ // trigger the OnServe hook and start the tcp listener
+ serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
+ handler, err := e.Router.BuildMux()
+ if err != nil {
+ return err
+ }
- if config.HttpsAddr != "" {
- schema = "https"
+ e.Server.Handler = handler
+ if config.HttpsAddr == "" {
+ baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
+ } else {
+ baseURL = "https://"
if len(config.CertificateDomains) > 0 {
- addr = config.CertificateDomains[0]
+ baseURL += config.CertificateDomains[0]
+ } else {
+ baseURL += serverAddrToHost(serveEvent.Server.Addr)
}
}
+ addr := e.Server.Addr
+ if addr == "" {
+ // fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
+ if config.HttpsAddr != "" {
+ addr = ":https"
+ } else {
+ addr = ":http"
+ }
+ }
+
+ if e.Listener == nil {
+ listener, err = net.Listen("tcp", addr)
+ if err != nil {
+ return err
+ }
+ } else {
+ listener = e.Listener
+ }
+
+ if e.InstallerFunc != nil {
+ app := e.App
+ installerFunc := e.InstallerFunc
+ routine.FireAndForget(func() {
+ if err := loadInstaller(app, baseURL, installerFunc); err != nil {
+ app.Logger().Warn("Failed to initialize installer", "error", err)
+ }
+ })
+ }
+
+ return nil
+ })
+ if serveHookErr != nil {
+ return serveHookErr
+ }
+
+ if listener == nil {
+ //nolint:staticcheck
+ return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
+ }
+
+ if config.ShowStartBanner {
date := new(strings.Builder)
log.New(date, "", log.LstdFlags).Print()
@@ -181,63 +281,52 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
bold.Printf(
"%s Server started at %s\n",
strings.TrimSpace(date.String()),
- color.CyanString("%s://%s", schema, addr),
+ color.CyanString("%s", baseURL),
)
regular := color.New()
- regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
- regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
+ if ui.DistDirFS == nil {
+ regular.Printf("└─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
+ } else {
+ regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
+ regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
+ }
}
- // try to gracefully shutdown the server on app termination
- app.OnTerminate().Add(func(e *core.TerminateEvent) error {
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
- server.Shutdown(ctx)
- return nil
- })
-
- // start HTTPS server
+ var serveErr error
if config.HttpsAddr != "" {
- // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
if config.HttpAddr != "" {
+ // start an additional HTTP server for redirecting the traffic to the HTTPS version
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
}
- return server, server.ListenAndServeTLS("", "")
+ // start HTTPS server
+ serveErr = serveEvent.Server.ServeTLS(listener, "", "")
+ } else {
+ // OR start HTTP server
+ serveErr = serveEvent.Server.Serve(listener)
+ }
+ if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+ return serveErr
}
- // OR start HTTP server
- return server, server.ListenAndServe()
-}
-
-type migrationsConnection struct {
- DB *dbx.DB
- MigrationsList migrate.MigrationsList
+ return nil
}
-func runMigrations(app core.App) error {
- connections := []migrationsConnection{
- {
- DB: app.DB(),
- MigrationsList: migrations.AppMigrations,
- },
- {
- DB: app.LogsDB(),
- MigrationsList: logs.LogsMigrations,
- },
+// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
+func serverAddrToHost(addr string) string {
+ if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
+ return "127.0.0.1"
}
+ return addr
+}
- for _, c := range connections {
- runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
- if err != nil {
- return err
- }
+type serverErrorLogWriter struct {
+ app core.App
+}
- if _, err := runner.Up(); err != nil {
- return err
- }
- }
+func (s *serverErrorLogWriter) Write(p []byte) (int, error) {
+ s.app.Logger().Debug(strings.TrimSpace(string(p)))
- return nil
+ return len(p), nil
}
diff --git a/apis/settings.go b/apis/settings.go
index e5e96ddfea..43ec12f927 100644
--- a/apis/settings.go
+++ b/apis/settings.go
@@ -3,137 +3,128 @@ package apis
import (
"net/http"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/labstack/echo/v5"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models/settings"
+ "github.com/pocketbase/pocketbase/tools/router"
)
// bindSettingsApi registers the settings api endpoints.
-func bindSettingsApi(app core.App, rg *echo.Group) {
- api := settingsApi{app: app}
-
- subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
- subGroup.GET("", api.list)
- subGroup.PATCH("", api.set)
- subGroup.POST("/test/s3", api.testS3)
- subGroup.POST("/test/email", api.testEmail)
- subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
+func bindSettingsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ subGroup := rg.Group("/settings").Bind(RequireSuperuserAuth())
+ subGroup.GET("", settingsList)
+ subGroup.PATCH("", settingsSet)
+ subGroup.POST("/test/s3", settingsTestS3)
+ subGroup.POST("/test/email", settingsTestEmail)
+
+ // @todo move to collections
+ subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret)
}
-type settingsApi struct {
- app core.App
-}
-
-func (api *settingsApi) list(c echo.Context) error {
- settings, err := api.app.Settings().RedactClone()
+func settingsList(e *core.RequestEvent) error {
+ clone, err := e.App.Settings().Clone()
if err != nil {
- return NewBadRequestError("", err)
+ return e.InternalServerError("", err)
}
- event := new(core.SettingsListEvent)
- event.HttpContext = c
- event.RedactedSettings = settings
-
- return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
+ event := new(core.SettingsListRequestEvent)
+ event.RequestEvent = e
+ event.Settings = clone
- return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
+ return e.App.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListRequestEvent) error {
+ return execAfterSuccessTx(true, e.App, func() error {
+ return e.JSON(http.StatusOK, e.Settings)
+ })
})
}
-func (api *settingsApi) set(c echo.Context) error {
- form := forms.NewSettingsUpsert(api.app)
+func settingsSet(e *core.RequestEvent) error {
+ event := new(core.SettingsUpdateRequestEvent)
+ event.RequestEvent = e
- // load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ if clone, err := e.App.Settings().Clone(); err == nil {
+ event.OldSettings = clone
+ } else {
+ return e.BadRequestError("", err)
}
- event := new(core.SettingsUpdateEvent)
- event.HttpContext = c
- event.OldSettings = api.app.Settings()
-
- // update the settings
- return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
- return func(s *settings.Settings) error {
- event.NewSettings = s
-
- return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
- if err := next(e.NewSettings); err != nil {
- return NewBadRequestError("An error occurred while submitting the form.", err)
- }
-
- return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
- if e.HttpContext.Response().Committed {
- return nil
- }
-
- redactedSettings, err := api.app.Settings().RedactClone()
- if err != nil {
- return NewBadRequestError("", err)
- }
-
- return e.HttpContext.JSON(http.StatusOK, redactedSettings)
- })
- })
+ if clone, err := e.App.Settings().Clone(); err == nil {
+ event.NewSettings = clone
+ } else {
+ return e.BadRequestError("", err)
+ }
+
+ if err := e.BindBody(&event.NewSettings); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
+ }
+
+ return e.App.OnSettingsUpdateRequest().Trigger(event, func(e *core.SettingsUpdateRequestEvent) error {
+ err := e.App.Save(e.NewSettings)
+ if err != nil {
+ return e.BadRequestError("An error occurred while saving the new settings.", err)
}
+
+ return execAfterSuccessTx(true, e.App, func() error {
+ appSettings, err := e.App.Settings().Clone()
+ if err != nil {
+ return e.InternalServerError("Failed to clone app settings.", err)
+ }
+
+ return e.JSON(http.StatusOK, appSettings)
+ })
})
}
-func (api *settingsApi) testS3(c echo.Context) error {
- form := forms.NewTestS3Filesystem(api.app)
+func settingsTestS3(e *core.RequestEvent) error {
+ form := forms.NewTestS3Filesystem(e.App)
// load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ if err := e.BindBody(form); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
- return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
+ return e.BadRequestError("Failed to test the S3 filesystem.", fErr)
}
// mailer error
- return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
+ return e.BadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
}
- return c.NoContent(http.StatusNoContent)
+ return e.NoContent(http.StatusNoContent)
}
-func (api *settingsApi) testEmail(c echo.Context) error {
- form := forms.NewTestEmailSend(api.app)
+func settingsTestEmail(e *core.RequestEvent) error {
+ form := forms.NewTestEmailSend(e.App)
// load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ if err := e.BindBody(form); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
- return NewBadRequestError("Failed to send the test email.", fErr)
+ return e.BadRequestError("Failed to send the test email.", fErr)
}
// mailer error
- return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
+ return e.BadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
}
- return c.NoContent(http.StatusNoContent)
+ return e.NoContent(http.StatusNoContent)
}
-func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
- form := forms.NewAppleClientSecretCreate(api.app)
+func settingsGenerateAppleClientSecret(e *core.RequestEvent) error {
+ form := forms.NewAppleClientSecretCreate(e.App)
// load request
- if err := c.Bind(form); err != nil {
- return NewBadRequestError("An error occurred while loading the submitted data.", err)
+ if err := e.BindBody(form); err != nil {
+ return e.BadRequestError("An error occurred while loading the submitted data.", err)
}
// generate
@@ -141,14 +132,14 @@ func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
if err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
- return NewBadRequestError("Invalid client secret data.", fErr)
+ return e.BadRequestError("Invalid client secret data.", fErr)
}
// secret generation error
- return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
+ return e.BadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
}
- return c.JSON(http.StatusOK, map[string]any{
+ return e.JSON(http.StatusOK, map[string]string{
"secret": secret,
})
}
diff --git a/apis/settings_test.go b/apis/settings_test.go
index bf0d96d2b6..87d3e116a1 100644
--- a/apis/settings_test.go
+++ b/apis/settings_test.go
@@ -6,42 +6,44 @@ import (
"crypto/rand"
"crypto/x509"
"encoding/pem"
- "errors"
"fmt"
"net/http"
"strings"
"testing"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
)
func TestSettingsList(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodGet,
- Url: "/api/settings",
+ URL: "/api/settings",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodGet,
- Url: "/api/settings",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/settings",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin",
+ Name: "authorized as superuser",
Method: http.MethodGet,
- Url: "/api/settings",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/settings",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -50,43 +52,39 @@ func TestSettingsList(t *testing.T) {
`"smtp":{`,
`"s3":{`,
`"backups":{`,
- `"adminAuthToken":{`,
- `"adminPasswordResetToken":{`,
- `"adminFileToken":{`,
- `"recordAuthToken":{`,
- `"recordPasswordResetToken":{`,
- `"recordEmailChangeToken":{`,
- `"recordVerificationToken":{`,
- `"recordFileToken":{`,
- `"emailAuth":{`,
- `"googleAuth":{`,
- `"facebookAuth":{`,
- `"githubAuth":{`,
- `"gitlabAuth":{`,
- `"twitterAuth":{`,
- `"discordAuth":{`,
- `"microsoftAuth":{`,
- `"spotifyAuth":{`,
- `"kakaoAuth":{`,
- `"twitchAuth":{`,
- `"stravaAuth":{`,
- `"giteeAuth":{`,
- `"livechatAuth":{`,
- `"giteaAuth":{`,
- `"oidcAuth":{`,
- `"oidc2Auth":{`,
- `"oidc3Auth":{`,
- `"appleAuth":{`,
- `"instagramAuth":{`,
- `"vkAuth":{`,
- `"yandexAuth":{`,
- `"secret":"******"`,
- `"clientSecret":"******"`,
+ `"batch":{`,
},
ExpectedEvents: map[string]int{
+ "*": 0,
"OnSettingsListRequest": 1,
},
},
+ {
+ Name: "OnSettingsListRequest tx body write check",
+ Method: http.MethodGet,
+ URL: "/api/settings",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnSettingsListRequest().BindFunc(func(e *core.SettingsListRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
+ })
+ },
+ ExpectedStatus: 400,
+ ExpectedEvents: map[string]int{"OnSettingsListRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
+ },
}
for _, scenario := range scenarios {
@@ -95,35 +93,44 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
- validData := `{"meta":{"appName":"update_test"}}`
+ t.Parallel()
+
+ validData := `{
+ "meta":{"appName":"update_test"},
+ "smtp":{"password": "new_smtp_password"},
+ "s3":{"secret": "new_s3_secret"},
+ "backups":{"s3":{"secret":"new_backups_s3_secret"}}
+ }`
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(validData),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(validData),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin submitting empty data",
+ Name: "authorized as superuser submitting empty data",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(``),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -132,67 +139,65 @@ func TestSettingsSet(t *testing.T) {
`"smtp":{`,
`"s3":{`,
`"backups":{`,
- `"adminAuthToken":{`,
- `"adminPasswordResetToken":{`,
- `"adminFileToken":{`,
- `"recordAuthToken":{`,
- `"recordPasswordResetToken":{`,
- `"recordEmailChangeToken":{`,
- `"recordVerificationToken":{`,
- `"recordFileToken":{`,
- `"emailAuth":{`,
- `"googleAuth":{`,
- `"facebookAuth":{`,
- `"githubAuth":{`,
- `"gitlabAuth":{`,
- `"discordAuth":{`,
- `"microsoftAuth":{`,
- `"spotifyAuth":{`,
- `"kakaoAuth":{`,
- `"twitchAuth":{`,
- `"stravaAuth":{`,
- `"giteeAuth":{`,
- `"livechatAuth":{`,
- `"giteaAuth":{`,
- `"oidcAuth":{`,
- `"oidc2Auth":{`,
- `"oidc3Auth":{`,
- `"appleAuth":{`,
- `"instagramAuth":{`,
- `"vkAuth":{`,
- `"yandexAuth":{`,
- `"secret":"******"`,
- `"clientSecret":"******"`,
- `"appName":"acme_test"`,
+ `"batch":{`,
},
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnSettingsBeforeUpdateRequest": 1,
- "OnSettingsAfterUpdateRequest": 1,
+ "*": 0,
+ "OnSettingsUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnSettingsReload": 1,
},
},
{
- Name: "authorized as admin submitting invalid data",
+ Name: "authorized as superuser submitting invalid data",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(`{"meta":{"appName":""}}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"meta":{"appName":{"code":"validation_required"`,
},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnModelUpdate": 1,
+ "OnModelAfterUpdateError": 1,
+ "OnModelValidate": 1,
+ "OnSettingsUpdateRequest": 1,
+ },
},
{
- Name: "authorized as admin submitting valid data",
+ Name: "authorized as superuser submitting valid data",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(validData),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ settings := app.Settings()
+
+ // verify that the secret values are persisted
+ secrets := map[string]struct {
+ current string
+ expected string
+ }{
+ "smtp.password": {settings.SMTP.Password, "new_smtp_password"},
+ "s3.secret": {settings.S3.Secret, "new_s3_secret"},
+ "backups.s3.secret": {settings.Backups.S3.Secret, "new_backups_s3_secret"},
+ }
+
+ for name, secret := range secrets {
+ if secret.current != secret.expected {
+ t.Errorf("[%s] expected secret %q, got %q", name, secret.expected, secret.current)
+ }
+ }
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -201,68 +206,49 @@ func TestSettingsSet(t *testing.T) {
`"smtp":{`,
`"s3":{`,
`"backups":{`,
- `"adminAuthToken":{`,
- `"adminPasswordResetToken":{`,
- `"adminFileToken":{`,
- `"recordAuthToken":{`,
- `"recordPasswordResetToken":{`,
- `"recordEmailChangeToken":{`,
- `"recordVerificationToken":{`,
- `"recordFileToken":{`,
- `"emailAuth":{`,
- `"googleAuth":{`,
- `"facebookAuth":{`,
- `"githubAuth":{`,
- `"gitlabAuth":{`,
- `"twitterAuth":{`,
- `"discordAuth":{`,
- `"microsoftAuth":{`,
- `"spotifyAuth":{`,
- `"kakaoAuth":{`,
- `"twitchAuth":{`,
- `"stravaAuth":{`,
- `"giteeAuth":{`,
- `"livechatAuth":{`,
- `"giteaAuth":{`,
- `"oidcAuth":{`,
- `"oidc2Auth":{`,
- `"oidc3Auth":{`,
- `"appleAuth":{`,
- `"instagramAuth":{`,
- `"vkAuth":{`,
- `"yandexAuth":{`,
- `"secret":"******"`,
- `"clientSecret":"******"`,
+ `"batch":{`,
`"appName":"update_test"`,
},
+ NotExpectedContent: []string{
+ "secret",
+ "password",
+ },
ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnSettingsBeforeUpdateRequest": 1,
- "OnSettingsAfterUpdateRequest": 1,
+ "*": 0,
+ "OnSettingsUpdateRequest": 1,
+ "OnModelUpdate": 1,
+ "OnModelUpdateExecute": 1,
+ "OnModelAfterUpdateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnSettingsReload": 1,
},
},
{
- Name: "OnSettingsAfterUpdateRequest error response",
+ Name: "OnSettingsUpdateRequest tx body write check",
Method: http.MethodPatch,
- Url: "/api/settings",
+ URL: "/api/settings",
Body: strings.NewReader(validData),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- },
- BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
- app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
- return errors.New("error")
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
+ app.OnSettingsUpdateRequest().BindFunc(func(e *core.SettingsUpdateRequestEvent) error {
+ original := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() { e.App = original }()
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return e.BadRequestError("TX_ERROR", nil)
+ })
})
},
ExpectedStatus: 400,
- ExpectedContent: []string{`"data":{}`},
- ExpectedEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnSettingsBeforeUpdateRequest": 1,
- "OnSettingsAfterUpdateRequest": 1,
- },
+ ExpectedEvents: map[string]int{"OnSettingsUpdateRequest": 1},
+ ExpectedContent: []string{"TX_ERROR"},
},
}
@@ -272,63 +258,70 @@ func TestSettingsSet(t *testing.T) {
}
func TestSettingsTestS3(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/settings/test/s3",
+ URL: "/api/settings/test/s3",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/settings/test/s3",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/settings/test/s3",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (missing body + no s3)",
+ Name: "authorized as superuser (missing body + no s3)",
Method: http.MethodPost,
- Url: "/api/settings/test/s3",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ URL: "/api/settings/test/s3",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (invalid filesystem)",
+ Name: "authorized as superuser (invalid filesystem)",
Method: http.MethodPost,
- Url: "/api/settings/test/s3",
+ URL: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"invalid"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"filesystem":{`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (valid filesystem and no s3)",
+ Name: "authorized as superuser (valid filesystem and no s3)",
Method: http.MethodPost,
- Url: "/api/settings/test/s3",
+ URL: "/api/settings/test/s3",
Body: strings.NewReader(`{"filesystem":"storage"}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
@@ -338,160 +331,205 @@ func TestSettingsTestS3(t *testing.T) {
}
func TestSettingsTestEmail(t *testing.T) {
+ t.Parallel()
+
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (invalid body)",
+ Name: "authorized as superuser (invalid body)",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (empty json)",
+ Name: "authorized as superuser (empty json)",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"email":{"code":"validation_required"`,
`"template":{"code":"validation_required"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (verifiation template)",
+ Name: "authorized as superuser (verifiation template)",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "verification",
"email": "test@example.com"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- if app.TestMailer.TotalSend != 1 {
- t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 1 {
+ t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend())
}
- if len(app.TestMailer.LastMessage.To) != 1 {
- t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
+ if len(app.TestMailer.LastMessage().To) != 1 {
+ t.Fatalf("[verification] Expected 1 recipient, got %v", app.TestMailer.LastMessage().To)
}
- if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
- t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
+ if app.TestMailer.LastMessage().To[0].Address != "test@example.com" {
+ t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage().To[0].Address)
}
- if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
- t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "Verify") {
+ t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage().Subject, app.TestMailer.LastMessage().HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeRecordVerificationSend": 1,
- "OnMailerAfterRecordVerificationSend": 1,
+ "*": 0,
+ "OnMailerSend": 1,
+ "OnMailerRecordVerificationSend": 1,
},
},
{
- Name: "authorized as admin (password reset template)",
+ Name: "authorized as superuser (password reset template)",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "password-reset",
"email": "test@example.com"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- if app.TestMailer.TotalSend != 1 {
- t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 1 {
+ t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend())
}
- if len(app.TestMailer.LastMessage.To) != 1 {
- t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
+ if len(app.TestMailer.LastMessage().To) != 1 {
+ t.Fatalf("[password-reset] Expected 1 recipient, got %v", app.TestMailer.LastMessage().To)
}
- if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
- t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
+ if app.TestMailer.LastMessage().To[0].Address != "test@example.com" {
+ t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage().To[0].Address)
}
- if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
- t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "Reset password") {
+ t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage().Subject, app.TestMailer.LastMessage().HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeRecordResetPasswordSend": 1,
- "OnMailerAfterRecordResetPasswordSend": 1,
+ "*": 0,
+ "OnMailerSend": 1,
+ "OnMailerRecordPasswordResetSend": 1,
},
},
{
- Name: "authorized as admin (email change)",
+ Name: "authorized as superuser (email change)",
Method: http.MethodPost,
- Url: "/api/settings/test/email",
+ URL: "/api/settings/test/email",
Body: strings.NewReader(`{
"template": "email-change",
"email": "test@example.com"
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
- AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
- if app.TestMailer.TotalSend != 1 {
- t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 1 {
+ t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend())
}
- if len(app.TestMailer.LastMessage.To) != 1 {
- t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage.To)
+ if len(app.TestMailer.LastMessage().To) != 1 {
+ t.Fatalf("[email-change] Expected 1 recipient, got %v", app.TestMailer.LastMessage().To)
}
- if app.TestMailer.LastMessage.To[0].Address != "test@example.com" {
- t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To[0].Address)
+ if app.TestMailer.LastMessage().To[0].Address != "test@example.com" {
+ t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage().To[0].Address)
}
- if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
- t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "Confirm new email") {
+ t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage().Subject, app.TestMailer.LastMessage().HTML)
}
},
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
- "OnMailerBeforeRecordChangeEmailSend": 1,
- "OnMailerAfterRecordChangeEmailSend": 1,
+ "*": 0,
+ "OnMailerSend": 1,
+ "OnMailerRecordEmailChangeSend": 1,
+ },
+ },
+ {
+ Name: "authorized as superuser (otp)",
+ Method: http.MethodPost,
+ URL: "/api/settings/test/email",
+ Body: strings.NewReader(`{
+ "template": "otp",
+ "email": "test@example.com"
+ }`),
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.TestMailer.TotalSend() != 1 {
+ t.Fatalf("[otp] Expected 1 sent email, got %d", app.TestMailer.TotalSend())
+ }
+
+ if len(app.TestMailer.LastMessage().To) != 1 {
+ t.Fatalf("[otp] Expected 1 recipient, got %v", app.TestMailer.LastMessage().To)
+ }
+
+ if app.TestMailer.LastMessage().To[0].Address != "test@example.com" {
+ t.Fatalf("[otp] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage().To[0].Address)
+ }
+
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, "one-time password") {
+ t.Fatalf("[otp] Expected to sent OTP email, got \n%v\n%v", app.TestMailer.LastMessage().Subject, app.TestMailer.LastMessage().HTML)
+ }
+ },
+ ExpectedStatus: 204,
+ ExpectedContent: []string{},
+ ExpectedEvents: map[string]int{
+ "*": 0,
+ "OnMailerSend": 1,
+ "OnMailerRecordOTPSend": 1,
},
},
}
@@ -502,6 +540,8 @@ func TestSettingsTestEmail(t *testing.T) {
}
func TestGenerateAppleClientSecret(t *testing.T) {
+ t.Parallel()
+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
@@ -523,38 +563,41 @@ func TestGenerateAppleClientSecret(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
+ URL: "/api/settings/apple/generate-client-secret",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as auth record",
+ Name: "authorized as regular user",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
+ URL: "/api/settings/apple/generate-client-secret",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
- ExpectedStatus: 401,
+ ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (invalid body)",
+ Name: "authorized as superuser (invalid body)",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
+ URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (empty json)",
+ Name: "authorized as superuser (empty json)",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
+ URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -564,11 +607,12 @@ func TestGenerateAppleClientSecret(t *testing.T) {
`"privateKey":{"code":"validation_required"`,
`"duration":{"code":"validation_required"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (invalid data)",
+ Name: "authorized as superuser (invalid data)",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
+ URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(`{
"clientId": "",
"teamId": "123456789",
@@ -576,8 +620,8 @@ func TestGenerateAppleClientSecret(t *testing.T) {
"privateKey": "invalid",
"duration": -1
}`),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -587,11 +631,12 @@ func TestGenerateAppleClientSecret(t *testing.T) {
`"privateKey":{"code":"validation_match_invalid"`,
`"duration":{"code":"validation_min_greater_equal_than_required"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
{
- Name: "authorized as admin (valid data)",
+ Name: "authorized as superuser (valid data)",
Method: http.MethodPost,
- Url: "/api/settings/apple/generate-client-secret",
+ URL: "/api/settings/apple/generate-client-secret",
Body: strings.NewReader(fmt.Sprintf(`{
"clientId": "123",
"teamId": "1234567890",
@@ -599,13 +644,14 @@ func TestGenerateAppleClientSecret(t *testing.T) {
"privateKey": %q,
"duration": 1
}`, privatePem)),
- RequestHeaders: map[string]string{
- "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
+ Headers: map[string]string{
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"secret":"`,
},
+ ExpectedEvents: map[string]int{"*": 0},
},
}
diff --git a/apis/sql.go b/apis/sql.go
new file mode 100644
index 0000000000..47cabc7bfd
--- /dev/null
+++ b/apis/sql.go
@@ -0,0 +1,194 @@
+package apis
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+const (
+ runSQLMaxRows = 1000
+ runSQLMaxTimeout = 3 * time.Minute
+)
+
+// bindSQLApi registers the SQL api endpoints.
+func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
+ subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
+ subGroup.POST("", runSQL)
+}
+
+func runSQL(e *core.RequestEvent) error {
+ // extra precaution in case manually invoked from somewhere else
+ if !e.HasSuperuserAuth() {
+ return e.ForbiddenError("", nil)
+ }
+
+ form := runSQLForm{}
+
+ err := e.BindBody(&form)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
+ }
+
+ err = form.validate()
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
+ }
+
+ result, err := executeQuery(e.App, form.Query, runSQLMaxRows)
+ if err != nil {
+ return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
+ }
+
+ return e.JSON(http.StatusOK, result)
+}
+
+type runSQLForm struct {
+ Query string `form:"query" json:"query"`
+}
+
+func (form *runSQLForm) validate() error {
+ return validation.ValidateStruct(form,
+ validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
+ )
+}
+
+type runSQLResultColumn struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Nullable bool `json:"nullable"`
+}
+
+type runSQLResult struct {
+ ExecTime int64 `json:"execTime"`
+ AffectedRows int64 `json:"affectedRows"`
+ Columns []runSQLResultColumn `json:"columns"`
+ Rows [][]any `json:"rows"`
+}
+
+var knownWriteQueryPrefixes = []string{
+ "INSERT", "CREATE", "UPDATE", "DELETE",
+ "DROP", "DETACH", "ALTER", "REPLACE",
+}
+
+func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
+ query = strings.TrimSpace(query)
+ if query == "" {
+ // see https://github.com/mattn/go-sqlite3/issues/950
+ return nil, errors.New("empty query")
+ }
+
+ var isPossibleWriteQuery bool
+
+ // loosely check the query type
+ ucQuery := strings.ToUpper(query)
+ if !strings.HasPrefix(ucQuery, "SELECT") {
+ for _, prefix := range knownWriteQueryPrefixes {
+ if strings.HasPrefix(ucQuery, prefix) {
+ isPossibleWriteQuery = true
+ break
+ }
+ }
+ }
+
+ // note: don't extend the request context to minimize the risk of
+ // causing integrity issues with custom non-transaction mutations
+ ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
+ defer cancelFunc()
+
+ result := &runSQLResult{
+ // init empty slices to ensure "[]" serialization
+ Columns: []runSQLResultColumn{},
+ Rows: [][]any{},
+ }
+
+ now := time.Now()
+ defer func() {
+ result.ExecTime = time.Since(now).Milliseconds()
+ }()
+
+ // assume write/mutation query
+ // ---------------------------------------------------------------
+ if isPossibleWriteQuery {
+ // auto wrap in transaction in case there are multiple inline queries
+ txErr := app.RunInTransaction(func(txApp core.App) error {
+ execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
+ if err != nil {
+ return err
+ }
+
+ result.AffectedRows, err = execResult.RowsAffected()
+ if err != nil {
+ // non-critical error (e.g. not supported by the driver)
+ txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
+ }
+
+ return nil
+ })
+ if txErr != nil {
+ return nil, txErr
+ }
+
+ return result, nil
+ }
+
+ // assume query returning rows
+ // ---------------------------------------------------------------
+ rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ // populate columns info
+ // ---
+ colTypes, err := rows.ColumnTypes()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, colType := range colTypes {
+ col := runSQLResultColumn{
+ Name: colType.Name(),
+ Type: colType.DatabaseTypeName(),
+ }
+ col.Nullable, _ = colType.Nullable()
+
+ result.Columns = append(result.Columns, col)
+ }
+
+ // populate rows
+ // ---
+ for rows.Next() {
+ if len(result.Rows) >= maxRows {
+ break
+ }
+
+ rowData := make([]any, len(colTypes))
+ for i := 0; i < len(colTypes); i++ {
+ var v *string
+ rowData[i] = &v
+ }
+
+ err := rows.Scan(rowData...)
+ if err != nil {
+ return nil, err
+ }
+
+ result.Rows = append(result.Rows, rowData)
+ }
+
+ err = rows.Err()
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
diff --git a/apis/sql_test.go b/apis/sql_test.go
new file mode 100644
index 0000000000..0281dbaa82
--- /dev/null
+++ b/apis/sql_test.go
@@ -0,0 +1,220 @@
+package apis_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestSQLRun(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []tests.ApiScenario{
+ {
+ Name: "guest",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"select 1"}`),
+ ExpectedStatus: 401,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "regular user",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"select 1"}`),
+ Headers: map[string]string{
+ // users, test2@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
+ },
+ ExpectedStatus: 403,
+ ExpectedContent: []string{`"data":{}`},
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "superuser",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"select 1"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"execTime":`,
+ `"affectedRows":0`,
+ `"columns":[{"name":"1","type":"","nullable":true}]`,
+ `"rows":[["1"]]`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "empty query",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":""}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"query":{`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "invalid query",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"invalid"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ `Raw error:`,
+ `SQL logic error`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "query with length above the limit",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{`,
+ `"query":{`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "query with length equal to the limit",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"select '` + strings.Repeat("a", 4985) + `' as id"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"execTime":`,
+ `"affectedRows":0`,
+ `"columns":[{"name":"id","type":"","nullable":true}]`,
+ `"rows":[["aaa`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "single write query",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key)"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if !app.HasTable("test_sql_table") {
+ t.Fatalf("Missing expected new %q table", "test_sql_table")
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"execTime":`,
+ `"affectedRows":0`,
+ `"columns":[]`,
+ `"rows":[]`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "multiple write queries",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1)"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ var total int
+ err := app.DB().NewQuery("select count(*) from test_sql_table").Row(&total)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if total != 1 {
+ t.Fatalf("Expected exactly 1 row, found: %d", total)
+ }
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"execTime":`,
+ `"affectedRows":1`,
+ `"columns":[]`,
+ `"rows":[]`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "multiple write queries (transaction rollback)",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1);invalid"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
+ if app.HasTable("test_sql_table") {
+ t.Fatalf("Expected table %q to not be created", "test_sql_table")
+ }
+ },
+ ExpectedStatus: 400,
+ ExpectedContent: []string{
+ `"data":{}`,
+ `Raw error:`,
+ `SQL logic error`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ {
+ Name: "multiple read queries",
+ Method: http.MethodPost,
+ URL: "/api/sql",
+ Body: strings.NewReader(`{"query":"select 1;select 2"}`),
+ Headers: map[string]string{
+ // superusers, test@example.com
+ "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
+ },
+ ExpectedStatus: 200,
+ ExpectedContent: []string{
+ `"execTime":`,
+ `"affectedRows":0`,
+ // only the result of the last query should be returned
+ `"columns":[{"name":"2","type":"","nullable":true}]`,
+ `"rows":[["2"]]`,
+ },
+ ExpectedEvents: map[string]int{"*": 0},
+ },
+ }
+
+ for _, scenario := range scenarios {
+ scenario.Test(t)
+ }
+}
diff --git a/cmd/admin.go b/cmd/admin.go
deleted file mode 100644
index fd76a9bd64..0000000000
--- a/cmd/admin.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package cmd
-
-import (
- "errors"
- "fmt"
-
- "github.com/fatih/color"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
- "github.com/spf13/cobra"
-)
-
-// NewAdminCommand creates and returns new command for managing
-// admin accounts (create, update, delete).
-func NewAdminCommand(app core.App) *cobra.Command {
- command := &cobra.Command{
- Use: "admin",
- Short: "Manages admin accounts",
- }
-
- command.AddCommand(adminCreateCommand(app))
- command.AddCommand(adminUpdateCommand(app))
- command.AddCommand(adminDeleteCommand(app))
-
- return command
-}
-
-func adminCreateCommand(app core.App) *cobra.Command {
- command := &cobra.Command{
- Use: "create",
- Example: "admin create test@example.com 1234567890",
- Short: "Creates a new admin account",
- // prevents printing the error log twice
- SilenceErrors: true,
- SilenceUsage: true,
- RunE: func(command *cobra.Command, args []string) error {
- if len(args) != 2 {
- return errors.New("Missing email and password arguments.")
- }
-
- if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
- return errors.New("Missing or invalid email address.")
- }
-
- if len(args[1]) < 8 {
- return errors.New("The password must be at least 8 chars long.")
- }
-
- admin := &models.Admin{}
- admin.Email = args[0]
- admin.SetPassword(args[1])
-
- if err := app.Dao().SaveAdmin(admin); err != nil {
- return fmt.Errorf("Failed to create new admin account: %v", err)
- }
-
- color.Green("Successfully created new admin %s!", admin.Email)
- return nil
- },
- }
-
- return command
-}
-
-func adminUpdateCommand(app core.App) *cobra.Command {
- command := &cobra.Command{
- Use: "update",
- Example: "admin update test@example.com 1234567890",
- Short: "Changes the password of a single admin account",
- // prevents printing the error log twice
- SilenceErrors: true,
- SilenceUsage: true,
- RunE: func(command *cobra.Command, args []string) error {
- if len(args) != 2 {
- return errors.New("Missing email and password arguments.")
- }
-
- if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
- return errors.New("Missing or invalid email address.")
- }
-
- if len(args[1]) < 8 {
- return errors.New("The new password must be at least 8 chars long.")
- }
-
- admin, err := app.Dao().FindAdminByEmail(args[0])
- if err != nil {
- return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
- }
-
- admin.SetPassword(args[1])
-
- if err := app.Dao().SaveAdmin(admin); err != nil {
- return fmt.Errorf("Failed to change admin %s password: %v", admin.Email, err)
- }
-
- color.Green("Successfully changed admin %s password!", admin.Email)
- return nil
- },
- }
-
- return command
-}
-
-func adminDeleteCommand(app core.App) *cobra.Command {
- command := &cobra.Command{
- Use: "delete",
- Example: "admin delete test@example.com",
- Short: "Deletes an existing admin account",
- // prevents printing the error log twice
- SilenceErrors: true,
- SilenceUsage: true,
- RunE: func(command *cobra.Command, args []string) error {
- if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
- return errors.New("Invalid or missing email address.")
- }
-
- admin, err := app.Dao().FindAdminByEmail(args[0])
- if err != nil {
- color.Yellow("Admin %s is already deleted.", args[0])
- return nil
- }
-
- if err := app.Dao().DeleteAdmin(admin); err != nil {
- return fmt.Errorf("Failed to delete admin %s: %v", admin.Email, err)
- }
-
- color.Green("Successfully deleted admin %s!", admin.Email)
- return nil
- },
- }
-
- return command
-}
diff --git a/cmd/admin_test.go b/cmd/admin_test.go
deleted file mode 100644
index c3fbbd709d..0000000000
--- a/cmd/admin_test.go
+++ /dev/null
@@ -1,219 +0,0 @@
-package cmd_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/cmd"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestAdminCreateCommand(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- email string
- password string
- expectError bool
- }{
- {
- "empty email and password",
- "",
- "",
- true,
- },
- {
- "empty email",
- "",
- "1234567890",
- true,
- },
- {
- "invalid email",
- "invalid",
- "1234567890",
- true,
- },
- {
- "duplicated email",
- "test@example.com",
- "1234567890",
- true,
- },
- {
- "empty password",
- "test@example.com",
- "",
- true,
- },
- {
- "short password",
- "test_new@example.com",
- "1234567",
- true,
- },
- {
- "valid email and password",
- "test_new@example.com",
- "12345678",
- false,
- },
- }
-
- for _, s := range scenarios {
- command := cmd.NewAdminCommand(app)
- command.SetArgs([]string{"create", s.email, s.password})
-
- err := command.Execute()
-
- hasErr := err != nil
- if s.expectError != hasErr {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
-
- if hasErr {
- continue
- }
-
- // check whether the admin account was actually created
- admin, err := app.Dao().FindAdminByEmail(s.email)
- if err != nil {
- t.Errorf("[%s] Failed to fetch created admin %s: %v", s.name, s.email, err)
- } else if !admin.ValidatePassword(s.password) {
- t.Errorf("[%s] Expected the admin password to match", s.name)
- }
- }
-}
-
-func TestAdminUpdateCommand(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- email string
- password string
- expectError bool
- }{
- {
- "empty email and password",
- "",
- "",
- true,
- },
- {
- "empty email",
- "",
- "1234567890",
- true,
- },
- {
- "invalid email",
- "invalid",
- "1234567890",
- true,
- },
- {
- "nonexisting admin",
- "test_missing@example.com",
- "1234567890",
- true,
- },
- {
- "empty password",
- "test@example.com",
- "",
- true,
- },
- {
- "short password",
- "test_new@example.com",
- "1234567",
- true,
- },
- {
- "valid email and password",
- "test@example.com",
- "12345678",
- false,
- },
- }
-
- for _, s := range scenarios {
- command := cmd.NewAdminCommand(app)
- command.SetArgs([]string{"update", s.email, s.password})
-
- err := command.Execute()
-
- hasErr := err != nil
- if s.expectError != hasErr {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
-
- if hasErr {
- continue
- }
-
- // check whether the admin password was actually changed
- admin, err := app.Dao().FindAdminByEmail(s.email)
- if err != nil {
- t.Errorf("[%s] Failed to fetch admin %s: %v", s.name, s.email, err)
- } else if !admin.ValidatePassword(s.password) {
- t.Errorf("[%s] Expected the admin password to match", s.name)
- }
- }
-}
-
-func TestAdminDeleteCommand(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- email string
- expectError bool
- }{
- {
- "empty email",
- "",
- true,
- },
- {
- "invalid email",
- "invalid",
- true,
- },
- {
- "nonexisting admin",
- "test_missing@example.com",
- false,
- },
- {
- "existing admin",
- "test@example.com",
- false,
- },
- }
-
- for _, s := range scenarios {
- command := cmd.NewAdminCommand(app)
- command.SetArgs([]string{"delete", s.email})
-
- err := command.Execute()
-
- hasErr := err != nil
- if s.expectError != hasErr {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
-
- if hasErr {
- continue
- }
-
- // check whether the admin account was actually deleted
- if _, err := app.Dao().FindAdminByEmail(s.email); err == nil {
- t.Errorf("[%s] Expected the admin account to be deleted", s.name)
- }
- }
-}
diff --git a/cmd/serve.go b/cmd/serve.go
index 4e204acd59..fbd7a475c0 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -1,7 +1,7 @@
package cmd
import (
- "log"
+ "errors"
"net/http"
"github.com/pocketbase/pocketbase/apis"
@@ -17,10 +17,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
var httpsAddr string
command := &cobra.Command{
- Use: "serve [domain(s)]",
- Args: cobra.ArbitraryArgs,
- Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
- Run: func(command *cobra.Command, args []string) {
+ Use: "serve [domain(s)]",
+ Args: cobra.ArbitraryArgs,
+ Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
// set default listener addresses if at least one domain is specified
if len(args) > 0 {
if httpAddr == "" {
@@ -35,7 +36,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
}
}
- _, err := apis.Serve(app, apis.ServeConfig{
+ err := apis.Serve(app, apis.ServeConfig{
HttpAddr: httpAddr,
HttpsAddr: httpsAddr,
ShowStartBanner: showStartBanner,
@@ -43,9 +44,11 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
CertificateDomains: args,
})
- if err != http.ErrServerClosed {
- log.Fatalln(err)
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
}
+
+ return err
},
}
diff --git a/cmd/superuser.go b/cmd/superuser.go
new file mode 100644
index 0000000000..042e5ca121
--- /dev/null
+++ b/cmd/superuser.go
@@ -0,0 +1,247 @@
+package cmd
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cobra"
+)
+
+// NewSuperuserCommand creates and returns new command for managing
+// superuser accounts (create, update, upsert, delete).
+func NewSuperuserCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "superuser",
+ Short: "Manage superusers",
+ }
+
+ command.AddCommand(superuserUpsertCommand(app))
+ command.AddCommand(superuserCreateCommand(app))
+ command.AddCommand(superuserUpdateCommand(app))
+ command.AddCommand(superuserDeleteCommand(app))
+ command.AddCommand(superuserOTPCommand(app))
+ command.AddCommand(superuserIPsCommand(app))
+
+ return command
+}
+
+func superuserUpsertCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "upsert",
+ Example: "superuser upsert test@example.com 1234567890",
+ Short: "Creates, or updates if email exists, a single superuser",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ if len(args) != 2 {
+ return errors.New("missing email and password arguments")
+ }
+
+ if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
+ return errors.New("missing or invalid email address")
+ }
+
+ superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(superusersCol, args[0])
+ if err != nil {
+ superuser = core.NewRecord(superusersCol)
+ }
+
+ superuser.SetEmail(args[0])
+ superuser.SetPassword(args[1])
+
+ if err := app.Save(superuser); err != nil {
+ return fmt.Errorf("failed to upsert superuser account: %w", err)
+ }
+
+ color.Green("Successfully saved superuser %q!", superuser.Email())
+ return nil
+ },
+ }
+
+ return command
+}
+
+func superuserCreateCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "create",
+ Example: "superuser create test@example.com 1234567890",
+ Short: "Creates a new superuser",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ if len(args) != 2 {
+ return errors.New("missing email and password arguments")
+ }
+
+ if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
+ return errors.New("missing or invalid email address")
+ }
+
+ superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
+ }
+
+ superuser := core.NewRecord(superusersCol)
+ superuser.SetEmail(args[0])
+ superuser.SetPassword(args[1])
+
+ if err := app.Save(superuser); err != nil {
+ return fmt.Errorf("failed to create new superuser account: %w", err)
+ }
+
+ color.Green("Successfully created new superuser %q!", superuser.Email())
+ return nil
+ },
+ }
+
+ return command
+}
+
+func superuserUpdateCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "update",
+ Example: "superuser update test@example.com 1234567890",
+ Short: "Changes the password of a single superuser",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ if len(args) != 2 {
+ return errors.New("missing email and password arguments")
+ }
+
+ if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
+ return errors.New("missing or invalid email address")
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
+ if err != nil {
+ return fmt.Errorf("superuser with email %q doesn't exist", args[0])
+ }
+
+ superuser.SetPassword(args[1])
+
+ if err := app.Save(superuser); err != nil {
+ return fmt.Errorf("failed to change superuser %q password: %w", superuser.Email(), err)
+ }
+
+ color.Green("Successfully changed superuser %q password!", superuser.Email())
+ return nil
+ },
+ }
+
+ return command
+}
+
+func superuserDeleteCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "delete",
+ Example: "superuser delete test@example.com",
+ Short: "Deletes an existing superuser",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
+ return errors.New("invalid or missing email address")
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
+ if err != nil {
+ color.Yellow("superuser %q is missing or already deleted", args[0])
+ return nil
+ }
+
+ if err := app.Delete(superuser); err != nil {
+ return fmt.Errorf("failed to delete superuser %q: %w", superuser.Email(), err)
+ }
+
+ color.Green("Successfully deleted superuser %q!", superuser.Email())
+ return nil
+ },
+ }
+
+ return command
+}
+
+func superuserOTPCommand(app core.App) *cobra.Command {
+ command := &cobra.Command{
+ Use: "otp",
+ Example: "superuser otp test@example.com",
+ Short: "Creates a new OTP for the specified superuser",
+ SilenceUsage: true,
+ RunE: func(command *cobra.Command, args []string) error {
+ if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
+ return errors.New("invalid or missing email address")
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
+ if err != nil {
+ return fmt.Errorf("superuser with email %q doesn't exist", args[0])
+ }
+
+ if !superuser.Collection().OTP.Enabled {
+ return errors.New("OTP auth is not enabled for the _superusers collection")
+ }
+
+ pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
+
+ otp := core.NewOTP(app)
+ otp.SetCollectionRef(superuser.Collection().Id)
+ otp.SetRecordRef(superuser.Id)
+ otp.SetPassword(pass)
+
+ err = app.Save(otp)
+ if err != nil {
+ return fmt.Errorf("failed to create OTP: %w", err)
+ }
+
+ color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
+ color.Green("\n├─ Id: %s", otp.Id)
+ color.Green("├─ Pass: %s", pass)
+ color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration)
+ return nil
+ },
+ }
+
+ 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
+}
diff --git a/cmd/superuser_test.go b/cmd/superuser_test.go
new file mode 100644
index 0000000000..215b53c55c
--- /dev/null
+++ b/cmd/superuser_test.go
@@ -0,0 +1,464 @@
+package cmd_test
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/cmd"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestSuperuserUpsertCommand(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ email string
+ password string
+ expectError bool
+ }{
+ {
+ "empty email and password",
+ "",
+ "",
+ true,
+ },
+ {
+ "empty email",
+ "",
+ "1234567890",
+ true,
+ },
+ {
+ "invalid email",
+ "invalid",
+ "1234567890",
+ true,
+ },
+ {
+ "empty password",
+ "test@example.com",
+ "",
+ true,
+ },
+ {
+ "short password",
+ "test_new@example.com",
+ "1234567",
+ true,
+ },
+ {
+ "existing user",
+ "test@example.com",
+ "1234567890!",
+ false,
+ },
+ {
+ "new user",
+ "test_new@example.com",
+ "1234567890!",
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ command := cmd.NewSuperuserCommand(app)
+ command.SetArgs([]string{"upsert", s.email, s.password})
+
+ 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
+ }
+
+ // check whether the superuser account was actually upserted
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, s.email)
+ if err != nil {
+ t.Fatalf("Failed to fetch superuser %s: %v", s.email, err)
+ } else if !superuser.ValidatePassword(s.password) {
+ t.Fatal("Expected the superuser password to match")
+ }
+ })
+ }
+}
+
+func TestSuperuserCreateCommand(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ email string
+ password string
+ expectError bool
+ }{
+ {
+ "empty email and password",
+ "",
+ "",
+ true,
+ },
+ {
+ "empty email",
+ "",
+ "1234567890",
+ true,
+ },
+ {
+ "invalid email",
+ "invalid",
+ "1234567890",
+ true,
+ },
+ {
+ "duplicated email",
+ "test@example.com",
+ "1234567890",
+ true,
+ },
+ {
+ "empty password",
+ "test@example.com",
+ "",
+ true,
+ },
+ {
+ "short password",
+ "test_new@example.com",
+ "1234567",
+ true,
+ },
+ {
+ "valid email and password",
+ "test_new@example.com",
+ "12345678",
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ command := cmd.NewSuperuserCommand(app)
+ command.SetArgs([]string{"create", s.email, s.password})
+
+ 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
+ }
+
+ // check whether the superuser account was actually created
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, s.email)
+ if err != nil {
+ t.Fatalf("Failed to fetch created superuser %s: %v", s.email, err)
+ } else if !superuser.ValidatePassword(s.password) {
+ t.Fatal("Expected the superuser password to match")
+ }
+ })
+ }
+}
+
+func TestSuperuserUpdateCommand(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ email string
+ password string
+ expectError bool
+ }{
+ {
+ "empty email and password",
+ "",
+ "",
+ true,
+ },
+ {
+ "empty email",
+ "",
+ "1234567890",
+ true,
+ },
+ {
+ "invalid email",
+ "invalid",
+ "1234567890",
+ true,
+ },
+ {
+ "nonexisting superuser",
+ "test_missing@example.com",
+ "1234567890",
+ true,
+ },
+ {
+ "empty password",
+ "test@example.com",
+ "",
+ true,
+ },
+ {
+ "short password",
+ "test_new@example.com",
+ "1234567",
+ true,
+ },
+ {
+ "valid email and password",
+ "test@example.com",
+ "12345678",
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ command := cmd.NewSuperuserCommand(app)
+ command.SetArgs([]string{"update", s.email, s.password})
+
+ 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
+ }
+
+ // check whether the superuser password was actually changed
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, s.email)
+ if err != nil {
+ t.Fatalf("Failed to fetch superuser %s: %v", s.email, err)
+ } else if !superuser.ValidatePassword(s.password) {
+ t.Fatal("Expected the superuser password to match")
+ }
+ })
+ }
+}
+
+func TestSuperuserDeleteCommand(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ email string
+ expectError bool
+ }{
+ {
+ "empty email",
+ "",
+ true,
+ },
+ {
+ "invalid email",
+ "invalid",
+ true,
+ },
+ {
+ "nonexisting superuser",
+ "test_missing@example.com",
+ false,
+ },
+ {
+ "existing superuser",
+ "test@example.com",
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ command := cmd.NewSuperuserCommand(app)
+ command.SetArgs([]string{"delete", s.email})
+
+ 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
+ }
+
+ if _, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, s.email); err == nil {
+ t.Fatal("Expected the superuser account to be deleted")
+ }
+ })
+ }
+}
+
+func TestSuperuserOTPCommand(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // remove all existing otps
+ otps, err := app.FindAllOTPsByCollection(superusersCollection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, otp := range otps {
+ err = app.Delete(otp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ scenarios := []struct {
+ name string
+ email string
+ enabled bool
+ expectError bool
+ }{
+ {
+ "empty email",
+ "",
+ true,
+ true,
+ },
+ {
+ "invalid email",
+ "invalid",
+ true,
+ true,
+ },
+ {
+ "nonexisting superuser",
+ "test_missing@example.com",
+ true,
+ true,
+ },
+ {
+ "existing superuser",
+ "test@example.com",
+ true,
+ false,
+ },
+ {
+ "existing superuser with disabled OTP",
+ "test@example.com",
+ false,
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ command := cmd.NewSuperuserCommand(app)
+ command.SetArgs([]string{"otp", s.email})
+
+ superusersCollection.OTP.Enabled = s.enabled
+ if err = app.SaveNoValidate(superusersCollection); err != nil {
+ t.Fatal(err)
+ }
+
+ 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
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ otps, _ := app.FindAllOTPsByRecord(superuser)
+ if total := len(otps); total != 1 {
+ t.Fatalf("Expected 1 OTP, got %d", total)
+ }
+ })
+ }
+}
+
+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)
+ }
+ }
+ })
+ }
+}
diff --git a/core/app.go b/core/app.go
index 6e1e66160f..05c22765d8 100644
--- a/core/app.go
+++ b/core/app.go
@@ -5,10 +5,11 @@ package core
import (
"context"
+ "log/slog"
+ "time"
"github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models/settings"
+ "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
@@ -17,90 +18,99 @@ import (
)
// App defines the main PocketBase app interface.
+//
+// Note that the interface is not intended to be implemented manually by users
+// and instead they should use core.BaseApp (either directly or as embedded field in a custom struct).
+//
+// This interface exists to make testing easier and to allow users to
+// create common and pluggable helpers and methods that doesn't rely
+// on a specific wrapped app struct (hence the large interface size).
type App interface {
- // Deprecated:
- // This method may get removed in the near future.
- // It is recommended to access the app db instance from app.Dao().DB() or
- // if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
+ // UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
+ //
+ // NB! Note that using the returned app instance may cause data integrity errors
+ // since the Record validations and data normalizations (including files uploads)
+ // rely on the app hooks to work.
+ UnsafeWithoutHooks() App
+
+ // Logger returns the default app logger.
//
- // DB returns the default app database instance.
- DB() *dbx.DB
+ // If the application is not bootstrapped yet, fallbacks to slog.Default().
+ Logger() *slog.Logger
+
+ // IsBootstrapped checks if the application was initialized
+ // (aka. whether Bootstrap() was called).
+ IsBootstrapped() bool
- // Dao returns the default app Dao instance.
+ // IsTransactional checks if the current app instance is part of a transaction.
+ IsTransactional() bool
+
+ // TxInfo returns the transaction associated with the current app instance (if any).
//
- // This Dao could operate only on the tables and models
- // associated with the default app database. For example,
- // trying to access the request logs table will result in error.
- Dao() *daos.Dao
+ // Could be used if you want to execute indirectly a function after
+ // the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
+ TxInfo() *TxAppInfo
- // Deprecated:
- // This method may get removed in the near future.
- // It is recommended to access the logs db instance from app.LogsDao().DB() or
- // if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
+ // Bootstrap initializes the application
+ // (aka. create data dir, open db connections, load settings, etc.).
//
- // LogsDB returns the app logs database instance.
- LogsDB() *dbx.DB
+ // It calls ClearBootstrap() if the application was already bootstrapped.
+ Bootstrap() error
- // LogsDao returns the app logs Dao instance.
+ // ClearBootstrap releases the initialized core app resources
+ // (closing db connections, stopping cron ticker, etc.).
//
- // This Dao could operate only on the tables and models
- // associated with the logs database. For example, trying to access
- // the users table from LogsDao will result in error.
- LogsDao() *daos.Dao
+ // This method is no-op if the application is not bootstrapped yet.
+ ClearBootstrap() error
+
+ // Deprecated: use ClearBootstrap().
+ ResetBootstrapState() error
// DataDir returns the app data directory path.
DataDir() string
// EncryptionEnv returns the name of the app secret env key
- // (used for settings encryption).
+ // (currently used primarily for optional settings encryption but this may change in the future).
EncryptionEnv() string
- // IsDebug returns whether the app is in debug mode
- // (showing more detailed error logs, executed sql statements, etc.).
- IsDebug() bool
+ // IsDev returns whether the app is in dev mode.
+ //
+ // When enabled logs, executed sql statements, etc. are printed to the stderr.
+ IsDev() bool
// Settings returns the loaded app settings.
- Settings() *settings.Settings
+ Settings() *Settings
+
+ // Store returns the app runtime store.
+ Store() *store.Store[string, any]
- // Cache returns the app internal cache store.
- Cache() *store.Store[any]
+ // Cron returns the app cron instance.
+ Cron() *cron.Cron
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
SubscriptionsBroker() *subscriptions.Broker
- // NewMailClient creates and returns a configured app mail client.
+ // NewMailClient creates and returns a new SMTP or Sendmail client
+ // based on the current app settings.
NewMailClient() mailer.Mailer
- // NewFilesystem creates and returns a configured filesystem.System instance
- // for managing regular app files (eg. collection uploads).
+ // NewFilesystem creates a new local or S3 filesystem instance
+ // for managing regular app files (ex. record uploads)
+ // based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
- // NewBackupsFilesystem creates and returns a configured filesystem.System instance
- // for managing app backups.
+ // NewBackupsFilesystem creates a new local or S3 filesystem instance
+ // for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
NewBackupsFilesystem() (*filesystem.System, error)
- // RefreshSettings reinitializes and reloads the stored application settings.
- RefreshSettings() error
-
- // IsBootstrapped checks if the application was initialized
- // (aka. whether Bootstrap() was called).
- IsBootstrapped() bool
-
- // Bootstrap takes care for initializing the application
- // (open db connections, load settings, etc.).
- //
- // It will call ResetBootstrapState() if the application was already bootstrapped.
- Bootstrap() error
-
- // ResetBootstrapState takes care for releasing initialized app resources
- // (eg. closing db connections).
- ResetBootstrapState() error
+ // ReloadSettings reinitializes and reloads the stored application settings.
+ ReloadSettings() error
// CreateBackup creates a new backup of the current app pb_data directory.
//
@@ -122,500 +132,1281 @@ type App interface {
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
RestoreBackup(ctx context.Context, name string) error
- // Restart restarts the current running application process.
+ // Restart restarts (aka. replaces) the current running application process.
//
- // Currently it is relying on execve so it is supported only on UNIX based systems.
+ // NB! It relies on execve which is supported only on UNIX based systems.
Restart() error
+ // RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
+ RunSystemMigrations() error
+
+ // RunAppMigrations applies all new migrations registered in the [core.AppMigrations] list.
+ RunAppMigrations() error
+
+ // RunAllMigrations applies all system and app migrations
+ // (aka. from both [core.SystemMigrations] and [core.AppMigrations]).
+ RunAllMigrations() error
+
// ---------------------------------------------------------------
- // App event hooks
+ // DB methods
// ---------------------------------------------------------------
- // OnBeforeBootstrap hook is triggered before initializing the main
- // application resources (eg. before db open and initial settings load).
- OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
+ // DB returns the default app data.db builder instance.
+ //
+ // To minimize SQLITE_BUSY errors, it automatically routes the
+ // SELECT queries to the underlying concurrent db pool and everything else
+ // to the nonconcurrent one.
+ //
+ // For more finer control over the used connections pools you can
+ // call directly ConcurrentDB() or NonconcurrentDB().
+ DB() dbx.Builder
+
+ // ConcurrentDB returns the concurrent app data.db builder instance.
+ //
+ // This method is used mainly internally for executing db read
+ // operations in a concurrent/non-blocking manner.
+ //
+ // Most users should use simply DB() as it will automatically
+ // route the query execution to ConcurrentDB() or NonconcurrentDB().
+ //
+ // In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+ ConcurrentDB() dbx.Builder
+
+ // NonconcurrentDB returns the nonconcurrent app data.db builder instance.
+ //
+ // The returned db instance is limited only to a single open connection,
+ // meaning that it can process only 1 db operation at a time (other queries queue up).
+ //
+ // This method is used mainly internally and in the tests to execute write
+ // (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ //
+ // Most users should use simply DB() as it will automatically
+ // route the query execution to ConcurrentDB() or NonconcurrentDB().
+ //
+ // In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+ NonconcurrentDB() dbx.Builder
+
+ // AuxDB returns the app auxiliary.db builder instance.
+ //
+ // To minimize SQLITE_BUSY errors, it automatically routes the
+ // SELECT queries to the underlying concurrent db pool and everything else
+ // to the nonconcurrent one.
+ //
+ // For more finer control over the used connections pools you can
+ // call directly AuxConcurrentDB() or AuxNonconcurrentDB().
+ AuxDB() dbx.Builder
- // OnAfterBootstrap hook is triggered after initializing the main
- // application resources (eg. after db open and initial settings load).
- OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
+ // AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
+ //
+ // This method is used mainly internally for executing db read
+ // operations in a concurrent/non-blocking manner.
+ //
+ // Most users should use simply AuxDB() as it will automatically
+ // route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ //
+ // In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+ AuxConcurrentDB() dbx.Builder
- // OnBeforeServe hook is triggered before serving the internal router (echo),
- // allowing you to adjust its options and attach new routes or middlewares.
- OnBeforeServe() *hook.Hook[*ServeEvent]
+ // AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
+ //
+ // The returned db instance is limited only to a single open connection,
+ // meaning that it can process only 1 db operation at a time (other queries queue up).
+ //
+ // This method is used mainly internally and in the tests to execute write
+ // (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ //
+ // Most users should use simply AuxDB() as it will automatically
+ // route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ //
+ // In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+ AuxNonconcurrentDB() dbx.Builder
- // OnBeforeApiError hook is triggered right before sending an error API
- // response to the client, allowing you to further modify the error data
- // or to return a completely different API response.
- OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
+ // HasTable checks if a table (or view) with the provided name exists (case insensitive).
+ // in the data.db.
+ HasTable(tableName string) bool
- // OnAfterApiError hook is triggered right after sending an error API
- // response to the client.
- // It could be used to log the final API error in external services.
- OnAfterApiError() *hook.Hook[*ApiErrorEvent]
+ // AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
+ // in the auxiliary.db.
+ AuxHasTable(tableName string) bool
- // OnTerminate hook is triggered when the app is in the process
- // of being terminated (eg. on SIGTERM signal).
- OnTerminate() *hook.Hook[*TerminateEvent]
+ // TableColumns returns all column names of a single table by its name.
+ TableColumns(tableName string) ([]string, error)
+
+ // TableInfo returns the "table_info" pragma result for the specified table.
+ TableInfo(tableName string) ([]*TableInfoRow, error)
+
+ // TableIndexes returns a name grouped map with all non empty index of the specified table.
+ //
+ // Note: This method doesn't return an error on nonexisting table.
+ TableIndexes(tableName string) (map[string]string, error)
+
+ // DeleteTable drops the specified table.
+ //
+ // This method is a no-op if a table with the provided name doesn't exist.
+ //
+ // NB! Be aware that this method is vulnerable to SQL injection and the
+ // "dangerousTableName" argument must come only from trusted input!
+ DeleteTable(dangerousTableName string) error
+
+ // DeleteView drops the specified view name.
+ //
+ // This method is a no-op if a view with the provided name doesn't exist.
+ //
+ // NB! Be aware that this method is vulnerable to SQL injection and the
+ // "dangerousViewName" argument must come only from trusted input!
+ DeleteView(dangerousViewName string) error
+
+ // SaveView creates (or updates already existing) persistent SQL view.
+ //
+ // NB! Be aware that this method is vulnerable to SQL injection and
+ // its arguments must come only from trusted input!
+ SaveView(dangerousViewName string, dangerousSelectQuery string) error
+
+ // CreateViewFields creates a new FieldsList from the provided select query.
+ //
+ // There are some caveats:
+ // - The select query must have an "id" column.
+ // - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
+ //
+ // NB! Be aware that this method is vulnerable to SQL injection and the
+ // "dangerousSelectQuery" argument must come only from trusted input!
+ CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
+
+ // DryRunView executes the provided query by creating a temporary view
+ // collection and returning a sample of the resulting query records (if valid).
+ //
+ // The same caveats from CreateViewFields apply here too.
+ //
+ // NB! Be aware that this method is vulnerable to SQL injection and the
+ // "dangerousSelectQuery" argument must come only from trusted input!
+ DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
+
+ // FindRecordByViewFile returns the original Record of the provided view collection file.
+ FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
+
+ // Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
+ Vacuum() error
+
+ // AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
+ AuxVacuum() error
+
+ // ---------------------------------------------------------------
+
+ // ModelQuery creates a new preconfigured select data.db query with preset
+ // SELECT, FROM and other common fields based on the provided model.
+ ModelQuery(model Model) *dbx.SelectQuery
+
+ // AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
+ // SELECT, FROM and other common fields based on the provided model.
+ AuxModelQuery(model Model) *dbx.SelectQuery
+
+ // Delete deletes the specified model from the regular app database.
+ Delete(model Model) error
+
+ // Delete deletes the specified model from the regular app database
+ // (the context could be used to limit the query execution).
+ DeleteWithContext(ctx context.Context, model Model) error
+
+ // AuxDelete deletes the specified model from the auxiliary database.
+ AuxDelete(model Model) error
+
+ // AuxDeleteWithContext deletes the specified model from the auxiliary database
+ // (the context could be used to limit the query execution).
+ AuxDeleteWithContext(ctx context.Context, model Model) error
+
+ // Save validates and saves the specified model into the regular app database.
+ //
+ // If you don't want to run validations, use [App.SaveNoValidate()].
+ Save(model Model) error
+
+ // SaveWithContext is the same as [App.Save()] but allows specifying a context to limit the db execution.
+ //
+ // If you don't want to run validations, use [App.SaveNoValidateWithContext()].
+ SaveWithContext(ctx context.Context, model Model) error
+
+ // SaveNoValidate saves the specified model into the regular app database without performing validations.
+ //
+ // If you want to also run validations before persisting, use [App.Save()].
+ SaveNoValidate(model Model) error
+
+ // SaveNoValidateWithContext is the same as [App.SaveNoValidate()]
+ // but allows specifying a context to limit the db execution.
+ //
+ // If you want to also run validations before persisting, use [App.SaveWithContext()].
+ SaveNoValidateWithContext(ctx context.Context, model Model) error
+
+ // AuxSave validates and saves the specified model into the auxiliary app database.
+ //
+ // If you don't want to run validations, use [App.AuxSaveNoValidate()].
+ AuxSave(model Model) error
+
+ // AuxSaveWithContext is the same as [App.AuxSave()] but allows specifying a context to limit the db execution.
+ //
+ // If you don't want to run validations, use [App.AuxSaveNoValidateWithContext()].
+ AuxSaveWithContext(ctx context.Context, model Model) error
+
+ // AuxSaveNoValidate saves the specified model into the auxiliary app database without performing validations.
+ //
+ // If you want to also run validations before persisting, use [App.AuxSave()].
+ AuxSaveNoValidate(model Model) error
+
+ // AuxSaveNoValidateWithContext is the same as [App.AuxSaveNoValidate()]
+ // but allows specifying a context to limit the db execution.
+ //
+ // If you want to also run validations before persisting, use [App.AuxSaveWithContext()].
+ AuxSaveNoValidateWithContext(ctx context.Context, model Model) error
+
+ // Validate triggers the OnModelValidate hook for the specified model.
+ Validate(model Model) error
+
+ // ValidateWithContext is the same as Validate but allows specifying the ModelEvent context.
+ ValidateWithContext(ctx context.Context, model Model) error
+
+ // RunInTransaction wraps fn into a transaction for the regular app database.
+ //
+ // It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
+ RunInTransaction(fn func(txApp App) error) error
+
+ // AuxRunInTransaction wraps fn into a transaction for the auxiliary app database.
+ //
+ // It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
+ AuxRunInTransaction(fn func(txApp App) error) error
// ---------------------------------------------------------------
- // Dao event hooks
+
+ // LogQuery returns a new Log select query.
+ LogQuery() *dbx.SelectQuery
+
+ // FindLogById finds a single Log entry by its id.
+ FindLogById(id string) (*Log, error)
+
+ // LogsStatsItem returns hourly grouped logs statistics.
+ LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
+
+ // DeleteOldLogs delete all logs that are created before createdBefore.
+ DeleteOldLogs(createdBefore time.Time) error
+
// ---------------------------------------------------------------
- // OnModelBeforeCreate hook is triggered before inserting a new
- // model in the DB, allowing you to modify or validate the stored data.
+ // CollectionQuery returns a new Collection select query.
+ CollectionQuery() *dbx.SelectQuery
+
+ // FindCollections finds all collections by the given type(s).
+ //
+ // If collectionTypes is not set, it returns all collections.
+ //
+ // Example:
+ //
+ // app.FindAllCollections() // all collections
+ // app.FindAllCollections("auth", "view") // only auth and view collections
+ FindAllCollections(collectionTypes ...string) ([]*Collection, error)
+
+ // ReloadCachedCollections fetches all collections and caches them into the app store.
+ ReloadCachedCollections() error
+
+ // FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.s
+ FindCollectionByNameOrId(nameOrId string) (*Collection, error)
+
+ // FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
+ // but retrieves the Collection from the app cache instead of making a db call.
+ //
+ // NB! This method is suitable for read-only Collection operations.
+ //
+ // Returns [sql.ErrNoRows] if no Collection is found for consistency
+ // with the [App.FindCollectionByNameOrId] method.
+ //
+ // If you plan making changes to the returned Collection model,
+ // use [App.FindCollectionByNameOrId] instead.
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // Caveats:
+ //
+ // - The returned Collection should be used only for read-only operations.
+ // Avoid directly modifying the returned cached Collection as it will affect
+ // the global cached value even if you don't persist the changes in the database!
+ // - If you are updating a Collection in a transaction and then call this method before commit,
+ // it'll return the cached Collection state and not the one from the uncommitted transaction.
+ // - The cache is automatically updated on collections db change (create/update/delete).
+ // To manually reload the cache you can call [App.ReloadCachedCollections]
+ FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
+
+ // FindCollectionReferences returns information for all relation
+ // fields referencing the provided collection.
+ //
+ // If the provided collection has reference to itself then it will be
+ // also included in the result. To exclude it, pass the collection id
+ // as the excludeIds argument.
+ FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
- // OnModelAfterCreate hook is triggered after successfully
- // inserting a new model in the DB.
+ // FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
+ // but retrieves the Collection from the app cache instead of making a db call.
+ //
+ // NB! This method is suitable for read-only Collection operations.
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // If you plan making changes to the returned Collection model,
+ // use [App.FindCollectionReferences] instead.
+ //
+ // Caveats:
+ //
+ // - The returned Collection should be used only for read-only operations.
+ // Avoid directly modifying the returned cached Collection as it will affect
+ // the global cached value even if you don't persist the changes in the database!
+ // - If you are updating a Collection in a transaction and then call this method before commit,
+ // it'll return the cached Collection state and not the one from the uncommitted transaction.
+ // - The cache is automatically updated on collections db change (create/update/delete).
+ // To manually reload the cache you can call [App.ReloadCachedCollections].
+ FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
- // OnModelBeforeUpdate hook is triggered before updating existing
- // model in the DB, allowing you to modify or validate the stored data.
+ // IsCollectionNameUnique checks that there is no existing collection
+ // with the provided name (case insensitive!).
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // Note: case insensitive check because the name is used also as
+ // table name for the records.
+ IsCollectionNameUnique(name string, excludeIds ...string) bool
- // OnModelAfterUpdate hook is triggered after successfully updating
- // existing model in the DB.
+ // TruncateCollection deletes all records associated with the provided collection.
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // The truncate operation is executed in a single transaction,
+ // aka. either everything is deleted or none.
+ //
+ // Note that this method will also trigger the records related
+ // cascade and file delete actions.
+ TruncateCollection(collection *Collection) error
- // OnModelBeforeDelete hook is triggered before deleting an
- // existing model from the DB.
+ // ImportCollections imports the provided collections data in a single transaction.
+ //
+ // For existing matching collections, the imported data is unmarshaled on top of the existing model.
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // NB! If deleteMissing is true, ALL NON-SYSTEM COLLECTIONS AND SCHEMA FIELDS,
+ // that are not present in the imported configuration, WILL BE DELETED
+ // (this includes their related records data).
+ ImportCollections(toImport []map[string]any, deleteMissing bool) error
- // OnModelAfterDelete hook is triggered after successfully deleting an
- // existing model from the DB.
+ // ImportCollectionsByMarshaledJSON is the same as [ImportCollections]
+ // but accept marshaled json array as import data (usually used for the autogenerated snapshots).
+ ImportCollectionsByMarshaledJSON(rawSliceOfMaps []byte, deleteMissing bool) error
+
+ // SyncRecordTableSchema compares the two provided collections
+ // and applies the necessary related record table changes.
+ //
+ // If oldCollection is null, then only newCollection is used to create the record table.
//
- // If the optional "tags" list (table names and/or the Collection id for Record models)
- // is specified, then all event handlers registered via the created hook
- // will be triggered and called only if their event data origin matches the tags.
- OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
+ // This method is automatically invoked as part of a collection create/update/delete operation.
+ SyncRecordTableSchema(newCollection *Collection, oldCollection *Collection) error
// ---------------------------------------------------------------
- // Mailer event hooks
+
+ // FindAllExternalAuthsByRecord returns all ExternalAuth models
+ // linked to the provided auth record.
+ FindAllExternalAuthsByRecord(authRecord *Record) ([]*ExternalAuth, error)
+
+ // FindAllExternalAuthsByCollection returns all ExternalAuth models
+ // linked to the provided auth collection.
+ FindAllExternalAuthsByCollection(collection *Collection) ([]*ExternalAuth, error)
+
+ // FindFirstExternalAuthByExpr returns the first available (the most recent created)
+ // ExternalAuth model that satisfies the non-nil expression.
+ FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error)
+
+ // DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
+ //
+ // Returns a combined error with the failed deletes.
+ DeleteAllExternalAuthsByRecord(authRecord *Record) error
+
// ---------------------------------------------------------------
- // OnMailerBeforeAdminResetPasswordSend hook is triggered right
- // before sending a password reset email to an admin, allowing you
- // to inspect and customize the email message that is being sent.
- OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
+ // FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
+ FindAllMFAsByRecord(authRecord *Record) ([]*MFA, error)
+
+ // FindAllMFAsByCollection returns all MFA models linked to the provided collection.
+ FindAllMFAsByCollection(collection *Collection) ([]*MFA, error)
- // OnMailerAfterAdminResetPasswordSend hook is triggered after
- // admin password reset email was successfully sent.
- OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
+ // FindMFAById returns a single MFA model by its id.
+ FindMFAById(id string) (*MFA, error)
- // OnMailerBeforeRecordResetPasswordSend hook is triggered right
- // before sending a password reset email to an auth record, allowing
- // you to inspect and customize the email message that is being sent.
+ // DeleteAllMFAsByRecord deletes all MFA models associated with the provided record.
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Returns a combined error with the failed deletes.
+ DeleteAllMFAsByRecord(authRecord *Record) error
+
+ // DeleteExpiredMFAs deletes the expired MFAs for all auth collections.
+ DeleteExpiredMFAs() error
+
+ // ---------------------------------------------------------------
+
+ // FindAllOTPsByRecord returns all OTP models linked to the provided auth record.
+ FindAllOTPsByRecord(authRecord *Record) ([]*OTP, error)
+
+ // FindAllOTPsByCollection returns all OTP models linked to the provided collection.
+ FindAllOTPsByCollection(collection *Collection) ([]*OTP, error)
+
+ // FindOTPById returns a single OTP model by its id.
+ FindOTPById(id string) (*OTP, error)
+
+ // DeleteAllOTPsByRecord deletes all OTP models associated with the provided record.
+ //
+ // Returns a combined error with the failed deletes.
+ DeleteAllOTPsByRecord(authRecord *Record) error
+
+ // DeleteExpiredOTPs deletes the expired OTPs for all auth collections.
+ DeleteExpiredOTPs() error
+
+ // ---------------------------------------------------------------
+
+ // FindAllAuthOriginsByRecord returns all AuthOrigin models linked to the provided auth record (in DESC order).
+ FindAllAuthOriginsByRecord(authRecord *Record) ([]*AuthOrigin, error)
+
+ // FindAllAuthOriginsByCollection returns all AuthOrigin models linked to the provided collection (in DESC order).
+ FindAllAuthOriginsByCollection(collection *Collection) ([]*AuthOrigin, error)
+
+ // FindAuthOriginById returns a single AuthOrigin model by its id.
+ FindAuthOriginById(id string) (*AuthOrigin, error)
+
+ // FindAuthOriginByRecordAndFingerprint returns a single AuthOrigin model
+ // by its authRecord relation and fingerprint.
+ FindAuthOriginByRecordAndFingerprint(authRecord *Record, fingerprint string) (*AuthOrigin, error)
+
+ // DeleteAllAuthOriginsByRecord deletes all AuthOrigin models associated with the provided record.
+ //
+ // Returns a combined error with the failed deletes.
+ DeleteAllAuthOriginsByRecord(authRecord *Record) error
+
+ // ---------------------------------------------------------------
+
+ // RecordQuery returns a new Record select query from a collection model, id or name.
+ //
+ // In case a collection id or name is provided and that collection doesn't
+ // actually exists, the generated query will be created with a cancelled context
+ // and will fail once an executor (Row(), One(), All(), etc.) is called.
+ RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery
+
+ // FindRecordById finds the Record model by its id.
+ FindRecordById(collectionModelOrIdentifier any, recordId string, optFilters ...func(q *dbx.SelectQuery) error) (*Record, error)
+
+ // FindRecordsByIds finds all records by the specified ids.
+ // If no records are found, returns an empty slice.
+ FindRecordsByIds(collectionModelOrIdentifier any, recordIds []string, optFilters ...func(q *dbx.SelectQuery) error) ([]*Record, error)
+
+ // FindAllRecords finds all records matching specified db expressions.
+ //
+ // Returns all collection records if no expression is provided.
+ //
+ // Returns an empty slice if no records are found.
+ //
+ // Example:
+ //
+ // // no extra expressions
+ // app.FindAllRecords("example")
+ //
+ // // with extra expressions
+ // expr1 := dbx.HashExp{"email": "test@example.com"}
+ // expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
+ // app.FindAllRecords("example", expr1, expr2)
+ FindAllRecords(collectionModelOrIdentifier any, exprs ...dbx.Expression) ([]*Record, error)
+
+ // FindFirstRecordByData returns the first found record matching
+ // the provided key-value pair.
+ FindFirstRecordByData(collectionModelOrIdentifier any, key string, value any) (*Record, error)
+
+ // FindRecordsByFilter returns limit number of records matching the
+ // provided string filter.
+ //
+ // NB! Use the last "params" argument to bind untrusted user variables!
+ //
+ // The filter argument is optional and can be empty string to target
+ // all available records.
+ //
+ // The sort argument is optional and can be empty string OR the same format
+ // used in the web APIs, ex. "-created,title".
+ //
+ // If the limit argument is <= 0, no limit is applied to the query and
+ // all matching records are returned.
+ //
+ // Returns an empty slice if no records are found.
+ //
+ // Example:
+ //
+ // app.FindRecordsByFilter(
+ // "posts",
+ // "title ~ {:title} && visible = {:visible}",
+ // "-created",
+ // 10,
+ // 0,
+ // dbx.Params{"title": "lorem ipsum", "visible": true}
+ // )
+ FindRecordsByFilter(
+ collectionModelOrIdentifier any,
+ filter string,
+ sort string,
+ limit int,
+ offset int,
+ params ...dbx.Params,
+ ) ([]*Record, error)
+
+ // FindFirstRecordByFilter returns the first available record matching the provided filter (if any).
+ //
+ // NB! Use the last params argument to bind untrusted user variables!
+ //
+ // Returns sql.ErrNoRows if no record is found.
+ //
+ // Example:
+ //
+ // app.FindFirstRecordByFilter("posts", "")
+ // app.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
+ FindFirstRecordByFilter(
+ collectionModelOrIdentifier any,
+ filter string,
+ params ...dbx.Params,
+ ) (*Record, error)
+
+ // CountRecords returns the total number of records in a collection.
+ CountRecords(collectionModelOrIdentifier any, exprs ...dbx.Expression) (int64, error)
+
+ // FindAuthRecordByToken finds the auth record associated with the provided JWT
+ // (auth, file, verifyEmail, changeEmail, passwordReset types).
+ //
+ // Optionally specify a list of validTypes to check tokens only from those types.
+ //
+ // Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
+ FindAuthRecordByToken(token string, validTypes ...string) (*Record, error)
+
+ // FindAuthRecordByEmail finds the auth record associated with the provided email.
+ //
+ // Returns an error if it is not an auth collection or the record is not found.
+ FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error)
+
+ // CanAccessRecord checks if a record is allowed to be accessed by the
+ // specified requestInfo and accessRule.
+ //
+ // Rule and db checks are ignored in case requestInfo.Auth is a superuser.
+ //
+ // The returned error indicate that something unexpected happened during
+ // the check (eg. invalid rule or db query error).
+ //
+ // The method always return false on invalid rule or db query error.
+ //
+ // Example:
+ //
+ // requestInfo, _ := e.RequestInfo()
+ // record, _ := app.FindRecordById("example", "RECORD_ID")
+ // rule := types.Pointer("@request.auth.id != '' || status = 'public'")
+ // // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
+ //
+ // if ok, _ := app.CanAccessRecord(record, requestInfo, rule); ok { ... }
+ CanAccessRecord(record *Record, requestInfo *RequestInfo, accessRule *string) (bool, error)
+
+ // ExpandRecord expands the relations of a single Record model.
+ //
+ // If optFetchFunc is not set, then a default function will be used
+ // that returns all relation records.
+ //
+ // Returns a map with the failed expand parameters and their errors.
+ ExpandRecord(record *Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error
+
+ // ExpandRecords expands the relations of the provided Record models list.
+ //
+ // If optFetchFunc is not set, then a default function will be used
+ // that returns all relation records.
+ //
+ // Returns a map with the failed expand parameters and their errors.
+ ExpandRecords(records []*Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error
+
+ // ---------------------------------------------------------------
+ // App event hooks
+ // ---------------------------------------------------------------
+
+ // OnBootstrap hook is triggered when initializing the main application
+ // resources (db, app settings, etc).
+ OnBootstrap() *hook.Hook[*BootstrapEvent]
+
+ // OnBootstrapClear hook is triggered when clearing the main application
+ // resources (db connections, cron, logger, etc.)
+ //
+ // It is usually invoked automatically right before app termination
+ // or when manually calling app.ClearBootstrap().
+ OnBootstrapClear() *hook.Hook[*BootstrapEvent]
+
+ // OnServe hook is triggered when the app web server is started
+ // (after starting the TCP listener but before initializing the blocking serve task),
+ // allowing you to adjust its options and attach new routes or middlewares.
+ OnServe() *hook.Hook[*ServeEvent]
+
+ // OnTerminate hook is triggered when the app is in the process
+ // of being terminated (ex. on SIGTERM signal).
+ //
+ // Note that the app could be terminated abruptly without awaiting the hook completion.
+ OnTerminate() *hook.Hook[*TerminateEvent]
+
+ // OnBackupCreate hook is triggered on each [App.CreateBackup] call.
+ OnBackupCreate() *hook.Hook[*BackupEvent]
+
+ // OnBackupRestore hook is triggered before app backup restore (aka. [App.RestoreBackup] call).
+ //
+ // Note that by default on success the application is restarted and the after state of the hook is ignored.
+ OnBackupRestore() *hook.Hook[*BackupEvent]
+
+ // ---------------------------------------------------------------
+ // DB models event hooks
+ // ---------------------------------------------------------------
+
+ // OnModelValidate is triggered every time when a model is being validated
+ // (e.g. triggered by App.Validate() or App.Save()).
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelValidate(tags ...string) *hook.TaggedHook[*ModelEvent]
+
+ // ---------------------------------------------------------------
- // OnMailerAfterRecordResetPasswordSend hook is triggered after
- // an auth record password reset email was successfully sent.
+ // OnModelCreate is triggered every time when a new model is being created
+ // (e.g. triggered by App.Save()).
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Operations BEFORE the e.Next() execute before the model validation
+ // and the INSERT DB statement.
+ //
+ // Operations AFTER the e.Next() execute after the model validation
+ // and the INSERT DB statement.
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is persisted in the database since its wrapping transaction may
+ // not have been committed yet.
+ // If you want to listen to only the actual persisted events, you can
+ // bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnMailerBeforeRecordVerificationSend hook is triggered right
- // before sending a verification email to an auth record, allowing
- // you to inspect and customize the email message that is being sent.
+ // OnModelCreateExecute is triggered after successful Model validation
+ // and right before the model INSERT DB statement execution.
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Usually it is triggered as part of the App.Save() in the following firing order:
+ // OnModelCreate {
+ // -> OnModelValidate (skipped with App.SaveNoValidate())
+ // -> OnModelCreateExecute
+ // }
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is persisted in the database since its wrapping transaction may have been
+ // committed yet.
+ // If you want to listen to only the actual persisted events,
+ // you can bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelCreateExecute(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnMailerAfterRecordVerificationSend hook is triggered after a
- // verification email was successfully sent to an auth record.
+ // OnModelAfterCreateSuccess is triggered after each successful
+ // Model DB create persistence.
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Note that when a Model is persisted as part of a transaction,
+ // this hook is delayed and executed only AFTER the transaction has been committed.
+ // This hook is NOT triggered in case the transaction rollbacks
+ // (aka. when the model wasn't persisted).
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelAfterCreateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnMailerBeforeRecordChangeEmailSend hook is triggered right before
- // sending a confirmation new address email to an auth record, allowing
- // you to inspect and customize the email message that is being sent.
+ // OnModelAfterCreateError is triggered after each failed
+ // Model DB create persistence.
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Note that the execution of this hook is either immediate or delayed
+ // depending on the error:
+ // - "immediate" on App.Save() failure
+ // - "delayed" on transaction rollback
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelAfterCreateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent]
- // OnMailerAfterRecordChangeEmailSend hook is triggered after a
- // verification email was successfully sent to an auth record.
+ // ---------------------------------------------------------------
+
+ // OnModelUpdate is triggered every time when a new model is being updated
+ // (e.g. triggered by App.Save()).
//
- // If the optional "tags" list (Collection ids or names) is specified,
+ // Operations BEFORE the e.Next() execute before the model validation
+ // and the UPDATE DB statement.
+ //
+ // Operations AFTER the e.Next() execute after the model validation
+ // and the UPDATE DB statement.
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is persisted in the database since its wrapping transaction may
+ // not have been committed yet.
+ // If you want to listen to only the actual persisted events, you can
+ // bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+ OnModelUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
- // ---------------------------------------------------------------
- // Realtime API event hooks
- // ---------------------------------------------------------------
+ // OnModelUpdateExecute is triggered after successful Model validation
+ // and right before the model UPDATE DB statement execution.
+ //
+ // Usually it is triggered as part of the App.Save() in the following firing order:
+ // OnModelUpdate {
+ // -> OnModelValidate (skipped with App.SaveNoValidate())
+ // -> OnModelUpdateExecute
+ // }
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is persisted in the database since its wrapping transaction may have been
+ // committed yet.
+ // If you want to listen to only the actual persisted events,
+ // you can bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelUpdateExecute(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnRealtimeConnectRequest hook is triggered right before establishing
- // the SSE client connection.
- OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
+ // OnModelAfterUpdateSuccess is triggered after each successful
+ // Model DB update persistence.
+ //
+ // Note that when a Model is persisted as part of a transaction,
+ // this hook is delayed and executed only AFTER the transaction has been committed.
+ // This hook is NOT triggered in case the transaction rollbacks
+ // (aka. when the model changes weren't persisted).
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent]
+
+ // OnModelAfterUpdateError is triggered after each failed
+ // Model DB update persistence.
+ //
+ // Note that the execution of this hook is either immediate or delayed
+ // depending on the error:
+ // - "immediate" on App.Save() failure
+ // - "delayed" on transaction rollback
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelAfterUpdateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent]
- // OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
- // SSE client connection.
- OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
+ // ---------------------------------------------------------------
- // OnRealtimeBeforeMessage hook is triggered right before sending
- // an SSE message to a client.
+ // OnModelDelete is triggered every time when a new model is being deleted
+ // (e.g. triggered by App.Delete()).
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is deleted from the database since its wrapping transaction may
+ // not have been committed yet.
+ // If you want to listen to only the actual persisted deleted events, you can
+ // bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
//
- // Returning [hook.StopPropagation] will prevent sending the message.
- // Returning any other non-nil error will close the realtime connection.
- OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnRealtimeBeforeMessage hook is triggered right after sending
- // an SSE message to a client.
- OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
+ // OnModelUpdateExecute is triggered right before the model
+ // DELETE DB statement execution.
+ //
+ // Usually it is triggered as part of the App.Delete() in the following firing order:
+ // OnModelDelete {
+ // -> (internal delete checks)
+ // -> OnModelDeleteExecute
+ // }
+ //
+ // Note that successful execution doesn't guarantee that the model
+ // is deleted from the database since its wrapping transaction may
+ // not have been committed yet.
+ // If you want to listen to only the actual persisted deleted events, you can
+ // bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelDeleteExecute(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnRealtimeBeforeSubscribeRequest hook is triggered before changing
- // the client subscriptions, allowing you to further validate and
- // modify the submitted change.
- OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent]
+ // OnModelAfterDeleteSuccess is triggered after each successful
+ // Model DB delete persistence.
+ //
+ // Note that when a Model is deleted as part of a transaction,
+ // this hook is delayed and executed only AFTER the transaction has been committed.
+ // This hook is NOT triggered in case the transaction rollbacks
+ // (aka. when the model delete wasn't persisted).
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*ModelEvent]
- // OnRealtimeAfterSubscribeRequest hook is triggered after the client
- // subscriptions were successfully changed.
- OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent]
+ // OnModelAfterDeleteError is triggered after each failed
+ // Model DB delete persistence.
+ //
+ // Note that the execution of this hook is either immediate or delayed
+ // depending on the error:
+ // - "immediate" on App.Delete() failure
+ // - "delayed" on transaction rollback
+ //
+ // For convenience, if you want to listen to only the Record models
+ // events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ //
+ // If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnModelAfterDeleteError(tags ...string) *hook.TaggedHook[*ModelErrorEvent]
// ---------------------------------------------------------------
- // Settings API event hooks
+ // Record models event hooks
// ---------------------------------------------------------------
- // OnSettingsListRequest hook is triggered on each successful
- // API Settings list request.
+ // OnRecordEnrich is triggered every time when a record is enriched
+ // (as part of the builtin Record responses, during realtime message seriazation, or when [apis.EnrichRecord] is invoked).
//
- // Could be used to validate or modify the response before
- // returning it to the client.
- OnSettingsListRequest() *hook.Hook[*SettingsListEvent]
-
- // OnSettingsBeforeUpdateRequest hook is triggered before each API
- // Settings update request (after request data load and before settings persistence).
+ // It could be used for example to redact/hide or add computed temporary
+ // Record model props only for the specific request info. For example:
//
- // Could be used to additionally validate the request data or
- // implement completely different persistence behavior.
- OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
+ // app.OnRecordEnrich("posts").BindFunc(func(e core.*RecordEnrichEvent) {
+ // // hide one or more fields
+ // e.Record.Hide("role")
+ //
+ // // add new custom field for registered users
+ // if e.RequestInfo.Auth != nil && e.RequestInfo.Auth.Collection().Name == "users" {
+ // e.Record.WithCustomData(true) // for security requires explicitly allowing it
+ // e.Record.Set("computedScore", e.Record.GetInt("score") * e.RequestInfo.Auth.GetInt("baseScore"))
+ // }
+ //
+ // return e.Next()
+ // })
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent]
- // OnSettingsAfterUpdateRequest hook is triggered after each
- // successful API Settings update request.
- OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
+ // OnRecordValidate is a Record proxy model hook of [OnModelValidate].
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordValidate(tags ...string) *hook.TaggedHook[*RecordEvent]
- // ---------------------------------------------------------------
- // File API event hooks
// ---------------------------------------------------------------
- // OnFileDownloadRequest hook is triggered before each API File download request.
+ // OnRecordCreate is a Record proxy model hook of [OnModelCreate].
//
- // Could be used to validate or modify the file response before
- // returning it to the client.
- OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnFileBeforeTokenRequest hook is triggered before each file
- // token API request.
+ // OnRecordCreateExecute is a Record proxy model hook of [OnModelCreateExecute].
//
- // If no token or model was submitted, e.Model and e.Token will be empty,
- // allowing you to implement your own custom model file auth implementation.
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
+
+ // OnRecordAfterCreateSuccess is a Record proxy model hook of [OnModelAfterCreateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
+ OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnFileAfterTokenRequest hook is triggered after each
- // successful file token API request.
+ // OnRecordAfterCreateError is a Record proxy model hook of [OnModelAfterCreateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
+ OnRecordAfterCreateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent]
- // ---------------------------------------------------------------
- // Admin API event hooks
// ---------------------------------------------------------------
- // OnAdminsListRequest hook is triggered on each API Admins list request.
+ // OnRecordUpdate is a Record proxy model hook of [OnModelUpdate].
//
- // Could be used to validate or modify the response before returning it to the client.
- OnAdminsListRequest() *hook.Hook[*AdminsListEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminViewRequest hook is triggered on each API Admin view request.
+ // OnRecordUpdateExecute is a Record proxy model hook of [OnModelUpdateExecute].
//
- // Could be used to validate or modify the response before returning it to the client.
- OnAdminViewRequest() *hook.Hook[*AdminViewEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminBeforeCreateRequest hook is triggered before each API
- // Admin create request (after request data load and before model persistence).
+ // OnRecordAfterUpdateSuccess is a Record proxy model hook of [OnModelAfterUpdateSuccess].
//
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior.
- OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
-
- // OnAdminAfterCreateRequest hook is triggered after each
- // successful API Admin create request.
- OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminBeforeUpdateRequest hook is triggered before each API
- // Admin update request (after request data load and before model persistence).
+ // OnRecordAfterUpdateError is a Record proxy model hook of [OnModelAfterUpdateError].
//
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior.
- OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordAfterUpdateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent]
- // OnAdminAfterUpdateRequest hook is triggered after each
- // successful API Admin update request.
- OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent]
+ // ---------------------------------------------------------------
- // OnAdminBeforeDeleteRequest hook is triggered before each API
- // Admin delete request (after model load and before actual deletion).
+ // OnRecordDelete is a Record proxy model hook of [OnModelDelete].
//
- // Could be used to additionally validate the request data or implement
- // completely different delete behavior.
- OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminAfterDeleteRequest hook is triggered after each
- // successful API Admin delete request.
- OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent]
+ // OnRecordDeleteExecute is a Record proxy model hook of [OnModelDeleteExecute].
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminAuthRequest hook is triggered on each successful API Admin
- // authentication request (sign-in, token refresh, etc.).
+ // OnRecordAfterDeleteSuccess is a Record proxy model hook of [OnModelAfterDeleteSuccess].
//
- // Could be used to additionally validate or modify the
- // authenticated admin data and token.
- OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
- // OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
- // auth with password API request (after request data load and before password validation).
+ // OnRecordAfterDeleteError is a Record proxy model hook of [OnModelAfterDeleteError].
//
- // Could be used to implement for example a custom password validation
- // or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
- OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordAfterDeleteError(tags ...string) *hook.TaggedHook[*RecordErrorEvent]
- // OnAdminAfterAuthWithPasswordRequest hook is triggered after each
- // successful Admin auth with password API request.
- OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
+ // ---------------------------------------------------------------
+ // Collection models event hooks
+ // ---------------------------------------------------------------
- // OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
- // auth refresh API request (right before generating a new auth token).
+ // OnCollectionValidate is a Collection proxy model hook of [OnModelValidate].
//
- // Could be used to additionally validate the request data or implement
- // completely different auth refresh behavior.
- OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionValidate(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnAdminAfterAuthRefreshRequest hook is triggered after each
- // successful auth refresh API request (right after generating a new auth token).
- OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
+ // ---------------------------------------------------------------
- // OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
- // request password reset API request (after request data load and before sending the reset email).
+ // OnCollectionCreate is a Collection proxy model hook of [OnModelCreate].
//
- // Could be used to additionally validate the request data or implement
- // completely different password reset behavior.
- OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnAdminAfterRequestPasswordResetRequest hook is triggered after each
- // successful request password reset API request.
- OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
+ // OnCollectionCreateExecute is a Collection proxy model hook of [OnModelCreateExecute].
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
- // confirm password reset API request (after request data load and before persistence).
+ // OnCollectionAfterCreateSuccess is a Collection proxy model hook of [OnModelAfterCreateSuccess].
//
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior.
- OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
- // successful confirm password reset API request.
- OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
+ // OnCollectionAfterCreateError is a Collection proxy model hook of [OnModelAfterCreateError].
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionAfterCreateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent]
- // ---------------------------------------------------------------
- // Record Auth API event hooks
// ---------------------------------------------------------------
- // OnRecordAuthRequest hook is triggered on each successful API
- // record authentication request (sign-in, token refresh, etc.).
- //
- // Could be used to additionally validate or modify the authenticated
- // record data and token.
+ // OnCollectionUpdate is a Collection proxy model hook of [OnModelUpdate].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
+ OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
- // auth with password API request (after request data load and before password validation).
+ // OnCollectionUpdateExecute is a Collection proxy model hook of [OnModelUpdateExecute].
//
- // Could be used to implement for example a custom password validation
- // or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
+
+ // OnCollectionAfterUpdateSuccess is a Collection proxy model hook of [OnModelAfterUpdateSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
+ OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnRecordAfterAuthWithPasswordRequest hook is triggered after each
- // successful Record auth with password API request.
+ // OnCollectionAfterUpdateError is a Collection proxy model hook of [OnModelAfterUpdateError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
+ OnCollectionAfterUpdateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent]
- // OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
- // OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
+ // ---------------------------------------------------------------
+
+ // OnCollectionDelete is a Collection proxy model hook of [OnModelDelete].
//
- // If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
- // request will try to create a new auth Record.
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent]
+
+ // OnCollectionDeleteExecute is a Collection proxy model hook of [OnModelDeleteExecute].
//
- // To assign or link a different existing record model you can
- // change the [RecordAuthWithOAuth2Event.Record] field.
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
+
+ // OnCollectionAfterDeleteSuccess is a Collection proxy model hook of [OnModelAfterDeleteSuccess].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
+ OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
- // OnRecordAfterAuthWithOAuth2Request hook is triggered after each
- // successful Record OAuth2 API request.
+ // OnCollectionAfterDeleteError is a Collection proxy model hook of [OnModelAfterDeleteError].
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
+ OnCollectionAfterDeleteError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent]
- // OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
- // auth refresh API request (right before generating a new auth token).
+ // ---------------------------------------------------------------
+ // Mailer event hooks
+ // ---------------------------------------------------------------
+
+ // OnMailerSend hook is triggered every time when a new email is
+ // being sent using the [App.NewMailClient()] instance.
//
- // Could be used to additionally validate the request data or implement
- // completely different auth refresh behavior.
+ // It allows intercepting the email message or to use a custom mailer client.
+ OnMailerSend() *hook.Hook[*MailerEvent]
+
+ // OnMailerRecordAuthAlertSend hook is triggered when
+ // sending a new device login auth alert email, allowing you to
+ // intercept and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
+ OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
- // OnRecordAfterAuthRefreshRequest hook is triggered after each
- // successful auth refresh API request (right after generating a new auth token).
+ // OnMailerBeforeRecordResetPasswordSend hook is triggered when
+ // sending a password reset email to an auth record, allowing
+ // you to intercept and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
+ OnMailerRecordPasswordResetSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
- // OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
- //
- // Could be used to validate or modify the response before returning it to the client.
+ // OnMailerBeforeRecordVerificationSend hook is triggered when
+ // sending a verification email to an auth record, allowing
+ // you to intercept and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
+ OnMailerRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
- // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
- // external auth unlink request (after models load and before the actual relation deletion).
- //
- // Could be used to additionally validate the request data or implement
- // completely different delete behavior.
+ // OnMailerRecordEmailChangeSend hook is triggered when sending a
+ // confirmation new address email to an auth record, allowing
+ // you to intercept and customize the email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
+ OnMailerRecordEmailChangeSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
- // OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
- // successful API record external auth unlink request.
+ // OnMailerRecordOTPSend hook is triggered when sending an OTP email
+ // to an auth record, allowing you to intercept and customize the
+ // email message that is being sent.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
+ OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
+
+ // ---------------------------------------------------------------
+ // Filesystem event hooks
+ // (not publicly exposed until file_field refactoring)
+ // ---------------------------------------------------------------
+
+ // onFilesystemNewWriter is an internal hook for app.NewFilesystem()
+ // instances that is triggered on every storage filesystem writer initialization
+ // (aka. whenever attempting to create a new file).
+ onFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent]
+
+ // onFilesystemDelete is an internal hook for app.NewFilesystem()
+ // instances that is triggered for every storage file delete call.
+ onFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent]
+
+ // ---------------------------------------------------------------
+ // Realtime API event hooks
+ // ---------------------------------------------------------------
- // OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
- // request password reset API request (after request data load and before sending the reset email).
+ // OnRealtimeConnectRequest hook is triggered when establishing the SSE client connection.
//
- // Could be used to additionally validate the request data or implement
- // completely different password reset behavior.
+ // Any execution after e.Next() of a hook handler happens after the client disconnects.
+ OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent]
+
+ // OnRealtimeMessageSend hook is triggered when sending an SSE message to a client.
+ OnRealtimeMessageSend() *hook.Hook[*RealtimeMessageEvent]
+
+ // OnRealtimeSubscribeRequest hook is triggered when updating the
+ // client subscriptions, allowing you to further validate and
+ // modify the submitted change.
+ OnRealtimeSubscribeRequest() *hook.Hook[*RealtimeSubscribeRequestEvent]
+
+ // ---------------------------------------------------------------
+ // Settings API event hooks
+ // ---------------------------------------------------------------
+
+ // OnSettingsListRequest hook is triggered on each API Settings list request.
+ //
+ // Could be used to validate or modify the response before returning it to the client.
+ OnSettingsListRequest() *hook.Hook[*SettingsListRequestEvent]
+
+ // OnSettingsUpdateRequest hook is triggered on each API Settings update request.
+ //
+ // Could be used to additionally validate the request data or
+ // implement completely different persistence behavior.
+ OnSettingsUpdateRequest() *hook.Hook[*SettingsUpdateRequestEvent]
+
+ // OnSettingsReload hook is triggered every time when the App.Settings()
+ // is being replaced with a new state.
+ //
+ // Calling App.Settings() after e.Next() returns the new state.
+ OnSettingsReload() *hook.Hook[*SettingsReloadEvent]
+
+ // ---------------------------------------------------------------
+ // File API event hooks
+ // ---------------------------------------------------------------
+
+ // OnFileDownloadRequest hook is triggered before each API File download request.
+ //
+ // Could be used to validate or modify the file response before
+ // returning it to the client.
+ OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent]
+
+ // OnFileBeforeTokenRequest hook is triggered on each auth file token API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
+ OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent]
+
+ // ---------------------------------------------------------------
+ // Record Auth API event hooks
+ // ---------------------------------------------------------------
- // OnRecordAfterRequestPasswordResetRequest hook is triggered after each
- // successful request password reset API request.
+ // OnRecordAuthRequest hook is triggered on each successful API
+ // record authentication request (sign-in, token refresh, etc.).
+ //
+ // Could be used to additionally validate or modify the authenticated
+ // record data and token.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
+ OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthRequestEvent]
- // OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
- // confirm password reset API request (after request data load and before persistence).
+ // OnRecordAuthWithPasswordRequest hook is triggered on each
+ // Record auth with password API request.
//
- // Could be used to additionally validate the request data or implement
- // completely different persistence behavior.
+ // [RecordAuthWithPasswordRequestEvent.Record] could be nil if no matching identity is found, allowing
+ // you to manually locate a different Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
+ OnRecordAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordRequestEvent]
- // OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
- // successful confirm password reset API request.
+ // OnRecordAuthWithOAuth2Request hook is triggered on each Record
+ // OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
+ //
+ // If [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
+ // request will try to create a new auth Record.
+ //
+ // To assign or link a different existing record model you can
+ // change the [RecordAuthWithOAuth2RequestEvent.Record] field.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
+ OnRecordAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2RequestEvent]
- // OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
- // request verification API request (after request data load and before sending the verification email).
+ // OnRecordAuthRefreshRequest hook is triggered on each Record
+ // auth refresh API request (right before generating a new auth token).
//
- // Could be used to additionally validate the loaded request data or implement
- // completely different verification behavior.
+ // Could be used to additionally validate the request data or implement
+ // completely different auth refresh behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
+ OnRecordAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshRequestEvent]
- // OnRecordAfterRequestVerificationRequest hook is triggered after each
- // successful request verification API request.
+ // OnRecordRequestPasswordResetRequest hook is triggered on
+ // each Record request password reset API request.
+ //
+ // Could be used to additionally validate the request data or implement
+ // completely different password reset behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
+ OnRecordRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetRequestEvent]
- // OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
- // confirm verification API request (after request data load and before persistence).
+ // OnRecordConfirmPasswordResetRequest hook is triggered on
+ // each Record confirm password reset API request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
@@ -623,37 +1414,43 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
+ OnRecordConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetRequestEvent]
- // OnRecordAfterConfirmVerificationRequest hook is triggered after each
- // successful confirm verification API request.
+ // OnRecordRequestVerificationRequest hook is triggered on
+ // each Record request verification API request.
+ //
+ // Could be used to additionally validate the loaded request data or implement
+ // completely different verification behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
+ OnRecordRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationRequestEvent]
- // OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
- // (after request data load and before sending the email link to confirm the change).
+ // OnRecordConfirmVerificationRequest hook is triggered on each
+ // Record confirm verification API request.
//
// Could be used to additionally validate the request data or implement
- // completely different request email change behavior.
+ // completely different persistence behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
+ OnRecordConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationRequestEvent]
- // OnRecordAfterRequestEmailChangeRequest hook is triggered after each
- // successful request email change API request.
+ // OnRecordRequestEmailChangeRequest hook is triggered on each
+ // Record request email change API request.
+ //
+ // Could be used to additionally validate the request data or implement
+ // completely different request email change behavior.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
+ OnRecordRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeRequestEvent]
- // OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
- // confirm email change API request (after request data load and before persistence).
+ // OnRecordConfirmEmailChangeRequest hook is triggered on each
+ // Record confirm email change API request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
@@ -661,15 +1458,26 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
+ OnRecordConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeRequestEvent]
+
+ // OnRecordRequestOTPRequest hook is triggered on each Record
+ // request OTP API request.
+ //
+ // [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
+ // you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.Record]).
+ //
+ // If the optional "tags" list (Collection ids or names) is specified,
+ // then all event handlers registered via the created hook will be
+ // triggered and called only if their event data origin matches the tags.
+ OnRecordRequestOTPRequest(tags ...string) *hook.TaggedHook[*RecordCreateOTPRequestEvent]
- // OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
- // successful confirm email change API request.
+ // OnRecordAuthWithOTPRequest hook is triggered on each Record
+ // auth with OTP API request.
//
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
+ OnRecordAuthWithOTPRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithOTPRequestEvent]
// ---------------------------------------------------------------
// Record CRUD API event hooks
@@ -682,7 +1490,7 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent]
+ OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListRequestEvent]
// OnRecordViewRequest hook is triggered on each API Record view request.
//
@@ -691,10 +1499,9 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent]
+ OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent]
- // OnRecordBeforeCreateRequest hook is triggered before each API Record
- // create request (after request data load and before model persistence).
+ // OnRecordCreateRequest hook is triggered on each API Record create request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
@@ -702,18 +1509,9 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
-
- // OnRecordAfterCreateRequest hook is triggered after each
- // successful API Record create request.
- //
- // If the optional "tags" list (Collection ids or names) is specified,
- // then all event handlers registered via the created hook will be
- // triggered and called only if their event data origin matches the tags.
- OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
+ OnRecordCreateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent]
- // OnRecordBeforeUpdateRequest hook is triggered before each API Record
- // update request (after request data load and before model persistence).
+ // OnRecordUpdateRequest hook is triggered on each API Record update request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
@@ -721,18 +1519,9 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
+ OnRecordUpdateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent]
- // OnRecordAfterUpdateRequest hook is triggered after each
- // successful API Record update request.
- //
- // If the optional "tags" list (Collection ids or names) is specified,
- // then all event handlers registered via the created hook will be
- // triggered and called only if their event data origin matches the tags.
- OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
-
- // OnRecordBeforeDeleteRequest hook is triggered before each API Record
- // delete request (after model load and before actual deletion).
+ // OnRecordDeleteRequest hook is triggered on each API Record delete request.
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior.
@@ -740,15 +1529,7 @@ type App interface {
// If the optional "tags" list (Collection ids or names) is specified,
// then all event handlers registered via the created hook will be
// triggered and called only if their event data origin matches the tags.
- OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
-
- // OnRecordAfterDeleteRequest hook is triggered after each
- // successful API Record delete request.
- //
- // If the optional "tags" list (Collection ids or names) is specified,
- // then all event handlers registered via the created hook will be
- // triggered and called only if their event data origin matches the tags.
- OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
+ OnRecordDeleteRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent]
// ---------------------------------------------------------------
// Collection API event hooks
@@ -757,54 +1538,44 @@ type App interface {
// OnCollectionsListRequest hook is triggered on each API Collections list request.
//
// Could be used to validate or modify the response before returning it to the client.
- OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent]
+ OnCollectionsListRequest() *hook.Hook[*CollectionsListRequestEvent]
// OnCollectionViewRequest hook is triggered on each API Collection view request.
//
// Could be used to validate or modify the response before returning it to the client.
- OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent]
+ OnCollectionViewRequest() *hook.Hook[*CollectionRequestEvent]
- // OnCollectionBeforeCreateRequest hook is triggered before each API Collection
- // create request (after request data load and before model persistence).
+ // OnCollectionCreateRequest hook is triggered on each API Collection create request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
- OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
+ OnCollectionCreateRequest() *hook.Hook[*CollectionRequestEvent]
- // OnCollectionAfterCreateRequest hook is triggered after each
- // successful API Collection create request.
- OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent]
-
- // OnCollectionBeforeUpdateRequest hook is triggered before each API Collection
- // update request (after request data load and before model persistence).
+ // OnCollectionUpdateRequest hook is triggered on each API Collection update request.
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior.
- OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
-
- // OnCollectionAfterUpdateRequest hook is triggered after each
- // successful API Collection update request.
- OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
+ OnCollectionUpdateRequest() *hook.Hook[*CollectionRequestEvent]
- // OnCollectionBeforeDeleteRequest hook is triggered before each API
- // Collection delete request (after model load and before actual deletion).
+ // OnCollectionDeleteRequest hook is triggered on each API Collection delete request.
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior.
- OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
+ OnCollectionDeleteRequest() *hook.Hook[*CollectionRequestEvent]
- // OnCollectionAfterDeleteRequest hook is triggered after each
- // successful API Collection delete request.
- OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
-
- // OnCollectionsBeforeImportRequest hook is triggered before each API
- // collections import request (after request data load and before the actual import).
+ // OnCollectionsBeforeImportRequest hook is triggered on each API
+ // collections import request.
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior.
- OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
+ OnCollectionsImportRequest() *hook.Hook[*CollectionsImportRequestEvent]
- // OnCollectionsAfterImportRequest hook is triggered after each
- // successful API collections import request.
- OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent]
+ // ---------------------------------------------------------------
+ // Batch API event hooks
+ // ---------------------------------------------------------------
+
+ // OnBatchRequest hook is triggered on each API batch request.
+ //
+ // Could be used to additionally validate or modify the submitted batch requests.
+ OnBatchRequest() *hook.Hook[*BatchRequestEvent]
}
diff --git a/core/auth_origin_model.go b/core/auth_origin_model.go
new file mode 100644
index 0000000000..6970ddfdbd
--- /dev/null
+++ b/core/auth_origin_model.go
@@ -0,0 +1,239 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "slices"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+const CollectionNameAuthOrigins = "_authOrigins"
+
+var (
+ _ Model = (*AuthOrigin)(nil)
+ _ PreValidator = (*AuthOrigin)(nil)
+ _ RecordProxy = (*AuthOrigin)(nil)
+)
+
+// AuthOrigin defines a Record proxy for working with the authOrigins collection.
+type AuthOrigin struct {
+ *Record
+}
+
+// NewAuthOrigin instantiates and returns a new blank *AuthOrigin model.
+//
+// Example usage:
+//
+// origin := core.NewOrigin(app)
+// origin.SetRecordRef(user.Id)
+// origin.SetCollectionRef(user.Collection().Id)
+// origin.SetFingerprint("...")
+// app.Save(origin)
+func NewAuthOrigin(app App) *AuthOrigin {
+ m := &AuthOrigin{}
+
+ c, err := app.FindCachedCollectionByNameOrId(CollectionNameAuthOrigins)
+ if err != nil {
+ // this is just to make tests easier since authOrigins is a system collection and it is expected to be always accessible
+ // (note: the loaded record is further checked on AuthOrigin.PreValidate())
+ c = NewBaseCollection("@___invalid___")
+ }
+
+ m.Record = NewRecord(c)
+
+ return m
+}
+
+// PreValidate implements the [PreValidator] interface and checks
+// whether the proxy is properly loaded.
+func (m *AuthOrigin) PreValidate(ctx context.Context, app App) error {
+ if m.Record == nil || m.Record.Collection().Name != CollectionNameAuthOrigins {
+ return errors.New("missing or invalid AuthOrigin ProxyRecord")
+ }
+
+ return nil
+}
+
+// ProxyRecord returns the proxied Record model.
+func (m *AuthOrigin) ProxyRecord() *Record {
+ return m.Record
+}
+
+// SetProxyRecord loads the specified record model into the current proxy.
+func (m *AuthOrigin) SetProxyRecord(record *Record) {
+ m.Record = record
+}
+
+// CollectionRef returns the "collectionRef" field value.
+func (m *AuthOrigin) CollectionRef() string {
+ return m.GetString("collectionRef")
+}
+
+// SetCollectionRef updates the "collectionRef" record field value.
+func (m *AuthOrigin) SetCollectionRef(collectionId string) {
+ m.Set("collectionRef", collectionId)
+}
+
+// RecordRef returns the "recordRef" record field value.
+func (m *AuthOrigin) RecordRef() string {
+ return m.GetString("recordRef")
+}
+
+// SetRecordRef updates the "recordRef" record field value.
+func (m *AuthOrigin) SetRecordRef(recordId string) {
+ m.Set("recordRef", recordId)
+}
+
+// Fingerprint returns the "fingerprint" record field value.
+func (m *AuthOrigin) Fingerprint() string {
+ return m.GetString("fingerprint")
+}
+
+// SetFingerprint updates the "fingerprint" record field value.
+func (m *AuthOrigin) SetFingerprint(fingerprint string) {
+ m.Set("fingerprint", fingerprint)
+}
+
+// Created returns the "created" record field value.
+func (m *AuthOrigin) Created() types.DateTime {
+ return m.GetDateTime("created")
+}
+
+// Updated returns the "updated" record field value.
+func (m *AuthOrigin) Updated() types.DateTime {
+ return m.GetDateTime("updated")
+}
+
+func (app *BaseApp) registerAuthOriginHooks() {
+ recordRefHooks[*AuthOrigin](app, CollectionNameAuthOrigins, CollectionTypeAuth)
+
+ // delete existing auth origins on password change
+ app.OnRecordUpdate().Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ err := e.Next()
+ if err != nil || !e.Record.Collection().IsAuth() {
+ return err
+ }
+
+ old := e.Record.Original().GetString(FieldNamePassword + ":hash")
+ new := e.Record.GetString(FieldNamePassword + ":hash")
+ if old != new {
+ err = e.App.DeleteAllAuthOriginsByRecord(e.Record)
+ if err != nil {
+ e.App.Logger().Warn(
+ "Failed to delete all previous auth origin fingerprints",
+ "error", err,
+ "recordId", e.Record.Id,
+ "collectionId", e.Record.Collection().Id,
+ )
+ }
+ }
+
+ return nil
+ },
+ Priority: 99,
+ })
+}
+
+// -------------------------------------------------------------------
+
+// recordRefHooks registers common hooks that are usually used with record proxies
+// that have polymorphic record relations (aka. "collectionRef" and "recordRef" fields).
+func recordRefHooks[T RecordProxy](app App, collectionName string, optCollectionTypes ...string) {
+ app.OnRecordValidate(collectionName).Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ collectionId := e.Record.GetString("collectionRef")
+ err := validation.Validate(collectionId, validation.Required, validation.By(validateCollectionId(e.App, optCollectionTypes...)))
+ if err != nil {
+ return validation.Errors{"collectionRef": err}
+ }
+
+ recordId := e.Record.GetString("recordRef")
+ err = validation.Validate(recordId, validation.Required, validation.By(validateRecordId(e.App, collectionId)))
+ if err != nil {
+ return validation.Errors{"recordRef": err}
+ }
+
+ return e.Next()
+ },
+ Priority: 99,
+ })
+
+ // delete on collection ref delete
+ app.OnCollectionDeleteExecute().Bind(&hook.Handler[*CollectionEvent]{
+ Func: func(e *CollectionEvent) error {
+ if e.Collection.Name == collectionName || (len(optCollectionTypes) > 0 && !slices.Contains(optCollectionTypes, e.Collection.Type)) {
+ return e.Next()
+ }
+
+ originalApp := e.App
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ rels, err := txApp.FindAllRecords(collectionName, dbx.HashExp{"collectionRef": e.Collection.Id})
+ if err != nil {
+ return err
+ }
+
+ for _, mfa := range rels {
+ if err := txApp.Delete(mfa); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ e.App = originalApp
+
+ return txErr
+ },
+ Priority: 99,
+ })
+
+ // delete on record ref delete
+ app.OnRecordDeleteExecute().Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ if e.Record.Collection().Name == collectionName ||
+ (len(optCollectionTypes) > 0 && !slices.Contains(optCollectionTypes, e.Record.Collection().Type)) {
+ return e.Next()
+ }
+
+ originalApp := e.App
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ rels, err := txApp.FindAllRecords(collectionName, dbx.HashExp{
+ "collectionRef": e.Record.Collection().Id,
+ "recordRef": e.Record.Id,
+ })
+ if err != nil {
+ return err
+ }
+
+ for _, rel := range rels {
+ if err := txApp.Delete(rel); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+ e.App = originalApp
+
+ return txErr
+ },
+ Priority: 99,
+ })
+}
diff --git a/core/auth_origin_model_test.go b/core/auth_origin_model_test.go
new file mode 100644
index 0000000000..2d06a2d3fb
--- /dev/null
+++ b/core/auth_origin_model_test.go
@@ -0,0 +1,332 @@
+package core_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNewAuthOrigin(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ if origin.Collection().Name != core.CollectionNameAuthOrigins {
+ t.Fatalf("Expected record with %q collection, got %q", core.CollectionNameAuthOrigins, origin.Collection().Name)
+ }
+}
+
+func TestAuthOriginProxyRecord(t *testing.T) {
+ t.Parallel()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.Id = "test_id"
+
+ origin := core.AuthOrigin{}
+ origin.SetProxyRecord(record)
+
+ if origin.ProxyRecord() == nil || origin.ProxyRecord().Id != record.Id {
+ t.Fatalf("Expected proxy record with id %q, got %v", record.Id, origin.ProxyRecord())
+ }
+}
+
+func TestAuthOriginRecordRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ origin.SetRecordRef(testValue)
+
+ if v := origin.RecordRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := origin.GetString("recordRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestAuthOriginCollectionRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ origin.SetCollectionRef(testValue)
+
+ if v := origin.CollectionRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := origin.GetString("collectionRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestAuthOriginFingerprint(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ origin.SetFingerprint(testValue)
+
+ if v := origin.Fingerprint(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := origin.GetString("fingerprint"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestAuthOriginCreated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ if v := origin.Created().String(); v != "" {
+ t.Fatalf("Expected empty created, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ origin.SetRaw("created", now)
+
+ if v := origin.Created().String(); v != now.String() {
+ t.Fatalf("Expected %q created, got %q", now.String(), v)
+ }
+}
+
+func TestAuthOriginUpdated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ origin := core.NewAuthOrigin(app)
+
+ if v := origin.Updated().String(); v != "" {
+ t.Fatalf("Expected empty updated, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ origin.SetRaw("updated", now)
+
+ if v := origin.Updated().String(); v != now.String() {
+ t.Fatalf("Expected %q updated, got %q", now.String(), v)
+ }
+}
+
+func TestAuthOriginPreValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ originsCol, err := app.FindCollectionByNameOrId(core.CollectionNameAuthOrigins)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("no proxy record", func(t *testing.T) {
+ origin := &core.AuthOrigin{}
+
+ if err := app.Validate(origin); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("non-AuthOrigin collection", func(t *testing.T) {
+ origin := &core.AuthOrigin{}
+ origin.SetProxyRecord(core.NewRecord(core.NewBaseCollection("invalid")))
+ origin.SetRecordRef(user.Id)
+ origin.SetCollectionRef(user.Collection().Id)
+ origin.SetFingerprint("abc")
+
+ if err := app.Validate(origin); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("AuthOrigin collection", func(t *testing.T) {
+ origin := &core.AuthOrigin{}
+ origin.SetProxyRecord(core.NewRecord(originsCol))
+ origin.SetRecordRef(user.Id)
+ origin.SetCollectionRef(user.Collection().Id)
+ origin.SetFingerprint("abc")
+
+ if err := app.Validate(origin); err != nil {
+ t.Fatalf("Expected nil validation error, got %v", err)
+ }
+ })
+}
+
+func TestAuthOriginValidateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ origin func() *core.AuthOrigin
+ expectErrors []string
+ }{
+ {
+ "empty",
+ func() *core.AuthOrigin {
+ return core.NewAuthOrigin(app)
+ },
+ []string{"collectionRef", "recordRef", "fingerprint"},
+ },
+ {
+ "non-auth collection",
+ func() *core.AuthOrigin {
+ origin := core.NewAuthOrigin(app)
+ origin.SetCollectionRef(demo1.Collection().Id)
+ origin.SetRecordRef(demo1.Id)
+ origin.SetFingerprint("abc")
+ return origin
+ },
+ []string{"collectionRef"},
+ },
+ {
+ "missing record id",
+ func() *core.AuthOrigin {
+ origin := core.NewAuthOrigin(app)
+ origin.SetCollectionRef(user.Collection().Id)
+ origin.SetRecordRef("missing")
+ origin.SetFingerprint("abc")
+ return origin
+ },
+ []string{"recordRef"},
+ },
+ {
+ "valid ref",
+ func() *core.AuthOrigin {
+ origin := core.NewAuthOrigin(app)
+ origin.SetCollectionRef(user.Collection().Id)
+ origin.SetRecordRef(user.Id)
+ origin.SetFingerprint("abc")
+ return origin
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := app.Validate(s.origin())
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestAuthOriginPasswordChangeDeletion(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ // no auth origin associated with it
+ user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ deletedIds []string
+ }{
+ {user1, nil},
+ {superuser2, []string{"5798yh833k6w6w0", "ic55o70g4f8pcl4", "dmy260k6ksjr4ib"}},
+ {client1, []string{"9r2j0m74260ur8i"}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ deletedIds := []string{}
+ app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ s.record.SetPassword("new_password")
+
+ err := app.Save(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(s.deletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
+ }
+
+ for _, id := range s.deletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ })
+ }
+}
diff --git a/core/auth_origin_query.go b/core/auth_origin_query.go
new file mode 100644
index 0000000000..c8fd20d839
--- /dev/null
+++ b/core/auth_origin_query.go
@@ -0,0 +1,101 @@
+package core
+
+import (
+ "errors"
+
+ "github.com/pocketbase/dbx"
+)
+
+// FindAllAuthOriginsByRecord returns all AuthOrigin models linked to the provided auth record (in DESC order).
+func (app *BaseApp) FindAllAuthOriginsByRecord(authRecord *Record) ([]*AuthOrigin, error) {
+ result := []*AuthOrigin{}
+
+ err := app.RecordQuery(CollectionNameAuthOrigins).
+ AndWhere(dbx.HashExp{
+ "collectionRef": authRecord.Collection().Id,
+ "recordRef": authRecord.Id,
+ }).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindAllAuthOriginsByCollection returns all AuthOrigin models linked to the provided collection (in DESC order).
+func (app *BaseApp) FindAllAuthOriginsByCollection(collection *Collection) ([]*AuthOrigin, error) {
+ result := []*AuthOrigin{}
+
+ err := app.RecordQuery(CollectionNameAuthOrigins).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindAuthOriginById returns a single AuthOrigin model by its id.
+func (app *BaseApp) FindAuthOriginById(id string) (*AuthOrigin, error) {
+ result := &AuthOrigin{}
+
+ err := app.RecordQuery(CollectionNameAuthOrigins).
+ AndWhere(dbx.HashExp{"id": id}).
+ Limit(1).
+ One(result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindAuthOriginByRecordAndFingerprint returns a single AuthOrigin model
+// by its authRecord relation and fingerprint.
+func (app *BaseApp) FindAuthOriginByRecordAndFingerprint(authRecord *Record, fingerprint string) (*AuthOrigin, error) {
+ result := &AuthOrigin{}
+
+ err := app.RecordQuery(CollectionNameAuthOrigins).
+ AndWhere(dbx.HashExp{
+ "collectionRef": authRecord.Collection().Id,
+ "recordRef": authRecord.Id,
+ "fingerprint": fingerprint,
+ }).
+ Limit(1).
+ One(result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteAllAuthOriginsByRecord deletes all AuthOrigin models associated with the provided record.
+//
+// Returns a combined error with the failed deletes.
+func (app *BaseApp) DeleteAllAuthOriginsByRecord(authRecord *Record) error {
+ models, err := app.FindAllAuthOriginsByRecord(authRecord)
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ for _, m := range models {
+ if err := app.Delete(m); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
diff --git a/core/auth_origin_query_test.go b/core/auth_origin_query_test.go
new file mode 100644
index 0000000000..eec036aeb1
--- /dev/null
+++ b/core/auth_origin_query_test.go
@@ -0,0 +1,268 @@
+package core_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestFindAllAuthOriginsByRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1, err := app.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ expected []string
+ }{
+ {demo1, nil},
+ {superuser2, []string{"5798yh833k6w6w0", "ic55o70g4f8pcl4", "dmy260k6ksjr4ib"}},
+ {superuser4, nil},
+ {client1, []string{"9r2j0m74260ur8i"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.record.Collection().Name+"_"+s.record.Id, func(t *testing.T) {
+ result, err := app.FindAllAuthOriginsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total origins %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAllAuthOriginsByCollection(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ clients, err := app.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ collection *core.Collection
+ expected []string
+ }{
+ {demo1, nil},
+ {superusers, []string{"5798yh833k6w6w0", "ic55o70g4f8pcl4", "dmy260k6ksjr4ib", "5f29jy38bf5zm3f"}},
+ {clients, []string{"9r2j0m74260ur8i"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.collection.Name, func(t *testing.T) {
+ result, err := app.FindAllAuthOriginsByCollection(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total origins %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAuthOriginById(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ id string
+ expectError bool
+ }{
+ {"", true},
+ {"84nmscqy84lsi1t", true}, // non-origin id
+ {"9r2j0m74260ur8i", false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.id, func(t *testing.T) {
+ result, err := app.FindAuthOriginById(s.id)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if result.Id != s.id {
+ t.Fatalf("Expected record with id %q, got %q", s.id, result.Id)
+ }
+ })
+ }
+}
+
+func TestFindAuthOriginByRecordAndFingerprint(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ fingerprint string
+ expectError bool
+ }{
+ {demo1, "6afbfe481c31c08c55a746cccb88ece0", true},
+ {superuser2, "", true},
+ {superuser2, "abc", true},
+ {superuser2, "22bbbcbed36e25321f384ccf99f60057", false}, // fingerprint from different origin
+ {superuser2, "6afbfe481c31c08c55a746cccb88ece0", false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Id, s.fingerprint), func(t *testing.T) {
+ result, err := app.FindAuthOriginByRecordAndFingerprint(s.record, s.fingerprint)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if result.Fingerprint() != s.fingerprint {
+ t.Fatalf("Expected origin with fingerprint %q, got %q", s.fingerprint, result.Fingerprint())
+ }
+
+ if result.RecordRef() != s.record.Id || result.CollectionRef() != s.record.Collection().Id {
+ t.Fatalf("Expected record %q (%q), got %q (%q)", s.record.Id, s.record.Collection().Id, result.RecordRef(), result.CollectionRef())
+ }
+ })
+ }
+}
+
+func TestDeleteAllAuthOriginsByRecord(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ deletedIds []string
+ }{
+ {demo1, nil}, // non-auth record
+ {superuser2, []string{"5798yh833k6w6w0", "ic55o70g4f8pcl4", "dmy260k6ksjr4ib"}},
+ {superuser4, nil},
+ {client1, []string{"9r2j0m74260ur8i"}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ deletedIds := []string{}
+ app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ err := app.DeleteAllAuthOriginsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(s.deletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
+ }
+
+ for _, id := range s.deletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ })
+ }
+}
diff --git a/core/backup.go b/core/backup.go
new file mode 100644
index 0000000000..f4a9be3370
--- /dev/null
+++ b/core/backup.go
@@ -0,0 +1,137 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "sort"
+ "time"
+
+ "github.com/pocketbase/pocketbase/tools/inflector"
+)
+
+const (
+ StoreKeyActiveBackup = "@activeBackup"
+)
+
+// generateBackupName generates a new backup name based on the app name and current date.
+func generateBackupName(app App, prefix string) string {
+ appName := inflector.Snakecase(app.Settings().Meta.AppName)
+ if len(appName) > 50 {
+ appName = appName[:50]
+ }
+
+ return fmt.Sprintf(
+ "%s%s_%s.zip",
+ prefix,
+ appName,
+ time.Now().UTC().Format("20060102150405"),
+ )
+}
+
+// registerAutobackupHooks registers the autobackup app serve hooks.
+func (app *BaseApp) registerAutobackupHooks() {
+ const jobId = "__pbAutoBackup__"
+
+ loadJob := func() {
+ rawSchedule := app.Settings().Backups.Cron
+ if rawSchedule == "" {
+ app.Cron().Remove(jobId)
+ return
+ }
+
+ app.Cron().Add(jobId, rawSchedule, func() {
+ const autoPrefix = "@auto_pb_backup_"
+
+ name := generateBackupName(app, autoPrefix)
+
+ if err := app.CreateBackup(context.Background(), name); err != nil {
+ app.Logger().Error(
+ "[Backup cron] Failed to create backup",
+ slog.String("name", name),
+ slog.String("error", err.Error()),
+ )
+
+ alertError := sendSystemAlertToAllSuperusers(
+ app,
+ "Autobackup failure",
+ "Failed to create/upload automated backup. Raw error:\n"+err.Error(),
+ )
+ if alertError != nil {
+ app.Logger().Warn(
+ "[Backup cron] Failed to send backup error alerts",
+ slog.String("name", name),
+ slog.String("error", alertError.Error()),
+ )
+ }
+ }
+
+ maxKeep := app.Settings().Backups.CronMaxKeep
+
+ if maxKeep == 0 {
+ return // no explicit limit
+ }
+
+ fsys, err := app.NewBackupsFilesystem()
+ if err != nil {
+ app.Logger().Error(
+ "[Backup cron] Failed to initialize the backup filesystem",
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+ defer fsys.Close()
+
+ files, err := fsys.List(autoPrefix)
+ if err != nil {
+ app.Logger().Error(
+ "[Backup cron] Failed to list autogenerated backups",
+ slog.String("error", err.Error()),
+ )
+ return
+ }
+
+ if maxKeep >= len(files) {
+ return // nothing to remove
+ }
+
+ // sort desc
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].ModTime.After(files[j].ModTime)
+ })
+
+ // keep only the most recent n auto backup files
+ toRemove := files[maxKeep:]
+
+ for _, f := range toRemove {
+ if err := fsys.Delete(f.Key); err != nil {
+ app.Logger().Error(
+ "[Backup cron] Failed to remove old autogenerated backup",
+ slog.String("key", f.Key),
+ slog.String("error", err.Error()),
+ )
+ }
+ }
+ })
+ }
+
+ app.OnBootstrap().BindFunc(func(e *BootstrapEvent) error {
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ loadJob()
+
+ return nil
+ })
+
+ app.OnSettingsReload().BindFunc(func(e *SettingsReloadEvent) error {
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ loadJob()
+
+ return nil
+ })
+}
diff --git a/core/backup_create.go b/core/backup_create.go
new file mode 100644
index 0000000000..85996fc54e
--- /dev/null
+++ b/core/backup_create.go
@@ -0,0 +1,437 @@
+package core
+
+import (
+ "archive/zip"
+ "compress/flate"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/store"
+)
+
+var errIsDir = errors.New("the specified path is a directory and not a regular file")
+
+// CreateBackup creates a new backup of the current app pb_data directory.
+//
+// If name is empty, it will be autogenerated.
+// If backup with the same name exists, the new backup file will replace it.
+//
+// To safely perform the backup, it is recommended to have free disk space
+// for at least 2x the size of the pb_data directory.
+//
+// By default backups are stored in pb_data/backups
+// (the backups directory itself is excluded from the generated backup).
+//
+// Backups can be stored on S3 if it is configured in app.Settings().Backups.
+// When using S3 storage for the uploaded collection files, you have to
+// take care manually to backup those since they are not part of the pb_data.
+//
+// DB write locks are minimal and isolated only for the duration of the
+// VACUUM INTO statement that creates a live copy of the app database.
+//
+// The backup works as follow:
+//
+// 1. Start listening for DELETED storage files.
+// In case a file is being deleted while the backup is still ongoing,
+// we directly copy it in the zip before the deletion and mark it as "excluded".
+//
+// 2. Copy the main database with VACUUM INTO, write it in the zip and mark it as "excluded".
+//
+// 3. Stop listening for DELETED files.
+//
+// 4. Start listening for NEW storage files and mark all new files from this point as "excluded".
+//
+// 5. Copy the logs database with VACUUM INTO, write it in the zip and mark it as "excluded".
+//
+// 6. Copy the rest of the pb_data files in the zip while ignoring the "excluded" list (it should be concurrent safe).
+//
+// 7. Stop listening for NEW storage files.
+//
+// While there is a risk for a race condition between steps 1, 2 and 3, it is an
+// acceptable trade-off between performance and correctness because in
+// the worst case there will be some unused storage files in the backup that don't do any harm.
+func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
+ if app.Store().Has(StoreKeyActiveBackup) {
+ return errors.New("try again later - another backup/restore operation has already been started")
+ }
+
+ app.Store().Set(StoreKeyActiveBackup, name)
+ defer app.Store().Remove(StoreKeyActiveBackup)
+
+ startTime := time.Now()
+
+ event := new(BackupEvent)
+ event.App = app
+ event.Context = ctx
+ event.Name = name
+ // default root dir entries to exclude from the backup generation
+ event.Exclude = []string{
+ LocalBackupsDirName,
+ LocalTempDirName,
+ LocalNotifyDirName,
+ LocalAutocertCacheDirName,
+ lostFoundDirName,
+ }
+
+ return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
+ if e.Name == "" {
+ e.Name = generateBackupName(e.App, "pb_backup_")
+ }
+
+ app.Logger().Debug("[" + e.Name + "] zip archive started")
+
+ // create backup zip
+ // (it needs to be inside the current pb_data to avoid "cross-device link" errors)
+ // -----------------------------------------------------------
+ tempZipPath := filepath.Join(app.DataDir(), LocalTempDirName, "pb_backup_"+security.PseudorandomString(6))
+ err := createZip(e, tempZipPath)
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tempZipPath)
+
+ // persist the backup in the backups filesystem
+ // -----------------------------------------------------------
+ fsys, err := e.App.NewBackupsFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+
+ fsys.SetContext(e.Context)
+
+ file, err := filesystem.NewFileFromPath(tempZipPath)
+ if err != nil {
+ return err
+ }
+ file.OriginalName = e.Name
+ file.Name = file.OriginalName
+
+ err = fsys.UploadFile(file, file.Name)
+ if err != nil {
+ return err
+ }
+
+ app.Logger().Debug(
+ "["+e.Name+"] zip archive completed",
+ slog.Float64("execTime", float64(time.Since(startTime))/float64(time.Millisecond)),
+ )
+
+ return nil
+ })
+}
+
+func createZip(be *BackupEvent, tempZipPath string) error {
+ logPrefix := "[" + be.Name + "] "
+
+ // make sure that the special temp directory exists
+ localTempDir := filepath.Dir(tempZipPath)
+ if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
+ return fmt.Errorf(logPrefix+"failed to create temp dir: %w", err)
+ }
+
+ const tempFilesHookId = "__pbTempBackupFilesystemWatcher__"
+ defer func() {
+ // unbind again in cacase of an error
+ be.App.onFilesystemDelete().Unbind(tempFilesHookId)
+ be.App.onFilesystemNewWriter().Unbind(tempFilesHookId)
+ }()
+
+ zipper, err := newZipWriter(tempZipPath)
+ if err != nil {
+ return err
+ }
+ defer zipper.close()
+
+ excluded := store.New[string, struct{}](nil)
+ for _, name := range be.Exclude {
+ excluded.Set(normalizePathExclude(name), struct{}{})
+ }
+
+ // init deleted files tracker
+ // ---------------------------------------------------------------
+ be.App.onFilesystemDelete().Bind(&hook.Handler[*FilesystemDeleteEvent]{
+ Id: tempFilesHookId,
+ Priority: -99,
+ Func: func(e *FilesystemDeleteEvent) error {
+ // note: the zip header name allow only forward slashes
+ zipPath := path.Join(LocalStorageDirName, e.FileKey)
+
+ if excluded.Has(normalizePathExclude(zipPath)) || be.App.Settings().S3.Enabled {
+ return e.Next()
+ }
+
+ localPath := filepath.Join(
+ be.App.DataDir(),
+ LocalStorageDirName,
+ e.FileKey,
+ )
+
+ // copy to zip before delete
+ err := zipper.copyFileToZip(localPath, zipPath)
+ if err != nil {
+ // it is ok to ignore directories
+ if !errors.Is(err, errIsDir) {
+ be.App.Logger().Warn(
+ logPrefix+"failed to copy file in backup zip before delete",
+ slog.Any("error", err),
+ slog.String("file", e.FileKey),
+ )
+ }
+ } else {
+ // mark that it was already copied
+ excluded.Set(normalizePathExclude(zipPath), struct{}{})
+ }
+
+ // proceed with the normal deletion
+ return e.Next()
+ },
+ })
+
+ // copy data.db
+ // ---------------------------------------------------------------
+ dataStartTime := time.Now()
+ tempDataDBPath := filepath.Join(localTempDir, dataDBFilename)
+
+ _, err = be.App.ConcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempDataDBPath}).Execute()
+ if err != nil {
+ return err
+ }
+
+ // eagerly stop listening for deleted files since we already have what we needed
+ be.App.onFilesystemDelete().Unbind(tempFilesHookId)
+
+ be.App.Logger().Debug(
+ logPrefix+dataDBFilename+" copy completed",
+ slog.Float64("execTime", float64(time.Since(dataStartTime))/float64(time.Millisecond)),
+ )
+
+ err = zipper.copyFileToZip(tempDataDBPath, dataDBFilename)
+ if err != nil {
+ _ = os.Remove(tempDataDBPath)
+ return err
+ }
+ _ = os.Remove(tempDataDBPath)
+
+ excluded.Set(normalizePathExclude(dataDBFilename), struct{}{})
+ excluded.Set(normalizePathExclude(dataDBFilename+"-wal"), struct{}{})
+ excluded.Set(normalizePathExclude(dataDBFilename+"-shm"), struct{}{})
+
+ // init to-be-created files tracker
+ // ---------------------------------------------------------------
+ be.App.onFilesystemNewWriter().Bind(&hook.Handler[*FilesystemNewWriterEvent]{
+ Id: tempFilesHookId,
+ Priority: -99,
+ Func: func(e *FilesystemNewWriterEvent) error {
+ if !be.App.Settings().S3.Enabled {
+ // mark for exclude even if the writer eventually fails
+ // (all record files have random name so collisions are unlikely)
+ name := normalizePathExclude(filepath.Join(LocalStorageDirName, e.FileKey))
+ excluded.Set(name, struct{}{})
+ }
+
+ return e.Next()
+ },
+ })
+
+ // copy auxiliary.db
+ // ---------------------------------------------------------------
+ auxStartTime := time.Now()
+ tempAuxDBPath := filepath.Join(localTempDir, auxDBFilename)
+
+ _, err = be.App.AuxConcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempAuxDBPath}).Execute()
+ if err != nil {
+ return err
+ }
+
+ be.App.Logger().Debug(
+ logPrefix+auxDBFilename+" copy completed",
+ slog.Float64("execTime", float64(time.Since(auxStartTime))/float64(time.Millisecond)),
+ )
+
+ err = zipper.copyFileToZip(tempAuxDBPath, auxDBFilename)
+ if err != nil {
+ _ = os.Remove(tempAuxDBPath)
+ return err
+ }
+ _ = os.Remove(tempAuxDBPath)
+
+ excluded.Set(normalizePathExclude(auxDBFilename), struct{}{})
+ excluded.Set(normalizePathExclude(auxDBFilename+"-wal"), struct{}{})
+ excluded.Set(normalizePathExclude(auxDBFilename+"-shm"), struct{}{})
+
+ // try to run manual checkpoints to ensure that all wal writes during the
+ // previous VACUUM INTO are transferred and don't accumulate
+ // (errors are ignore because some drivers may not support the wal_checkpoint pragma)
+ // ---------------------------------------------------------------
+ _, _ = be.App.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
+ _, _ = be.App.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
+
+ // copy the rest of the pb_data
+ // ---------------------------------------------------------------
+ err = zipper.copyDirToZip(os.DirFS(be.App.DataDir()), excluded)
+ if err != nil {
+ return err
+ }
+
+ return zipper.close()
+}
+
+// normalize the provided file path to always end with forward slash
+func normalizePathExclude(filePath string) string {
+ return path.Clean(filePath) + "/"
+}
+
+type zipWriter struct {
+ mu sync.Mutex
+ w *zip.Writer
+ f *os.File
+ closed bool
+}
+
+func newZipWriter(zipFilePath string) (*zipWriter, error) {
+ f, err := os.Create(zipFilePath)
+ if err != nil {
+ return nil, err
+ }
+
+ w := zip.NewWriter(f)
+ w.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
+ return flate.NewWriter(out, flate.BestSpeed)
+ })
+
+ return &zipWriter{
+ w: w,
+ f: f,
+ }, nil
+}
+
+func (z *zipWriter) close() error {
+ z.mu.Lock()
+ defer z.mu.Unlock()
+
+ if z.closed {
+ return nil
+ }
+
+ z.closed = true
+
+ return errors.Join(z.w.Close(), z.f.Close())
+}
+
+func (z *zipWriter) copyFileToZip(localPath string, zipPath string) error {
+ info, err := os.Stat(localPath)
+ if err != nil {
+ return err
+ }
+
+ if info.IsDir() {
+ return errIsDir
+ }
+
+ h, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+
+ h.Name = zipPath
+ h.Method = zip.Deflate
+
+ z.mu.Lock()
+ defer z.mu.Unlock()
+
+ if z.closed {
+ return errors.New("zip writer is already closed")
+ }
+
+ fw, err := z.w.CreateHeader(h)
+ if err != nil {
+ return err
+ }
+
+ f, err := os.Open(localPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = io.Copy(fw, f)
+
+ return err
+}
+
+func (z *zipWriter) copyDirToZip(fsys fs.FS, excludedPrefixes *store.Store[string, struct{}]) error {
+ return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // skip excluded prefixes
+ if excludedPrefixes != nil {
+ check := normalizePathExclude(name)
+ prefixes := excludedPrefixes.Keys() // refetch to avoid races
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(check, prefix) {
+ if d.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ }
+ }
+
+ if d.IsDir() {
+ return nil
+ }
+
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+
+ h, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+
+ h.Name = name
+ h.Method = zip.Deflate
+
+ z.mu.Lock()
+ defer z.mu.Unlock()
+
+ if z.closed {
+ // note: fs.WalkDir perform direct comparison with the value
+ return fs.SkipAll
+ }
+
+ fw, err := z.w.CreateHeader(h)
+ if err != nil {
+ return err
+ }
+
+ f, err := fsys.Open(name)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = io.Copy(fw, f)
+
+ return err
+ })
+}
diff --git a/core/backup_restore.go b/core/backup_restore.go
new file mode 100644
index 0000000000..c4a17ccafa
--- /dev/null
+++ b/core/backup_restore.go
@@ -0,0 +1,196 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/pocketbase/pocketbase/tools/archive"
+ "github.com/pocketbase/pocketbase/tools/osutils"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+// RestoreBackup restores the backup with the specified name and restarts
+// the current running application process.
+//
+// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
+//
+// To safely perform the restore it is recommended to have free disk space
+// for at least 2x the size of the restored pb_data backup.
+//
+// The performed steps are:
+//
+// 1. Download the backup with the specified name in a temp location
+// (this is in case of S3; otherwise it creates a temp copy of the zip)
+//
+// 2. Extract the backup in a temp directory inside the app "pb_data"
+// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
+//
+// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
+// under another temp sub dir that will be deleted on the next app start up
+// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
+// This is because on some environments it may not be allowed
+// to delete the currently open "pb_data" files.
+//
+// 4. Move the extracted dir content to the app "pb_data".
+//
+// 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
+//
+// If a failure occur during the restore process the dir changes are reverted.
+// If for whatever reason the revert is not possible, it panics.
+//
+// Note that if your pb_data has custom network mounts as subdirectories, then
+// it is possible the restore to fail during the `os.Rename` operations
+// (see https://github.com/pocketbase/pocketbase/issues/4647).
+func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
+ if app.Store().Has(StoreKeyActiveBackup) {
+ return errors.New("try again later - another backup/restore operation has already been started")
+ }
+
+ app.Store().Set(StoreKeyActiveBackup, name)
+ defer app.Store().Remove(StoreKeyActiveBackup)
+
+ event := new(BackupEvent)
+ event.App = app
+ event.Context = ctx
+ event.Name = name
+ // default root dir entries to exclude from the backup restore
+ event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
+
+ return app.OnBackupRestore().Trigger(event, func(e *BackupEvent) error {
+ if runtime.GOOS == "windows" {
+ return errors.New("restore is not supported on Windows")
+ }
+
+ // make sure that the special temp directory exists
+ // note: it needs to be inside the current pb_data to avoid "cross-device link" errors
+ localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
+ if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
+ return fmt.Errorf("failed to create a temp dir: %w", err)
+ }
+
+ fsys, err := e.App.NewBackupsFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+
+ fsys.SetContext(e.Context)
+
+ if ok, _ := fsys.Exists(name); !ok {
+ return fmt.Errorf("missing or invalid backup file %q to restore", name)
+ }
+
+ extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
+ defer os.RemoveAll(extractedDataDir)
+
+ // extract the zip
+ if e.App.Settings().Backups.S3.Enabled {
+ br, err := fsys.GetReader(name)
+ if err != nil {
+ return err
+ }
+ defer br.Close()
+
+ // create a temp zip file from the blob.Reader and try to extract it
+ tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tempZip.Name())
+ defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
+
+ _, err = io.Copy(tempZip, br)
+ if err != nil {
+ return err
+ }
+
+ err = archive.Extract(tempZip.Name(), extractedDataDir)
+ if err != nil {
+ return err
+ }
+
+ // remove the temp zip file since we no longer need it
+ // (this is in case the app restarts and the defer calls are not called)
+ _ = tempZip.Close()
+ err = os.Remove(tempZip.Name())
+ if err != nil {
+ e.App.Logger().Warn(
+ "[RestoreBackup] Failed to remove the temp zip backup file",
+ slog.String("file", tempZip.Name()),
+ slog.String("error", err.Error()),
+ )
+ }
+ } else {
+ // manually construct the local path to avoid creating a copy of the zip file
+ // since the blob reader currently doesn't implement ReaderAt
+ zipPath := filepath.Join(e.App.DataDir(), LocalBackupsDirName, filepath.Base(name))
+
+ err = archive.Extract(zipPath, extractedDataDir)
+ if err != nil {
+ return err
+ }
+ }
+
+ // ensure that at least a database file exists
+ extractedDB := filepath.Join(extractedDataDir, "data.db")
+ if _, err := os.Stat(extractedDB); err != nil {
+ return fmt.Errorf("data.db file is missing or invalid: %w", err)
+ }
+
+ oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
+
+ replaceErr := e.App.RunInTransaction(func(txApp App) error {
+ return txApp.AuxRunInTransaction(func(txApp App) error {
+ // move the current pb_data content to a special temp location
+ // that will hold the old data between dirs replace
+ // (the temp dir will be automatically removed on the next app start)
+ if err := osutils.MoveDirContent(txApp.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
+ return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
+ }
+
+ // move the extracted archive content to the app's pb_data
+ if err := osutils.MoveDirContent(extractedDataDir, txApp.DataDir(), e.Exclude...); err != nil {
+ return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
+ }
+
+ return nil
+ })
+ })
+ if replaceErr != nil {
+ return replaceErr
+ }
+
+ revertDataDirChanges := func() error {
+ return e.App.RunInTransaction(func(txApp App) error {
+ return txApp.AuxRunInTransaction(func(txApp App) error {
+ if err := osutils.MoveDirContent(txApp.DataDir(), extractedDataDir, e.Exclude...); err != nil {
+ return fmt.Errorf("failed to revert the extracted dir change: %w", err)
+ }
+
+ if err := osutils.MoveDirContent(oldTempDataDir, txApp.DataDir(), e.Exclude...); err != nil {
+ return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
+ }
+
+ return nil
+ })
+ })
+ }
+
+ // restart the app
+ if err := e.App.Restart(); err != nil {
+ if revertErr := revertDataDirChanges(); revertErr != nil {
+ panic(revertErr)
+ }
+
+ return fmt.Errorf("failed to restart the app process: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/core/backup_test.go b/core/backup_test.go
new file mode 100644
index 0000000000..9a1eb85bdc
--- /dev/null
+++ b/core/backup_test.go
@@ -0,0 +1,160 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/archive"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func TestCreateBackup(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // set some long app name with spaces and special characters
+ app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
+
+ expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
+
+ // test pending error
+ app.Store().Set(core.StoreKeyActiveBackup, "")
+ if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
+ t.Fatal("Expected pending error, got nil")
+ }
+ app.Store().Remove(core.StoreKeyActiveBackup)
+
+ // create with auto generated name
+ if err := app.CreateBackup(context.Background(), ""); err != nil {
+ t.Fatal("Failed to create a backup with autogenerated name")
+ }
+
+ // create with custom name
+ if err := app.CreateBackup(context.Background(), "custom"); err != nil {
+ t.Fatal("Failed to create a backup with custom name")
+ }
+
+ // create new with the same name (aka. replace)
+ if err := app.CreateBackup(context.Background(), "custom"); err != nil {
+ t.Fatal("Failed to create and replace a backup with the same name")
+ }
+
+ backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
+
+ entries, err := os.ReadDir(backupsDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectedFiles := []string{
+ `^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
+ `^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
+ "custom",
+ "custom.attrs",
+ }
+
+ if len(entries) != len(expectedFiles) {
+ names := getEntryNames(entries)
+ t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
+ }
+
+ for i, entry := range entries {
+ if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
+ t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
+ }
+
+ if strings.HasSuffix(entry.Name(), ".attrs") {
+ continue
+ }
+
+ path := filepath.Join(backupsDir, entry.Name())
+
+ if err := verifyBackupContent(app, path); err != nil {
+ t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
+ }
+ }
+}
+
+func TestRestoreBackup(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // create a initial test backup to ensure that there are at least 1
+ // backup file and that the generated zip doesn't contain the backups dir
+ if err := app.CreateBackup(context.Background(), "initial"); err != nil {
+ t.Fatal("Failed to create test initial backup")
+ }
+
+ // create test backup
+ if err := app.CreateBackup(context.Background(), "test"); err != nil {
+ t.Fatal("Failed to create test backup")
+ }
+
+ // test pending error
+ app.Store().Set(core.StoreKeyActiveBackup, "")
+ if err := app.RestoreBackup(context.Background(), "test"); err == nil {
+ t.Fatal("Expected pending error, got nil")
+ }
+ app.Store().Remove(core.StoreKeyActiveBackup)
+
+ // missing backup
+ if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
+ t.Fatal("Expected missing error, got nil")
+ }
+}
+
+// -------------------------------------------------------------------
+
+func verifyBackupContent(app core.App, path string) error {
+ dir, err := os.MkdirTemp("", "backup_test")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ if err := archive.Extract(path, dir); err != nil {
+ return err
+ }
+
+ expectedRootEntries := []string{
+ "storage",
+ "data.db",
+ "auxiliary.db",
+ ".gitignore",
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+
+ if len(entries) != len(expectedRootEntries) {
+ names := getEntryNames(entries)
+ return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
+ }
+
+ for _, entry := range entries {
+ if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
+ return fmt.Errorf("Didn't expect %q entry", entry.Name())
+ }
+ }
+
+ return nil
+}
+
+func getEntryNames(entries []fs.DirEntry) []string {
+ names := make([]string, len(entries))
+
+ for i, entry := range entries {
+ names[i] = entry.Name()
+ }
+
+ return names
+}
diff --git a/core/base.go b/core/base.go
index 4ed8f099f2..fc9fec6b96 100644
--- a/core/base.go
+++ b/core/base.go
@@ -4,176 +4,208 @@ import (
"context"
"database/sql"
"errors"
+ "fmt"
"log"
+ "log/slog"
"os"
"path/filepath"
+ "regexp"
"runtime"
- "syscall"
+ "strings"
+ "sync/atomic"
"time"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/settings"
+ "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+ "golang.org/x/sync/semaphore"
)
const (
- DefaultDataMaxOpenConns int = 120
- DefaultDataMaxIdleConns int = 20
- DefaultLogsMaxOpenConns int = 10
- DefaultLogsMaxIdleConns int = 2
-
- LocalStorageDirName string = "storage"
- LocalBackupsDirName string = "backups"
- LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
+ DefaultDataMaxOpenConns int = 120
+ DefaultDataMaxIdleConns int = 15
+ DefaultAuxMaxOpenConns int = 20
+ DefaultAuxMaxIdleConns int = 3
+ DefaultQueryTimeout time.Duration = 30 * time.Second
+
+ LocalStorageDirName string = "storage"
+ 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()
+
+ // @todo consider removing after backups refactoring
+ lostFoundDirName string = "lost+found"
+
+ dataDBFilename string = "data.db"
+ auxDBFilename string = "auxiliary.db"
)
+// FilesManager defines an interface with common methods that files manager models should implement.
+type FilesManager interface {
+ // BaseFilesPath returns the storage dir path used by the interface instance.
+ BaseFilesPath() string
+}
+
+// DBConnectFunc defines a database connection initialization function.
+type DBConnectFunc func(dbPath string) (*dbx.DB, error)
+
+// BaseAppConfig defines a BaseApp configuration option
+type BaseAppConfig struct {
+ DBConnect DBConnectFunc
+ DataDir string
+ EncryptionEnv string
+ QueryTimeout time.Duration
+ DataMaxOpenConns int
+ DataMaxIdleConns int
+ AuxMaxOpenConns int
+ AuxMaxIdleConns int
+ IsDev bool
+}
+
+// ensures that the BaseApp implements the App interface.
var _ App = (*BaseApp)(nil)
// BaseApp implements core.App and defines the base PocketBase app structure.
type BaseApp struct {
- // configurable parameters
- isDebug bool
- dataDir string
- encryptionEnv string
- dataMaxOpenConns int
- dataMaxIdleConns int
- logsMaxOpenConns int
- logsMaxIdleConns int
-
- // internals
- cache *store.Store[any]
- settings *settings.Settings
- dao *daos.Dao
- logsDao *daos.Dao
+ config *BaseAppConfig
+ txInfo *TxAppInfo
+ store *store.Store[string, any]
+ cron *cron.Cron
+ settings *Settings
subscriptionsBroker *subscriptions.Broker
+ logger *slog.Logger
+ concurrentDB dbx.Builder
+ nonconcurrentDB dbx.Builder
+ auxConcurrentDB dbx.Builder
+ auxNonconcurrentDB dbx.Builder
// app event hooks
- onBeforeBootstrap *hook.Hook[*BootstrapEvent]
- onAfterBootstrap *hook.Hook[*BootstrapEvent]
- onBeforeServe *hook.Hook[*ServeEvent]
- onBeforeApiError *hook.Hook[*ApiErrorEvent]
- onAfterApiError *hook.Hook[*ApiErrorEvent]
- onTerminate *hook.Hook[*TerminateEvent]
-
- // dao event hooks
- onModelBeforeCreate *hook.Hook[*ModelEvent]
- onModelAfterCreate *hook.Hook[*ModelEvent]
- onModelBeforeUpdate *hook.Hook[*ModelEvent]
- onModelAfterUpdate *hook.Hook[*ModelEvent]
- onModelBeforeDelete *hook.Hook[*ModelEvent]
- onModelAfterDelete *hook.Hook[*ModelEvent]
+ onBootstrap *hook.Hook[*BootstrapEvent]
+ onBootstrapClear *hook.Hook[*BootstrapEvent]
+ onServe *hook.Hook[*ServeEvent]
+ onTerminate *hook.Hook[*TerminateEvent]
+ onBackupCreate *hook.Hook[*BackupEvent]
+ onBackupRestore *hook.Hook[*BackupEvent]
+
+ // db model hooks
+ onModelValidate *hook.Hook[*ModelEvent]
+ onModelCreate *hook.Hook[*ModelEvent]
+ onModelCreateExecute *hook.Hook[*ModelEvent]
+ onModelAfterCreateSuccess *hook.Hook[*ModelEvent]
+ onModelAfterCreateError *hook.Hook[*ModelErrorEvent]
+ onModelUpdate *hook.Hook[*ModelEvent]
+ onModelUpdateWrite *hook.Hook[*ModelEvent]
+ onModelAfterUpdateSuccess *hook.Hook[*ModelEvent]
+ onModelAfterUpdateError *hook.Hook[*ModelErrorEvent]
+ onModelDelete *hook.Hook[*ModelEvent]
+ onModelDeleteExecute *hook.Hook[*ModelEvent]
+ onModelAfterDeleteSuccess *hook.Hook[*ModelEvent]
+ onModelAfterDeleteError *hook.Hook[*ModelErrorEvent]
+
+ // db record hooks
+ onRecordEnrich *hook.Hook[*RecordEnrichEvent]
+ onRecordValidate *hook.Hook[*RecordEvent]
+ onRecordCreate *hook.Hook[*RecordEvent]
+ onRecordCreateExecute *hook.Hook[*RecordEvent]
+ onRecordAfterCreateSuccess *hook.Hook[*RecordEvent]
+ onRecordAfterCreateError *hook.Hook[*RecordErrorEvent]
+ onRecordUpdate *hook.Hook[*RecordEvent]
+ onRecordUpdateExecute *hook.Hook[*RecordEvent]
+ onRecordAfterUpdateSuccess *hook.Hook[*RecordEvent]
+ onRecordAfterUpdateError *hook.Hook[*RecordErrorEvent]
+ onRecordDelete *hook.Hook[*RecordEvent]
+ onRecordDeleteExecute *hook.Hook[*RecordEvent]
+ onRecordAfterDeleteSuccess *hook.Hook[*RecordEvent]
+ onRecordAfterDeleteError *hook.Hook[*RecordErrorEvent]
+
+ // db collection hooks
+ onCollectionValidate *hook.Hook[*CollectionEvent]
+ onCollectionCreate *hook.Hook[*CollectionEvent]
+ onCollectionCreateExecute *hook.Hook[*CollectionEvent]
+ onCollectionAfterCreateSuccess *hook.Hook[*CollectionEvent]
+ onCollectionAfterCreateError *hook.Hook[*CollectionErrorEvent]
+ onCollectionUpdate *hook.Hook[*CollectionEvent]
+ onCollectionUpdateExecute *hook.Hook[*CollectionEvent]
+ onCollectionAfterUpdateSuccess *hook.Hook[*CollectionEvent]
+ onCollectionAfterUpdateError *hook.Hook[*CollectionErrorEvent]
+ onCollectionDelete *hook.Hook[*CollectionEvent]
+ onCollectionDeleteExecute *hook.Hook[*CollectionEvent]
+ onCollectionAfterDeleteSuccess *hook.Hook[*CollectionEvent]
+ onCollectionAfterDeleteError *hook.Hook[*CollectionErrorEvent]
// mailer event hooks
- onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
- onMailerBeforeRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
- onMailerAfterRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
- onMailerBeforeRecordVerificationSend *hook.Hook[*MailerRecordEvent]
- onMailerAfterRecordVerificationSend *hook.Hook[*MailerRecordEvent]
- onMailerBeforeRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
- onMailerAfterRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
+ onMailerSend *hook.Hook[*MailerEvent]
+ onMailerRecordPasswordResetSend *hook.Hook[*MailerRecordEvent]
+ onMailerRecordVerificationSend *hook.Hook[*MailerRecordEvent]
+ onMailerRecordEmailChangeSend *hook.Hook[*MailerRecordEvent]
+ onMailerRecordOTPSend *hook.Hook[*MailerRecordEvent]
+ onMailerRecordAuthAlertSend *hook.Hook[*MailerRecordEvent]
+
+ // filesystem event hooks
+ //
+ // @todo 1:
+ // intentionally not exposed since the events are too "chatty" and
+ // can cause unnecessary userland tests breaking changes;
+ // reevaluate once refactoring the file_field
+ //
+ // @todo 2: if exposed consider registering the same for the backup filesystem
+ _onFilesystemNewWriter *hook.Hook[*FilesystemNewWriterEvent]
+ _onFilesystemDelete *hook.Hook[*FilesystemDeleteEvent]
// realtime api event hooks
- onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
- onRealtimeDisconnectRequest *hook.Hook[*RealtimeDisconnectEvent]
- onRealtimeBeforeMessageSend *hook.Hook[*RealtimeMessageEvent]
- onRealtimeAfterMessageSend *hook.Hook[*RealtimeMessageEvent]
- onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
- onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
-
- // settings api event hooks
- onSettingsListRequest *hook.Hook[*SettingsListEvent]
- onSettingsBeforeUpdateRequest *hook.Hook[*SettingsUpdateEvent]
- onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
+ onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent]
+ onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent]
+ onRealtimeSubscribeRequest *hook.Hook[*RealtimeSubscribeRequestEvent]
+
+ // settings event hooks
+ onSettingsListRequest *hook.Hook[*SettingsListRequestEvent]
+ onSettingsUpdateRequest *hook.Hook[*SettingsUpdateRequestEvent]
+ onSettingsReload *hook.Hook[*SettingsReloadEvent]
// file api event hooks
- onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
- onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
- onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
-
- // admin api event hooks
- onAdminsListRequest *hook.Hook[*AdminsListEvent]
- onAdminViewRequest *hook.Hook[*AdminViewEvent]
- onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
- onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
- onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
- onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
- onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
- onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
- onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
- onAdminBeforeAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
- onAdminAfterAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
- onAdminBeforeAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
- onAdminAfterAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
- onAdminBeforeRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
- onAdminAfterRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
- onAdminBeforeConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
- onAdminAfterConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
+ onFileDownloadRequest *hook.Hook[*FileDownloadRequestEvent]
+ onFileTokenRequest *hook.Hook[*FileTokenRequestEvent]
// record auth API event hooks
- onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
- onRecordBeforeAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
- onRecordAfterAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
- onRecordBeforeAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
- onRecordAfterAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
- onRecordBeforeAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
- onRecordAfterAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
- onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
- onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
- onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
- onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
- onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
- onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
- onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
- onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
- onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
- onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
- onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
- onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
- onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
- onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
- onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
+ onRecordAuthRequest *hook.Hook[*RecordAuthRequestEvent]
+ onRecordAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordRequestEvent]
+ onRecordAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2RequestEvent]
+ onRecordAuthRefreshRequest *hook.Hook[*RecordAuthRefreshRequestEvent]
+ onRecordRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetRequestEvent]
+ onRecordConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetRequestEvent]
+ onRecordRequestVerificationRequest *hook.Hook[*RecordRequestVerificationRequestEvent]
+ onRecordConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationRequestEvent]
+ onRecordRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeRequestEvent]
+ onRecordConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeRequestEvent]
+ onRecordRequestOTPRequest *hook.Hook[*RecordCreateOTPRequestEvent]
+ onRecordAuthWithOTPRequest *hook.Hook[*RecordAuthWithOTPRequestEvent]
// record crud API event hooks
- onRecordsListRequest *hook.Hook[*RecordsListEvent]
- onRecordViewRequest *hook.Hook[*RecordViewEvent]
- onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent]
- onRecordAfterCreateRequest *hook.Hook[*RecordCreateEvent]
- onRecordBeforeUpdateRequest *hook.Hook[*RecordUpdateEvent]
- onRecordAfterUpdateRequest *hook.Hook[*RecordUpdateEvent]
- onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent]
- onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
+ onRecordsListRequest *hook.Hook[*RecordsListRequestEvent]
+ onRecordViewRequest *hook.Hook[*RecordRequestEvent]
+ onRecordCreateRequest *hook.Hook[*RecordRequestEvent]
+ onRecordUpdateRequest *hook.Hook[*RecordRequestEvent]
+ onRecordDeleteRequest *hook.Hook[*RecordRequestEvent]
// collection API event hooks
- onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
- onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
- onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
- onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
- onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
- onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
- onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
- onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
- onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent]
- onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
-}
+ onCollectionsListRequest *hook.Hook[*CollectionsListRequestEvent]
+ onCollectionViewRequest *hook.Hook[*CollectionRequestEvent]
+ onCollectionCreateRequest *hook.Hook[*CollectionRequestEvent]
+ onCollectionUpdateRequest *hook.Hook[*CollectionRequestEvent]
+ onCollectionDeleteRequest *hook.Hook[*CollectionRequestEvent]
+ onCollectionsImportRequest *hook.Hook[*CollectionsImportRequestEvent]
-// BaseAppConfig defines a BaseApp configuration option
-type BaseAppConfig struct {
- DataDir string
- EncryptionEnv string
- IsDebug bool
- DataMaxOpenConns int // default to 500
- DataMaxIdleConns int // default 20
- LogsMaxOpenConns int // default to 100
- LogsMaxIdleConns int // default to 5
+ onBatchRequest *hook.Hook[*BatchRequestEvent]
}
// NewBaseApp creates and returns a new BaseApp instance
@@ -182,276 +214,435 @@ type BaseAppConfig struct {
// To initialize the app, you need to call `app.Bootstrap()`.
func NewBaseApp(config BaseAppConfig) *BaseApp {
app := &BaseApp{
- dataDir: config.DataDir,
- isDebug: config.IsDebug,
- encryptionEnv: config.EncryptionEnv,
- dataMaxOpenConns: config.DataMaxOpenConns,
- dataMaxIdleConns: config.DataMaxIdleConns,
- logsMaxOpenConns: config.LogsMaxOpenConns,
- logsMaxIdleConns: config.LogsMaxIdleConns,
- cache: store.New[any](nil),
- settings: settings.New(),
+ settings: newDefaultSettings(),
+ store: store.New[string, any](nil),
+ cron: cron.New(),
subscriptionsBroker: subscriptions.NewBroker(),
+ config: &config,
+ }
- // app event hooks
- onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{},
- onAfterBootstrap: &hook.Hook[*BootstrapEvent]{},
- onBeforeServe: &hook.Hook[*ServeEvent]{},
- onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
- onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
- onTerminate: &hook.Hook[*TerminateEvent]{},
-
- // dao event hooks
- onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
- onModelAfterCreate: &hook.Hook[*ModelEvent]{},
- onModelBeforeUpdate: &hook.Hook[*ModelEvent]{},
- onModelAfterUpdate: &hook.Hook[*ModelEvent]{},
- onModelBeforeDelete: &hook.Hook[*ModelEvent]{},
- onModelAfterDelete: &hook.Hook[*ModelEvent]{},
-
- // mailer event hooks
- onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
- onMailerBeforeRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
- onMailerAfterRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
- onMailerBeforeRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
- onMailerAfterRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
- onMailerBeforeRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
- onMailerAfterRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
-
- // realtime API event hooks
- onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
- onRealtimeDisconnectRequest: &hook.Hook[*RealtimeDisconnectEvent]{},
- onRealtimeBeforeMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
- onRealtimeAfterMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
- onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
- onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
-
- // settings API event hooks
- onSettingsListRequest: &hook.Hook[*SettingsListEvent]{},
- onSettingsBeforeUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
- onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
-
- // file API event hooks
- onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
- onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
- onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
-
- // admin API event hooks
- onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
- onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
- onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
- onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
- onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
- onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
- onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
- onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
- onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
- onAdminBeforeAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
- onAdminAfterAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
- onAdminBeforeAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
- onAdminAfterAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
- onAdminBeforeRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
- onAdminAfterRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
- onAdminBeforeConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
- onAdminAfterConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
-
- // record auth API event hooks
- onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
- onRecordBeforeAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
- onRecordAfterAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
- onRecordBeforeAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
- onRecordAfterAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
- onRecordBeforeAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
- onRecordAfterAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
- onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
- onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
- onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
- onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
- onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
- onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
- onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
- onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
- onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
- onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
- onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
- onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
- onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
- onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
- onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
-
- // record crud API event hooks
- onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
- onRecordViewRequest: &hook.Hook[*RecordViewEvent]{},
- onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{},
- onRecordAfterCreateRequest: &hook.Hook[*RecordCreateEvent]{},
- onRecordBeforeUpdateRequest: &hook.Hook[*RecordUpdateEvent]{},
- onRecordAfterUpdateRequest: &hook.Hook[*RecordUpdateEvent]{},
- onRecordBeforeDeleteRequest: &hook.Hook[*RecordDeleteEvent]{},
- onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{},
-
- // collection API event hooks
- onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
- onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
- onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
- onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
- onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
- onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
- onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
- onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
- onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{},
- onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{},
+ // apply config defaults
+ if app.config.DBConnect == nil {
+ app.config.DBConnect = DefaultDBConnect
+ }
+ if app.config.DataMaxOpenConns <= 0 {
+ app.config.DataMaxOpenConns = DefaultDataMaxOpenConns
+ }
+ if app.config.DataMaxIdleConns <= 0 {
+ app.config.DataMaxIdleConns = DefaultDataMaxIdleConns
+ }
+ if app.config.AuxMaxOpenConns <= 0 {
+ app.config.AuxMaxOpenConns = DefaultAuxMaxOpenConns
+ }
+ if app.config.AuxMaxIdleConns <= 0 {
+ app.config.AuxMaxIdleConns = DefaultAuxMaxIdleConns
+ }
+ if app.config.QueryTimeout <= 0 {
+ app.config.QueryTimeout = DefaultQueryTimeout
}
- app.registerDefaultHooks()
+ app.initHooks()
+ app.registerBaseHooks()
return app
}
+// initHooks initializes all app hook handlers.
+func (app *BaseApp) initHooks() {
+ // app event hooks
+ app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
+ app.onBootstrapClear = &hook.Hook[*BootstrapEvent]{}
+ app.onServe = &hook.Hook[*ServeEvent]{}
+ app.onTerminate = &hook.Hook[*TerminateEvent]{}
+ app.onBackupCreate = &hook.Hook[*BackupEvent]{}
+ app.onBackupRestore = &hook.Hook[*BackupEvent]{}
+
+ // db model hooks
+ app.onModelValidate = &hook.Hook[*ModelEvent]{}
+ app.onModelCreate = &hook.Hook[*ModelEvent]{}
+ app.onModelCreateExecute = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterCreateSuccess = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterCreateError = &hook.Hook[*ModelErrorEvent]{}
+ app.onModelUpdate = &hook.Hook[*ModelEvent]{}
+ app.onModelUpdateWrite = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterUpdateSuccess = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterUpdateError = &hook.Hook[*ModelErrorEvent]{}
+ app.onModelDelete = &hook.Hook[*ModelEvent]{}
+ app.onModelDeleteExecute = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterDeleteSuccess = &hook.Hook[*ModelEvent]{}
+ app.onModelAfterDeleteError = &hook.Hook[*ModelErrorEvent]{}
+
+ // db record hooks
+ app.onRecordEnrich = &hook.Hook[*RecordEnrichEvent]{}
+ app.onRecordValidate = &hook.Hook[*RecordEvent]{}
+ app.onRecordCreate = &hook.Hook[*RecordEvent]{}
+ app.onRecordCreateExecute = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterCreateSuccess = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterCreateError = &hook.Hook[*RecordErrorEvent]{}
+ app.onRecordUpdate = &hook.Hook[*RecordEvent]{}
+ app.onRecordUpdateExecute = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterUpdateSuccess = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterUpdateError = &hook.Hook[*RecordErrorEvent]{}
+ app.onRecordDelete = &hook.Hook[*RecordEvent]{}
+ app.onRecordDeleteExecute = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterDeleteSuccess = &hook.Hook[*RecordEvent]{}
+ app.onRecordAfterDeleteError = &hook.Hook[*RecordErrorEvent]{}
+
+ // db collection hooks
+ app.onCollectionValidate = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionCreate = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionCreateExecute = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterCreateSuccess = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterCreateError = &hook.Hook[*CollectionErrorEvent]{}
+ app.onCollectionUpdate = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionUpdateExecute = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterUpdateSuccess = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterUpdateError = &hook.Hook[*CollectionErrorEvent]{}
+ app.onCollectionDelete = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterDeleteSuccess = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionDeleteExecute = &hook.Hook[*CollectionEvent]{}
+ app.onCollectionAfterDeleteError = &hook.Hook[*CollectionErrorEvent]{}
+
+ // mailer event hooks
+ app.onMailerSend = &hook.Hook[*MailerEvent]{}
+ app.onMailerRecordPasswordResetSend = &hook.Hook[*MailerRecordEvent]{}
+ app.onMailerRecordVerificationSend = &hook.Hook[*MailerRecordEvent]{}
+ app.onMailerRecordEmailChangeSend = &hook.Hook[*MailerRecordEvent]{}
+ app.onMailerRecordOTPSend = &hook.Hook[*MailerRecordEvent]{}
+ app.onMailerRecordAuthAlertSend = &hook.Hook[*MailerRecordEvent]{}
+
+ // filesystem event hooks
+ app._onFilesystemNewWriter = &hook.Hook[*FilesystemNewWriterEvent]{}
+ app._onFilesystemDelete = &hook.Hook[*FilesystemDeleteEvent]{}
+
+ // realtime API event hooks
+ app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
+ app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
+ app.onRealtimeSubscribeRequest = &hook.Hook[*RealtimeSubscribeRequestEvent]{}
+
+ // settings event hooks
+ app.onSettingsListRequest = &hook.Hook[*SettingsListRequestEvent]{}
+ app.onSettingsUpdateRequest = &hook.Hook[*SettingsUpdateRequestEvent]{}
+ app.onSettingsReload = &hook.Hook[*SettingsReloadEvent]{}
+
+ // file API event hooks
+ app.onFileDownloadRequest = &hook.Hook[*FileDownloadRequestEvent]{}
+ app.onFileTokenRequest = &hook.Hook[*FileTokenRequestEvent]{}
+
+ // record auth API event hooks
+ app.onRecordAuthRequest = &hook.Hook[*RecordAuthRequestEvent]{}
+ app.onRecordAuthWithPasswordRequest = &hook.Hook[*RecordAuthWithPasswordRequestEvent]{}
+ app.onRecordAuthWithOAuth2Request = &hook.Hook[*RecordAuthWithOAuth2RequestEvent]{}
+ app.onRecordAuthRefreshRequest = &hook.Hook[*RecordAuthRefreshRequestEvent]{}
+ app.onRecordRequestPasswordResetRequest = &hook.Hook[*RecordRequestPasswordResetRequestEvent]{}
+ app.onRecordConfirmPasswordResetRequest = &hook.Hook[*RecordConfirmPasswordResetRequestEvent]{}
+ app.onRecordRequestVerificationRequest = &hook.Hook[*RecordRequestVerificationRequestEvent]{}
+ app.onRecordConfirmVerificationRequest = &hook.Hook[*RecordConfirmVerificationRequestEvent]{}
+ app.onRecordRequestEmailChangeRequest = &hook.Hook[*RecordRequestEmailChangeRequestEvent]{}
+ app.onRecordConfirmEmailChangeRequest = &hook.Hook[*RecordConfirmEmailChangeRequestEvent]{}
+ app.onRecordRequestOTPRequest = &hook.Hook[*RecordCreateOTPRequestEvent]{}
+ app.onRecordAuthWithOTPRequest = &hook.Hook[*RecordAuthWithOTPRequestEvent]{}
+
+ // record crud API event hooks
+ app.onRecordsListRequest = &hook.Hook[*RecordsListRequestEvent]{}
+ app.onRecordViewRequest = &hook.Hook[*RecordRequestEvent]{}
+ app.onRecordCreateRequest = &hook.Hook[*RecordRequestEvent]{}
+ app.onRecordUpdateRequest = &hook.Hook[*RecordRequestEvent]{}
+ app.onRecordDeleteRequest = &hook.Hook[*RecordRequestEvent]{}
+
+ // collection API event hooks
+ app.onCollectionsListRequest = &hook.Hook[*CollectionsListRequestEvent]{}
+ app.onCollectionViewRequest = &hook.Hook[*CollectionRequestEvent]{}
+ app.onCollectionCreateRequest = &hook.Hook[*CollectionRequestEvent]{}
+ app.onCollectionUpdateRequest = &hook.Hook[*CollectionRequestEvent]{}
+ app.onCollectionDeleteRequest = &hook.Hook[*CollectionRequestEvent]{}
+ app.onCollectionsImportRequest = &hook.Hook[*CollectionsImportRequestEvent]{}
+
+ app.onBatchRequest = &hook.Hook[*BatchRequestEvent]{}
+}
+
+// UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
+//
+// NB! Note that using the returned app instance may cause data integrity errors
+// since the Record validations and data normalizations (including files uploads)
+// rely on the app hooks to work.
+func (app *BaseApp) UnsafeWithoutHooks() App {
+ clone := *app
+
+ // reset all hook handlers
+ clone.initHooks()
+
+ return &clone
+}
+
+// Logger returns the default app logger.
+//
+// If the application is not bootstrapped yet, fallbacks to slog.Default().
+func (app *BaseApp) Logger() *slog.Logger {
+ if app.logger == nil {
+ return slog.Default()
+ }
+
+ return app.logger
+}
+
+// TxInfo returns the transaction associated with the current app instance (if any).
+//
+// Could be used if you want to execute indirectly a function after
+// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
+func (app *BaseApp) TxInfo() *TxAppInfo {
+ return app.txInfo
+}
+
+// IsTransactional checks if the current app instance is part of a transaction.
+func (app *BaseApp) IsTransactional() bool {
+ return app.TxInfo() != nil
+}
+
// IsBootstrapped checks if the application was initialized
// (aka. whether Bootstrap() was called).
func (app *BaseApp) IsBootstrapped() bool {
- return app.dao != nil && app.logsDao != nil && app.settings != nil
+ return app.concurrentDB != nil && app.auxConcurrentDB != nil
}
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.).
//
-// It will call ResetBootstrapState() if the application was already bootstrapped.
+// It calls ClearBootstrap() if the application was already bootstrapped.
func (app *BaseApp) Bootstrap() error {
- event := &BootstrapEvent{app}
-
- if err := app.OnBeforeBootstrap().Trigger(event); err != nil {
- return err
- }
-
- // clear resources of previous core state (if any)
- if err := app.ResetBootstrapState(); err != nil {
- return err
- }
-
- // ensure that data dir exist
- if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
- return err
- }
-
- if err := app.initDataDB(); err != nil {
- return err
- }
+ event := &BootstrapEvent{}
+ event.App = app
- if err := app.initLogsDB(); err != nil {
- return err
- }
+ err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
+ // clear previous bootstrap state (if any)
+ if err := app.ClearBootstrap(); err != nil {
+ return err
+ }
- // we don't check for an error because the db migrations may have not been executed yet
- app.RefreshSettings()
+ // ensure that data dir exist
+ if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
+ return err
+ }
- // cleanup the pb_data temp directory (if any)
- os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
+ if err := app.initDataDB(); err != nil {
+ return err
+ }
- return app.OnAfterBootstrap().Trigger(event)
-}
+ if err := app.initAuxDB(); err != nil {
+ return err
+ }
-// ResetBootstrapState takes care for releasing initialized app resources
-// (eg. closing db connections).
-func (app *BaseApp) ResetBootstrapState() error {
- if app.Dao() != nil {
- if err := app.Dao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
+ if err := app.initLogger(); err != nil {
return err
}
- if err := app.Dao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
+
+ if err := app.RunSystemMigrations(); err != nil {
return err
}
- }
- if app.LogsDao() != nil {
- if err := app.LogsDao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
+ if err := app.ReloadCachedCollections(); err != nil {
return err
}
- if err := app.LogsDao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
+
+ if err := app.ReloadSettings(); err != nil {
return err
}
+
+ // try to cleanup the pb_data temp directory (if any)
+ _ = os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
+
+ return nil
+ })
+
+ // add a more user friendly message in case users forgot to call
+ // e.Next() as part of their bootstrap hook
+ if err == nil && !app.IsBootstrapped() {
+ app.Logger().Warn("OnBootstrap hook didn't fail but the app is still not bootstrapped - maybe missing e.Next()?")
}
- app.dao = nil
- app.logsDao = nil
+ return err
+}
- return nil
+// Deprecated: use [ClearBootstrap].
+func (app *BaseApp) ResetBootstrapState() error {
+ return app.ClearBootstrap()
}
-// Deprecated:
-// This method may get removed in the near future.
-// It is recommended to access the db instance from app.Dao().DB() or
-// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
+// ClearBootstrap releases the initialized core app resources
+// (closing db connections, stopping cron ticker, etc.).
//
-// DB returns the default app database instance.
-func (app *BaseApp) DB() *dbx.DB {
- if app.Dao() == nil {
+// This method is no-op if the application is not bootstrapped yet.
+func (app *BaseApp) ClearBootstrap() error {
+ if !app.IsBootstrapped() {
return nil
}
- db, ok := app.Dao().DB().(*dbx.DB)
- if !ok {
+ event := &BootstrapEvent{}
+ event.App = app
+
+ return app.OnBootstrapClear().Trigger(event, func(e *BootstrapEvent) error {
+ type closer interface {
+ Close() error
+ }
+
+ var errs []error
+
+ dbs := []*dbx.Builder{
+ &app.concurrentDB,
+ &app.nonconcurrentDB,
+ &app.auxConcurrentDB,
+ &app.auxNonconcurrentDB,
+ }
+
+ for _, db := range dbs {
+ if db == nil {
+ continue
+ }
+ if v, ok := (*db).(closer); ok {
+ if err := v.Close(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ *db = nil
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
return nil
+ })
+}
+
+// DB returns the default app data.db builder instance.
+//
+// To minimize SQLITE_BUSY errors, it automatically routes the
+// SELECT queries to the underlying concurrent db pool and everything
+// else to the nonconcurrent one.
+//
+// For more finer control over the used connections pools you can
+// call directly ConcurrentDB() or NonconcurrentDB().
+func (app *BaseApp) DB() dbx.Builder {
+ // transactional or both are nil
+ if app.concurrentDB == app.nonconcurrentDB {
+ return app.concurrentDB
+ }
+
+ return &dualDBBuilder{
+ concurrentDB: app.concurrentDB,
+ nonconcurrentDB: app.nonconcurrentDB,
}
+}
- return db
+// ConcurrentDB returns the concurrent app data.db builder instance.
+//
+// This method is used mainly internally for executing db read
+// operations in a concurrent/non-blocking manner.
+//
+// Most users should use simply DB() as it will automatically
+// route the query execution to ConcurrentDB() or NonconcurrentDB().
+//
+// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+func (app *BaseApp) ConcurrentDB() dbx.Builder {
+ return app.concurrentDB
}
-// Dao returns the default app Dao instance.
-func (app *BaseApp) Dao() *daos.Dao {
- return app.dao
+// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
+//
+// The returned db instance is limited only to a single open connection,
+// meaning that it can process only 1 db operation at a time (other queries queue up).
+//
+// This method is used mainly internally and in the tests to execute write
+// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+//
+// Most users should use simply DB() as it will automatically
+// route the query execution to ConcurrentDB() or NonconcurrentDB().
+//
+// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+func (app *BaseApp) NonconcurrentDB() dbx.Builder {
+ return app.nonconcurrentDB
}
-// Deprecated:
-// This method may get removed in the near future.
-// It is recommended to access the logs db instance from app.LogsDao().DB() or
-// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
+// AuxDB returns the app auxiliary.db builder instance.
//
-// LogsDB returns the app logs database instance.
-func (app *BaseApp) LogsDB() *dbx.DB {
- if app.LogsDao() == nil {
- return nil
+// To minimize SQLITE_BUSY errors, it automatically routes the
+// SELECT queries to the underlying concurrent db pool and everything
+// else to the nonconcurrent one.
+//
+// For more finer control over the used connections pools you can
+// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
+func (app *BaseApp) AuxDB() dbx.Builder {
+ // transactional or both are nil
+ if app.auxConcurrentDB == app.auxNonconcurrentDB {
+ return app.auxConcurrentDB
}
- db, ok := app.LogsDao().DB().(*dbx.DB)
- if !ok {
- return nil
+ return &dualDBBuilder{
+ concurrentDB: app.auxConcurrentDB,
+ nonconcurrentDB: app.auxNonconcurrentDB,
}
+}
- return db
+// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
+//
+// This method is used mainly internally for executing db read
+// operations in a concurrent/non-blocking manner.
+//
+// Most users should use simply AuxDB() as it will automatically
+// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+//
+// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
+ return app.auxConcurrentDB
}
-// LogsDao returns the app logs Dao instance.
-func (app *BaseApp) LogsDao() *daos.Dao {
- return app.logsDao
+// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
+//
+// The returned db instance is limited only to a single open connection,
+// meaning that it can process only 1 db operation at a time (other queries queue up).
+//
+// This method is used mainly internally and in the tests to execute write
+// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+//
+// Most users should use simply AuxDB() as it will automatically
+// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+//
+// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
+ return app.auxNonconcurrentDB
}
// DataDir returns the app data directory path.
func (app *BaseApp) DataDir() string {
- return app.dataDir
+ return app.config.DataDir
}
// EncryptionEnv returns the name of the app secret env key
-// (used for settings encryption).
+// (currently used primarily for optional settings encryption but this may change in the future).
func (app *BaseApp) EncryptionEnv() string {
- return app.encryptionEnv
+ return app.config.EncryptionEnv
}
-// IsDebug returns whether the app is in debug mode
-// (showing more detailed error logs, executed sql statements, etc.).
-func (app *BaseApp) IsDebug() bool {
- return app.isDebug
+// IsDev returns whether the app is in dev mode.
+//
+// When enabled logs, executed sql statements, etc. are printed to the stderr.
+func (app *BaseApp) IsDev() bool {
+ return app.config.IsDev
}
// Settings returns the loaded app settings.
-func (app *BaseApp) Settings() *settings.Settings {
+func (app *BaseApp) Settings() *Settings {
return app.settings
}
-// Cache returns the app internal cache store.
-func (app *BaseApp) Cache() *store.Store[any] {
- return app.cache
+// Store returns the app runtime store.
+func (app *BaseApp) Store() *store.Store[string, any] {
+ return app.store
+}
+
+// Cron returns the app cron instance.
+func (app *BaseApp) Cron() *cron.Cron {
+ return app.cron
}
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
@@ -462,30 +653,104 @@ func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
// NewMailClient creates and returns a new SMTP or Sendmail client
// based on the current app settings.
func (app *BaseApp) NewMailClient() mailer.Mailer {
- if app.Settings().Smtp.Enabled {
- return &mailer.SmtpClient{
- Host: app.Settings().Smtp.Host,
- Port: app.Settings().Smtp.Port,
- Username: app.Settings().Smtp.Username,
- Password: app.Settings().Smtp.Password,
- Tls: app.Settings().Smtp.Tls,
- AuthMethod: app.Settings().Smtp.AuthMethod,
- LocalName: app.Settings().Smtp.LocalName,
+ var client mailer.Mailer
+
+ // init mailer client
+ if app.Settings().SMTP.Enabled {
+ client = &mailer.SMTPClient{
+ Host: app.Settings().SMTP.Host,
+ Port: app.Settings().SMTP.Port,
+ Username: app.Settings().SMTP.Username,
+ Password: app.Settings().SMTP.Password,
+ TLS: app.Settings().SMTP.TLS,
+ AuthMethod: app.Settings().SMTP.AuthMethod,
+ LocalName: app.Settings().SMTP.LocalName,
}
+ } else {
+ client = &mailer.Sendmail{}
+ }
+
+ // register the app level hook
+ if h, ok := client.(mailer.SendInterceptor); ok {
+ h.OnSend().Bind(&hook.Handler[*mailer.SendEvent]{
+ Id: "__pbMailerOnSend__",
+ Func: func(e *mailer.SendEvent) error {
+ appEvent := new(MailerEvent)
+ appEvent.App = app
+ appEvent.Mailer = client
+ appEvent.Message = e.Message
+
+ return app.OnMailerSend().Trigger(appEvent, func(ae *MailerEvent) error {
+ e.Message = ae.Message
+
+ // print the mail in the console to assist with the debugging
+ if app.IsDev() {
+ logDate := new(strings.Builder)
+ log.New(logDate, "", log.LstdFlags).Print()
+
+ mailLog := new(strings.Builder)
+ mailLog.WriteString(strings.TrimSpace(logDate.String()))
+ mailLog.WriteString(" Mail sent\n")
+ fmt.Fprintf(mailLog, "├─ From: %v\n", ae.Message.From)
+ fmt.Fprintf(mailLog, "├─ To: %v\n", ae.Message.To)
+ fmt.Fprintf(mailLog, "├─ Cc: %v\n", ae.Message.Cc)
+ fmt.Fprintf(mailLog, "├─ Bcc: %v\n", ae.Message.Bcc)
+ fmt.Fprintf(mailLog, "├─ Subject: %v\n", ae.Message.Subject)
+
+ if len(ae.Message.Attachments) > 0 {
+ attachmentKeys := make([]string, 0, len(ae.Message.Attachments))
+ for k := range ae.Message.Attachments {
+ attachmentKeys = append(attachmentKeys, k)
+ }
+ fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
+ }
+
+ if len(ae.Message.InlineAttachments) > 0 {
+ attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
+ for k := range ae.Message.InlineAttachments {
+ attachmentKeys = append(attachmentKeys, k)
+ }
+ fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
+ }
+
+ const indentation = " "
+ if ae.Message.Text != "" {
+ textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
+ textIndented := indentation + strings.Join(textParts, "\n"+indentation)
+ fmt.Fprintf(mailLog, "└─ Text:\n%s", textIndented)
+ } else {
+ htmlParts := strings.Split(strings.TrimSpace(ae.Message.HTML), "\n")
+ htmlIndented := indentation + strings.Join(htmlParts, "\n"+indentation)
+ fmt.Fprintf(mailLog, "└─ HTML:\n%s", htmlIndented)
+ }
+
+ color.HiBlack("%s", mailLog.String())
+ }
+
+ // send the email with the new mailer in case it was replaced
+ if client != ae.Mailer {
+ return ae.Mailer.Send(e.Message)
+ }
+
+ return e.Next()
+ })
+ },
+ })
}
- return &mailer.Sendmail{}
+ return client
}
// NewFilesystem creates a new local or S3 filesystem instance
-// for managing regular app files (eg. collection uploads)
+// for managing regular app files (ex. record uploads)
// based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
// after you are done working with it.
-func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
+func (app *BaseApp) NewFilesystem() (fsys *filesystem.System, err error) {
if app.settings != nil && app.settings.S3.Enabled {
- return filesystem.NewS3(
+ // S3
+ fsys, err = filesystem.NewS3(
app.settings.S3.Bucket,
app.settings.S3.Region,
app.settings.S3.Endpoint,
@@ -493,13 +758,44 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
app.settings.S3.Secret,
app.settings.S3.ForcePathStyle,
)
+ } else {
+ // local filesystem
+ fsys, err = filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
+ }
+ if err != nil {
+ return nil, err
}
- // fallback to local filesystem
- return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
+ // attach delete hook
+ if app._onFilesystemDelete.Length() > 0 {
+ fsys.OnDelete().BindFunc(func(originalEvent *filesystem.DeleteEvent) error {
+ appEvent := new(FilesystemDeleteEvent)
+ appEvent.DeleteEvent = originalEvent
+ appEvent.App = app
+
+ return app._onFilesystemDelete.Trigger(appEvent, func(fde *FilesystemDeleteEvent) error {
+ return originalEvent.Next()
+ })
+ })
+ }
+
+ // attach write hook
+ if app._onFilesystemNewWriter.Length() > 0 {
+ fsys.OnNewWriter().BindFunc(func(originalEvent *filesystem.NewWriterEvent) error {
+ appEvent := new(FilesystemNewWriterEvent)
+ appEvent.NewWriterEvent = originalEvent
+ appEvent.App = app
+
+ return app._onFilesystemNewWriter.Trigger(appEvent, func(fwe *FilesystemNewWriterEvent) error {
+ return originalEvent.Next()
+ })
+ })
+ }
+
+ return fsys, nil
}
-// NewFilesystem creates a new local or S3 filesystem instance
+// NewBackupsFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call Close() on the returned result
@@ -533,623 +829,832 @@ func (app *BaseApp) Restart() error {
return err
}
- // optimistically reset the app bootstrap state
- app.ResetBootstrapState()
+ event := &TerminateEvent{}
+ event.App = app
+ event.IsRestart = true
- if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
- // restart the app bootstrap state
- app.Bootstrap()
+ return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
+ _ = e.App.ClearBootstrap()
- return err
- }
+ // attempt to restart the bootstrap process in case execve returns an error for some reason
+ defer func() {
+ if err := e.App.Bootstrap(); err != nil {
+ app.Logger().Error("Failed to rebootstrap the application after failed app.Restart()", "error", err)
+ }
+ }()
- return nil
+ return execve(execPath, os.Args, os.Environ())
+ })
}
-// RefreshSettings reinitializes and reloads the stored application settings.
-func (app *BaseApp) RefreshSettings() error {
- if app.settings == nil {
- app.settings = settings.New()
- }
-
- encryptionKey := os.Getenv(app.EncryptionEnv())
-
- storedSettings, err := app.Dao().FindSettings(encryptionKey)
- if err != nil && err != sql.ErrNoRows {
- return err
- }
-
- // no settings were previously stored
- if storedSettings == nil {
- return app.Dao().SaveSettings(app.settings, encryptionKey)
- }
+// RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
+func (app *BaseApp) RunSystemMigrations() error {
+ _, err := NewMigrationsRunner(app, SystemMigrations).Up()
+ return err
+}
- // load the settings from the stored param into the app ones
- if err := app.settings.Merge(storedSettings); err != nil {
- return err
- }
+// RunAppMigrations applies all new migrations registered in the [core.AppMigrations] list.
+func (app *BaseApp) RunAppMigrations() error {
+ _, err := NewMigrationsRunner(app, AppMigrations).Up()
+ return err
+}
- return nil
+// RunAllMigrations applies all system and app migrations
+// (aka. from both [core.SystemMigrations] and [core.AppMigrations]).
+func (app *BaseApp) RunAllMigrations() error {
+ list := MigrationsList{}
+ list.Copy(SystemMigrations)
+ list.Copy(AppMigrations)
+ _, err := NewMigrationsRunner(app, list).Up()
+ return err
}
// -------------------------------------------------------------------
// App event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] {
- return app.onBeforeBootstrap
+func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
+ return app.onBootstrap
}
-func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] {
- return app.onAfterBootstrap
+func (app *BaseApp) OnBootstrapClear() *hook.Hook[*BootstrapEvent] {
+ return app.onBootstrapClear
}
-func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] {
- return app.onBeforeServe
+func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
+ return app.onServe
}
-func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] {
- return app.onBeforeApiError
+func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
+ return app.onTerminate
}
-func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
- return app.onAfterApiError
+func (app *BaseApp) OnBackupCreate() *hook.Hook[*BackupEvent] {
+ return app.onBackupCreate
}
-func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
- return app.onTerminate
+func (app *BaseApp) OnBackupRestore() *hook.Hook[*BackupEvent] {
+ return app.onBackupRestore
}
-// -------------------------------------------------------------------
-// Dao event hooks
-// -------------------------------------------------------------------
+// ---------------------------------------------------------------
-func (app *BaseApp) OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelBeforeCreate, tags...)
+func (app *BaseApp) OnModelCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelCreate, tags...)
}
-func (app *BaseApp) OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelAfterCreate, tags...)
+func (app *BaseApp) OnModelCreateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelCreateExecute, tags...)
}
-func (app *BaseApp) OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelBeforeUpdate, tags...)
+func (app *BaseApp) OnModelAfterCreateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelAfterCreateSuccess, tags...)
}
-func (app *BaseApp) OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelAfterUpdate, tags...)
+func (app *BaseApp) OnModelAfterCreateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
+ return hook.NewTaggedHook(app.onModelAfterCreateError, tags...)
}
-func (app *BaseApp) OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelBeforeDelete, tags...)
+func (app *BaseApp) OnModelUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelUpdate, tags...)
}
-func (app *BaseApp) OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
- return hook.NewTaggedHook(app.onModelAfterDelete, tags...)
+func (app *BaseApp) OnModelUpdateExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelUpdateWrite, tags...)
}
-// -------------------------------------------------------------------
-// Mailer event hooks
-// -------------------------------------------------------------------
-
-func (app *BaseApp) OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] {
- return app.onMailerBeforeAdminResetPasswordSend
+func (app *BaseApp) OnModelAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelAfterUpdateSuccess, tags...)
}
-func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] {
- return app.onMailerAfterAdminResetPasswordSend
+func (app *BaseApp) OnModelAfterUpdateError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
+ return hook.NewTaggedHook(app.onModelAfterUpdateError, tags...)
}
-func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerBeforeRecordResetPasswordSend, tags...)
+func (app *BaseApp) OnModelValidate(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelValidate, tags...)
}
-func (app *BaseApp) OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerAfterRecordResetPasswordSend, tags...)
+func (app *BaseApp) OnModelDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelDelete, tags...)
}
-func (app *BaseApp) OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerBeforeRecordVerificationSend, tags...)
+func (app *BaseApp) OnModelDeleteExecute(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelDeleteExecute, tags...)
}
-func (app *BaseApp) OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerAfterRecordVerificationSend, tags...)
+func (app *BaseApp) OnModelAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*ModelEvent] {
+ return hook.NewTaggedHook(app.onModelAfterDeleteSuccess, tags...)
}
-func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerBeforeRecordChangeEmailSend, tags...)
+func (app *BaseApp) OnModelAfterDeleteError(tags ...string) *hook.TaggedHook[*ModelErrorEvent] {
+ return hook.NewTaggedHook(app.onModelAfterDeleteError, tags...)
}
-func (app *BaseApp) OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
- return hook.NewTaggedHook(app.onMailerAfterRecordChangeEmailSend, tags...)
+func (app *BaseApp) OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent] {
+ return hook.NewTaggedHook(app.onRecordEnrich, tags...)
}
-// -------------------------------------------------------------------
-// Realtime API event hooks
-// -------------------------------------------------------------------
-
-func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] {
- return app.onRealtimeConnectRequest
+func (app *BaseApp) OnRecordValidate(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordValidate, tags...)
}
-func (app *BaseApp) OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] {
- return app.onRealtimeDisconnectRequest
+func (app *BaseApp) OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordCreate, tags...)
}
-func (app *BaseApp) OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
- return app.onRealtimeBeforeMessageSend
+func (app *BaseApp) OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordCreateExecute, tags...)
}
-func (app *BaseApp) OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] {
- return app.onRealtimeAfterMessageSend
+func (app *BaseApp) OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterCreateSuccess, tags...)
}
-func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] {
- return app.onRealtimeBeforeSubscribeRequest
+func (app *BaseApp) OnRecordAfterCreateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterCreateError, tags...)
}
-func (app *BaseApp) OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] {
- return app.onRealtimeAfterSubscribeRequest
+func (app *BaseApp) OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordUpdate, tags...)
}
-// -------------------------------------------------------------------
-// Settings API event hooks
-// -------------------------------------------------------------------
+func (app *BaseApp) OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordUpdateExecute, tags...)
+}
-func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListEvent] {
- return app.onSettingsListRequest
+func (app *BaseApp) OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterUpdateSuccess, tags...)
}
-func (app *BaseApp) OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] {
- return app.onSettingsBeforeUpdateRequest
+func (app *BaseApp) OnRecordAfterUpdateError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterUpdateError, tags...)
}
-func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent] {
- return app.onSettingsAfterUpdateRequest
+func (app *BaseApp) OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordDelete, tags...)
}
-// -------------------------------------------------------------------
-// File API event hooks
-// -------------------------------------------------------------------
+func (app *BaseApp) OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordDeleteExecute, tags...)
+}
-func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent] {
- return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
+func (app *BaseApp) OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterDeleteSuccess, tags...)
}
-func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
- return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
+func (app *BaseApp) OnRecordAfterDeleteError(tags ...string) *hook.TaggedHook[*RecordErrorEvent] {
+ return hook.NewTaggedHook(app.onRecordAfterDeleteError, tags...)
}
-func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
- return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
+func (app *BaseApp) OnCollectionValidate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionValidate, tags...)
}
-// -------------------------------------------------------------------
-// Admin API event hooks
-// -------------------------------------------------------------------
+func (app *BaseApp) OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionCreate, tags...)
+}
-func (app *BaseApp) OnAdminsListRequest() *hook.Hook[*AdminsListEvent] {
- return app.onAdminsListRequest
+func (app *BaseApp) OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionCreateExecute, tags...)
}
-func (app *BaseApp) OnAdminViewRequest() *hook.Hook[*AdminViewEvent] {
- return app.onAdminViewRequest
+func (app *BaseApp) OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterCreateSuccess, tags...)
}
-func (app *BaseApp) OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] {
- return app.onAdminBeforeCreateRequest
+func (app *BaseApp) OnCollectionAfterCreateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterCreateError, tags...)
}
-func (app *BaseApp) OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent] {
- return app.onAdminAfterCreateRequest
+func (app *BaseApp) OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionUpdate, tags...)
}
-func (app *BaseApp) OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] {
- return app.onAdminBeforeUpdateRequest
+func (app *BaseApp) OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionUpdateExecute, tags...)
}
-func (app *BaseApp) OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent] {
- return app.onAdminAfterUpdateRequest
+func (app *BaseApp) OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterUpdateSuccess, tags...)
}
-func (app *BaseApp) OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] {
- return app.onAdminBeforeDeleteRequest
+func (app *BaseApp) OnCollectionAfterUpdateError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterUpdateError, tags...)
}
-func (app *BaseApp) OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent] {
- return app.onAdminAfterDeleteRequest
+func (app *BaseApp) OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionDelete, tags...)
}
-func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
- return app.onAdminAuthRequest
+func (app *BaseApp) OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionDeleteExecute, tags...)
}
-func (app *BaseApp) OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
- return app.onAdminBeforeAuthWithPasswordRequest
+func (app *BaseApp) OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterDeleteSuccess, tags...)
}
-func (app *BaseApp) OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
- return app.onAdminAfterAuthWithPasswordRequest
+func (app *BaseApp) OnCollectionAfterDeleteError(tags ...string) *hook.TaggedHook[*CollectionErrorEvent] {
+ return hook.NewTaggedHook(app.onCollectionAfterDeleteError, tags...)
}
-func (app *BaseApp) OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
- return app.onAdminBeforeAuthRefreshRequest
+// -------------------------------------------------------------------
+// Mailer event hooks
+// -------------------------------------------------------------------
+
+func (app *BaseApp) OnMailerSend() *hook.Hook[*MailerEvent] {
+ return app.onMailerSend
}
-func (app *BaseApp) OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
- return app.onAdminAfterAuthRefreshRequest
+func (app *BaseApp) OnMailerRecordPasswordResetSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
+ return hook.NewTaggedHook(app.onMailerRecordPasswordResetSend, tags...)
}
-func (app *BaseApp) OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
- return app.onAdminBeforeRequestPasswordResetRequest
+func (app *BaseApp) OnMailerRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
+ return hook.NewTaggedHook(app.onMailerRecordVerificationSend, tags...)
}
-func (app *BaseApp) OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
- return app.onAdminAfterRequestPasswordResetRequest
+func (app *BaseApp) OnMailerRecordEmailChangeSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
+ return hook.NewTaggedHook(app.onMailerRecordEmailChangeSend, tags...)
}
-func (app *BaseApp) OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
- return app.onAdminBeforeConfirmPasswordResetRequest
+func (app *BaseApp) OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
+ return hook.NewTaggedHook(app.onMailerRecordOTPSend, tags...)
}
-func (app *BaseApp) OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
- return app.onAdminAfterConfirmPasswordResetRequest
+func (app *BaseApp) OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
+ return hook.NewTaggedHook(app.onMailerRecordAuthAlertSend, tags...)
}
// -------------------------------------------------------------------
-// Record auth API event hooks
+// Filesystem event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent] {
- return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
+func (app *BaseApp) onFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent] {
+ return app._onFilesystemNewWriter
}
-func (app *BaseApp) OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeAuthWithPasswordRequest, tags...)
+func (app *BaseApp) onFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent] {
+ return app._onFilesystemDelete
}
-func (app *BaseApp) OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
- return hook.NewTaggedHook(app.onRecordAfterAuthWithPasswordRequest, tags...)
+// -------------------------------------------------------------------
+// Realtime API event hooks
+// -------------------------------------------------------------------
+
+func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent] {
+ return app.onRealtimeConnectRequest
}
-func (app *BaseApp) OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
- return hook.NewTaggedHook(app.onRecordBeforeAuthWithOAuth2Request, tags...)
+func (app *BaseApp) OnRealtimeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
+ return app.onRealtimeMessageSend
}
-func (app *BaseApp) OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
- return hook.NewTaggedHook(app.onRecordAfterAuthWithOAuth2Request, tags...)
+func (app *BaseApp) OnRealtimeSubscribeRequest() *hook.Hook[*RealtimeSubscribeRequestEvent] {
+ return app.onRealtimeSubscribeRequest
}
-func (app *BaseApp) OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeAuthRefreshRequest, tags...)
+// -------------------------------------------------------------------
+// Settings API event hooks
+// -------------------------------------------------------------------
+
+func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListRequestEvent] {
+ return app.onSettingsListRequest
}
-func (app *BaseApp) OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
- return hook.NewTaggedHook(app.onRecordAfterAuthRefreshRequest, tags...)
+func (app *BaseApp) OnSettingsUpdateRequest() *hook.Hook[*SettingsUpdateRequestEvent] {
+ return app.onSettingsUpdateRequest
}
-func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeRequestPasswordResetRequest, tags...)
+func (app *BaseApp) OnSettingsReload() *hook.Hook[*SettingsReloadEvent] {
+ return app.onSettingsReload
}
-func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
- return hook.NewTaggedHook(app.onRecordAfterRequestPasswordResetRequest, tags...)
+// -------------------------------------------------------------------
+// File API event hooks
+// -------------------------------------------------------------------
+
+func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent] {
+ return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeConfirmPasswordResetRequest, tags...)
+func (app *BaseApp) OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent] {
+ return hook.NewTaggedHook(app.onFileTokenRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
- return hook.NewTaggedHook(app.onRecordAfterConfirmPasswordResetRequest, tags...)
+// -------------------------------------------------------------------
+// Record auth API event hooks
+// -------------------------------------------------------------------
+
+func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeRequestVerificationRequest, tags...)
+func (app *BaseApp) OnRecordAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordAuthWithPasswordRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
- return hook.NewTaggedHook(app.onRecordAfterRequestVerificationRequest, tags...)
+func (app *BaseApp) OnRecordAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2RequestEvent] {
+ return hook.NewTaggedHook(app.onRecordAuthWithOAuth2Request, tags...)
}
-func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeConfirmVerificationRequest, tags...)
+func (app *BaseApp) OnRecordAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordAuthRefreshRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
- return hook.NewTaggedHook(app.onRecordAfterConfirmVerificationRequest, tags...)
+func (app *BaseApp) OnRecordRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordRequestPasswordResetRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeRequestEmailChangeRequest, tags...)
+func (app *BaseApp) OnRecordConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordConfirmPasswordResetRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
- return hook.NewTaggedHook(app.onRecordAfterRequestEmailChangeRequest, tags...)
+func (app *BaseApp) OnRecordRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordRequestVerificationRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeConfirmEmailChangeRequest, tags...)
+func (app *BaseApp) OnRecordConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordConfirmVerificationRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
- return hook.NewTaggedHook(app.onRecordAfterConfirmEmailChangeRequest, tags...)
+func (app *BaseApp) OnRecordRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordRequestEmailChangeRequest, tags...)
}
-func (app *BaseApp) OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent] {
- return hook.NewTaggedHook(app.onRecordListExternalAuthsRequest, tags...)
+func (app *BaseApp) OnRecordConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordConfirmEmailChangeRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeUnlinkExternalAuthRequest, tags...)
+func (app *BaseApp) OnRecordRequestOTPRequest(tags ...string) *hook.TaggedHook[*RecordCreateOTPRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordRequestOTPRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
- return hook.NewTaggedHook(app.onRecordAfterUnlinkExternalAuthRequest, tags...)
+func (app *BaseApp) OnRecordAuthWithOTPRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithOTPRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordAuthWithOTPRequest, tags...)
}
// -------------------------------------------------------------------
// Record CRUD API event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent] {
+func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListRequestEvent] {
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
}
-func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent] {
+func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeCreateRequest, tags...)
-}
-
-func (app *BaseApp) OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
- return hook.NewTaggedHook(app.onRecordAfterCreateRequest, tags...)
-}
-
-func (app *BaseApp) OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeUpdateRequest, tags...)
-}
-
-func (app *BaseApp) OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
- return hook.NewTaggedHook(app.onRecordAfterUpdateRequest, tags...)
+func (app *BaseApp) OnRecordCreateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordCreateRequest, tags...)
}
-func (app *BaseApp) OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
- return hook.NewTaggedHook(app.onRecordBeforeDeleteRequest, tags...)
+func (app *BaseApp) OnRecordUpdateRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordUpdateRequest, tags...)
}
-func (app *BaseApp) OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
- return hook.NewTaggedHook(app.onRecordAfterDeleteRequest, tags...)
+func (app *BaseApp) OnRecordDeleteRequest(tags ...string) *hook.TaggedHook[*RecordRequestEvent] {
+ return hook.NewTaggedHook(app.onRecordDeleteRequest, tags...)
}
// -------------------------------------------------------------------
// Collection API event hooks
// -------------------------------------------------------------------
-func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent] {
+func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListRequestEvent] {
return app.onCollectionsListRequest
}
-func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent] {
+func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionRequestEvent] {
return app.onCollectionViewRequest
}
-func (app *BaseApp) OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] {
- return app.onCollectionBeforeCreateRequest
-}
-
-func (app *BaseApp) OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent] {
- return app.onCollectionAfterCreateRequest
+func (app *BaseApp) OnCollectionCreateRequest() *hook.Hook[*CollectionRequestEvent] {
+ return app.onCollectionCreateRequest
}
-func (app *BaseApp) OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] {
- return app.onCollectionBeforeUpdateRequest
+func (app *BaseApp) OnCollectionUpdateRequest() *hook.Hook[*CollectionRequestEvent] {
+ return app.onCollectionUpdateRequest
}
-func (app *BaseApp) OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent] {
- return app.onCollectionAfterUpdateRequest
+func (app *BaseApp) OnCollectionDeleteRequest() *hook.Hook[*CollectionRequestEvent] {
+ return app.onCollectionDeleteRequest
}
-func (app *BaseApp) OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] {
- return app.onCollectionBeforeDeleteRequest
+func (app *BaseApp) OnCollectionsImportRequest() *hook.Hook[*CollectionsImportRequestEvent] {
+ return app.onCollectionsImportRequest
}
-func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] {
- return app.onCollectionAfterDeleteRequest
-}
-
-func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] {
- return app.onCollectionsBeforeImportRequest
-}
-
-func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] {
- return app.onCollectionsAfterImportRequest
+func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
+ return app.onBatchRequest
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
-func (app *BaseApp) initLogsDB() error {
- maxOpenConns := DefaultLogsMaxOpenConns
- maxIdleConns := DefaultLogsMaxIdleConns
- if app.logsMaxOpenConns > 0 {
- maxOpenConns = app.logsMaxOpenConns
- }
- if app.logsMaxIdleConns > 0 {
- maxIdleConns = app.logsMaxIdleConns
- }
+func (app *BaseApp) initDataDB() error {
+ dbPath := filepath.Join(app.DataDir(), dataDBFilename)
- concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
+ concurrentDB, err := app.config.DBConnect(dbPath)
if err != nil {
return err
}
- concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
- concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
- concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
+ concurrentDB.DB().SetMaxOpenConns(app.config.DataMaxOpenConns)
+ concurrentDB.DB().SetMaxIdleConns(app.config.DataMaxIdleConns)
+ concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
- nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
+ nonconcurrentDB, err := app.config.DBConnect(dbPath)
if err != nil {
return err
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
- nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
+ nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
+
+ if app.IsDev() {
+ nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
+ }
+ nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), normalizeSQLLog(sql))
+ }
+ concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
+ concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
+ }
- app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
+ app.concurrentDB = concurrentDB
+ app.nonconcurrentDB = nonconcurrentDB
return nil
}
-func (app *BaseApp) initDataDB() error {
- maxOpenConns := DefaultDataMaxOpenConns
- maxIdleConns := DefaultDataMaxIdleConns
- if app.dataMaxOpenConns > 0 {
- maxOpenConns = app.dataMaxOpenConns
- }
- if app.dataMaxIdleConns > 0 {
- maxIdleConns = app.dataMaxIdleConns
+var sqlLogReplacements = []struct {
+ pattern *regexp.Regexp
+ replacement string
+}{
+ {regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
+ {regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
+ {regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
+ {regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
+ {regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
+ {regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
+ {regexp.MustCompile(``), "NULL"},
+}
+
+// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
+// The query is still not suitable for execution and should be used only for log and debug purposes
+// (the normalization is done here to avoid breaking changes in dbx).
+func normalizeSQLLog(sql string) string {
+ for _, item := range sqlLogReplacements {
+ sql = item.pattern.ReplaceAllString(sql, item.replacement)
}
- concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
+ return sql
+}
+
+func (app *BaseApp) initAuxDB() error {
+ // note: renamed to "auxiliary" because "aux" is a reserved Windows filename
+ // (see https://github.com/pocketbase/pocketbase/issues/5607)
+ dbPath := filepath.Join(app.DataDir(), auxDBFilename)
+
+ concurrentDB, err := app.config.DBConnect(dbPath)
if err != nil {
return err
}
- concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
- concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
- concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
+ concurrentDB.DB().SetMaxOpenConns(app.config.AuxMaxOpenConns)
+ concurrentDB.DB().SetMaxIdleConns(app.config.AuxMaxIdleConns)
+ concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
- nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
+ nonconcurrentDB, err := app.config.DBConnect(dbPath)
if err != nil {
return err
}
nonconcurrentDB.DB().SetMaxOpenConns(1)
nonconcurrentDB.DB().SetMaxIdleConns(1)
- nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
+ nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
- if app.IsDebug() {
- nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
- color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
- }
- concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
+ app.auxConcurrentDB = concurrentDB
+ app.auxNonconcurrentDB = nonconcurrentDB
- nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
- color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
+ return nil
+}
+
+// @todo remove after refactoring the FilesManager interface
+func supportFiles(m Model) bool {
+ var collection *Collection
+ switch v := m.(type) {
+ case *Collection:
+ collection = v
+ case *Record:
+ collection = v.Collection()
+ case RecordProxy:
+ if v.ProxyRecord() != nil {
+ collection = v.ProxyRecord().Collection()
}
- concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
}
- app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
+ if collection == nil {
+ return true
+ }
- return nil
+ for _, f := range collection.Fields {
+ if f.Type() == FieldTypeFile {
+ return true
+ }
+ }
+
+ return false
}
-func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
- dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
+func (app *BaseApp) registerBaseHooks() {
+ deletePrefix := func(prefix string) error {
+ fs, err := app.NewFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fs.Close()
- dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
+ failed := fs.DeletePrefix(prefix)
+ if len(failed) > 0 {
+ return errors.New("failed to delete the files at " + prefix)
+ }
- return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
- return action()
- })
+ return nil
}
- dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
-
- return app.OnModelAfterCreate().Trigger(e)
+ maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
+ if maxFilesDeleteWorkers <= 0 {
+ maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
}
- dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
+ deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
- return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
- return action()
- })
- }
+ // try to delete the storage files from deleted Collection, Records, etc. model
+ app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: "__pbFilesManagerDelete__",
+ Func: func(e *ModelEvent) error {
+ if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
+ // ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
+ // (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
+ prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
+
+ // note: for now assume no context cancellation
+ err := deleteSem.Acquire(context.Background(), 1)
+ if err != nil {
+ app.Logger().Error(
+ "Failed to delete storage prefix (couldn't acquire a worker)",
+ slog.String("prefix", prefix),
+ slog.String("error", err.Error()),
+ )
+ } else {
+ // run in the background for "optimistic" delete to avoid blocking the delete transaction
+ routine.FireAndForget(func() {
+ defer deleteSem.Release(1)
+
+ if err := deletePrefix(prefix); err != nil {
+ app.Logger().Error(
+ "Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
+ slog.String("prefix", prefix),
+ slog.String("error", err.Error()),
+ )
+ }
+ })
+ }
+ }
- dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
+ return e.Next()
+ },
+ Priority: -99,
+ })
- return app.OnModelAfterUpdate().Trigger(e)
- }
+ app.OnServe().Bind(&hook.Handler[*ServeEvent]{
+ Id: "__pbCronStart__",
+ Func: func(e *ServeEvent) error {
+ app.Cron().Start()
+ return e.Next()
+ },
+ Priority: 999,
+ })
- dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
+ app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
+ Id: "__pbCronStop__",
+ Func: func(e *BootstrapEvent) error {
+ app.Cron().Stop()
+ return e.Next()
+ },
+ Priority: 999,
+ })
- return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
- return action()
- })
- }
+ app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
+ _, execErr := app.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
+ if execErr != nil {
+ app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the main DB", slog.String("error", execErr.Error()))
+ }
- dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- e := new(ModelEvent)
- e.Dao = eventDao
- e.Model = m
+ _, execErr = app.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
+ if execErr != nil {
+ app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
+ }
- return app.OnModelAfterDelete().Trigger(e)
+ _, execErr = app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
+ if execErr != nil {
+ app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
+ }
+ })
+
+ app.registerSettingsHooks()
+ app.registerAutobackupHooks()
+ app.registerCollectionHooks()
+ app.registerRecordHooks()
+ app.registerSuperuserHooks()
+ app.registerExternalAuthHooks()
+ app.registerMFAHooks()
+ app.registerOTPHooks()
+ app.registerAuthOriginHooks()
+ app.registerNotifyWatcherHooks()
+}
+
+// getLoggerMinLevel returns the logger min level based on the
+// app configurations (dev mode, settings, etc.).
+//
+// If not in dev mode - returns the level from the app settings.
+//
+// If the app is in dev mode it returns -9999 level allowing to print
+// practically all logs to the terminal.
+// In this case DB logs are still filtered but the checks for the min level are done
+// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
+func getLoggerMinLevel(app App) slog.Level {
+ var minLevel slog.Level
+
+ if app.IsDev() {
+ minLevel = -99999
+ } else if app.Settings() != nil {
+ minLevel = slog.Level(app.Settings().Logs.MinLevel)
}
- return dao
+ return minLevel
}
-func (app *BaseApp) registerDefaultHooks() {
- deletePrefix := func(prefix string) error {
- fs, err := app.NewFilesystem()
- if err != nil {
- return err
- }
- defer fs.Close()
+func (app *BaseApp) initLogger() error {
+ var stopped atomic.Bool
- failed := fs.DeletePrefix(prefix)
- if len(failed) > 0 {
- return errors.New("failed to delete the files at " + prefix)
+ duration := 3 * time.Second
+ ticker := time.NewTicker(duration)
+
+ done := make(chan struct{}, 1)
+
+ runLogsWrite := func(logs []*logger.Log) {
+ if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
+ return
}
- return nil
+ // write the accumulated logs
+ //
+ // note: based on several local tests there is no
+ // significant performance difference between small number
+ // of separate write queries vs 1 big INSERT
+ app.AuxRunInTransaction(func(txApp App) error {
+ model := &Log{}
+ for _, l := range logs {
+ model.MarkAsNew()
+ model.Id = GenerateDefaultRandomId()
+ model.Level = int(l.Level)
+ model.Message = l.Message
+ model.Data = l.Data
+ model.Created, _ = types.ParseDateTime(l.Time)
+
+ if err := txApp.AuxSave(model); err != nil {
+ log.Println("Failed to write log", model, err)
+ }
+ }
+
+ return nil
+ })
}
- // try to delete the storage files from deleted Collection, Records, etc. model
- app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
- if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
- prefix := m.BaseFilesPath()
-
- // run in the background for "optimistic" delete to avoid
- // blocking the delete transaction
- //
- // @todo consider creating a bg process queue so that the
- // call could be "retried" in case of a failure.
- routine.FireAndForget(func() {
- if err := deletePrefix(prefix); err != nil && app.IsDebug() {
- // non critical error - only log for debug
- // (usually could happen because of S3 api limits)
- log.Println(err)
+ handler := logger.NewBatchHandler(logger.BatchOptions{
+ Level: getLoggerMinLevel(app),
+ BatchSize: 200,
+ BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
+ if app.IsDev() {
+ printLog(log)
+
+ // manually check the log level and skip if necessary
+ if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
+ return false
}
- })
+ }
+
+ if !stopped.Load() {
+ ticker.Reset(duration)
+ }
+
+ return app.Settings().Logs.MaxDays > 0
+ },
+ WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
+ // don't block and wait for the write transaction to complete
+ // when we can't be sure if the logs write wasn't triggered while
+ // inside another AUX db transaction (ticker or batch threshold reached)
+ // which can block indefinitely and cause deadlock
+ // (https://github.com/pocketbase/pocketbase/issues/7836)
+ shouldBlock, _ := ctx.Value(logger.BlockKey).(bool)
+ if shouldBlock {
+ runLogsWrite(logs)
+ } else {
+ routine.FireAndForget(func() { runLogsWrite(logs) })
+ }
+
+ return nil
+ },
+ })
+
+ routine.FireAndForget(func() {
+ ctx := context.Background()
+
+ for {
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ handler.WriteAll(ctx)
+ }
}
+ })
- return nil
+ app.logger = slog.New(handler)
+
+ // attempt to write all queued logs before clearing the application bootstrap state
+ app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
+ Id: "__pbAppLoggerFlushBeforeStop__",
+ Func: func(e *BootstrapEvent) error {
+ // extra precaution in case the hook was manually triggered while inside aux db transaction
+ _, isTx := e.App.AuxNonconcurrentDB().(*dbx.Tx)
+ ctx := context.WithValue(context.Background(), logger.BlockKey, !isTx)
+ handler.WriteAll(ctx)
+
+ stopped.Store(true)
+ ticker.Stop()
+
+ // don't block in case the hook is triggered more than once
+ select {
+ case done <- struct{}{}:
+ default:
+ }
+
+ return e.Next()
+ },
+ Priority: -999,
})
- app.OnTerminate().Add(func(e *TerminateEvent) error {
- app.ResetBootstrapState()
- return nil
+ // reload log handler level (if initialized)
+ app.OnSettingsReload().Bind(&hook.Handler[*SettingsReloadEvent]{
+ Id: "__pbAppLoggerOnSettingsReload__",
+ Func: func(e *SettingsReloadEvent) error {
+ err := e.Next()
+ if err != nil {
+ return err
+ }
+
+ if e.App.Logger() != nil {
+ if h, ok := e.App.Logger().Handler().(*logger.BatchHandler); ok {
+ h.SetLevel(getLoggerMinLevel(e.App))
+ }
+ }
+
+ // try to clear old logs not matching the new settings
+ createdBefore := types.NowDateTime().AddDate(0, 0, -1*e.App.Settings().Logs.MaxDays)
+ expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
+ "date": createdBefore.String(),
+ "level": e.App.Settings().Logs.MinLevel,
+ })
+ _, err = e.App.AuxNonconcurrentDB().Delete((&Log{}).TableName(), expr).Execute()
+ if err != nil {
+ e.App.Logger().Debug("Failed to cleanup old logs", "error", err)
+ }
+
+ // no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
+ if e.App.Settings().Logs.MaxDays == 0 {
+ err = e.App.AuxVacuum()
+ if err != nil {
+ e.App.Logger().Debug("Failed to VACUUM aux database", "error", err)
+ }
+ }
+
+ return nil
+ },
+ Priority: -999,
})
- if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
- log.Println(err)
- }
+ // cleanup old logs
+ app.Cron().Add("__pbLogsCleanup__", "0 */6 * * *", func() {
+ deleteErr := app.DeleteOldLogs(time.Now().AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
+ if deleteErr != nil {
+ app.Logger().Warn("Failed to delete old logs", "error", deleteErr)
+ }
+ })
+
+ return nil
}
diff --git a/core/base_backup.go b/core/base_backup.go
deleted file mode 100644
index 48fad17df6..0000000000
--- a/core/base_backup.go
+++ /dev/null
@@ -1,344 +0,0 @@
-package core
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "log"
- "os"
- "path/filepath"
- "runtime"
- "sort"
- "time"
-
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/archive"
- "github.com/pocketbase/pocketbase/tools/cron"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/inflector"
- "github.com/pocketbase/pocketbase/tools/osutils"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-const CacheKeyActiveBackup string = "@activeBackup"
-
-// CreateBackup creates a new backup of the current app pb_data directory.
-//
-// If name is empty, it will be autogenerated.
-// If backup with the same name exists, the new backup file will replace it.
-//
-// The backup is executed within a transaction, meaning that new writes
-// will be temporary "blocked" until the backup file is generated.
-//
-// To safely perform the backup, it is recommended to have free disk space
-// for at least 2x the size of the pb_data directory.
-//
-// By default backups are stored in pb_data/backups
-// (the backups directory itself is excluded from the generated backup).
-//
-// When using S3 storage for the uploaded collection files, you have to
-// take care manually to backup those since they are not part of the pb_data.
-//
-// Backups can be stored on S3 if it is configured in app.Settings().Backups.
-func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
- if app.Cache().Has(CacheKeyActiveBackup) {
- return errors.New("try again later - another backup/restore operation has already been started")
- }
-
- if name == "" {
- name = app.generateBackupName("pb_backup_")
- }
-
- app.Cache().Set(CacheKeyActiveBackup, name)
- defer app.Cache().Remove(CacheKeyActiveBackup)
-
- // root dir entries to exclude from the backup generation
- exclude := []string{LocalBackupsDirName, LocalTempDirName}
-
- // make sure that the special temp directory exists
- // note: it needs to be inside the current pb_data to avoid "cross-device link" errors
- localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
- if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
- return fmt.Errorf("failed to create a temp dir: %w", err)
- }
-
- // Archive pb_data in a temp directory, exluding the "backups" and the temp dirs.
- //
- // Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
- // ---
- tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
- createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
- // @todo consider experimenting with temp switching the readonly pragma after the db interface change
- return archive.Create(app.DataDir(), tempPath, exclude...)
- })
- if createErr != nil {
- return createErr
- }
- defer os.Remove(tempPath)
-
- // Persist the backup in the backups filesystem.
- // ---
- fsys, err := app.NewBackupsFilesystem()
- if err != nil {
- return err
- }
- defer fsys.Close()
-
- fsys.SetContext(ctx)
-
- file, err := filesystem.NewFileFromPath(tempPath)
- if err != nil {
- return err
- }
- file.OriginalName = name
- file.Name = file.OriginalName
-
- if err := fsys.UploadFile(file, file.Name); err != nil {
- return err
- }
-
- return nil
-}
-
-// RestoreBackup restores the backup with the specified name and restarts
-// the current running application process.
-//
-// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
-//
-// To safely perform the restore it is recommended to have free disk space
-// for at least 2x the size of the restored pb_data backup.
-//
-// The performed steps are:
-//
-// 1. Download the backup with the specified name in a temp location
-// (this is in case of S3; otherwise it creates a temp copy of the zip)
-//
-// 2. Extract the backup in a temp directory inside the app "pb_data"
-// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
-//
-// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
-// under another temp sub dir that will be deleted on the next app start up
-// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
-// This is because on some environments it may not be allowed
-// to delete the currently open "pb_data" files.
-//
-// 4. Move the extracted dir content to the app "pb_data".
-//
-// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
-//
-// If a failure occure during the restore process the dir changes are reverted.
-// If for whatever reason the revert is not possible, it panics.
-func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
- if runtime.GOOS == "windows" {
- return errors.New("restore is not supported on windows")
- }
-
- if app.Cache().Has(CacheKeyActiveBackup) {
- return errors.New("try again later - another backup/restore operation has already been started")
- }
-
- app.Cache().Set(CacheKeyActiveBackup, name)
- defer app.Cache().Remove(CacheKeyActiveBackup)
-
- fsys, err := app.NewBackupsFilesystem()
- if err != nil {
- return err
- }
- defer fsys.Close()
-
- fsys.SetContext(ctx)
-
- // fetch the backup file in a temp location
- br, err := fsys.GetFile(name)
- if err != nil {
- return err
- }
- defer br.Close()
-
- // make sure that the special temp directory exists
- // note: it needs to be inside the current pb_data to avoid "cross-device link" errors
- localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
- if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
- return fmt.Errorf("failed to create a temp dir: %w", err)
- }
-
- // create a temp zip file from the blob.Reader and try to extract it
- tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
- if err != nil {
- return err
- }
- defer os.Remove(tempZip.Name())
-
- if _, err := io.Copy(tempZip, br); err != nil {
- return err
- }
-
- extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
- defer os.RemoveAll(extractedDataDir)
- if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
- return err
- }
-
- // ensure that a database file exists
- extractedDB := filepath.Join(extractedDataDir, "data.db")
- if _, err := os.Stat(extractedDB); err != nil {
- return fmt.Errorf("data.db file is missing or invalid: %w", err)
- }
-
- // remove the extracted zip file since we no longer need it
- // (this is in case the app restarts and the defer calls are not called)
- if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
- log.Println(err)
- }
-
- // root dir entries to exclude from the backup restore
- exclude := []string{LocalBackupsDirName, LocalTempDirName}
-
- // move the current pb_data content to a special temp location
- // that will hold the old data between dirs replace
- // (the temp dir will be automatically removed on the next app start)
- oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
- if err := osutils.MoveDirContent(app.DataDir(), oldTempDataDir, exclude...); err != nil {
- return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
- }
-
- // move the extracted archive content to the app's pb_data
- if err := osutils.MoveDirContent(extractedDataDir, app.DataDir(), exclude...); err != nil {
- return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
- }
-
- revertDataDirChanges := func() error {
- if err := osutils.MoveDirContent(app.DataDir(), extractedDataDir, exclude...); err != nil {
- return fmt.Errorf("failed to revert the extracted dir change: %w", err)
- }
-
- if err := osutils.MoveDirContent(oldTempDataDir, app.DataDir(), exclude...); err != nil {
- return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
- }
-
- return nil
- }
-
- // restart the app
- if err := app.Restart(); err != nil {
- if err := revertDataDirChanges(); err != nil {
- panic(err)
- }
-
- return fmt.Errorf("failed to restart the app process: %w", err)
- }
-
- return nil
-}
-
-// initAutobackupHooks registers the autobackup app serve hooks.
-func (app *BaseApp) initAutobackupHooks() error {
- c := cron.New()
- isServe := false
-
- loadJob := func() {
- c.Stop()
-
- rawSchedule := app.Settings().Backups.Cron
- if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
- return
- }
-
- c.Add("@autobackup", rawSchedule, func() {
- const autoPrefix = "@auto_pb_backup_"
-
- name := app.generateBackupName(autoPrefix)
-
- if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
- // @todo replace after logs generalization
- log.Println(err)
- }
-
- maxKeep := app.Settings().Backups.CronMaxKeep
-
- if maxKeep == 0 {
- return // no explicit limit
- }
-
- fsys, err := app.NewBackupsFilesystem()
- if err != nil && app.IsDebug() {
- // @todo replace after logs generalization
- log.Println(err)
- return
- }
- defer fsys.Close()
-
- files, err := fsys.List(autoPrefix)
- if err != nil && app.IsDebug() {
- // @todo replace after logs generalization
- log.Println(err)
- return
- }
-
- if maxKeep >= len(files) {
- return // nothing to remove
- }
-
- // sort desc
- sort.Slice(files, func(i, j int) bool {
- return files[i].ModTime.After(files[j].ModTime)
- })
-
- // keep only the most recent n auto backup files
- toRemove := files[maxKeep:]
-
- for _, f := range toRemove {
- if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
- // @todo replace after logs generalization
- log.Println(err)
- }
- }
- })
-
- // restart the ticker
- c.Start()
- }
-
- // load on app serve
- app.OnBeforeServe().Add(func(e *ServeEvent) error {
- isServe = true
- loadJob()
- return nil
- })
-
- // stop the ticker on app termination
- app.OnTerminate().Add(func(e *TerminateEvent) error {
- c.Stop()
- return nil
- })
-
- // reload on app settings change
- app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
- p := e.Model.(*models.Param)
- if p == nil || p.Key != models.ParamAppSettings {
- return nil
- }
-
- loadJob()
-
- return nil
- })
-
- return nil
-}
-
-func (app *BaseApp) generateBackupName(prefix string) string {
- appName := inflector.Snakecase(app.Settings().Meta.AppName)
- if len(appName) > 50 {
- appName = appName[:50]
- }
-
- return fmt.Sprintf(
- "%s%s_%s.zip",
- prefix,
- appName,
- time.Now().UTC().Format("20060102150405"),
- )
-}
diff --git a/core/base_backup_test.go b/core/base_backup_test.go
deleted file mode 100644
index 5125c6fd74..0000000000
--- a/core/base_backup_test.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package core_test
-
-import (
- "context"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/archive"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-func TestCreateBackup(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // set some long app name with spaces and special characters
- app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
-
- expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
-
- // test pending error
- app.Cache().Set(core.CacheKeyActiveBackup, "")
- if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
- t.Fatal("Expected pending error, got nil")
- }
- app.Cache().Remove(core.CacheKeyActiveBackup)
-
- // create with auto generated name
- if err := app.CreateBackup(context.Background(), ""); err != nil {
- t.Fatal("Failed to create a backup with autogenerated name")
- }
-
- // create with custom name
- if err := app.CreateBackup(context.Background(), "custom"); err != nil {
- t.Fatal("Failed to create a backup with custom name")
- }
-
- // create new with the same name (aka. replace)
- if err := app.CreateBackup(context.Background(), "custom"); err != nil {
- t.Fatal("Failed to create and replace a backup with the same name")
- }
-
- backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
-
- entries, err := os.ReadDir(backupsDir)
- if err != nil {
- t.Fatal(err)
- }
-
- expectedFiles := []string{
- `^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
- `^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
- "custom",
- "custom.attrs",
- }
-
- if len(entries) != len(expectedFiles) {
- names := getEntryNames(entries)
- t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
- }
-
- for i, entry := range entries {
- if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
- t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
- }
-
- if strings.HasSuffix(entry.Name(), ".attrs") {
- continue
- }
-
- path := filepath.Join(backupsDir, entry.Name())
-
- if err := verifyBackupContent(app, path); err != nil {
- t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
- }
- }
-}
-
-func TestRestoreBackup(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create a initial test backup to ensure that there are at least 1
- // backup file and that the generated zip doesn't contain the backups dir
- if err := app.CreateBackup(context.Background(), "initial"); err != nil {
- t.Fatal("Failed to create test initial backup")
- }
-
- // create test backup
- if err := app.CreateBackup(context.Background(), "test"); err != nil {
- t.Fatal("Failed to create test backup")
- }
-
- // test pending error
- app.Cache().Set(core.CacheKeyActiveBackup, "")
- if err := app.RestoreBackup(context.Background(), "test"); err == nil {
- t.Fatal("Expected pending error, got nil")
- }
- app.Cache().Remove(core.CacheKeyActiveBackup)
-
- // missing backup
- if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
- t.Fatal("Expected missing error, got nil")
- }
-}
-
-// -------------------------------------------------------------------
-
-func verifyBackupContent(app core.App, path string) error {
- dir, err := os.MkdirTemp("", "backup_test")
- if err != nil {
- return err
- }
- defer os.RemoveAll(dir)
-
- if err := archive.Extract(path, dir); err != nil {
- return err
- }
-
- expectedRootEntries := []string{
- "storage",
- "data.db",
- "data.db-shm",
- "data.db-wal",
- "logs.db",
- "logs.db-shm",
- "logs.db-wal",
- ".gitignore",
- }
-
- entries, err := os.ReadDir(dir)
- if err != nil {
- return err
- }
-
- if len(entries) != len(expectedRootEntries) {
- names := getEntryNames(entries)
- return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
- }
-
- for _, entry := range entries {
- if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
- return fmt.Errorf("Didn't expect %q entry", entry.Name())
- }
- }
-
- return nil
-}
-
-func getEntryNames(entries []fs.DirEntry) []string {
- names := make([]string, len(entries))
-
- for i, entry := range entries {
- names[i] = entry.Name()
- }
-
- return names
-}
diff --git a/core/base_settings_test.go b/core/base_settings_test.go
deleted file mode 100644
index 08c515847b..0000000000
--- a/core/base_settings_test.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package core_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestBaseAppRefreshSettings(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // cleanup all stored settings
- if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
- t.Fatalf("Failed to delete all test settings: %v", err)
- }
-
- // check if the new settings are saved in the db
- app.ResetEventCalls()
- if err := app.RefreshSettings(); err != nil {
- t.Fatal("Failed to refresh the settings after delete")
- }
- testEventCalls(t, app, map[string]int{
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- })
- param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
- if err != nil {
- t.Fatalf("Expected new settings to be persisted, got %v", err)
- }
-
- // change the db entry and refresh the app settings (ensure that there was no db update)
- param.Value = types.JsonRaw([]byte(`{"example": 123}`))
- if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
- t.Fatalf("Failed to update the test settings: %v", err)
- }
- app.ResetEventCalls()
- if err := app.RefreshSettings(); err != nil {
- t.Fatalf("Failed to refresh the app settings: %v", err)
- }
- testEventCalls(t, app, nil)
-
- // try to refresh again without doing any changes
- app.ResetEventCalls()
- if err := app.RefreshSettings(); err != nil {
- t.Fatalf("Failed to refresh the app settings without change: %v", err)
- }
- testEventCalls(t, app, nil)
-}
-
-func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
- if len(events) != len(app.EventCalls) {
- t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls)
- }
-
- for name, total := range events {
- if v, ok := app.EventCalls[name]; !ok || v != total {
- t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls)
- }
- }
-}
diff --git a/core/base_test.go b/core/base_test.go
index 1c7f1f0146..62947a51c8 100644
--- a/core/base_test.go
+++ b/core/base_test.go
@@ -1,9 +1,21 @@
-package core
+package core_test
import (
+ "context"
+ "database/sql"
+ "log/slog"
"os"
+ "slices"
"testing"
+ "testing/synctest"
+ "time"
+ _ "unsafe"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/logger"
"github.com/pocketbase/pocketbase/tools/mailer"
)
@@ -11,34 +23,38 @@ func TestNewBaseApp(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
+ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "test_env",
- IsDebug: true,
+ IsDev: true,
})
- if app.dataDir != testDataDir {
- t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
+ if app.DataDir() != testDataDir {
+ t.Fatalf("expected DataDir %q, got %q", testDataDir, app.DataDir())
+ }
+
+ if app.EncryptionEnv() != "test_env" {
+ t.Fatalf("expected EncryptionEnv test_env, got %q", app.EncryptionEnv())
}
- if app.encryptionEnv != "test_env" {
- t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
+ if !app.IsDev() {
+ t.Fatalf("expected IsDev true, got %v", app.IsDev())
}
- if !app.isDebug {
- t.Fatalf("expected isDebug true, got %v", app.isDebug)
+ if app.Store() == nil {
+ t.Fatal("expected Store to be set, got nil")
}
- if app.cache == nil {
- t.Fatal("expected cache to be set, got nil")
+ if app.Settings() == nil {
+ t.Fatal("expected Settings to be set, got nil")
}
- if app.settings == nil {
- t.Fatal("expected settings to be set, got nil")
+ if app.SubscriptionsBroker() == nil {
+ t.Fatal("expected SubscriptionsBroker to be set, got nil")
}
- if app.subscriptionsBroker == nil {
- t.Fatal("expected subscriptionsBroker to be set, got nil")
+ if app.Cron() == nil {
+ t.Fatal("expected Cron to be set, got nil")
}
}
@@ -46,18 +62,15 @@ func TestBaseAppBootstrap(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
- DataDir: testDataDir,
- EncryptionEnv: "pb_test_env",
- IsDebug: false,
+ app := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: testDataDir,
})
- defer app.ResetBootstrapState()
+ defer app.ClearBootstrap()
if app.IsBootstrapped() {
t.Fatal("Didn't expect the application to be bootstrapped.")
}
- // bootstrap
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
@@ -70,136 +83,128 @@ func TestBaseAppBootstrap(t *testing.T) {
t.Fatal("Expected test data directory to be created.")
}
- if app.dao == nil {
- t.Fatal("Expected app.dao to be initialized, got nil.")
- }
-
- if app.dao.BeforeCreateFunc == nil {
- t.Fatal("Expected app.dao.BeforeCreateFunc to be set, got nil.")
- }
-
- if app.dao.AfterCreateFunc == nil {
- t.Fatal("Expected app.dao.AfterCreateFunc to be set, got nil.")
+ type nilCheck struct {
+ name string
+ value any
+ expectNil bool
}
- if app.dao.BeforeUpdateFunc == nil {
- t.Fatal("Expected app.dao.BeforeUpdateFunc to be set, got nil.")
+ runNilChecks := func(checks []nilCheck) {
+ for _, check := range checks {
+ t.Run(check.name, func(t *testing.T) {
+ isNil := check.value == nil
+ if isNil != check.expectNil {
+ t.Fatalf("Expected isNil %v, got %v", check.expectNil, isNil)
+ }
+ })
+ }
}
- if app.dao.AfterUpdateFunc == nil {
- t.Fatal("Expected app.dao.AfterUpdateFunc to be set, got nil.")
+ nilChecksBeforeReset := []nilCheck{
+ {"[before] db", app.DB(), false},
+ {"[before] concurrentDB", app.ConcurrentDB(), false},
+ {"[before] nonconcurrentDB", app.NonconcurrentDB(), false},
+ {"[before] auxDB", app.AuxDB(), false},
+ {"[before] auxConcurrentDB", app.AuxConcurrentDB(), false},
+ {"[before] auxNonconcurrentDB", app.AuxNonconcurrentDB(), false},
+ {"[before] settings", app.Settings(), false},
+ {"[before] logger", app.Logger(), false},
+ {"[before] cached collections", app.Store().Get(core.StoreKeyCachedCollections), false},
}
- if app.dao.BeforeDeleteFunc == nil {
- t.Fatal("Expected app.dao.BeforeDeleteFunc to be set, got nil.")
- }
-
- if app.dao.AfterDeleteFunc == nil {
- t.Fatal("Expected app.dao.AfterDeleteFunc to be set, got nil.")
- }
-
- if app.logsDao == nil {
- t.Fatal("Expected app.logsDao to be initialized, got nil.")
- }
-
- if app.settings == nil {
- t.Fatal("Expected app.settings to be initialized, got nil.")
- }
+ runNilChecks(nilChecksBeforeReset)
// reset
- if err := app.ResetBootstrapState(); err != nil {
+ if err := app.ClearBootstrap(); err != nil {
t.Fatal(err)
}
- if app.dao != nil {
- t.Fatalf("Expected app.dao to be nil, got %v.", app.dao)
+ nilChecksAfterReset := []nilCheck{
+ {"[after] db", app.DB(), true},
+ {"[after] concurrentDB", app.ConcurrentDB(), true},
+ {"[after] nonconcurrentDB", app.NonconcurrentDB(), true},
+ {"[after] auxDB", app.AuxDB(), true},
+ {"[after] auxConcurrentDB", app.AuxConcurrentDB(), true},
+ {"[after] auxNonconcurrentDB", app.AuxNonconcurrentDB(), true},
+ {"[after] settings", app.Settings(), false},
+ {"[after] logger", app.Logger(), false},
+ {"[after] cached collections", app.Store().Get(core.StoreKeyCachedCollections), false},
}
- if app.logsDao != nil {
- t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao)
- }
+ runNilChecks(nilChecksAfterReset)
}
-func TestBaseAppGetters(t *testing.T) {
+func TestNewBaseAppTx(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
- DataDir: testDataDir,
- EncryptionEnv: "pb_test_env",
- IsDebug: false,
+ app := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: testDataDir,
})
- defer app.ResetBootstrapState()
+ defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil {
t.Fatal(err)
}
- if app.dao != app.Dao() {
- t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
- }
+ mustNotHaveTx := func(app core.App) {
+ if app.IsTransactional() {
+ t.Fatalf("Didn't expect the app to be transactional")
+ }
- if app.dao.ConcurrentDB() != app.DB() {
- t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
+ if app.TxInfo() != nil {
+ t.Fatalf("Didn't expect the app.txInfo to be loaded")
+ }
}
- if app.logsDao != app.LogsDao() {
- t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
- }
+ mustHaveTx := func(app core.App) {
+ if !app.IsTransactional() {
+ t.Fatalf("Expected the app to be transactional")
+ }
- if app.logsDao.ConcurrentDB() != app.LogsDB() {
- t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
+ if app.TxInfo() == nil {
+ t.Fatalf("Expected the app.txInfo to be loaded")
+ }
}
- if app.dataDir != app.DataDir() {
- t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
- }
+ mustNotHaveTx(app)
- if app.encryptionEnv != app.EncryptionEnv() {
- t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
- }
-
- if app.isDebug != app.IsDebug() {
- t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug)
- }
-
- if app.settings != app.Settings() {
- t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
- }
-
- if app.cache != app.Cache() {
- t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache)
- }
-
- if app.subscriptionsBroker != app.SubscriptionsBroker() {
- t.Fatalf("Expected app.SubscriptionsBroker %v, got %v", app.SubscriptionsBroker(), app.subscriptionsBroker)
- }
+ app.RunInTransaction(func(txApp core.App) error {
+ mustHaveTx(txApp)
+ return nil
+ })
- if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil {
- t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe)
- }
+ mustNotHaveTx(app)
}
func TestBaseAppNewMailClient(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
+ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
- IsDebug: false,
})
+ defer app.ClearBootstrap()
client1 := app.NewMailClient()
- if val, ok := client1.(*mailer.Sendmail); !ok {
- t.Fatalf("Expected mailer.Sendmail instance, got %v", val)
+ m1, ok := client1.(*mailer.Sendmail)
+ if !ok {
+ t.Fatalf("Expected mailer.Sendmail instance, got %v", m1)
+ }
+ if m1.OnSend() == nil || m1.OnSend().Length() == 0 {
+ t.Fatal("Expected OnSend hook to be registered")
}
- app.Settings().Smtp.Enabled = true
+ app.Settings().SMTP.Enabled = true
client2 := app.NewMailClient()
- if val, ok := client2.(*mailer.SmtpClient); !ok {
- t.Fatalf("Expected mailer.SmtpClient instance, got %v", val)
+ m2, ok := client2.(*mailer.SMTPClient)
+ if !ok {
+ t.Fatalf("Expected mailer.SMTPClient instance, got %v", m2)
+ }
+ if m2.OnSend() == nil || m2.OnSend().Length() == 0 {
+ t.Fatal("Expected OnSend hook to be registered")
}
}
@@ -207,11 +212,10 @@ func TestBaseAppNewFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
- DataDir: testDataDir,
- EncryptionEnv: "pb_test_env",
- IsDebug: false,
+ app := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: testDataDir,
})
+ defer app.ClearBootstrap()
// local
local, localErr := app.NewFilesystem()
@@ -237,11 +241,10 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
- app := NewBaseApp(BaseAppConfig{
- DataDir: testDataDir,
- EncryptionEnv: "pb_test_env",
- IsDebug: false,
+ app := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: testDataDir,
})
+ defer app.ClearBootstrap()
// local
local, localErr := app.NewBackupsFilesystem()
@@ -262,3 +265,411 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
}
}
+
+const logsThreshold = 200
+
+func assertLogsCount(t *testing.T, app core.App, expected int) {
+ var total int
+
+ err := app.LogQuery().Select("count(*)").Row(&total)
+ if err != nil {
+ t.Fatalf("Failed to fetch total logs: %v", err)
+ }
+
+ if total != expected {
+ t.Fatalf("Expected %d log(s), got %d", expected, total)
+ }
+}
+
+func TestBaseAppLoggerWrites(t *testing.T) {
+ t.Parallel()
+
+ // note: outside of synctest because the bootstrap tickers could deadlock
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // clear old logs
+ err := app.DeleteOldLogs(time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("disabled logs retention", func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ app.Settings().Logs.MaxDays = 0
+
+ for i := 0; i < logsThreshold+1; i++ {
+ app.Logger().Error("test")
+ }
+
+ // short delay for the non-blocking write goroutine
+ synctest.Sleep(time.Nanosecond)
+
+ assertLogsCount(t, app, 0)
+ })
+ })
+
+ t.Run("test batch logs writes", func(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ app.Settings().Logs.MaxDays = 2
+
+ for i := 0; i < logsThreshold-1; i++ {
+ app.Logger().Error("test")
+ }
+
+ // short delay for the non-blocking write goroutine
+ synctest.Sleep(time.Nanosecond)
+
+ // below threshold
+ assertLogsCount(t, app, 0)
+
+ // threshold reached -> should trigger batch write
+ app.Logger().Error("test")
+
+ // should be skipped from this batch and added for the next
+ app.Logger().Error("test")
+
+ // short delay for the non-blocking write goroutine
+ synctest.Sleep(time.Nanosecond)
+
+ assertLogsCount(t, app, logsThreshold)
+
+ // note: we can't test the flush timer here because the ticker
+ // was started out of the synctest buble to avoid deadlocks
+ // (see TestBaseAppLoggerWritesAwaited for a flaky but real timer test)
+ })
+ })
+}
+
+func TestBaseAppLoggerWritesAwaited(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // clear old logs
+ err := app.DeleteOldLogs(time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // enable logs persistence
+ app.Settings().Logs.MaxDays = 1
+ err = app.Save(app.Settings())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("flush on timer tick", func(t *testing.T) {
+ timeout := time.After(5 * time.Second)
+ done := make(chan struct{})
+
+ logsHook := app.OnModelAfterCreateSuccess("_logs")
+ hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
+ done <- struct{}{}
+ return e.Next()
+ })
+ defer logsHook.Unbind(hookId)
+
+ app.Logger().Error("test")
+
+ // short wait to ensure that there is no non-blocking write
+ time.Sleep(500 * time.Millisecond)
+
+ assertLogsCount(t, app, 0)
+
+ // wait for the ticker to write the db record
+ select {
+ case <-timeout:
+ t.Fatal("ticker wait timeout")
+ case <-done:
+ }
+
+ assertLogsCount(t, app, 1)
+ })
+
+ t.Run("before ClearBootstrap flush", func(t *testing.T) {
+ app.Logger().Error("test")
+
+ app.Bootstrap()
+
+ assertLogsCount(t, app, 2)
+ })
+
+ t.Run("batch flush inside aux transaction shouldn't hang", func(t *testing.T) {
+ timeout := time.After(1 * time.Second)
+ done := make(chan struct{})
+ totalCreated := 0
+
+ logsHook := app.OnModelAfterCreateSuccess("_logs")
+ hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
+ totalCreated++
+ if totalCreated == 200 {
+ done <- struct{}{}
+ }
+ return e.Next()
+ })
+ defer logsHook.Unbind(hookId)
+
+ app.AuxRunInTransaction(func(txApp core.App) error {
+ for range logsThreshold {
+ txApp.Logger().Error("test")
+ }
+
+ return nil
+ })
+
+ // wait for the non-blocking write
+ select {
+ case <-timeout:
+ t.Fatal("non-blocking write timeout")
+ case <-done:
+ }
+
+ assertLogsCount(t, app, 202)
+
+ // force clear to ensure that there are no other logs
+ app.Bootstrap()
+
+ assertLogsCount(t, app, 202)
+ })
+}
+
+func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
+ scenarios := []struct {
+ name string
+ isDev bool
+ level int
+ // level->enabled map
+ expectations map[int]bool
+ }{
+ {
+ "dev mode",
+ true,
+ 4,
+ map[int]bool{
+ 3: true,
+ 4: true,
+ 5: true,
+ },
+ },
+ {
+ "nondev mode",
+ false,
+ 4,
+ map[int]bool{
+ 3: false,
+ 4: true,
+ 5: true,
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ const testDataDir = "./pb_base_app_test_data_dir/"
+ defer os.RemoveAll(testDataDir)
+
+ app := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: testDataDir,
+ IsDev: s.isDev,
+ })
+ defer app.ClearBootstrap()
+
+ if err := app.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ // silence query logs
+ app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
+ app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
+ app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
+
+ handler, ok := app.Logger().Handler().(*logger.BatchHandler)
+ if !ok {
+ t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
+ }
+
+ app.Settings().Logs.MinLevel = s.level
+
+ if err := app.Save(app.Settings()); err != nil {
+ t.Fatalf("Failed to save settings: %v", err)
+ }
+
+ for level, enabled := range s.expectations {
+ if v := handler.Enabled(context.Background(), slog.Level(level)); v != enabled {
+ t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
+ }
+ }
+ })
+ }
+}
+
+func TestBaseAppDBDualBuilder(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ concurrentQueries := []string{}
+ nonconcurrentQueries := []string{}
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+ app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+ app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ nonconcurrentQueries = append(nonconcurrentQueries, sql)
+ }
+ app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ nonconcurrentQueries = append(nonconcurrentQueries, sql)
+ }
+
+ type testQuery struct {
+ query string
+ isConcurrent bool
+ }
+
+ regularTests := []testQuery{
+ {" \n sEleCt 1", true},
+ {"With abc(x) AS (select 2) SELECT x FROM abc", true},
+ {"create table t1(x int)", false},
+ {"insert into t1(x) values(1)", false},
+ {"update t1 set x = 2", false},
+ {"delete from t1", false},
+ }
+
+ txTests := []testQuery{
+ {"select 3", false},
+ {" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
+ {"create table t2(x int)", false},
+ {"insert into t2(x) values(1)", false},
+ {"update t2 set x = 2", false},
+ {"delete from t2", false},
+ }
+
+ for _, item := range regularTests {
+ _, err := app.DB().NewQuery(item.query).Execute()
+ if err != nil {
+ t.Fatalf("Failed to execute query %q error: %v", item.query, err)
+ }
+ }
+
+ app.RunInTransaction(func(txApp core.App) error {
+ for _, item := range txTests {
+ _, err := txApp.DB().NewQuery(item.query).Execute()
+ if err != nil {
+ t.Fatalf("Failed to execute query %q error: %v", item.query, err)
+ }
+ }
+
+ return nil
+ })
+
+ allTests := append(regularTests, txTests...)
+ for _, item := range allTests {
+ if item.isConcurrent {
+ if !slices.Contains(concurrentQueries, item.query) {
+ t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
+ }
+ } else {
+ if !slices.Contains(nonconcurrentQueries, item.query) {
+ t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
+ }
+ }
+ }
+}
+
+func TestBaseAppAuxDBDualBuilder(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ concurrentQueries := []string{}
+ nonconcurrentQueries := []string{}
+ app.AuxConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+ app.AuxConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+ app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ nonconcurrentQueries = append(nonconcurrentQueries, sql)
+ }
+ app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ nonconcurrentQueries = append(nonconcurrentQueries, sql)
+ }
+
+ type testQuery struct {
+ query string
+ isConcurrent bool
+ }
+
+ regularTests := []testQuery{
+ {" \n sEleCt 1", true},
+ {"With abc(x) AS (select 2) SELECT x FROM abc", true},
+ {"create table t1(x int)", false},
+ {"insert into t1(x) values(1)", false},
+ {"update t1 set x = 2", false},
+ {"delete from t1", false},
+ }
+
+ txTests := []testQuery{
+ {"select 3", false},
+ {" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
+ {"create table t2(x int)", false},
+ {"insert into t2(x) values(1)", false},
+ {"update t2 set x = 2", false},
+ {"delete from t2", false},
+ }
+
+ for _, item := range regularTests {
+ _, err := app.AuxDB().NewQuery(item.query).Execute()
+ if err != nil {
+ t.Fatalf("Failed to execute query %q error: %v", item.query, err)
+ }
+ }
+
+ app.AuxRunInTransaction(func(txApp core.App) error {
+ for _, item := range txTests {
+ _, err := txApp.AuxDB().NewQuery(item.query).Execute()
+ if err != nil {
+ t.Fatalf("Failed to execute query %q error: %v", item.query, err)
+ }
+ }
+
+ return nil
+ })
+
+ allTests := append(regularTests, txTests...)
+ for _, item := range allTests {
+ if item.isConcurrent {
+ if !slices.Contains(concurrentQueries, item.query) {
+ t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
+ }
+ } else {
+ if !slices.Contains(nonconcurrentQueries, item.query) {
+ t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
+ }
+ }
+ }
+}
+
+func TestBaseAppTriggerOnTerminate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ event := new(core.TerminateEvent)
+ event.App = app
+
+ // trigger OnTerminate multiple times to ensure that it doesn't deadlock
+ // https://github.com/pocketbase/pocketbase/pull/7305
+ app.OnTerminate().Trigger(event)
+ app.OnTerminate().Trigger(event)
+ app.OnTerminate().Trigger(event)
+}
diff --git a/core/collection_import.go b/core/collection_import.go
new file mode 100644
index 0000000000..e169a55892
--- /dev/null
+++ b/core/collection_import.go
@@ -0,0 +1,205 @@
+package core
+
+import (
+ "cmp"
+ "context"
+ "database/sql"
+ "encoding/json/jsontext"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/spf13/cast"
+)
+
+// ImportCollectionsByMarshaledJSON is the same as [ImportCollections]
+// but accept marshaled json array as import data (usually used for the autogenerated snapshots).
+func (app *BaseApp) ImportCollectionsByMarshaledJSON(rawSliceOfMaps []byte, deleteMissing bool) error {
+ data := []map[string]any{}
+
+ err := json.Unmarshal(rawSliceOfMaps, &data)
+ if err != nil {
+ return err
+ }
+
+ return app.ImportCollections(data, deleteMissing)
+}
+
+// ImportCollections imports the provided collections data in a single transaction.
+//
+// For existing matching collections, the imported data is unmarshaled on top of the existing model.
+//
+// NB! If deleteMissing is true, ALL NON-SYSTEM COLLECTIONS AND SCHEMA FIELDS,
+// that are not present in the imported configuration, WILL BE DELETED
+// (this includes their related records data).
+func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing bool) error {
+ if len(toImport) == 0 {
+ // prevent accidentally deleting all collections
+ return errors.New("no collections to import")
+ }
+
+ importedCollections := make([]*Collection, len(toImport))
+ mappedImported := make(map[string]*Collection, len(toImport))
+
+ // normalize imported collections data to ensure that all
+ // collection fields are present and properly initialized
+ for i, data := range toImport {
+ var imported *Collection
+
+ identifier := cast.ToString(data["id"])
+ if identifier == "" {
+ identifier = cast.ToString(data["name"])
+ }
+
+ existing, err := app.FindCollectionByNameOrId(identifier)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+
+ if existing != nil {
+ // refetch for deep copy
+ imported, err = app.FindCollectionByNameOrId(existing.Id)
+ if err != nil {
+ return err
+ }
+
+ // ensure that the fields will be cleared
+ if data["fields"] == nil && deleteMissing {
+ data["fields"] = []map[string]any{}
+ }
+
+ rawData, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ // load the imported data
+ err = json.Unmarshal(rawData, imported)
+ if err != nil {
+ return err
+ }
+
+ // extend with the existing fields if necessary
+ for _, f := range existing.Fields {
+ if !f.GetSystem() && deleteMissing {
+ continue
+ }
+ if imported.Fields.GetById(f.GetId()) == nil {
+ // replace with the existing id to prevent accidental column deletion
+ // since otherwise the imported field will be treated as a new one
+ found := imported.Fields.GetByName(f.GetName())
+ if found != nil && found.Type() == f.Type() {
+ found.SetId(f.GetId())
+ }
+ imported.Fields.Add(f)
+ }
+ }
+ } else {
+ imported = &Collection{}
+
+ rawData, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ // load the imported data
+ err = json.Unmarshal(rawData, imported)
+ if err != nil {
+ return err
+ }
+ }
+
+ imported.IntegrityChecks(false)
+
+ importedCollections[i] = imported
+ mappedImported[imported.Id] = imported
+ }
+
+ // reorder views last since the view query could depend on some of the other collections
+ slices.SortStableFunc(importedCollections, func(a, b *Collection) int {
+ cmpA := -1
+ if a.IsView() {
+ cmpA = 1
+ }
+
+ cmpB := -1
+ if b.IsView() {
+ cmpB = 1
+ }
+
+ res := cmp.Compare(cmpA, cmpB)
+ if res == 0 {
+ res = a.Created.Compare(b.Created)
+ if res == 0 {
+ res = a.Updated.Compare(b.Updated)
+ }
+ }
+ return res
+ })
+
+ return app.RunInTransaction(func(txApp App) error {
+ existingCollections := []*Collection{}
+ if err := txApp.CollectionQuery().OrderBy("updated ASC").All(&existingCollections); err != nil {
+ return err
+ }
+ mappedExisting := make(map[string]*Collection, len(existingCollections))
+ for _, existing := range existingCollections {
+ existing.IntegrityChecks(false)
+ mappedExisting[existing.Id] = existing
+ }
+
+ // delete old collections not available in the new configuration
+ // (before saving the imports in case a deleted collection name is being reused)
+ if deleteMissing {
+ for _, existing := range existingCollections {
+ if mappedImported[existing.Id] != nil || existing.System {
+ continue // exist or system
+ }
+
+ // delete collection
+ if err := txApp.Delete(existing); err != nil {
+ return err
+ }
+ }
+ }
+
+ // upsert imported collections
+ for _, imported := range importedCollections {
+ if err := txApp.SaveNoValidate(imported); err != nil {
+ return fmt.Errorf("failed to save collection %q: %w", imported.Name, err)
+ }
+ }
+
+ // run validations
+ for _, imported := range importedCollections {
+ original := mappedExisting[imported.Id]
+ if original == nil {
+ original = imported
+ }
+
+ validator := newCollectionValidator(
+ context.Background(),
+ txApp,
+ imported,
+ original,
+ )
+ if err := validator.run(); err != nil {
+ // serialize the validation error(s)
+ serializedErr, _ := json.Marshal(
+ err,
+ jsontext.WithIndentPrefix(""),
+ jsontext.WithIndent(" "),
+ )
+
+ return validation.Errors{"collections": validation.NewError(
+ "validation_collections_import_failure",
+ fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", imported.Name, imported.Id, serializedErr),
+ )}
+ }
+ }
+
+ return nil
+ })
+}
diff --git a/core/collection_import_test.go b/core/collection_import_test.go
new file mode 100644
index 0000000000..3e5f952d7a
--- /dev/null
+++ b/core/collection_import_test.go
@@ -0,0 +1,476 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestImportCollections(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ var regularCollections []*core.Collection
+ err := testApp.CollectionQuery().AndWhere(dbx.HashExp{"system": false}).All(®ularCollections)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var systemCollections []*core.Collection
+ err = testApp.CollectionQuery().AndWhere(dbx.HashExp{"system": true}).All(&systemCollections)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ totalRegularCollections := len(regularCollections)
+ totalSystemCollections := len(systemCollections)
+ totalCollections := totalRegularCollections + totalSystemCollections
+
+ scenarios := []struct {
+ name string
+ data []map[string]any
+ deleteMissing bool
+ expectError bool
+ expectCollectionsCount int
+ afterTestFunc func(testApp *tests.TestApp, resultCollections []*core.Collection)
+ }{
+ {
+ name: "empty collections",
+ data: []map[string]any{},
+ expectError: true,
+ expectCollectionsCount: totalCollections,
+ },
+ {
+ name: "minimal collection import (with missing system fields)",
+ data: []map[string]any{
+ {"name": "import_test1", "type": "auth"},
+ {
+ "name": "import_test2", "fields": []map[string]any{
+ {"name": "test", "type": "text"},
+ },
+ },
+ },
+ deleteMissing: false,
+ expectError: false,
+ expectCollectionsCount: totalCollections + 2,
+ },
+ {
+ name: "minimal collection import (trigger collection model validations)",
+ data: []map[string]any{
+ {"name": ""},
+ {
+ "name": "import_test2", "fields": []map[string]any{
+ {"name": "test", "type": "text"},
+ },
+ },
+ },
+ deleteMissing: false,
+ expectError: true,
+ expectCollectionsCount: totalCollections,
+ },
+ {
+ name: "minimal collection import (trigger field settings validation)",
+ data: []map[string]any{
+ {"name": "import_test", "fields": []map[string]any{{"name": "test", "type": "text", "min": -1}}},
+ },
+ deleteMissing: false,
+ expectError: true,
+ expectCollectionsCount: totalCollections,
+ },
+ {
+ name: "new + update + delete (system collections delete should be ignored)",
+ data: []map[string]any{
+ {
+ "id": "wsmn24bux7wo113",
+ "name": "demo",
+ "fields": []map[string]any{
+ {
+ "id": "_2hlxbmp",
+ "name": "title",
+ "type": "text",
+ "system": false,
+ "required": true,
+ "min": 3,
+ "max": nil,
+ "pattern": "",
+ },
+ },
+ "indexes": []string{},
+ },
+ {
+ "name": "import1",
+ "fields": []map[string]any{
+ {
+ "name": "active",
+ "type": "bool",
+ },
+ },
+ },
+ },
+ deleteMissing: true,
+ expectError: false,
+ expectCollectionsCount: totalSystemCollections + 2,
+ },
+ {
+ name: "test with deleteMissing: false",
+ data: []map[string]any{
+ {
+ // "id": "wsmn24bux7wo113", // test update with only name as identifier
+ "name": "demo1",
+ "fields": []map[string]any{
+ {
+ "id": "_2hlxbmp",
+ "name": "title",
+ "type": "text",
+ "system": false,
+ "required": true,
+ "min": 3,
+ "max": nil,
+ "pattern": "",
+ },
+ {
+ "id": "_2hlxbmp",
+ "name": "field_with_duplicate_id",
+ "type": "text",
+ "system": false,
+ "required": true,
+ "unique": false,
+ "min": 4,
+ "max": nil,
+ "pattern": "",
+ },
+ {
+ "id": "abcd_import",
+ "name": "new_field",
+ "type": "text",
+ },
+ },
+ },
+ {
+ "name": "new_import",
+ "fields": []map[string]any{
+ {
+ "id": "abcd_import",
+ "name": "active",
+ "type": "bool",
+ },
+ },
+ },
+ },
+ deleteMissing: false,
+ expectError: false,
+ expectCollectionsCount: totalCollections + 1,
+ afterTestFunc: func(testApp *tests.TestApp, resultCollections []*core.Collection) {
+ expectedCollectionFields := map[string]int{
+ core.CollectionNameAuthOrigins: 6,
+ "nologin": 10,
+ "demo1": 19,
+ "demo2": 5,
+ "demo3": 5,
+ "demo4": 16,
+ "demo5": 9,
+ "new_import": 2,
+ }
+ for name, expectedCount := range expectedCollectionFields {
+ collection, err := testApp.FindCollectionByNameOrId(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if totalFields := len(collection.Fields); totalFields != expectedCount {
+ t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields)
+ }
+ }
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ err := testApp.ImportCollections(s.data, s.deleteMissing)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ // check collections count
+ collections := []*core.Collection{}
+ if err := testApp.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+ if len(collections) != s.expectCollectionsCount {
+ t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
+ }
+
+ if s.afterTestFunc != nil {
+ s.afterTestFunc(testApp, collections)
+ }
+ })
+ }
+}
+
+func TestImportCollectionsByMarshaledJSON(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ var regularCollections []*core.Collection
+ err := testApp.CollectionQuery().AndWhere(dbx.HashExp{"system": false}).All(®ularCollections)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var systemCollections []*core.Collection
+ err = testApp.CollectionQuery().AndWhere(dbx.HashExp{"system": true}).All(&systemCollections)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ totalRegularCollections := len(regularCollections)
+ totalSystemCollections := len(systemCollections)
+ totalCollections := totalRegularCollections + totalSystemCollections
+
+ scenarios := []struct {
+ name string
+ data string
+ deleteMissing bool
+ expectError bool
+ expectCollectionsCount int
+ afterTestFunc func(testApp *tests.TestApp, resultCollections []*core.Collection)
+ }{
+ {
+ name: "invalid json array",
+ data: `{"test":123}`,
+ expectError: true,
+ expectCollectionsCount: totalCollections,
+ },
+ {
+ name: "new + update + delete (system collections delete should be ignored)",
+ data: `[
+ {
+ "id": "wsmn24bux7wo113",
+ "name": "demo",
+ "fields": [
+ {
+ "id": "_2hlxbmp",
+ "name": "title",
+ "type": "text",
+ "system": false,
+ "required": true,
+ "min": 3,
+ "max": null,
+ "pattern": ""
+ }
+ ],
+ "indexes": []
+ },
+ {
+ "name": "import1",
+ "fields": [
+ {
+ "name": "active",
+ "type": "bool"
+ }
+ ]
+ }
+ ]`,
+ deleteMissing: true,
+ expectError: false,
+ expectCollectionsCount: totalSystemCollections + 2,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ err := testApp.ImportCollectionsByMarshaledJSON([]byte(s.data), s.deleteMissing)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ // check collections count
+ collections := []*core.Collection{}
+ if err := testApp.CollectionQuery().All(&collections); err != nil {
+ t.Fatal(err)
+ }
+ if len(collections) != s.expectCollectionsCount {
+ t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
+ }
+
+ if s.afterTestFunc != nil {
+ s.afterTestFunc(testApp, collections)
+ }
+ })
+ }
+}
+
+func TestImportCollectionsUpdateRules(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ data map[string]any
+ deleteMissing bool
+ }{
+ {
+ "extend existing by name (without deleteMissing)",
+ map[string]any{"name": "clients", "authToken": map[string]any{"duration": 100}, "fields": []map[string]any{{"name": "test", "type": "text"}}},
+ false,
+ },
+ {
+ "extend existing by id (without deleteMissing)",
+ map[string]any{"id": "v851q4r790rhknl", "authToken": map[string]any{"duration": 100}, "fields": []map[string]any{{"name": "test", "type": "text"}}},
+ false,
+ },
+ {
+ "extend with delete missing",
+ map[string]any{
+ "id": "v851q4r790rhknl",
+ "authToken": map[string]any{"duration": 100},
+ "fields": []map[string]any{{"name": "test", "type": "text"}},
+ "passwordAuth": map[string]any{"identityFields": []string{"email"}},
+ "indexes": []string{
+ // min required system fields indexes
+ "CREATE UNIQUE INDEX `_v851q4r790rhknl_email_idx` ON `clients` (email) WHERE email != ''",
+ "CREATE UNIQUE INDEX `_v851q4r790rhknl_tokenKey_idx` ON `clients` (tokenKey)",
+ },
+ },
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ beforeCollection, err := testApp.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = testApp.ImportCollections([]map[string]any{s.data}, s.deleteMissing)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ afterCollection, err := testApp.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if afterCollection.AuthToken.Duration != 100 {
+ t.Fatalf("Expected AuthToken duration to be %d, got %d", 100, afterCollection.AuthToken.Duration)
+ }
+ if beforeCollection.AuthToken.Secret != afterCollection.AuthToken.Secret {
+ t.Fatalf("Expected AuthToken secrets to remain the same, got\n%q\nVS\n%q", beforeCollection.AuthToken.Secret, afterCollection.AuthToken.Secret)
+ }
+ if beforeCollection.Name != afterCollection.Name {
+ t.Fatalf("Expected Name to remain the same, got\n%q\nVS\n%q", beforeCollection.Name, afterCollection.Name)
+ }
+ if beforeCollection.Id != afterCollection.Id {
+ t.Fatalf("Expected Id to remain the same, got\n%q\nVS\n%q", beforeCollection.Id, afterCollection.Id)
+ }
+
+ if !s.deleteMissing {
+ totalExpectedFields := len(beforeCollection.Fields) + 1
+ if v := len(afterCollection.Fields); v != totalExpectedFields {
+ t.Fatalf("Expected %d total fields, got %d", totalExpectedFields, v)
+ }
+
+ if afterCollection.Fields.GetByName("test") == nil {
+ t.Fatalf("Missing new field %q", "test")
+ }
+
+ // ensure that the old fields still exist
+ oldFields := beforeCollection.Fields.FieldNames()
+ for _, name := range oldFields {
+ if afterCollection.Fields.GetByName(name) == nil {
+ t.Fatalf("Missing expected old field %q", name)
+ }
+ }
+ } else {
+ totalExpectedFields := 1
+ for _, f := range beforeCollection.Fields {
+ if f.GetSystem() {
+ totalExpectedFields++
+ }
+ }
+
+ if v := len(afterCollection.Fields); v != totalExpectedFields {
+ t.Fatalf("Expected %d total fields, got %d", totalExpectedFields, v)
+ }
+
+ if afterCollection.Fields.GetByName("test") == nil {
+ t.Fatalf("Missing new field %q", "test")
+ }
+
+ // ensure that the old system fields still exist
+ for _, f := range beforeCollection.Fields {
+ if f.GetSystem() && afterCollection.Fields.GetByName(f.GetName()) == nil {
+ t.Fatalf("Missing expected old field %q", f.GetName())
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestImportCollectionsCreateRules(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ err := testApp.ImportCollections([]map[string]any{
+ {"name": "new_test", "type": "auth", "authToken": map[string]any{"duration": 123}, "fields": []map[string]any{{"name": "test", "type": "text"}}},
+ }, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ collection, err := testApp.FindCollectionByNameOrId("new_test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(collection, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ expectedParts := []string{
+ `"name":"new_test"`,
+ `"fields":[`,
+ `"name":"id"`,
+ `"name":"email"`,
+ `"name":"tokenKey"`,
+ `"name":"password"`,
+ `"name":"test"`,
+ `"indexes":[`,
+ `CREATE UNIQUE INDEX`,
+ `"duration":123`,
+ }
+
+ for _, part := range expectedParts {
+ if !strings.Contains(rawStr, part) {
+ t.Errorf("Missing %q in\n%s", part, rawStr)
+ }
+ }
+}
diff --git a/core/collection_model.go b/core/collection_model.go
new file mode 100644
index 0000000000..2ed040ab3d
--- /dev/null
+++ b/core/collection_model.go
@@ -0,0 +1,1107 @@
+package core
+
+import (
+ "encoding/json/v2"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+var (
+ _ Model = (*Collection)(nil)
+ _ DBExporter = (*Collection)(nil)
+ _ FilesManager = (*Collection)(nil)
+)
+
+const (
+ CollectionTypeBase = "base"
+ CollectionTypeAuth = "auth"
+ CollectionTypeView = "view"
+)
+
+const systemHookIdCollection = "__pbCollectionSystemHook__"
+
+const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
+
+func (app *BaseApp) registerCollectionHooks() {
+ app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelCreate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelCreateExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterCreateError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelErrorEvent) error {
+ if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelUpdate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelUpdateExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterUpdateError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelErrorEvent) error {
+ if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelDelete().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelDeleteExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelEvent) error {
+ if ce, ok := newCollectionEventFromModelEvent(me); ok {
+ err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
+ syncModelEventWithCollectionEvent(me, ce)
+ defer syncCollectionEventWithModelEvent(ce, me)
+ return me.Next()
+ })
+ syncModelEventWithCollectionEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterDeleteError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: func(me *ModelErrorEvent) error {
+ if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithCollectionErrorEvent(me, ce)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ // --------------------------------------------------------------
+
+ app.OnCollectionValidate().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionValidate,
+ Priority: 99,
+ })
+
+ app.OnCollectionCreate().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionSave,
+ Priority: -99,
+ })
+
+ app.OnCollectionUpdate().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionSave,
+ Priority: -99,
+ })
+
+ app.OnCollectionCreateExecute().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionSaveExecute,
+ // execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
+ Priority: 99,
+ })
+
+ app.OnCollectionUpdateExecute().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionSaveExecute,
+ Priority: 99, // execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
+ })
+
+ app.OnCollectionDeleteExecute().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdCollection,
+ Func: onCollectionDeleteExecute,
+ Priority: 99, // execute as latest as possible, aka. closer to the db action to minimize the transactions lock time
+ })
+
+ // reload cache on failure
+ // ---
+ onErrorReloadCachedCollections := func(ce *CollectionErrorEvent) error {
+ if err := ce.App.ReloadCachedCollections(); err != nil {
+ ce.App.Logger().Warn("Failed to reload collections cache after collection change error", "error", err)
+ }
+
+ return ce.Next()
+ }
+ app.OnCollectionAfterCreateError().Bind(&hook.Handler[*CollectionErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: onErrorReloadCachedCollections,
+ Priority: -99,
+ })
+ app.OnCollectionAfterUpdateError().Bind(&hook.Handler[*CollectionErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: onErrorReloadCachedCollections,
+ Priority: -99,
+ })
+ app.OnCollectionAfterDeleteError().Bind(&hook.Handler[*CollectionErrorEvent]{
+ Id: systemHookIdCollection,
+ Func: onErrorReloadCachedCollections,
+ Priority: -99,
+ })
+ // ---
+
+ app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
+ Id: systemHookIdCollection,
+ Func: func(e *BootstrapEvent) error {
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ if err := e.App.ReloadCachedCollections(); err != nil {
+ return fmt.Errorf("failed to load collections cache: %w", err)
+ }
+
+ return nil
+ },
+ Priority: 99, // execute as latest as possible
+ })
+}
+
+// @todo experiment eventually replacing the rules *string with a struct?
+// @todo consider changing the Indexes field to a "getter" for the sqlite_master table?
+type baseCollection struct {
+ BaseModel
+
+ disableIntegrityChecks bool
+ autogeneratedId string
+
+ ListRule *string `db:"listRule" json:"listRule" form:"listRule"`
+ ViewRule *string `db:"viewRule" json:"viewRule" form:"viewRule"`
+ CreateRule *string `db:"createRule" json:"createRule" form:"createRule"`
+ UpdateRule *string `db:"updateRule" json:"updateRule" form:"updateRule"`
+ DeleteRule *string `db:"deleteRule" json:"deleteRule" form:"deleteRule"`
+
+ // RawOptions represents the raw serialized collection option loaded from the DB.
+ // NB! This field shouldn't be modified manually. It is automatically updated
+ // with the collection type specific option before save.
+ RawOptions types.JSONRaw `db:"options" json:"-" xml:"-" form:"-"`
+
+ Name string `db:"name" json:"name" form:"name"`
+ Type string `db:"type" json:"type" form:"type"`
+ Fields FieldsList `db:"fields" json:"fields" form:"fields"`
+ Indexes types.JSONArray[string] `db:"indexes" json:"indexes" form:"indexes"`
+ Created types.DateTime `db:"created" json:"created"`
+ Updated types.DateTime `db:"updated" json:"updated"`
+
+ // System prevents the collection rename, deletion and rules change.
+ // It is used primarily for internal purposes for collections like "_superusers", "_externalAuths", etc.
+ System bool `db:"system" json:"system" form:"system"`
+}
+
+// Collection defines the table, fields and various options related to a set of records.
+type Collection struct {
+ baseCollection
+ collectionAuthOptions
+ collectionViewOptions
+}
+
+// NewCollection initializes and returns a new Collection model with the specified type and name.
+//
+// It also loads the minimal default configuration for the collection
+// (eg. system fields, indexes, type specific options, etc.).
+func NewCollection(typ, name string, optId ...string) *Collection {
+ switch typ {
+ case CollectionTypeAuth:
+ return NewAuthCollection(name, optId...)
+ case CollectionTypeView:
+ return NewViewCollection(name, optId...)
+ default:
+ return NewBaseCollection(name, optId...)
+ }
+}
+
+// NewBaseCollection initializes and returns a new "base" Collection model.
+//
+// It also loads the minimal default configuration for the collection
+// (eg. system fields, indexes, type specific options, etc.).
+func NewBaseCollection(name string, optId ...string) *Collection {
+ m := &Collection{}
+ m.Name = name
+ m.Type = CollectionTypeBase
+
+ // @todo consider removing once inferred composite literals are supported
+ if len(optId) > 0 {
+ m.Id = optId[0]
+ }
+
+ m.initDefaultId()
+ m.initDefaultFields()
+
+ return m
+}
+
+// NewViewCollection initializes and returns a new "view" Collection model.
+//
+// It also loads the minimal default configuration for the collection
+// (eg. system fields, indexes, type specific options, etc.).
+func NewViewCollection(name string, optId ...string) *Collection {
+ m := &Collection{}
+ m.Name = name
+ m.Type = CollectionTypeView
+
+ // @todo consider removing once inferred composite literals are supported
+ if len(optId) > 0 {
+ m.Id = optId[0]
+ }
+
+ m.initDefaultId()
+ m.initDefaultFields()
+
+ return m
+}
+
+// NewAuthCollection initializes and returns a new "auth" Collection model.
+//
+// It also loads the minimal default configuration for the collection
+// (eg. system fields, indexes, type specific options, etc.).
+func NewAuthCollection(name string, optId ...string) *Collection {
+ m := &Collection{}
+ m.Name = name
+ m.Type = CollectionTypeAuth
+
+ // @todo consider removing once inferred composite literals are supported
+ if len(optId) > 0 {
+ m.Id = optId[0]
+ }
+
+ m.initDefaultId()
+ m.initDefaultFields()
+ m.setDefaultAuthOptions()
+
+ return m
+}
+
+// TableName returns the Collection model SQL table name.
+func (m *Collection) TableName() string {
+ return "_collections"
+}
+
+// BaseFilesPath returns the storage dir path used by the collection.
+func (m *Collection) BaseFilesPath() string {
+ return m.Id
+}
+
+// IsBase checks if the current collection has "base" type.
+func (m *Collection) IsBase() bool {
+ return m.Type == CollectionTypeBase
+}
+
+// IsAuth checks if the current collection has "auth" type.
+func (m *Collection) IsAuth() bool {
+ return m.Type == CollectionTypeAuth
+}
+
+// IsView checks if the current collection has "view" type.
+func (m *Collection) IsView() bool {
+ return m.Type == CollectionTypeView
+}
+
+// IntegrityChecks toggles the current collection integrity checks (ex. checking references on delete).
+func (m *Collection) IntegrityChecks(enable bool) {
+ m.disableIntegrityChecks = !enable
+}
+
+// PostScan implements the [dbx.PostScanner] interface to auto unmarshal
+// the raw serialized options into the concrete type specific fields.
+func (m *Collection) PostScan() error {
+ if err := m.BaseModel.PostScan(); err != nil {
+ return err
+ }
+
+ return m.unmarshalRawOptions()
+}
+
+func (m *Collection) unmarshalRawOptions() error {
+ raw, err := m.RawOptions.MarshalJSON()
+ if err != nil {
+ return nil
+ }
+
+ switch m.Type {
+ case CollectionTypeView:
+ return json.Unmarshal(raw, &m.collectionViewOptions)
+ case CollectionTypeAuth:
+ return json.Unmarshal(raw, &m.collectionAuthOptions)
+ }
+
+ return nil
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+//
+// For new/"blank" Collection models it replaces the model with a factory
+// instance and then unmarshal the provided data one on top of it.
+func (m *Collection) UnmarshalJSON(b []byte) error {
+ // initialize the default fields
+ // (e.g. in case the collection was NOT created using the designated factories)
+ if m.IsNew() && m.Type == "" {
+ minimal := &struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Id string `json:"id"`
+ }{}
+ if err := json.Unmarshal(b, minimal); err != nil {
+ return err
+ }
+
+ blank := NewCollection(minimal.Type, minimal.Name, minimal.Id)
+ *m = *blank
+ }
+
+ type alias Collection
+ return json.Unmarshal(b, (*alias)(m))
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+//
+// Note that non-type related fields are ignored from the serialization
+// (ex. for "view" collections the "auth" fields are skipped).
+func (m Collection) MarshalJSON() ([]byte, error) {
+ switch m.Type {
+ case CollectionTypeView:
+ alias := struct {
+ baseCollection
+ collectionViewOptions
+ }{m.baseCollection, m.collectionViewOptions}
+
+ return json.Marshal(alias, json.Deterministic(true))
+ case CollectionTypeAuth:
+ alias := struct {
+ baseCollection
+ collectionAuthOptions
+ }{m.baseCollection, m.collectionAuthOptions}
+
+ // @todo to avoid the below changes consider omitting the field values from the individual structs json tags
+ //
+ // hide secret keys from the serialization
+ alias.AuthToken.Secret = ""
+ alias.FileToken.Secret = ""
+ alias.PasswordResetToken.Secret = ""
+ alias.EmailChangeToken.Secret = ""
+ alias.VerificationToken.Secret = ""
+
+ if alias.OAuth2.Providers == nil {
+ // ensure that it is always returned as array
+ alias.OAuth2.Providers = []OAuth2ProviderConfig{}
+ } else {
+ // create a deep copy of the slice to avoid modifying the cached model state
+ redactedProviders := make([]OAuth2ProviderConfig, len(alias.OAuth2.Providers))
+ copy(redactedProviders, alias.OAuth2.Providers)
+ for i := range redactedProviders {
+ redactedProviders[i].ClientSecret = ""
+ }
+ alias.OAuth2.Providers = redactedProviders
+ }
+
+ return json.Marshal(alias, json.Deterministic(true))
+ default:
+ return json.Marshal(m.baseCollection, json.Deterministic(true))
+ }
+}
+
+// String returns a string representation of the current collection.
+func (m Collection) String() string {
+ raw, _ := m.MarshalJSON()
+ return string(raw)
+}
+
+// DBExport prepares and exports the current collection data for db persistence.
+func (m *Collection) DBExport(app App) (map[string]any, error) {
+ result := map[string]any{
+ "id": m.Id,
+ "type": m.Type,
+ "listRule": m.ListRule,
+ "viewRule": m.ViewRule,
+ "createRule": m.CreateRule,
+ "updateRule": m.UpdateRule,
+ "deleteRule": m.DeleteRule,
+ "name": m.Name,
+ "fields": m.Fields,
+ "indexes": m.Indexes,
+ "system": m.System,
+ "created": m.Created,
+ "updated": m.Updated,
+ "options": `{}`,
+ }
+
+ switch m.Type {
+ case CollectionTypeView:
+ if raw, err := types.ParseJSONRaw(m.collectionViewOptions); err == nil {
+ result["options"] = raw
+ } else {
+ return nil, err
+ }
+ case CollectionTypeAuth:
+ if raw, err := types.ParseJSONRaw(m.collectionAuthOptions); err == nil {
+ result["options"] = raw
+ } else {
+ return nil, err
+ }
+ }
+
+ return result, nil
+}
+
+// GetIndex returns s single Collection index expression by its name.
+func (m *Collection) GetIndex(name string) string {
+ for _, idx := range m.Indexes {
+ if strings.EqualFold(dbutils.ParseIndex(idx).IndexName, name) {
+ return idx
+ }
+ }
+
+ return ""
+}
+
+// AddIndex adds a new index into the current collection.
+//
+// If the collection has an existing index matching the new name it will be replaced with the new one.
+func (m *Collection) AddIndex(name string, unique bool, columnsExpr string, optWhereExpr string) {
+ m.RemoveIndex(name)
+
+ var idx strings.Builder
+
+ idx.WriteString("CREATE ")
+ if unique {
+ idx.WriteString("UNIQUE ")
+ }
+ idx.WriteString("INDEX `")
+ idx.WriteString(name)
+ idx.WriteString("` ")
+ idx.WriteString("ON `")
+ idx.WriteString(m.Name)
+ idx.WriteString("` (")
+ idx.WriteString(columnsExpr)
+ idx.WriteString(")")
+ if optWhereExpr != "" {
+ idx.WriteString(" WHERE ")
+ idx.WriteString(optWhereExpr)
+ }
+
+ m.Indexes = append(m.Indexes, idx.String())
+}
+
+// RemoveIndex removes a single index with the specified name from the current collection.
+func (m *Collection) RemoveIndex(name string) {
+ for i, idx := range m.Indexes {
+ if strings.EqualFold(dbutils.ParseIndex(idx).IndexName, name) {
+ m.Indexes = append(m.Indexes[:i], m.Indexes[i+1:]...)
+ return
+ }
+ }
+}
+
+// delete hook
+// -------------------------------------------------------------------
+
+func onCollectionDeleteExecute(e *CollectionEvent) error {
+ if e.Collection.System {
+ return fmt.Errorf("[%s] system collections cannot be deleted", e.Collection.Name)
+ }
+
+ defer func() {
+ if err := e.App.ReloadCachedCollections(); err != nil {
+ e.App.Logger().Warn("Failed to reload collections cache", "error", err)
+ }
+ }()
+
+ if !e.Collection.disableIntegrityChecks {
+ // ensure that there aren't any existing references.
+ // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
+ references, err := e.App.FindCollectionReferences(e.Collection, e.Collection.Id)
+ if err != nil {
+ return fmt.Errorf("[%s] failed to check collection references: %w", e.Collection.Name, err)
+ }
+ if total := len(references); total > 0 {
+ names := make([]string, 0, len(references))
+ for ref := range references {
+ names = append(names, ref.Name)
+ }
+ return fmt.Errorf("[%s] failed to delete due to existing relation references: %s", e.Collection.Name, strings.Join(names, ", "))
+ }
+ }
+
+ originalApp := e.App
+
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ // delete the related view or records table
+ if e.Collection.IsView() {
+ if err := txApp.DeleteView(e.Collection.Name); err != nil {
+ return err
+ }
+ } else {
+ if err := txApp.DeleteTable(e.Collection.Name); err != nil {
+ return err
+ }
+ }
+
+ if !e.Collection.disableIntegrityChecks {
+ // trigger views resave to check for dependencies
+ if err := resaveViewsWithChangedFields(txApp, e.Collection.Id); err != nil {
+ return fmt.Errorf("[%s] failed to delete due to existing view dependency: %w", e.Collection.Name, err)
+ }
+ }
+
+ // delete
+ return e.Next()
+ })
+
+ e.App = originalApp
+
+ return txErr
+}
+
+// save hook
+// -------------------------------------------------------------------
+
+func (c *Collection) idChecksum() string {
+ return "pbc_" + crc32Checksum(c.Type+c.Name)
+}
+
+func (c *Collection) initDefaultId() {
+ if c.Id == "" {
+ c.Id = c.idChecksum()
+ c.autogeneratedId = c.Id
+ }
+}
+
+func (c *Collection) updateGeneratedIdIfExists(app App) {
+ if !c.IsNew() ||
+ // the id was explicitly cleared
+ c.Id == "" ||
+ // the id was manually set
+ c.Id != c.autogeneratedId {
+ return
+ }
+
+ // generate an up-to-date checksum
+ newId := c.idChecksum()
+
+ // add a number to the current id (if already exists)
+ for i := 2; i < 1000; i++ {
+ var exists int
+ _ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
+ if exists == 0 {
+ break
+ }
+ newId = c.idChecksum() + strconv.Itoa(i)
+ }
+
+ // no change
+ if c.Id == newId {
+ return
+ }
+
+ // replace the old id in the index names (if any)
+ for i, idx := range c.Indexes {
+ parsed := dbutils.ParseIndex(idx)
+ original := parsed.IndexName
+ parsed.IndexName = strings.ReplaceAll(parsed.IndexName, c.Id, newId)
+ if parsed.IndexName != original {
+ c.Indexes[i] = parsed.Build()
+ }
+ }
+
+ // update model id
+ c.Id = newId
+}
+
+func onCollectionSave(e *CollectionEvent) error {
+ if e.Collection.Type == "" {
+ e.Collection.Type = CollectionTypeBase
+ }
+
+ if e.Collection.IsNew() {
+ e.Collection.initDefaultId()
+ e.Collection.Created = types.NowDateTime()
+ }
+
+ e.Collection.Updated = types.NowDateTime()
+
+ // recreate the fields list to ensure that all normalizations
+ // like default field id are applied
+ e.Collection.Fields = NewFieldsList(e.Collection.Fields...)
+
+ e.Collection.initDefaultFields()
+
+ if e.Collection.IsAuth() {
+ e.Collection.unsetMissingOAuth2MappedFields()
+ }
+
+ 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()
+}
+
+func onCollectionSaveExecute(e *CollectionEvent) error {
+ defer func() {
+ if err := e.App.ReloadCachedCollections(); err != nil {
+ e.App.Logger().Warn("Failed to reload collections cache", "error", err)
+ }
+ }()
+
+ var oldCollection *Collection
+ if !e.Collection.IsNew() {
+ var err error
+ oldCollection, err = e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
+ if err != nil {
+ return err
+ }
+
+ // invalidate previously issued auth tokens on auth rule change
+ if oldCollection.AuthRule != e.Collection.AuthRule &&
+ cast.ToString(oldCollection.AuthRule) != cast.ToString(e.Collection.AuthRule) {
+ e.Collection.AuthToken.Secret = security.RandomString(50)
+ }
+ }
+
+ originalApp := e.App
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ isView := e.Collection.IsView()
+
+ // ensures that the view collection shema is properly loaded
+ if isView {
+ query := e.Collection.ViewQuery
+
+ // generate collection fields list from the query
+ viewFields, err := e.App.CreateViewFields(query)
+ if err != nil {
+ return err
+ }
+
+ // delete old renamed view
+ if oldCollection != nil {
+ if err := e.App.DeleteView(oldCollection.Name); err != nil {
+ return err
+ }
+ }
+
+ // wrap view query if necessary
+ query, err = normalizeViewQueryId(e.App, query)
+ if err != nil {
+ return fmt.Errorf("failed to normalize view query id: %w", err)
+ }
+
+ // (re)create the view
+ if err := e.App.SaveView(e.Collection.Name, query); err != nil {
+ return err
+ }
+
+ // updates newCollection.Fields based on the generated view table info and query
+ e.Collection.Fields = viewFields
+ }
+
+ // save the Collection model
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ // sync the changes with the related records table
+ if !isView {
+ if err := e.App.SyncRecordTableSchema(e.Collection, oldCollection); err != nil {
+ // note: don't wrap to allow propagating indexes validation.Errors
+ return err
+ }
+ }
+
+ return nil
+ })
+ e.App = originalApp
+
+ if txErr != nil {
+ return txErr
+ }
+
+ // 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)
+ depViewsErr := 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
+}
+
+func (c *Collection) initDefaultFields() {
+ switch c.Type {
+ case CollectionTypeBase:
+ c.initIdField()
+ case CollectionTypeAuth:
+ c.initIdField()
+ c.initPasswordField()
+ c.initTokenKeyField()
+ c.initEmailField()
+ c.initEmailVisibilityField()
+ c.initVerifiedField()
+ case CollectionTypeView:
+ // view fields are autogenerated
+ }
+}
+
+func (c *Collection) initIdField() {
+ field, _ := c.Fields.GetByName(FieldNameId).(*TextField)
+ if field == nil {
+ // create default field
+ field = &TextField{
+ Name: FieldNameId,
+ System: true,
+ PrimaryKey: true,
+ Required: true,
+ Min: 15,
+ Max: 15,
+ Pattern: defaultLowercaseRecordIdPattern,
+ AutogeneratePattern: `[a-z0-9]{15}`,
+ }
+
+ // prepend it
+ c.Fields = NewFieldsList(append([]Field{field}, c.Fields...)...)
+ } else {
+ // enforce system defaults
+ field.System = true
+ field.Required = true
+ field.PrimaryKey = true
+ field.Hidden = false
+ if field.Pattern == "" {
+ field.Pattern = defaultLowercaseRecordIdPattern
+ }
+ }
+}
+
+func (c *Collection) initPasswordField() {
+ field, _ := c.Fields.GetByName(FieldNamePassword).(*PasswordField)
+ if field == nil {
+ // load default field
+ c.Fields.Add(&PasswordField{
+ Name: FieldNamePassword,
+ System: true,
+ Hidden: true,
+ Required: true,
+ Min: 8,
+ })
+ } else {
+ // enforce system defaults
+ field.System = true
+ field.Hidden = true
+ field.Required = true
+ }
+}
+
+func (c *Collection) initTokenKeyField() {
+ field, _ := c.Fields.GetByName(FieldNameTokenKey).(*TextField)
+ if field == nil {
+ // load default field
+ c.Fields.Add(&TextField{
+ Name: FieldNameTokenKey,
+ System: true,
+ Hidden: true,
+ Min: 30,
+ Max: 60,
+ Required: true,
+ AutogeneratePattern: `[a-zA-Z0-9]{50}`,
+ })
+ } else {
+ // enforce system defaults
+ field.System = true
+ field.Hidden = true
+ field.Required = true
+ }
+
+ // ensure that there is a unique index for the field
+ if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
+ c.Indexes = append(c.Indexes, fmt.Sprintf(
+ "CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
+ c.fieldIndexName(FieldNameTokenKey),
+ c.Name,
+ FieldNameTokenKey,
+ ))
+ }
+}
+
+func (c *Collection) initEmailField() {
+ field, _ := c.Fields.GetByName(FieldNameEmail).(*EmailField)
+ if field == nil {
+ // load default field
+ c.Fields.Add(&EmailField{
+ Name: FieldNameEmail,
+ System: true,
+ Required: true,
+ })
+ } else {
+ // enforce system defaults
+ field.System = true
+ field.Hidden = false // managed by the emailVisibility flag
+ }
+
+ // ensure that there is a unique index for the email field
+ if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
+ c.Indexes = append(c.Indexes, fmt.Sprintf(
+ "CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
+ c.fieldIndexName(FieldNameEmail),
+ c.Name,
+ FieldNameEmail,
+ FieldNameEmail,
+ ))
+ }
+}
+
+func (c *Collection) initEmailVisibilityField() {
+ field, _ := c.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
+ if field == nil {
+ // load default field
+ c.Fields.Add(&BoolField{
+ Name: FieldNameEmailVisibility,
+ System: true,
+ })
+ } else {
+ // enforce system defaults
+ field.System = true
+ }
+}
+
+func (c *Collection) initVerifiedField() {
+ field, _ := c.Fields.GetByName(FieldNameVerified).(*BoolField)
+ if field == nil {
+ // load default field
+ c.Fields.Add(&BoolField{
+ Name: FieldNameVerified,
+ System: true,
+ })
+ } else {
+ // enforce system defaults
+ field.System = true
+ }
+}
+
+func (c *Collection) fieldIndexName(field string) string {
+ name := "idx_" + field + "_"
+
+ if c.Id != "" {
+ name += c.Id
+ } else if c.Name != "" {
+ name += c.Name
+ } else {
+ name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
+ }
+
+ if len(name) > 64 {
+ return name[:64]
+ }
+
+ return name
+}
diff --git a/core/collection_model_auth_options.go b/core/collection_model_auth_options.go
new file mode 100644
index 0000000000..1911941585
--- /dev/null
+++ b/core/collection_model_auth_options.go
@@ -0,0 +1,611 @@
+package core
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+func (m *Collection) unsetMissingOAuth2MappedFields() {
+ if !m.IsAuth() {
+ return
+ }
+
+ if m.OAuth2.MappedFields.Id != "" {
+ if m.Fields.GetByName(m.OAuth2.MappedFields.Id) == nil {
+ m.OAuth2.MappedFields.Id = ""
+ }
+ }
+
+ if m.OAuth2.MappedFields.Name != "" {
+ if m.Fields.GetByName(m.OAuth2.MappedFields.Name) == nil {
+ m.OAuth2.MappedFields.Name = ""
+ }
+ }
+
+ if m.OAuth2.MappedFields.Username != "" {
+ if m.Fields.GetByName(m.OAuth2.MappedFields.Username) == nil {
+ m.OAuth2.MappedFields.Username = ""
+ }
+ }
+
+ if m.OAuth2.MappedFields.AvatarURL != "" {
+ if m.Fields.GetByName(m.OAuth2.MappedFields.AvatarURL) == nil {
+ m.OAuth2.MappedFields.AvatarURL = ""
+ }
+ }
+}
+
+func (m *Collection) setDefaultAuthOptions() {
+ m.collectionAuthOptions = collectionAuthOptions{
+ VerificationTemplate: defaultVerificationTemplate,
+ ResetPasswordTemplate: defaultResetPasswordTemplate,
+ ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
+ AuthRule: types.Pointer(""),
+ AuthAlert: AuthAlertConfig{
+ Enabled: true,
+ EmailTemplate: defaultAuthAlertTemplate,
+ },
+ PasswordAuth: PasswordAuthConfig{
+ Enabled: true,
+ IdentityFields: []string{FieldNameEmail},
+ },
+ MFA: MFAConfig{
+ Enabled: false,
+ Duration: 600, // 10min
+ },
+ OTP: OTPConfig{
+ Enabled: false,
+ Duration: 180, // 3min
+ Length: 8,
+ EmailTemplate: defaultOTPTemplate,
+ },
+ AuthToken: TokenConfig{
+ Secret: security.RandomString(50),
+ Duration: 432000, // 5days
+ },
+ PasswordResetToken: TokenConfig{
+ Secret: security.RandomString(50),
+ Duration: 1800, // 30min
+ },
+ EmailChangeToken: TokenConfig{
+ Secret: security.RandomString(50),
+ Duration: 1800, // 30min
+ },
+ VerificationToken: TokenConfig{
+ Secret: security.RandomString(50),
+ Duration: 86400, // 1day
+ },
+ FileToken: TokenConfig{
+ Secret: security.RandomString(50),
+ Duration: 180, // 3min
+ },
+ }
+}
+
+var _ optionsValidator = (*collectionAuthOptions)(nil)
+
+// collectionAuthOptions defines the options for the "auth" type collection.
+type collectionAuthOptions struct {
+ // AuthRule could be used to specify additional record constraints
+ // applied after record authentication and right before returning the
+ // auth token response to the client.
+ //
+ // For example, to allow only verified users you could set it to
+ // "verified = true".
+ //
+ // Set it to empty string to allow any Auth collection record to authenticate.
+ //
+ // Set it to nil to disallow authentication altogether for the collection
+ // (that includes password, OAuth2, etc.).
+ AuthRule *string `form:"authRule" json:"authRule"`
+
+ // ManageRule gives admin-like permissions to allow fully managing
+ // the auth record(s), eg. changing the password without requiring
+ // to enter the old one, directly updating the verified state and email, etc.
+ //
+ // This rule is executed in addition to the Create and Update API rules.
+ ManageRule *string `form:"manageRule" json:"manageRule"`
+
+ // AuthAlert defines options related to the auth alerts on new device login.
+ AuthAlert AuthAlertConfig `form:"authAlert" json:"authAlert"`
+
+ // OAuth2 specifies whether OAuth2 auth is enabled for the collection
+ // and which OAuth2 providers are allowed.
+ OAuth2 OAuth2Config `form:"oauth2" json:"oauth2"`
+
+ // PasswordAuth defines options related to the collection password authentication.
+ PasswordAuth PasswordAuthConfig `form:"passwordAuth" json:"passwordAuth"`
+
+ // MFA defines options related to the Multi-factor authentication (MFA).
+ MFA MFAConfig `form:"mfa" json:"mfa"`
+
+ // OTP defines options related to the One-time password authentication (OTP).
+ OTP OTPConfig `form:"otp" json:"otp"`
+
+ // Various token configurations
+ // ---
+ AuthToken TokenConfig `form:"authToken" json:"authToken"`
+ PasswordResetToken TokenConfig `form:"passwordResetToken" json:"passwordResetToken"`
+ EmailChangeToken TokenConfig `form:"emailChangeToken" json:"emailChangeToken"`
+ VerificationToken TokenConfig `form:"verificationToken" json:"verificationToken"`
+ FileToken TokenConfig `form:"fileToken" json:"fileToken"`
+
+ // Default email templates
+ // ---
+ VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"`
+ ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"`
+ ConfirmEmailChangeTemplate EmailTemplate `form:"confirmEmailChangeTemplate" json:"confirmEmailChangeTemplate"`
+}
+
+func (o *collectionAuthOptions) validate(cv *collectionValidator) error {
+ err := validation.ValidateStruct(o,
+ validation.Field(
+ &o.AuthRule,
+ validation.By(cv.checkRule),
+ validation.By(cv.ensureNoSystemRuleChange(cv.original.AuthRule)),
+ ),
+ validation.Field(
+ &o.ManageRule,
+ validation.NilOrNotEmpty,
+ validation.By(cv.checkRule),
+ validation.By(cv.ensureNoSystemRuleChange(cv.original.ManageRule)),
+ ),
+ validation.Field(&o.AuthAlert),
+ validation.Field(&o.PasswordAuth),
+ validation.Field(&o.OAuth2),
+ validation.Field(&o.OTP),
+ validation.Field(&o.MFA),
+ validation.Field(&o.AuthToken),
+ validation.Field(&o.PasswordResetToken),
+ validation.Field(&o.EmailChangeToken),
+ validation.Field(&o.VerificationToken),
+ validation.Field(&o.FileToken),
+ validation.Field(&o.VerificationTemplate, validation.Required),
+ validation.Field(&o.ResetPasswordTemplate, validation.Required),
+ validation.Field(&o.ConfirmEmailChangeTemplate, validation.Required),
+ )
+ if err != nil {
+ return err
+ }
+
+ if o.MFA.Enabled {
+ // if MFA is enabled require at least 2 auth methods
+ //
+ // @todo maybe consider disabling the check because if custom auth methods
+ // are registered it may fail since we don't have mechanism to detect them at the moment
+ authsEnabled := 0
+ if o.PasswordAuth.Enabled {
+ authsEnabled++
+ }
+ if o.OAuth2.Enabled {
+ authsEnabled++
+ }
+ if o.OTP.Enabled {
+ authsEnabled++
+ }
+ if authsEnabled < 2 {
+ return validation.Errors{
+ "mfa": validation.Errors{
+ "enabled": validation.NewError("validation_mfa_not_enough_auths", "MFA requires at least 2 auth methods to be enabled."),
+ },
+ }
+ }
+
+ if o.MFA.Rule != "" {
+ mfaRuleValidators := []validation.RuleFunc{
+ cv.checkRule,
+ cv.ensureNoSystemRuleChange(&cv.original.MFA.Rule),
+ }
+
+ for _, validator := range mfaRuleValidators {
+ err := validator(&o.MFA.Rule)
+ if err != nil {
+ return validation.Errors{
+ "mfa": validation.Errors{
+ "rule": err,
+ },
+ }
+ }
+ }
+ }
+ }
+
+ // extra check to ensure that only unique identity fields are used
+ if o.PasswordAuth.Enabled {
+ err = validation.Validate(o.PasswordAuth.IdentityFields, validation.By(cv.checkFieldsForUniqueIndex))
+ if err != nil {
+ return validation.Errors{
+ "passwordAuth": validation.Errors{
+ "identityFields": err,
+ },
+ }
+ }
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+type EmailTemplate struct {
+ Subject string `form:"subject" json:"subject"`
+ Body string `form:"body" json:"body"`
+}
+
+// Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface.
+func (t EmailTemplate) Validate() error {
+ return validation.ValidateStruct(&t,
+ validation.Field(&t.Subject, validation.Required),
+ validation.Field(&t.Body, validation.Required),
+ )
+}
+
+// Resolve replaces the placeholder parameters in the current email
+// template and returns its components as ready-to-use strings.
+func (t EmailTemplate) Resolve(placeholders map[string]any) (subject, body string) {
+ body = t.Body
+ subject = t.Subject
+
+ for k, v := range placeholders {
+ vStr := cast.ToString(v)
+
+ // replace subject placeholder params (if any)
+ subject = strings.ReplaceAll(subject, k, vStr)
+
+ // replace body placeholder params (if any)
+ body = strings.ReplaceAll(body, k, vStr)
+ }
+
+ return subject, body
+}
+
+// -------------------------------------------------------------------
+
+type AuthAlertConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+ EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"`
+}
+
+// Validate makes AuthAlertConfig validatable by implementing [validation.Validatable] interface.
+func (c AuthAlertConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ // note: for now always run the email template validations even
+ // if not enabled since it could be used separately
+ validation.Field(&c.EmailTemplate),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type TokenConfig struct {
+ Secret string `form:"secret" json:"secret,omitempty"`
+
+ // Duration specifies how long an issued token to be valid (in seconds)
+ Duration int64 `form:"duration" json:"duration"`
+}
+
+// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface.
+func (c TokenConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Secret, validation.Required, validation.Length(30, 255)),
+ validation.Field(&c.Duration, validation.Required, validation.Min(10), validation.Max(94670856)), // ~3y max
+ )
+}
+
+// DurationTime returns the current Duration as [time.Duration].
+func (c TokenConfig) DurationTime() time.Duration {
+ return time.Duration(c.Duration) * time.Second
+}
+
+// -------------------------------------------------------------------
+
+type OTPConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+
+ // Duration specifies how long the OTP to be valid (in seconds)
+ Duration int64 `form:"duration" json:"duration"`
+
+ // Length specifies the auto generated password length.
+ Length int `form:"length" json:"length"`
+
+ // EmailTemplate is the default OTP email template that will be send to the auth record.
+ //
+ // In addition to the system placeholders you can also make use of
+ // [core.EmailPlaceholderOTPId] and [core.EmailPlaceholderOTP].
+ EmailTemplate EmailTemplate `form:"emailTemplate" json:"emailTemplate"`
+}
+
+// Validate makes OTPConfig validatable by implementing [validation.Validatable] interface.
+func (c OTPConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))),
+ validation.Field(&c.Length, validation.When(c.Enabled, validation.Required, validation.Min(4))),
+ // note: for now always run the email template validations even
+ // if not enabled since it could be used separately
+ validation.Field(&c.EmailTemplate),
+ )
+}
+
+// DurationTime returns the current Duration as [time.Duration].
+func (c OTPConfig) DurationTime() time.Duration {
+ return time.Duration(c.Duration) * time.Second
+}
+
+// -------------------------------------------------------------------
+
+type MFAConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+
+ // Duration specifies how long an issued MFA to be valid (in seconds)
+ Duration int64 `form:"duration" json:"duration"`
+
+ // Rule is an optional field to restrict MFA only for the records that satisfy the rule.
+ //
+ // Leave it empty to enable MFA for everyone.
+ Rule string `form:"rule" json:"rule"`
+}
+
+// Validate makes MFAConfig validatable by implementing [validation.Validatable] interface.
+func (c MFAConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Duration, validation.When(c.Enabled, validation.Required, validation.Min(10), validation.Max(86400))),
+ )
+}
+
+// DurationTime returns the current Duration as [time.Duration].
+func (c MFAConfig) DurationTime() time.Duration {
+ return time.Duration(c.Duration) * time.Second
+}
+
+// -------------------------------------------------------------------
+
+type PasswordAuthConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+
+ // IdentityFields is a list of field names that could be used as
+ // identity during password authentication.
+ //
+ // Usually only fields that has single column UNIQUE index are accepted as values.
+ IdentityFields []string `form:"identityFields" json:"identityFields"`
+}
+
+// Validate makes PasswordAuthConfig validatable by implementing [validation.Validatable] interface.
+func (c PasswordAuthConfig) Validate() error {
+ // strip duplicated values
+ c.IdentityFields = list.ToUniqueStringSlice(c.IdentityFields)
+
+ if !c.Enabled {
+ return nil // no need to validate
+ }
+
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.IdentityFields, validation.Required),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type OAuth2KnownFields struct {
+ Id string `form:"id" json:"id"`
+ Name string `form:"name" json:"name"`
+ Username string `form:"username" json:"username"`
+ AvatarURL string `form:"avatarURL" json:"avatarURL"`
+}
+
+type OAuth2Config struct {
+ Providers []OAuth2ProviderConfig `form:"providers" json:"providers"`
+
+ MappedFields OAuth2KnownFields `form:"mappedFields" json:"mappedFields"`
+
+ Enabled bool `form:"enabled" json:"enabled"`
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+//
+// The main difference from the standard unmarshalization is that
+// instead of replacing the entire providers config slice, we ensure
+// that partially submitted provider data (e.g. without clientSecret)
+// is merged on per config level based on the provider name
+// (https://github.com/pocketbase/pocketbase/issues/7815).
+func (c *OAuth2Config) UnmarshalJSON(b []byte) error {
+ originalProviders := slices.Clone(c.Providers)
+
+ type alias OAuth2Config
+ err := json.Unmarshal(b, (*alias)(c))
+ if err != nil {
+ return err
+ }
+
+ if len(c.Providers) == 0 {
+ return nil
+ }
+
+ // unmarshal again but this time into a plain array of objects
+ // so that we have only the submitted fields and no zero defaults
+ plain := struct {
+ Providers []map[string]any `json:"providers"`
+ }{}
+ err = json.Unmarshal(b, &plain)
+ if err != nil {
+ return err
+ }
+
+ // no providers were submitted
+ if len(plain.Providers) == 0 {
+ return nil
+ }
+
+ if len(c.Providers) != len(plain.Providers) {
+ return errors.New("the length of the plain unmarshalized providers and the ones from the config doesn't match")
+ }
+
+ProvidersMergeLoop:
+ for i, plain := range plain.Providers {
+ for _, original := range originalProviders {
+ if original.Name == plain["name"] {
+ raw, err := json.Marshal(plain)
+ if err != nil {
+ return err
+ }
+
+ // unmarshal the new plain data on top of the original one
+ err = json.Unmarshal(raw, &original)
+ if err != nil {
+ return err
+ }
+
+ // reassigne to the updated original
+ c.Providers[i] = original
+
+ continue ProvidersMergeLoop
+ }
+ }
+ }
+
+ return nil
+}
+
+// GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name.
+//
+// Returns false and zero config if no such provider is available in c.Providers.
+func (c OAuth2Config) GetProviderConfig(name string) (config OAuth2ProviderConfig, exists bool) {
+ for _, p := range c.Providers {
+ if p.Name == name {
+ return p, true
+ }
+ }
+ return
+}
+
+// Validate makes OAuth2Config validatable by implementing [validation.Validatable] interface.
+func (c OAuth2Config) Validate() error {
+ if !c.Enabled {
+ return nil // no need to validate
+ }
+
+ return validation.ValidateStruct(&c,
+ // note: don't require providers for now as they could be externally registered/removed
+ validation.Field(&c.Providers, validation.By(checkForDuplicatedProviders)),
+ )
+}
+
+func checkForDuplicatedProviders(value any) error {
+ configs, _ := value.([]OAuth2ProviderConfig)
+
+ existing := map[string]struct{}{}
+
+ for i, c := range configs {
+ if c.Name == "" {
+ continue // the name nonempty state is validated separately
+ }
+ if _, ok := existing[c.Name]; ok {
+ return validation.Errors{
+ strconv.Itoa(i): validation.Errors{
+ "name": validation.NewError("validation_duplicated_provider", "The provider {{.name}} is already registered.").
+ SetParams(map[string]any{"name": c.Name}),
+ },
+ }
+ }
+ existing[c.Name] = struct{}{}
+ }
+
+ return nil
+}
+
+type OAuth2ProviderConfig struct {
+ // PKCE overwrites the default provider PKCE config option.
+ //
+ // This usually shouldn't be needed but some OAuth2 vendors, like the LinkedIn OIDC,
+ // may require manual adjustment due to returning error if extra parameters are added to the request
+ // (https://github.com/pocketbase/pocketbase/discussions/3799#discussioncomment-7640312)
+ PKCE *bool `form:"pkce" json:"pkce"`
+
+ Name string `form:"name" json:"name"`
+ ClientId string `form:"clientId" json:"clientId"`
+ ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
+ AuthURL string `form:"authURL" json:"authURL"`
+ TokenURL string `form:"tokenURL" json:"tokenURL"`
+ UserInfoURL string `form:"userInfoURL" json:"userInfoURL"`
+ DisplayName string `form:"displayName" json:"displayName"`
+ Extra map[string]any `form:"extra" json:"extra"`
+}
+
+// Validate makes OAuth2ProviderConfig validatable by implementing [validation.Validatable] interface.
+func (c OAuth2ProviderConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Name, validation.Required, validation.By(checkProviderName)),
+ validation.Field(&c.ClientId, validation.Required),
+ validation.Field(&c.ClientSecret, validation.Required),
+ validation.Field(&c.AuthURL, is.URL),
+ validation.Field(&c.TokenURL, is.URL),
+ validation.Field(&c.UserInfoURL, is.URL),
+ )
+}
+
+func checkProviderName(value any) error {
+ name, _ := value.(string)
+ if name == "" {
+ return nil // nothing to check
+ }
+
+ if _, err := auth.NewProviderByName(name); err != nil {
+ return validation.NewError("validation_missing_provider", "Invalid or missing provider with name {{.name}}.").
+ SetParams(map[string]any{"name": name})
+ }
+
+ return nil
+}
+
+// InitProvider returns a new auth.Provider instance loaded with the current OAuth2ProviderConfig options.
+func (c OAuth2ProviderConfig) InitProvider() (auth.Provider, error) {
+ provider, err := auth.NewProviderByName(c.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ if c.ClientId != "" {
+ provider.SetClientId(c.ClientId)
+ }
+
+ if c.ClientSecret != "" {
+ provider.SetClientSecret(c.ClientSecret)
+ }
+
+ if c.AuthURL != "" {
+ provider.SetAuthURL(c.AuthURL)
+ }
+
+ if c.UserInfoURL != "" {
+ provider.SetUserInfoURL(c.UserInfoURL)
+ }
+
+ if c.TokenURL != "" {
+ provider.SetTokenURL(c.TokenURL)
+ }
+
+ if c.DisplayName != "" {
+ provider.SetDisplayName(c.DisplayName)
+ }
+
+ if c.PKCE != nil {
+ provider.SetPKCE(*c.PKCE)
+ }
+
+ if c.Extra != nil {
+ provider.SetExtra(c.Extra)
+ }
+
+ return provider, nil
+}
diff --git a/core/collection_model_auth_options_test.go b/core/collection_model_auth_options_test.go
new file mode 100644
index 0000000000..284ebf7409
--- /dev/null
+++ b/core/collection_model_auth_options_test.go
@@ -0,0 +1,1096 @@
+package core_test
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestCollectionAuthOptionsValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ collection func(app core.App) (*core.Collection, error)
+ expectedErrors []string
+ }{
+ // authRule
+ {
+ name: "nil authRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.AuthRule = nil
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "empty authRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.AuthRule = types.Pointer("")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "invalid authRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.AuthRule = types.Pointer("missing != ''")
+ return c, nil
+ },
+ expectedErrors: []string{"authRule"},
+ },
+ {
+ name: "valid authRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.AuthRule = types.Pointer("id != ''")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // manageRule
+ {
+ name: "nil manageRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ManageRule = nil
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "empty manageRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ManageRule = types.Pointer("")
+ return c, nil
+ },
+ expectedErrors: []string{"manageRule"},
+ },
+ {
+ name: "invalid manageRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ManageRule = types.Pointer("missing != ''")
+ return c, nil
+ },
+ expectedErrors: []string{"manageRule"},
+ },
+ {
+ name: "valid manageRule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ManageRule = types.Pointer("id != ''")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // passwordAuth
+ {
+ name: "trigger passwordAuth validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.PasswordAuth = core.PasswordAuthConfig{
+ Enabled: true,
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"passwordAuth"},
+ },
+ {
+ name: "passwordAuth with non-unique identity fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(&core.TextField{Name: "test"})
+ c.PasswordAuth = core.PasswordAuthConfig{
+ Enabled: true,
+ IdentityFields: []string{"email", "test"},
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"passwordAuth"},
+ },
+ {
+ name: "passwordAuth with non-unique identity fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(&core.TextField{Name: "test"})
+ c.AddIndex("auth_test_idx", true, "test", "")
+ c.PasswordAuth = core.PasswordAuthConfig{
+ Enabled: true,
+ IdentityFields: []string{"email", "test"},
+ }
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // oauth2
+ {
+ name: "trigger oauth2 validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.OAuth2 = core.OAuth2Config{
+ Enabled: true,
+ Providers: []core.OAuth2ProviderConfig{
+ {Name: "missing"},
+ },
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"oauth2"},
+ },
+
+ // otp
+ {
+ name: "trigger otp validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.OTP = core.OTPConfig{
+ Enabled: true,
+ Duration: -10,
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"otp"},
+ },
+
+ // mfa
+ {
+ name: "trigger mfa validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.MFA = core.MFAConfig{
+ Enabled: true,
+ Duration: -10,
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"mfa"},
+ },
+ {
+ name: "mfa enabled with < 2 auth methods",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.MFA.Enabled = true
+ c.PasswordAuth.Enabled = true
+ c.OTP.Enabled = false
+ c.OAuth2.Enabled = false
+ return c, nil
+ },
+ expectedErrors: []string{"mfa"},
+ },
+ {
+ name: "mfa enabled with >= 2 auth methods",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.MFA.Enabled = true
+ c.PasswordAuth.Enabled = true
+ c.OTP.Enabled = true
+ c.OAuth2.Enabled = false
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "mfa disabled with invalid rule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.PasswordAuth.Enabled = true
+ c.OTP.Enabled = true
+ c.MFA.Enabled = false
+ c.MFA.Rule = "invalid"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "mfa enabled with invalid rule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.PasswordAuth.Enabled = true
+ c.OTP.Enabled = true
+ c.MFA.Enabled = true
+ c.MFA.Rule = "invalid"
+ return c, nil
+ },
+ expectedErrors: []string{"mfa"},
+ },
+ {
+ name: "mfa enabled with valid rule",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.PasswordAuth.Enabled = true
+ c.OTP.Enabled = true
+ c.MFA.Enabled = true
+ c.MFA.Rule = "1=1"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // tokens
+ {
+ name: "trigger authToken validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.AuthToken.Secret = ""
+ return c, nil
+ },
+ expectedErrors: []string{"authToken"},
+ },
+ {
+ name: "trigger passwordResetToken validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.PasswordResetToken.Secret = ""
+ return c, nil
+ },
+ expectedErrors: []string{"passwordResetToken"},
+ },
+ {
+ name: "trigger emailChangeToken validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.EmailChangeToken.Secret = ""
+ return c, nil
+ },
+ expectedErrors: []string{"emailChangeToken"},
+ },
+ {
+ name: "trigger verificationToken validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.VerificationToken.Secret = ""
+ return c, nil
+ },
+ expectedErrors: []string{"verificationToken"},
+ },
+ {
+ name: "trigger fileToken validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.FileToken.Secret = ""
+ return c, nil
+ },
+ expectedErrors: []string{"fileToken"},
+ },
+
+ // templates
+ {
+ name: "trigger verificationTemplate validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.VerificationTemplate.Body = ""
+ return c, nil
+ },
+ expectedErrors: []string{"verificationTemplate"},
+ },
+ {
+ name: "trigger resetPasswordTemplate validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ResetPasswordTemplate.Body = ""
+ return c, nil
+ },
+ expectedErrors: []string{"resetPasswordTemplate"},
+ },
+ {
+ name: "trigger confirmEmailChangeTemplate validations",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.ConfirmEmailChangeTemplate.Body = ""
+ return c, nil
+ },
+ expectedErrors: []string{"confirmEmailChangeTemplate"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := s.collection(app)
+ if err != nil {
+ t.Fatalf("Failed to retrieve test collection: %v", err)
+ }
+
+ result := app.Validate(collection)
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestEmailTemplateValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ template core.EmailTemplate
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.EmailTemplate{},
+ []string{"subject", "body"},
+ },
+ {
+ "non-empty data",
+ core.EmailTemplate{
+ Subject: "a",
+ Body: "b",
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.template.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestEmailTemplateResolve(t *testing.T) {
+ template := core.EmailTemplate{
+ Subject: "test_subject {PARAM3} {PARAM1}-{PARAM2} repeat-{PARAM1}",
+ Body: "test_body {PARAM3} {PARAM2}-{PARAM1} repeat-{PARAM2}",
+ }
+
+ scenarios := []struct {
+ name string
+ placeholders map[string]any
+ template core.EmailTemplate
+ expectedSubject string
+ expectedBody string
+ }{
+ {
+ "no placeholders",
+ nil,
+ template,
+ template.Subject,
+ template.Body,
+ },
+ {
+ "no matching placeholders",
+ map[string]any{"{A}": "abc", "{B}": 456},
+ template,
+ template.Subject,
+ template.Body,
+ },
+ {
+ "at least one matching placeholder",
+ map[string]any{"{PARAM1}": "abc", "{PARAM2}": 456},
+ template,
+ "test_subject {PARAM3} abc-456 repeat-abc",
+ "test_body {PARAM3} 456-abc repeat-456",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ subject, body := s.template.Resolve(s.placeholders)
+
+ if subject != s.expectedSubject {
+ t.Fatalf("Expected subject\n%v\ngot\n%v", s.expectedSubject, subject)
+ }
+
+ if body != s.expectedBody {
+ t.Fatalf("Expected body\n%v\ngot\n%v", s.expectedBody, body)
+ }
+ })
+ }
+}
+
+func TestTokenConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.TokenConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.TokenConfig{},
+ []string{"secret", "duration"},
+ },
+ {
+ "invalid data",
+ core.TokenConfig{
+ Secret: strings.Repeat("a", 29),
+ Duration: 9,
+ },
+ []string{"secret", "duration"},
+ },
+ {
+ "valid data",
+ core.TokenConfig{
+ Secret: strings.Repeat("a", 30),
+ Duration: 10,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestTokenConfigDurationTime(t *testing.T) {
+ scenarios := []struct {
+ config core.TokenConfig
+ expected time.Duration
+ }{
+ {core.TokenConfig{}, 0 * time.Second},
+ {core.TokenConfig{Duration: 1234}, 1234 * time.Second},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
+ result := s.config.DurationTime()
+
+ if result != s.expected {
+ t.Fatalf("Expected duration %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestAuthAlertConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.AuthAlertConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.AuthAlertConfig{},
+ []string{"emailTemplate"},
+ },
+ {
+ "zero value (enabled)",
+ core.AuthAlertConfig{Enabled: true},
+ []string{"emailTemplate"},
+ },
+ {
+ "invalid template",
+ core.AuthAlertConfig{
+ EmailTemplate: core.EmailTemplate{Body: "", Subject: "b"},
+ },
+ []string{"emailTemplate"},
+ },
+ {
+ "valid data",
+ core.AuthAlertConfig{
+ EmailTemplate: core.EmailTemplate{Body: "a", Subject: "b"},
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestOTPConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.OTPConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.OTPConfig{},
+ []string{"emailTemplate"},
+ },
+ {
+ "zero value (enabled)",
+ core.OTPConfig{Enabled: true},
+ []string{"duration", "length", "emailTemplate"},
+ },
+ {
+ "invalid length (< 3)",
+ core.OTPConfig{
+ Enabled: true,
+ EmailTemplate: core.EmailTemplate{Body: "a", Subject: "b"},
+ Duration: 100,
+ Length: 3,
+ },
+ []string{"length"},
+ },
+ {
+ "invalid duration (< 10)",
+ core.OTPConfig{
+ Enabled: true,
+ EmailTemplate: core.EmailTemplate{Body: "a", Subject: "b"},
+ Duration: 9,
+ Length: 100,
+ },
+ []string{"duration"},
+ },
+ {
+ "invalid duration (> 86400)",
+ core.OTPConfig{
+ Enabled: true,
+ EmailTemplate: core.EmailTemplate{Body: "a", Subject: "b"},
+ Duration: 86401,
+ Length: 100,
+ },
+ []string{"duration"},
+ },
+ {
+ "invalid template (triggering EmailTemplate validations)",
+ core.OTPConfig{
+ Enabled: true,
+ EmailTemplate: core.EmailTemplate{Body: "", Subject: "b"},
+ Duration: 86400,
+ Length: 4,
+ },
+ []string{"emailTemplate"},
+ },
+ {
+ "valid data",
+ core.OTPConfig{
+ Enabled: true,
+ EmailTemplate: core.EmailTemplate{Body: "a", Subject: "b"},
+ Duration: 86400,
+ Length: 4,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestOTPConfigDurationTime(t *testing.T) {
+ scenarios := []struct {
+ config core.OTPConfig
+ expected time.Duration
+ }{
+ {core.OTPConfig{}, 0 * time.Second},
+ {core.OTPConfig{Duration: 1234}, 1234 * time.Second},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
+ result := s.config.DurationTime()
+
+ if result != s.expected {
+ t.Fatalf("Expected duration %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestMFAConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.MFAConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.MFAConfig{},
+ []string{},
+ },
+ {
+ "zero value (enabled)",
+ core.MFAConfig{Enabled: true},
+ []string{"duration"},
+ },
+ {
+ "invalid duration (< 10)",
+ core.MFAConfig{Enabled: true, Duration: 9},
+ []string{"duration"},
+ },
+ {
+ "invalid duration (> 86400)",
+ core.MFAConfig{Enabled: true, Duration: 86401},
+ []string{"duration"},
+ },
+ {
+ "valid data",
+ core.MFAConfig{Enabled: true, Duration: 86400},
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestMFAConfigDurationTime(t *testing.T) {
+ scenarios := []struct {
+ config core.MFAConfig
+ expected time.Duration
+ }{
+ {core.MFAConfig{}, 0 * time.Second},
+ {core.MFAConfig{Duration: 1234}, 1234 * time.Second},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.config.Duration), func(t *testing.T) {
+ result := s.config.DurationTime()
+
+ if result != s.expected {
+ t.Fatalf("Expected duration %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestPasswordAuthConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.PasswordAuthConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.PasswordAuthConfig{},
+ []string{},
+ },
+ {
+ "zero value (enabled)",
+ core.PasswordAuthConfig{Enabled: true},
+ []string{"identityFields"},
+ },
+ {
+ "empty values",
+ core.PasswordAuthConfig{Enabled: true, IdentityFields: []string{"", ""}},
+ []string{"identityFields"},
+ },
+ {
+ "valid data",
+ core.PasswordAuthConfig{Enabled: true, IdentityFields: []string{"abc"}},
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestOAuth2ConfigUnmarshalJSON(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ newJSON string
+ expected string
+ }{
+ {
+ "missing",
+ `{
+ "enabled": true,
+ "mappedFields": {"username": "username_test"}
+ }`,
+ `{"providers":[{"pkce":null,"name":"a","clientId":"a_clientId","clientSecret":"a_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}},{"pkce":null,"name":"b","clientId":"b_clientId","clientSecret":"b_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}}],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
+ },
+ {
+ "empty",
+ `{
+ "enabled": true,
+ "mappedFields": {"username": "username_test"},
+ "providers": []
+ }`,
+ `{"providers":[],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
+ },
+ {
+ "non-empty",
+ `{
+ "enabled": true,
+ "mappedFields": {"username": "username_test"},
+ "providers": [
+ {"name": "c", "clientId": "c_clientId", "clientSecret": "c_clientSecret"},
+ {"name": "a", "displayName": "a_displayName"}
+ ]
+ }`,
+ `{"providers":[{"pkce":null,"name":"c","clientId":"c_clientId","clientSecret":"c_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}},{"pkce":null,"name":"a","clientId":"a_clientId","clientSecret":"a_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"a_displayName","extra":{}}],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ config := core.OAuth2Config{
+ Enabled: false,
+ MappedFields: core.OAuth2KnownFields{
+ Name: "name_test",
+ },
+ Providers: []core.OAuth2ProviderConfig{
+ {Name: "a", ClientId: "a_clientId", ClientSecret: "a_clientSecret"},
+ {Name: "b", ClientId: "b_clientId", ClientSecret: "b_clientSecret"},
+ },
+ }
+
+ err := json.Unmarshal([]byte(s.newJSON), &config)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(config, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected OAuth2ProviderConfig\n%s\ngot\n%s", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestOAuth2ConfigGetProviderConfig(t *testing.T) {
+ scenarios := []struct {
+ name string
+ providerName string
+ config core.OAuth2Config
+ expectedExists bool
+ }{
+ {
+ "zero value",
+ "gitlab",
+ core.OAuth2Config{},
+ false,
+ },
+ {
+ "empty config with valid provider",
+ "gitlab",
+ core.OAuth2Config{},
+ false,
+ },
+ {
+ "non-empty config with missing provider",
+ "gitlab",
+ core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: "google"}, {Name: "github"}}},
+ false,
+ },
+ {
+ "config with existing provider",
+ "github",
+ core.OAuth2Config{Providers: []core.OAuth2ProviderConfig{{Name: "google"}, {Name: "github"}}},
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ config, exists := s.config.GetProviderConfig(s.providerName)
+
+ if exists != s.expectedExists {
+ t.Fatalf("Expected exists %v, got %v", s.expectedExists, exists)
+ }
+
+ if exists {
+ if config.Name != s.providerName {
+ t.Fatalf("Expected config with name %q, got %q", s.providerName, config.Name)
+ }
+ } else {
+ if config.Name != "" {
+ t.Fatalf("Expected empty config, got %v", config)
+ }
+ }
+ })
+ }
+}
+
+func TestOAuth2ConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.OAuth2Config
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.OAuth2Config{},
+ []string{},
+ },
+ {
+ "zero value (enabled)",
+ core.OAuth2Config{Enabled: true},
+ []string{},
+ },
+ {
+ "unknown provider",
+ core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
+ {Name: "missing", ClientId: "abc", ClientSecret: "456"},
+ }},
+ []string{"providers"},
+ },
+ {
+ "known provider with invalid data",
+ core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
+ {Name: "gitlab", ClientId: "abc", TokenURL: "!invalid!"},
+ }},
+ []string{"providers"},
+ },
+ {
+ "known provider with valid data",
+ core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
+ {Name: "gitlab", ClientId: "abc", ClientSecret: "456", TokenURL: "https://example.com"},
+ }},
+ []string{},
+ },
+ {
+ "known provider with valid data (duplicated)",
+ core.OAuth2Config{Enabled: true, Providers: []core.OAuth2ProviderConfig{
+ {Name: "gitlab", ClientId: "abc1", ClientSecret: "1", TokenURL: "https://example1.com"},
+ {Name: "google", ClientId: "abc2", ClientSecret: "2", TokenURL: "https://example2.com"},
+ {Name: "gitlab", ClientId: "abc3", ClientSecret: "3", TokenURL: "https://example3.com"},
+ }},
+ []string{"providers"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestOAuth2ProviderConfigValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.OAuth2ProviderConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.OAuth2ProviderConfig{},
+ []string{"name", "clientId", "clientSecret"},
+ },
+ {
+ "minimum valid data",
+ core.OAuth2ProviderConfig{Name: "gitlab", ClientId: "abc", ClientSecret: "456"},
+ []string{},
+ },
+ {
+ "non-existing provider",
+ core.OAuth2ProviderConfig{Name: "missing", ClientId: "abc", ClientSecret: "456"},
+ []string{"name"},
+ },
+ {
+ "invalid urls",
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ ClientId: "abc",
+ ClientSecret: "456",
+ AuthURL: "!invalid!",
+ TokenURL: "!invalid!",
+ UserInfoURL: "!invalid!",
+ },
+ []string{"authURL", "tokenURL", "userInfoURL"},
+ },
+ {
+ "valid urls",
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ ClientId: "abc",
+ ClientSecret: "456",
+ AuthURL: "https://example.com/a",
+ TokenURL: "https://example.com/b",
+ UserInfoURL: "https://example.com/c",
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestOAuth2ProviderConfigInitProvider(t *testing.T) {
+ scenarios := []struct {
+ name string
+ config core.OAuth2ProviderConfig
+ expectedConfig core.OAuth2ProviderConfig
+ expectedError bool
+ }{
+ {
+ "empty config",
+ core.OAuth2ProviderConfig{},
+ core.OAuth2ProviderConfig{},
+ true,
+ },
+ {
+ "missing provider",
+ core.OAuth2ProviderConfig{
+ Name: "missing",
+ ClientId: "test_ClientId",
+ ClientSecret: "test_ClientSecret",
+ AuthURL: "test_AuthURL",
+ TokenURL: "test_TokenURL",
+ UserInfoURL: "test_UserInfoURL",
+ DisplayName: "test_DisplayName",
+ PKCE: types.Pointer(true),
+ },
+ core.OAuth2ProviderConfig{
+ Name: "missing",
+ ClientId: "test_ClientId",
+ ClientSecret: "test_ClientSecret",
+ AuthURL: "test_AuthURL",
+ TokenURL: "test_TokenURL",
+ UserInfoURL: "test_UserInfoURL",
+ DisplayName: "test_DisplayName",
+ PKCE: types.Pointer(true),
+ },
+ true,
+ },
+ {
+ "existing provider minimal",
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ },
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ ClientId: "",
+ ClientSecret: "",
+ AuthURL: "https://gitlab.com/oauth/authorize",
+ TokenURL: "https://gitlab.com/oauth/token",
+ UserInfoURL: "https://gitlab.com/api/v4/user",
+ DisplayName: "GitLab",
+ PKCE: types.Pointer(true),
+ },
+ false,
+ },
+ {
+ "existing provider with all fields",
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ ClientId: "test_ClientId",
+ ClientSecret: "test_ClientSecret",
+ AuthURL: "test_AuthURL",
+ TokenURL: "test_TokenURL",
+ UserInfoURL: "test_UserInfoURL",
+ DisplayName: "test_DisplayName",
+ PKCE: types.Pointer(true),
+ Extra: map[string]any{"a": 1},
+ },
+ core.OAuth2ProviderConfig{
+ Name: "gitlab",
+ ClientId: "test_ClientId",
+ ClientSecret: "test_ClientSecret",
+ AuthURL: "test_AuthURL",
+ TokenURL: "test_TokenURL",
+ UserInfoURL: "test_UserInfoURL",
+ DisplayName: "test_DisplayName",
+ PKCE: types.Pointer(true),
+ Extra: map[string]any{"a": 1},
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ provider, err := s.config.InitProvider()
+
+ hasErr := err != nil
+ if hasErr != s.expectedError {
+ t.Fatalf("Expected hasErr %v, got %v", s.expectedError, hasErr)
+ }
+
+ if hasErr {
+ if provider != nil {
+ t.Fatalf("Expected nil provider, got %v", provider)
+ }
+ return
+ }
+
+ factory, ok := auth.Providers[s.expectedConfig.Name]
+ if !ok {
+ t.Fatalf("Missing factory for provider %q", s.expectedConfig.Name)
+ }
+
+ expectedType := fmt.Sprintf("%T", factory())
+ providerType := fmt.Sprintf("%T", provider)
+ if expectedType != providerType {
+ t.Fatalf("Expected provider instanceof %q, got %q", expectedType, providerType)
+ }
+
+ if provider.ClientId() != s.expectedConfig.ClientId {
+ t.Fatalf("Expected ClientId %q, got %q", s.expectedConfig.ClientId, provider.ClientId())
+ }
+
+ if provider.ClientSecret() != s.expectedConfig.ClientSecret {
+ t.Fatalf("Expected ClientSecret %q, got %q", s.expectedConfig.ClientSecret, provider.ClientSecret())
+ }
+
+ if provider.AuthURL() != s.expectedConfig.AuthURL {
+ t.Fatalf("Expected AuthURL %q, got %q", s.expectedConfig.AuthURL, provider.AuthURL())
+ }
+
+ if provider.UserInfoURL() != s.expectedConfig.UserInfoURL {
+ t.Fatalf("Expected UserInfoURL %q, got %q", s.expectedConfig.UserInfoURL, provider.UserInfoURL())
+ }
+
+ if provider.TokenURL() != s.expectedConfig.TokenURL {
+ t.Fatalf("Expected TokenURL %q, got %q", s.expectedConfig.TokenURL, provider.TokenURL())
+ }
+
+ if provider.DisplayName() != s.expectedConfig.DisplayName {
+ t.Fatalf("Expected DisplayName %q, got %q", s.expectedConfig.DisplayName, provider.DisplayName())
+ }
+
+ if provider.PKCE() != *s.expectedConfig.PKCE {
+ t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
+ }
+
+ rawMeta, _ := json.Marshal(provider.Extra(), json.Deterministic(true))
+ expectedMeta, _ := json.Marshal(s.expectedConfig.Extra, json.Deterministic(true))
+ if !bytes.Equal(rawMeta, expectedMeta) {
+ t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
+ }
+ })
+ }
+}
diff --git a/core/collection_model_auth_templates.go b/core/collection_model_auth_templates.go
new file mode 100644
index 0000000000..64ff6e9dc0
--- /dev/null
+++ b/core/collection_model_auth_templates.go
@@ -0,0 +1,78 @@
+package core
+
+// Common settings placeholder tokens
+const (
+ EmailPlaceholderAppName string = "{APP_NAME}"
+ EmailPlaceholderAppURL string = "{APP_URL}"
+ EmailPlaceholderToken string = "{TOKEN}"
+ EmailPlaceholderOTP string = "{OTP}"
+ EmailPlaceholderOTPId string = "{OTP_ID}"
+ EmailPlaceholderAlertInfo string = "{ALERT_INFO}"
+)
+
+var defaultVerificationTemplate = EmailTemplate{
+ Subject: "Verify your " + EmailPlaceholderAppName + " email",
+ Body: `Hello,
+Thank you for joining us at ` + EmailPlaceholderAppName + `.
+Click on the button below to verify your email address.
+
+ Verify
+
+If you didn't recently register, please ignore this email.
+
+ Thanks,
+ ` + EmailPlaceholderAppName + ` team
+
`,
+}
+
+var defaultResetPasswordTemplate = EmailTemplate{
+ Subject: "Reset your " + EmailPlaceholderAppName + " password",
+ Body: `Hello,
+Click on the button below to reset your password.
+
+ Reset password
+
+If you didn't ask to reset your password, please ignore this email.
+
+ Thanks,
+ ` + EmailPlaceholderAppName + ` team
+
`,
+}
+
+var defaultConfirmEmailChangeTemplate = EmailTemplate{
+ Subject: "Confirm your " + EmailPlaceholderAppName + " new email address",
+ Body: `Hello,
+Click on the button below to confirm your new email address.
+
+ Confirm new email
+
+If you didn't ask to change your email address, please ignore this email.
+
+ Thanks,
+ ` + EmailPlaceholderAppName + ` team
+
`,
+}
+
+var defaultOTPTemplate = EmailTemplate{
+ Subject: "OTP for " + EmailPlaceholderAppName,
+ Body: `Hello,
+Your one-time password is: ` + EmailPlaceholderOTP + `
+If you didn't ask for the one-time password, you can ignore this email.
+
+ Thanks,
+ ` + EmailPlaceholderAppName + ` team
+
`,
+}
+
+var defaultAuthAlertTemplate = EmailTemplate{
+ Subject: "Login from a new location",
+ Body: `Hello,
+We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location:
+` + EmailPlaceholderAlertInfo + `
+If this wasn't you, you should immediately change your ` + EmailPlaceholderAppName + ` account password to revoke access from all other locations.
+If this was you, you may disregard this email.
+
+ Thanks,
+ ` + EmailPlaceholderAppName + ` team
+
`,
+}
diff --git a/core/collection_model_base_options.go b/core/collection_model_base_options.go
new file mode 100644
index 0000000000..924312bf5b
--- /dev/null
+++ b/core/collection_model_base_options.go
@@ -0,0 +1,11 @@
+package core
+
+var _ optionsValidator = (*collectionBaseOptions)(nil)
+
+// collectionBaseOptions defines the options for the "base" type collection.
+type collectionBaseOptions struct {
+}
+
+func (o *collectionBaseOptions) validate(cv *collectionValidator) error {
+ return nil
+}
diff --git a/core/collection_model_test.go b/core/collection_model_test.go
new file mode 100644
index 0000000000..e68095a94c
--- /dev/null
+++ b/core/collection_model_test.go
@@ -0,0 +1,1717 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNewCollection(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ typ string
+ name string
+ expected []string
+ }{
+ {
+ "",
+ "",
+ []string{
+ `"id":"pbc_`,
+ `"name":""`,
+ `"type":"base"`,
+ `"system":false`,
+ `"indexes":[]`,
+ `"fields":[{`,
+ `"name":"id"`,
+ `"type":"text"`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "unknown",
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"base"`,
+ `"system":false`,
+ `"indexes":[]`,
+ `"fields":[{`,
+ `"name":"id"`,
+ `"type":"text"`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "base",
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"base"`,
+ `"system":false`,
+ `"indexes":[]`,
+ `"fields":[{`,
+ `"name":"id"`,
+ `"type":"text"`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "view",
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"view"`,
+ `"indexes":[]`,
+ `"fields":[]`,
+ `"system":false`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "auth",
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"auth"`,
+ `"fields":[{`,
+ `"system":false`,
+ `"type":"text"`,
+ `"type":"email"`,
+ `"name":"id"`,
+ `"name":"email"`,
+ `"name":"password"`,
+ `"name":"tokenKey"`,
+ `"name":"emailVisibility"`,
+ `"name":"verified"`,
+ `idx_email`,
+ `idx_tokenKey`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ `"identityFields":["email"]`,
+ },
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.typ, s.name), func(t *testing.T) {
+ result := core.NewCollection(s.typ, s.name).String()
+
+ for _, part := range s.expected {
+ if !strings.Contains(result, part) {
+ t.Fatalf("Missing part %q in\n%v", part, result)
+ }
+ }
+ })
+ }
+}
+
+func TestNewBaseCollection(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ expected []string
+ }{
+ {
+ "",
+ []string{
+ `"id":"pbc_`,
+ `"name":""`,
+ `"type":"base"`,
+ `"system":false`,
+ `"indexes":[]`,
+ `"fields":[{`,
+ `"name":"id"`,
+ `"type":"text"`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"base"`,
+ `"system":false`,
+ `"indexes":[]`,
+ `"fields":[{`,
+ `"name":"id"`,
+ `"type":"text"`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
+ result := core.NewBaseCollection(s.name).String()
+
+ for _, part := range s.expected {
+ if !strings.Contains(result, part) {
+ t.Fatalf("Missing part %q in\n%v", part, result)
+ }
+ }
+ })
+ }
+}
+
+func TestNewViewCollection(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ expected []string
+ }{
+ {
+ "",
+ []string{
+ `"id":"pbc_`,
+ `"name":""`,
+ `"type":"view"`,
+ `"indexes":[]`,
+ `"fields":[]`,
+ `"system":false`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ {
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"view"`,
+ `"indexes":[]`,
+ `"fields":[]`,
+ `"system":false`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ },
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
+ result := core.NewViewCollection(s.name).String()
+
+ for _, part := range s.expected {
+ if !strings.Contains(result, part) {
+ t.Fatalf("Missing part %q in\n%v", part, result)
+ }
+ }
+ })
+ }
+}
+
+func TestNewAuthCollection(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ expected []string
+ }{
+ {
+ "",
+ []string{
+ `"id":""`,
+ `"name":""`,
+ `"type":"auth"`,
+ `"fields":[{`,
+ `"system":false`,
+ `"type":"text"`,
+ `"type":"email"`,
+ `"name":"id"`,
+ `"name":"email"`,
+ `"name":"password"`,
+ `"name":"tokenKey"`,
+ `"name":"emailVisibility"`,
+ `"name":"verified"`,
+ `idx_email`,
+ `idx_tokenKey`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ `"identityFields":["email"]`,
+ },
+ },
+ {
+ "test",
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"auth"`,
+ `"fields":[{`,
+ `"system":false`,
+ `"type":"text"`,
+ `"type":"email"`,
+ `"name":"id"`,
+ `"name":"email"`,
+ `"name":"password"`,
+ `"name":"tokenKey"`,
+ `"name":"emailVisibility"`,
+ `"name":"verified"`,
+ `idx_email`,
+ `idx_tokenKey`,
+ `"listRule":null`,
+ `"viewRule":null`,
+ `"createRule":null`,
+ `"updateRule":null`,
+ `"deleteRule":null`,
+ `"identityFields":["email"]`,
+ },
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
+ result := core.NewAuthCollection(s.name).String()
+
+ for _, part := range s.expected {
+ if !strings.Contains(result, part) {
+ t.Fatalf("Missing part %q in\n%v", part, result)
+ }
+ }
+ })
+ }
+}
+
+func TestCollectionTableName(t *testing.T) {
+ t.Parallel()
+
+ c := core.NewBaseCollection("test")
+ if c.TableName() != "_collections" {
+ t.Fatalf("Expected tableName %q, got %q", "_collections", c.TableName())
+ }
+}
+
+func TestCollectionBaseFilesPath(t *testing.T) {
+ t.Parallel()
+
+ c := core.Collection{}
+
+ if c.BaseFilesPath() != "" {
+ t.Fatalf("Expected empty string, got %q", c.BaseFilesPath())
+ }
+
+ c.Id = "test"
+
+ if c.BaseFilesPath() != c.Id {
+ t.Fatalf("Expected %q, got %q", c.Id, c.BaseFilesPath())
+ }
+}
+
+func TestCollectionIsBase(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ typ string
+ expected bool
+ }{
+ {"unknown", false},
+ {core.CollectionTypeBase, true},
+ {core.CollectionTypeView, false},
+ {core.CollectionTypeAuth, false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.typ, func(t *testing.T) {
+ c := core.Collection{}
+ c.Type = s.typ
+
+ if v := c.IsBase(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestCollectionIsView(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ typ string
+ expected bool
+ }{
+ {"unknown", false},
+ {core.CollectionTypeBase, false},
+ {core.CollectionTypeView, true},
+ {core.CollectionTypeAuth, false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.typ, func(t *testing.T) {
+ c := core.Collection{}
+ c.Type = s.typ
+
+ if v := c.IsView(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestCollectionIsAuth(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ typ string
+ expected bool
+ }{
+ {"unknown", false},
+ {core.CollectionTypeBase, false},
+ {core.CollectionTypeView, false},
+ {core.CollectionTypeAuth, true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.typ, func(t *testing.T) {
+ c := core.Collection{}
+ c.Type = s.typ
+
+ if v := c.IsAuth(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestCollectionPostScan(t *testing.T) {
+ t.Parallel()
+
+ rawOptions := types.JSONRaw(`{
+ "viewQuery":"select 1",
+ "authRule":"1=2"
+ }`)
+
+ scenarios := []struct {
+ typ string
+ rawOptions types.JSONRaw
+ expected []string
+ }{
+ {
+ core.CollectionTypeBase,
+ rawOptions,
+ []string{
+ `lastSavedPK:"test"`,
+ `ViewQuery:""`,
+ `AuthRule:(*string)(nil)`,
+ },
+ },
+ {
+ core.CollectionTypeView,
+ rawOptions,
+ []string{
+ `lastSavedPK:"test"`,
+ `ViewQuery:"select 1"`,
+ `AuthRule:(*string)(nil)`,
+ },
+ },
+ {
+ core.CollectionTypeAuth,
+ rawOptions,
+ []string{
+ `lastSavedPK:"test"`,
+ `ViewQuery:""`,
+ `AuthRule:(*string)(0x`,
+ },
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.typ), func(t *testing.T) {
+ c := core.Collection{}
+ c.Id = "test"
+ c.Type = s.typ
+ c.RawOptions = s.rawOptions
+
+ err := c.PostScan()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if c.IsNew() {
+ t.Fatal("Expected the collection to be marked as not new")
+ }
+
+ rawModel := fmt.Sprintf("%#v", c)
+
+ for _, part := range s.expected {
+ if !strings.Contains(rawModel, part) {
+ t.Fatalf("Missing part %q in\n%v", part, rawModel)
+ }
+ }
+ })
+ }
+}
+
+func TestCollectionUnmarshalJSON(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ raw string
+ collection func() *core.Collection
+ expected []string
+ notExpected []string
+ }{
+ {
+ "base new empty",
+ `{"type":"base","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
+ func() *core.Collection {
+ return &core.Collection{}
+ },
+ []string{
+ `"type":"base"`,
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"listRule":"1=2"`,
+ `"fields":[`,
+ `"name":"id"`,
+ `"indexes":[]`,
+ },
+ []string{
+ `"authRule":"1=3"`,
+ `"viewQuery":"abc"`,
+ },
+ },
+ {
+ "view new empty",
+ `{"type":"view","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
+ func() *core.Collection {
+ return &core.Collection{}
+ },
+ []string{
+ `"type":"view"`,
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"listRule":"1=2"`,
+ `"fields":[]`,
+ `"viewQuery":"abc"`,
+ `"indexes":[]`,
+ },
+ []string{
+ `"authRule":"1=3"`,
+ },
+ },
+ {
+ "auth new empty",
+ `{"type":"auth","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
+ func() *core.Collection {
+ return &core.Collection{}
+ },
+ []string{
+ `"type":"auth"`,
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"listRule":"1=2"`,
+ `"authRule":"1=3"`,
+ `"fields":[`,
+ `"name":"id"`,
+ },
+ []string{
+ `"indexes":[]`,
+ `"viewQuery":"abc"`,
+ },
+ },
+ {
+ "new but with set type (no default fields load)",
+ `{"type":"base","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
+ func() *core.Collection {
+ c := &core.Collection{}
+ c.Type = core.CollectionTypeBase
+ return c
+ },
+ []string{
+ `"type":"base"`,
+ `"id":""`,
+ `"name":"test"`,
+ `"listRule":"1=2"`,
+ `"fields":[]`,
+ },
+ []string{
+ `"authRule":"1=3"`,
+ `"viewQuery":"abc"`,
+ },
+ },
+ {
+ "existing (no default fields load)",
+ `{"type":"auth","name":"test","listRule":"1=2","authRule":"1=3","viewQuery":"abc"}`,
+ func() *core.Collection {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ return c
+ },
+ []string{
+ `"type":"auth"`,
+ `"name":"test"`,
+ `"listRule":"1=2"`,
+ `"authRule":"1=3"`,
+ `"fields":[`,
+ `"name":"id"`,
+ },
+ []string{
+ `"name":"tokenKey"`,
+ `"viewQuery":"abc"`,
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := s.collection()
+
+ err := json.Unmarshal([]byte(s.raw), collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rawResult, err := json.Marshal(collection, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawResultStr := string(rawResult)
+
+ for _, part := range s.expected {
+ if !strings.Contains(rawResultStr, part) {
+ t.Fatalf("Missing expected %q in\n%v", part, rawResultStr)
+ }
+ }
+
+ for _, part := range s.notExpected {
+ if strings.Contains(rawResultStr, part) {
+ t.Fatalf("Didn't expected %q in\n%v", part, rawResultStr)
+ }
+ }
+ })
+ }
+}
+
+func TestCollectionSerialize(t *testing.T) {
+ scenarios := []struct {
+ name string
+ collection func() *core.Collection
+ expected []string
+ notExpected []string
+ }{
+ {
+ "base",
+ func() *core.Collection {
+ c := core.NewCollection(core.CollectionTypeBase, "test")
+ c.ViewQuery = "1=1"
+ c.OAuth2.Providers = []core.OAuth2ProviderConfig{
+ {Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
+ {Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
+ }
+
+ return c
+ },
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"base"`,
+ },
+ []string{
+ "verificationTemplate",
+ "manageRule",
+ "authRule",
+ "secret",
+ "oauth2",
+ "clientId",
+ "clientSecret",
+ "viewQuery",
+ },
+ },
+ {
+ "view",
+ func() *core.Collection {
+ c := core.NewCollection(core.CollectionTypeView, "test")
+ c.ViewQuery = "1=1"
+ c.OAuth2.Providers = []core.OAuth2ProviderConfig{
+ {Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
+ {Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
+ }
+
+ return c
+ },
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"view"`,
+ `"viewQuery":"1=1"`,
+ },
+ []string{
+ "verificationTemplate",
+ "manageRule",
+ "authRule",
+ "secret",
+ "oauth2",
+ "clientId",
+ "clientSecret",
+ },
+ },
+ {
+ "auth",
+ func() *core.Collection {
+ c := core.NewCollection(core.CollectionTypeAuth, "test")
+ c.ViewQuery = "1=1"
+ c.OAuth2.Providers = []core.OAuth2ProviderConfig{
+ {Name: "test1", ClientId: "test_client_id1", ClientSecret: "test_client_secret1"},
+ {Name: "test2", ClientId: "test_client_id2", ClientSecret: "test_client_secret2"},
+ }
+
+ return c
+ },
+ []string{
+ `"id":"pbc_`,
+ `"name":"test"`,
+ `"type":"auth"`,
+ `"oauth2":{`,
+ `"providers":[{`,
+ `"clientId":"test_client_id1"`,
+ `"clientId":"test_client_id2"`,
+ },
+ []string{
+ "viewQuery",
+ "secret",
+ "clientSecret",
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := s.collection()
+
+ raw, err := collection.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != collection.String() {
+ t.Fatalf("Expected the same serialization, got\n%v\nVS\n%v", collection.String(), rawStr)
+ }
+
+ for _, part := range s.expected {
+ if !strings.Contains(rawStr, part) {
+ t.Fatalf("Missing part %q in\n%v", part, rawStr)
+ }
+ }
+
+ for _, part := range s.notExpected {
+ if strings.Contains(rawStr, part) {
+ t.Fatalf("Didn't expect part %q in\n%v", part, rawStr)
+ }
+ }
+ })
+ }
+}
+
+func TestCollectionSerializeNotModifyingCache(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ c, err := app.FindCachedCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ redactedFields := map[string]string{
+ "AuthToken.Secret": c.AuthToken.Secret,
+ "FileToken.Secret": c.FileToken.Secret,
+ "PasswordResetToken.Secret": c.PasswordResetToken.Secret,
+ "EmailChangeToken.Secret": c.EmailChangeToken.Secret,
+ "VerificationToken.Secret": c.VerificationToken.Secret,
+ }
+
+ if len(c.OAuth2.Providers) == 0 {
+ t.Fatal("Expected at least one users OAuth2 provider, got 0")
+ }
+ for _, p := range c.OAuth2.Providers {
+ redactedFields[p.Name+".ClientSecret"] = p.ClientSecret
+ }
+
+ for k, v := range redactedFields {
+ t.Run(k, func(t *testing.T) {
+ if v == "" {
+ t.Fatalf("Expected the redacted field %q to remain unmodified after serialization, got empty value", k)
+ }
+ })
+ }
+}
+
+func TestCollectionDBExport(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ date, err := types.ParseDateTime("2024-07-01 01:02:03.456Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ typ string
+ expected string
+ }{
+ {
+ "unknown",
+ `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
+ },
+ {
+ core.CollectionTypeBase,
+ `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
+ },
+ {
+ core.CollectionTypeView,
+ `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
+ },
+ {
+ core.CollectionTypeAuth,
+ `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":[],"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":[]},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.typ), func(t *testing.T) {
+ c := core.Collection{}
+ c.Type = s.typ
+ c.Id = "test_id"
+ c.Name = "test_name"
+ c.System = true
+ c.ListRule = types.Pointer("1=1")
+ c.ViewRule = types.Pointer("1=2")
+ c.CreateRule = types.Pointer("1=3")
+ c.UpdateRule = types.Pointer("1=4")
+ c.DeleteRule = types.Pointer("1=5")
+ c.ManageRule = types.Pointer("1=6")
+ c.ViewRule = types.Pointer("1=7")
+ c.Created = date
+ c.Updated = date
+ c.Indexes = types.JSONArray[string]{"CREATE INDEX idx1 on test_name(id)", "CREATE INDEX idx2 on test_name(id)"}
+ c.ViewQuery = "select 1"
+ c.Fields.Add(&core.BoolField{Id: "f1_id", Name: "f1", System: true})
+ c.Fields.Add(&core.BoolField{Id: "f2_id", Name: "f2", Required: true})
+ c.RawOptions = types.JSONRaw(`{"viewQuery": "select 2"}`) // should be ignored
+
+ result, err := c.DBExport(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(result, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if str := string(raw); str != s.expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str)
+ }
+ })
+ }
+}
+
+func TestCollectionIndexHelpers(t *testing.T) {
+ t.Parallel()
+
+ checkIndexes := func(t *testing.T, indexes, expectedIndexes []string) {
+ if len(indexes) != len(expectedIndexes) {
+ t.Fatalf("Expected %d indexes, got %d\n%v", len(expectedIndexes), len(indexes), indexes)
+ }
+
+ for _, idx := range expectedIndexes {
+ if !slices.Contains(indexes, idx) {
+ t.Fatalf("Missing index\n%v\nin\n%v", idx, indexes)
+ }
+ }
+ }
+
+ c := core.NewBaseCollection("test")
+ checkIndexes(t, c.Indexes, nil)
+
+ c.AddIndex("idx1", false, "colA,colB", "colA != 1")
+ c.AddIndex("idx2", true, "colA", "")
+ c.AddIndex("idx3", false, "colA", "")
+ c.AddIndex("idx3", false, "colB", "") // should overwrite the previous one
+
+ idx1 := "CREATE INDEX `idx1` ON `test` (colA,colB) WHERE colA != 1"
+ idx2 := "CREATE UNIQUE INDEX `idx2` ON `test` (colA)"
+ idx3 := "CREATE INDEX `idx3` ON `test` (colB)"
+
+ checkIndexes(t, c.Indexes, []string{idx1, idx2, idx3})
+
+ c.RemoveIndex("iDx2") // case-insensitive
+ c.RemoveIndex("missing") // noop
+
+ checkIndexes(t, c.Indexes, []string{idx1, idx3})
+
+ expectedIndexes := map[string]string{
+ "missing": "",
+ "idx1": idx1,
+ // the name is case insensitive
+ "iDX3": idx3,
+ }
+ for key, expectedIdx := range expectedIndexes {
+ idx := c.GetIndex(key)
+ if idx != expectedIdx {
+ t.Errorf("Expected index %q to be\n%v\ngot\n%v", key, expectedIdx, idx)
+ }
+ }
+}
+
+// -------------------------------------------------------------------
+
+func TestCollectionDelete(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ collection string
+ disableIntegrityChecks bool
+ expectError bool
+ }{
+ {
+ name: "unsaved",
+ collection: "",
+ expectError: true,
+ },
+ {
+ name: "system",
+ collection: core.CollectionNameSuperusers,
+ expectError: true,
+ },
+ {
+ name: "base with references",
+ collection: "demo1",
+ expectError: true,
+ },
+ {
+ name: "base with references with disabled integrity checks",
+ collection: "demo1",
+ disableIntegrityChecks: true,
+ expectError: false,
+ },
+ {
+ name: "base without references",
+ collection: "demo1",
+ expectError: true,
+ },
+ {
+ name: "view with reference",
+ collection: "view1",
+ expectError: true,
+ },
+ {
+ name: "view with references with disabled integrity checks",
+ collection: "view1",
+ disableIntegrityChecks: true,
+ expectError: false,
+ },
+ {
+ name: "view without references",
+ collection: "view2",
+ disableIntegrityChecks: true,
+ expectError: false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ var col *core.Collection
+
+ if s.collection == "" {
+ col = core.NewBaseCollection("test")
+ } else {
+ var err error
+ col, err = app.FindCollectionByNameOrId(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if s.disableIntegrityChecks {
+ col.IntegrityChecks(!s.disableIntegrityChecks)
+ }
+
+ err := app.Delete(col)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ exists := app.HasTable(col.Name)
+
+ if !col.IsNew() && exists != hasErr {
+ t.Fatalf("Expected HasTable %v, got %v", hasErr, exists)
+ }
+
+ if !hasErr {
+ cache, _ := app.FindCachedCollectionByNameOrId(col.Id)
+ if cache != nil {
+ t.Fatal("Expected the collection to be removed from the cache.")
+ }
+ }
+ })
+ }
+}
+
+func TestCollectionModelEventSync(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ testCollections := make([]*core.Collection, 4)
+ for i := 0; i < 4; i++ {
+ testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
+ if err := app.Save(testCollections[i]); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ createModelEvent := func() *core.ModelEvent {
+ event := new(core.ModelEvent)
+ event.App = app
+ event.Context = context.Background()
+ event.Type = "test_a"
+ event.Model = testCollections[0]
+ return event
+ }
+
+ createModelErrorEvent := func() *core.ModelErrorEvent {
+ event := new(core.ModelErrorEvent)
+ event.ModelEvent = *createModelEvent()
+ event.Error = errors.New("error_a")
+ return event
+ }
+
+ changeCollectionEventBefore := func(e *core.CollectionEvent) {
+ e.Type = "test_b"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 123)
+ e.Collection = testCollections[1]
+ }
+
+ modelEventFinalizerChange := func(e *core.ModelEvent) {
+ e.Type = "test_c"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 456)
+ e.Model = testCollections[2]
+ }
+
+ changeCollectionEventAfter := func(e *core.CollectionEvent) {
+ e.Type = "test_d"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 789)
+ e.Collection = testCollections[3]
+ }
+
+ expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
+ if e.Type != "test_a" {
+ t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != nil {
+ t.Fatalf("Expected context value %v, got %v", nil, v)
+ }
+
+ if e.Model.PK() != testCollections[0].Id {
+ t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
+ }
+ }
+
+ expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
+ if e.Type != "test_d" {
+ t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != 789 {
+ t.Fatalf("Expected context value %v, got %v", 789, v)
+ }
+
+ if e.Model.PK() != testCollections[3].Id {
+ t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
+ }
+ }
+
+ expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
+ if e.Type != "test_a" {
+ t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != nil {
+ t.Fatalf("Expected context value %v, got %v", nil, v)
+ }
+
+ if e.Collection.Id != testCollections[0].Id {
+ t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
+ }
+ }
+
+ expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
+ if e.Type != "test_c" {
+ t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != 456 {
+ t.Fatalf("Expected context value %v, got %v", 456, v)
+ }
+
+ if e.Collection.Id != testCollections[2].Id {
+ t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
+ }
+ }
+
+ modelEventFinalizer := func(e *core.ModelEvent) error {
+ modelEventFinalizerChange(e)
+ return nil
+ }
+
+ modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
+ modelEventFinalizerChange(&e.ModelEvent)
+ e.Error = errors.New("error_c")
+ return nil
+ }
+
+ modelEventHandler := &hook.Handler[*core.ModelEvent]{
+ Priority: -999,
+ Func: func(e *core.ModelEvent) error {
+ t.Run("before model", func(t *testing.T) {
+ expectedBeforeModelEventHandlerChecks(t, e)
+ })
+
+ _ = e.Next()
+
+ t.Run("after model", func(t *testing.T) {
+ expectedAfterModelEventHandlerChecks(t, e)
+ })
+
+ return nil
+ },
+ }
+
+ modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
+ Priority: -999,
+ Func: func(e *core.ModelErrorEvent) error {
+ t.Run("before model error", func(t *testing.T) {
+ expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
+ if v := e.Error.Error(); v != "error_a" {
+ t.Fatalf("Expected error %q, got %q", "error_a", v)
+ }
+ })
+
+ _ = e.Next()
+
+ t.Run("after model error", func(t *testing.T) {
+ expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
+ if v := e.Error.Error(); v != "error_d" {
+ t.Fatalf("Expected error %q, got %q", "error_d", v)
+ }
+ })
+
+ return nil
+ },
+ }
+
+ recordEventHandler := &hook.Handler[*core.CollectionEvent]{
+ Priority: -999,
+ Func: func(e *core.CollectionEvent) error {
+ t.Run("before collection", func(t *testing.T) {
+ expectedBeforeCollectionEventHandlerChecks(t, e)
+ })
+
+ changeCollectionEventBefore(e)
+
+ _ = e.Next()
+
+ t.Run("after collection", func(t *testing.T) {
+ expectedAfterCollectionEventHandlerChecks(t, e)
+ })
+
+ changeCollectionEventAfter(e)
+
+ return nil
+ },
+ }
+
+ collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
+ Priority: -999,
+ Func: func(e *core.CollectionErrorEvent) error {
+ t.Run("before collection error", func(t *testing.T) {
+ expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
+ if v := e.Error.Error(); v != "error_a" {
+ t.Fatalf("Expected error %q, got %q", "error_c", v)
+ }
+ })
+
+ changeCollectionEventBefore(&e.CollectionEvent)
+ e.Error = errors.New("error_b")
+
+ _ = e.Next()
+
+ t.Run("after collection error", func(t *testing.T) {
+ expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
+ if v := e.Error.Error(); v != "error_c" {
+ t.Fatalf("Expected error %q, got %q", "error_c", v)
+ }
+ })
+
+ changeCollectionEventAfter(&e.CollectionEvent)
+ e.Error = errors.New("error_d")
+
+ return nil
+ },
+ }
+
+ // OnModelValidate
+ app.OnCollectionValidate().Bind(recordEventHandler)
+ app.OnModelValidate().Bind(modelEventHandler)
+ app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelCreate
+ app.OnCollectionCreate().Bind(recordEventHandler)
+ app.OnModelCreate().Bind(modelEventHandler)
+ app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelCreateExecute
+ app.OnCollectionCreateExecute().Bind(recordEventHandler)
+ app.OnModelCreateExecute().Bind(modelEventHandler)
+ app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterCreateSuccess
+ app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
+ app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
+ app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterCreateError
+ app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
+ app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
+ app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+
+ // OnModelUpdate
+ app.OnCollectionUpdate().Bind(recordEventHandler)
+ app.OnModelUpdate().Bind(modelEventHandler)
+ app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelUpdateExecute
+ app.OnCollectionUpdateExecute().Bind(recordEventHandler)
+ app.OnModelUpdateExecute().Bind(modelEventHandler)
+ app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterUpdateSuccess
+ app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
+ app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
+ app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterUpdateError
+ app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
+ app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
+ app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+
+ // OnModelDelete
+ app.OnCollectionDelete().Bind(recordEventHandler)
+ app.OnModelDelete().Bind(modelEventHandler)
+ app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelDeleteExecute
+ app.OnCollectionDeleteExecute().Bind(recordEventHandler)
+ app.OnModelDeleteExecute().Bind(modelEventHandler)
+ app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterDeleteSuccess
+ app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
+ app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
+ app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterDeleteError
+ app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
+ app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
+ app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+}
+
+func TestCollectionSaveModel(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ collection func(app core.App) (*core.Collection, error)
+ expectError bool
+ expectColumns []string
+ }{
+ // trigger validators
+ {
+ name: "create - trigger validators",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("!invalid")
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: true,
+ },
+ {
+ name: "update - trigger validators",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo5")
+ c.Name = "demo1"
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.Fields.RemoveByName("file")
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: true,
+ },
+
+ // create
+ {
+ name: "create base collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new")
+ c.Type = "" // should be auto set to "base"
+ c.Fields.RemoveByName("id") // ensure that the default fields will be loaded
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "example",
+ },
+ },
+ {
+ name: "create auth collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new")
+ c.Fields.RemoveByName("id") // ensure that the default fields will be loaded
+ c.Fields.RemoveByName("email") // ensure that the default fields will be loaded
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "email", "tokenKey", "password",
+ "verified", "emailVisibility", "example",
+ },
+ },
+ {
+ name: "create view collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new")
+ c.Fields.Add(&core.TextField{Name: "ignored"}) // should be ignored
+ c.ViewQuery = "select 1 as id, 2 as example"
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "example",
+ },
+ },
+
+ // update
+ {
+ name: "update base collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo5")
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.Fields.RemoveByName("file")
+ c.Fields.GetByName("total").SetName("total_updated")
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "select_one", "select_many", "rel_one", "rel_many",
+ "total_updated", "created", "updated", "example",
+ },
+ },
+ {
+ name: "update auth collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("clients")
+ c.Fields.Add(&core.TextField{Name: "example"})
+ c.Fields.RemoveByName("file")
+ c.Fields.GetByName("name").SetName("name_updated")
+ c.AddIndex("test_save_idx", false, "example", "")
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "email", "emailVisibility", "password", "tokenKey",
+ "verified", "username", "name_updated", "created", "updated", "example",
+ },
+ },
+ {
+ name: "update view collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("view2")
+ c.Fields.Add(&core.TextField{Name: "example"}) // should be ignored
+ c.ViewQuery = "select 1 as id, 2 as example"
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "example",
+ },
+ },
+
+ // auth normalization
+ {
+ name: "unset missing oauth2 mapped fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new")
+ c.OAuth2.Enabled = true
+ // shouldn't fail
+ c.OAuth2.MappedFields = core.OAuth2KnownFields{
+ Id: "missing",
+ Name: "missing",
+ Username: "missing",
+ AvatarURL: "missing",
+ }
+ return c, nil
+ },
+ expectError: false,
+ expectColumns: []string{
+ "id", "email", "emailVisibility", "password", "tokenKey", "verified",
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := s.collection(app)
+ if err != nil {
+ t.Fatalf("Failed to retrieve test collection: %v", err)
+ }
+
+ saveErr := app.Save(collection)
+
+ hasErr := saveErr != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", hasErr, s.expectError, saveErr)
+ }
+
+ if hasErr {
+ return
+ }
+
+ // the collection should always have an id after successful Save
+ if collection.Id == "" {
+ t.Fatal("Expected collection id to be set")
+ }
+
+ // the timestamp fields should be non-empty after successful Save
+ if collection.Created.String() == "" {
+ t.Fatal("Expected collection created to be set")
+ }
+ if collection.Updated.String() == "" {
+ t.Fatal("Expected collection updated to be set")
+ }
+
+ // check if the records table was synced
+ hasTable := app.HasTable(collection.Name)
+ if !hasTable {
+ t.Fatalf("Expected records table %s to be created", collection.Name)
+ }
+
+ // check if the records table has the fields fields
+ columns, err := app.TableColumns(collection.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(columns) != len(s.expectColumns) {
+ t.Fatalf("Expected columns\n%v\ngot\n%v", s.expectColumns, columns)
+ }
+ for i, c := range columns {
+ if !slices.Contains(s.expectColumns, c) {
+ t.Fatalf("[%d] Didn't expect record column %q", i, c)
+ }
+ }
+
+ // make sure that all collection indexes exists
+ indexes, err := app.TableIndexes(collection.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(indexes) != len(collection.Indexes) {
+ t.Fatalf("Expected %d indexes, got %d", len(collection.Indexes), len(indexes))
+ }
+ for _, idx := range collection.Indexes {
+ parsed := dbutils.ParseIndex(idx)
+ if _, ok := indexes[parsed.IndexName]; !ok {
+ t.Fatalf("Missing index %q in\n%v", idx, indexes)
+ }
+ }
+ })
+ }
+}
+
+// indirect update of a field used in view should cause view(s) update
+func TestCollectionSaveIndirectViewsUpdate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // update MaxSelect fields
+ {
+ relMany := collection.Fields.GetByName("rel_many").(*core.RelationField)
+ relMany.MaxSelect = 1
+
+ fileOne := collection.Fields.GetByName("file_one").(*core.FileField)
+ fileOne.MaxSelect = 10
+
+ if err := app.Save(collection); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // check view1 fields
+ {
+ view1, err := app.FindCollectionByNameOrId("view1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ relMany := view1.Fields.GetByName("rel_many").(*core.RelationField)
+ if relMany.MaxSelect != 1 {
+ t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relMany.MaxSelect)
+ }
+
+ fileOne := view1.Fields.GetByName("file_one").(*core.FileField)
+ if fileOne.MaxSelect != 10 {
+ t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOne.MaxSelect)
+ }
+ }
+
+ // check view2 fields
+ {
+ view2, err := app.FindCollectionByNameOrId("view2")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ relMany := view2.Fields.GetByName("rel_many").(*core.RelationField)
+ if relMany.MaxSelect != 1 {
+ t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relMany.MaxSelect)
+ }
+ }
+}
+
+func TestCollectionSaveViewWrapping(t *testing.T) {
+ t.Parallel()
+
+ viewName := "test_wrapping"
+
+ // note: some of the queries use "limit 0" because the tested field value could be empty
+ // which will trigger the extra sample records validation that are not important for this test
+ scenarios := []struct {
+ name string
+ query string
+ expected string
+ }{
+ {
+ "no wrapping - id field",
+ "select id, bool from demo1",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select id, bool from demo1)",
+ },
+ {
+ "no wrapping - text field",
+ "select text as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
+ },
+ {
+ "no wrapping - relation field",
+ "select rel_one as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
+ },
+ {
+ "no wrapping - select field",
+ "select select_many as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
+ },
+ {
+ "no wrapping - email field",
+ "select email as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
+ },
+ {
+ "no wrapping - datetime field",
+ "select datetime as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
+ },
+ {
+ "no wrapping - url field",
+ "select url as id, bool from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
+ },
+ {
+ "wrapping - bool field",
+ "select bool as id, text as txt, url from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1 limit 0))",
+ },
+ {
+ "wrapping - bool field (different order)",
+ "select text as txt, url, bool as id from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,CAST(`id` as TEXT) `id` FROM (select text as txt, url, bool as id from demo1 limit 0))",
+ },
+ {
+ "wrapping - json field",
+ "select json as id, text, url from demo1 limit 0",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
+ },
+ {
+ "wrapping - numeric id",
+ "select 1 as id",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id` FROM (select 1 as id))",
+ },
+ {
+ "wrapping - expresion",
+ "select ('test') as id",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id` FROM (select ('test') as id))",
+ },
+ {
+ "no wrapping - cast as text",
+ "select cast('test' as text) as id",
+ "CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewViewCollection(viewName)
+ collection.ViewQuery = s.query
+
+ err := app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var sql string
+
+ rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
+ Bind(dbx.Params{"name": viewName}).
+ Row(&sql)
+ if rowErr != nil {
+ t.Fatalf("Failed to retrieve view sql: %v", rowErr)
+ }
+
+ if sql != s.expected {
+ t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
+ }
+ })
+ }
+}
+
+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)
+ }
+ }
+}
diff --git a/core/collection_model_view_options.go b/core/collection_model_view_options.go
new file mode 100644
index 0000000000..d0242c0222
--- /dev/null
+++ b/core/collection_model_view_options.go
@@ -0,0 +1,18 @@
+package core
+
+import (
+ validation "github.com/pocketbase/ozzo-validation/v4"
+)
+
+var _ optionsValidator = (*collectionViewOptions)(nil)
+
+// collectionViewOptions defines the options for the "view" type collection.
+type collectionViewOptions struct {
+ ViewQuery string `form:"viewQuery" json:"viewQuery"`
+}
+
+func (o *collectionViewOptions) validate(cv *collectionValidator) error {
+ return validation.ValidateStruct(o,
+ validation.Field(&o.ViewQuery, validation.Required, validation.By(cv.checkViewQuery)),
+ )
+}
diff --git a/core/collection_model_view_options_test.go b/core/collection_model_view_options_test.go
new file mode 100644
index 0000000000..cb50bbb5be
--- /dev/null
+++ b/core/collection_model_view_options_test.go
@@ -0,0 +1,97 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestCollectionViewOptionsValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ collection func(app core.App) (*core.Collection, error)
+ expectedErrors []string
+ }{
+ {
+ name: "view with empty query",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ return c, nil
+ },
+ expectedErrors: []string{"fields", "viewQuery"},
+ },
+ {
+ name: "view with invalid query",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ c.ViewQuery = "invalid"
+ return c, nil
+ },
+ expectedErrors: []string{"fields", "viewQuery"},
+ },
+ {
+ name: "view with valid query but missing id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ c.ViewQuery = "select 1"
+ return c, nil
+ },
+ expectedErrors: []string{"fields", "viewQuery"},
+ },
+ {
+ name: "view with valid query but empty sample id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ c.ViewQuery = "select '' as id"
+ return c, nil
+ },
+ expectedErrors: []string{"viewQuery"},
+ },
+ {
+ name: "view with valid query but duplicated sample id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ c.ViewQuery = "(select 'a' as id union all select 'a' as id union all select 'c' as id)"
+ return c, nil
+ },
+ expectedErrors: []string{"viewQuery"},
+ },
+ {
+ name: "view with valid query",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new_auth")
+ c.ViewQuery = "select demo1.id, text as example from demo1"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "update view query ",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("view2")
+ c.ViewQuery = "select demo1.id, text as example from demo1"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := s.collection(app)
+ if err != nil {
+ t.Fatalf("Failed to retrieve test collection: %v", err)
+ }
+
+ result := app.Validate(collection)
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
diff --git a/core/collection_query.go b/core/collection_query.go
new file mode 100644
index 0000000000..3821067886
--- /dev/null
+++ b/core/collection_query.go
@@ -0,0 +1,354 @@
+package core
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+const StoreKeyCachedCollections = "pbAppCachedCollections"
+
+// CollectionQuery returns a new Collection select query.
+func (app *BaseApp) CollectionQuery() *dbx.SelectQuery {
+ return app.ModelQuery(&Collection{})
+}
+
+// FindCollections finds all collections by the given type(s).
+//
+// If collectionTypes is not set, it returns all collections.
+//
+// Example:
+//
+// app.FindAllCollections() // all collections
+// app.FindAllCollections("auth", "view") // only auth and view collections
+func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection, error) {
+ collections := []*Collection{}
+
+ q := app.CollectionQuery()
+
+ types := list.NonzeroUniques(collectionTypes)
+ if len(types) > 0 {
+ q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
+ }
+
+ err := q.OrderBy("rowid ASC").All(&collections)
+ if err != nil {
+ return nil, err
+ }
+
+ return collections, nil
+}
+
+// ReloadCachedCollections fetches all collections and caches them into the app store.
+func (app *BaseApp) ReloadCachedCollections() error {
+ collections, err := app.FindAllCollections()
+ if err != nil {
+ return err
+ }
+
+ app.Store().Set(StoreKeyCachedCollections, collections)
+
+ return nil
+}
+
+// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
+func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, error) {
+ m := &Collection{}
+
+ err := app.CollectionQuery().
+ AndWhere(dbx.NewExp("[[id]]={:id} OR LOWER([[name]])={:name}", dbx.Params{
+ "id": nameOrId,
+ "name": strings.ToLower(nameOrId),
+ })).
+ Limit(1).
+ One(m)
+ if err != nil {
+ return nil, err
+ }
+
+ return m, nil
+}
+
+// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
+// but retrieves the Collection from the app cache instead of making a db call.
+//
+// NB! This method is suitable for read-only Collection operations.
+//
+// Returns [sql.ErrNoRows] if no Collection is found for consistency
+// with the [BaseApp.FindCollectionByNameOrId] method.
+//
+// If you plan making changes to the returned Collection model,
+// use [BaseApp.FindCollectionByNameOrId] instead.
+//
+// Caveats:
+//
+// - The returned Collection should be used only for read-only operations.
+// Avoid directly modifying the returned cached Collection as it will affect
+// the global cached value even if you don't persist the changes in the database!
+// - If you are updating a Collection in a transaction and then call this method before commit,
+// it'll return the cached Collection state and not the one from the uncommitted transaction.
+// - The cache is automatically updated on collections db change (create/update/delete).
+// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
+func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
+ collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
+ if collections == nil {
+ // cache is not initialized yet (eg. run in a system migration)
+ return app.FindCollectionByNameOrId(nameOrId)
+ }
+
+ for _, c := range collections {
+ if strings.EqualFold(c.Name, nameOrId) || c.Id == nameOrId {
+ return c, nil
+ }
+ }
+
+ return nil, sql.ErrNoRows
+}
+
+// FindCollectionReferences returns information for all relation fields
+// referencing the provided collection.
+//
+// If the provided collection has reference to itself then it will be
+// also included in the result. To exclude it, pass the collection id
+// as the excludeIds argument.
+func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
+ collections := []*Collection{}
+
+ query := app.CollectionQuery()
+
+ if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
+ query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
+ }
+
+ if err := query.All(&collections); err != nil {
+ return nil, err
+ }
+
+ result := map[*Collection][]Field{}
+
+ for _, c := range collections {
+ for _, rawField := range c.Fields {
+ f, ok := rawField.(*RelationField)
+ if ok && f.CollectionId == collection.Id {
+ result[c] = append(result[c], f)
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
+// but retrieves the Collection from the app cache instead of making a db call.
+//
+// NB! This method is suitable for read-only Collection operations.
+//
+// If you plan making changes to the returned Collection model,
+// use [BaseApp.FindCollectionReferences] instead.
+//
+// Caveats:
+//
+// - The returned Collection should be used only for read-only operations.
+// Avoid directly modifying the returned cached Collection as it will affect
+// the global cached value even if you don't persist the changes in the database!
+// - If you are updating a Collection in a transaction and then call this method before commit,
+// it'll return the cached Collection state and not the one from the uncommitted transaction.
+// - The cache is automatically updated on collections db change (create/update/delete).
+// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
+func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
+ collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
+ if collections == nil {
+ // cache is not initialized yet (eg. run in a system migration)
+ return app.FindCollectionReferences(collection, excludeIds...)
+ }
+
+ result := map[*Collection][]Field{}
+
+ for _, c := range collections {
+ if slices.Contains(excludeIds, c.Id) {
+ continue
+ }
+
+ for _, rawField := range c.Fields {
+ f, ok := rawField.(*RelationField)
+ if ok && f.CollectionId == collection.Id {
+ result[c] = append(result[c], f)
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// IsCollectionNameUnique checks that there is no existing collection
+// with the provided name (case insensitive!).
+//
+// Note: case insensitive check because the name is used also as
+// table name for the records.
+func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
+ if name == "" {
+ return false
+ }
+
+ query := app.CollectionQuery().
+ Select("count(*)").
+ AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
+ Limit(1)
+
+ if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
+ query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
+ }
+
+ var total int
+
+ return query.Row(&total) == nil && total == 0
+}
+
+// TruncateCollection deletes all records associated with the provided collection.
+//
+// The truncate operation is executed in a single transaction,
+// aka. either everything is deleted or none.
+//
+// Note that this method will also trigger the records related
+// cascade and file delete actions.
+func (app *BaseApp) TruncateCollection(collection *Collection) error {
+ if collection.IsView() {
+ return errors.New("view collections cannot be truncated since they don't store their own records")
+ }
+
+ return app.RunInTransaction(func(txApp App) error {
+ records := make([]*Record, 0, 500)
+
+ for {
+ err := txApp.RecordQuery(collection).Limit(500).All(&records)
+ if err != nil {
+ return err
+ }
+
+ if len(records) == 0 {
+ return nil
+ }
+
+ for _, record := range records {
+ err = txApp.Delete(record)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ }
+
+ records = records[:0]
+ }
+ })
+}
+
+// -------------------------------------------------------------------
+
+// normalizeViewQueryId wraps (if necessary) the provided view query
+// with a subselect to ensure that the id column is a text since
+// currently we don't support non-string model ids
+// (see https://github.com/pocketbase/pocketbase/issues/3110).
+func normalizeViewQueryId(app App, query string) (string, error) {
+ query = strings.Trim(strings.TrimSpace(query), ";")
+
+ info, err := getQueryTableInfo(app, query)
+ if err != nil {
+ return "", err
+ }
+
+ for _, row := range info {
+ if strings.EqualFold(row.Name, FieldNameId) && strings.EqualFold(row.Type, "TEXT") {
+ return query, nil // no wrapping needed
+ }
+ }
+
+ // raw parse to preserve the columns order
+ rawParsed := new(identifiersParser)
+ if err := rawParsed.parse(query); err != nil {
+ return "", err
+ }
+
+ columns := make([]string, 0, len(rawParsed.columns))
+ for _, col := range rawParsed.columns {
+ if col.alias == FieldNameId {
+ columns = append(columns, fmt.Sprintf("CAST([[%s]] as TEXT) [[%s]]", col.alias, col.alias))
+ } else {
+ columns = append(columns, "[["+col.alias+"]]")
+ }
+ }
+
+ query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
+
+ return query, nil
+}
+
+// resaveViewsWithChangedFields updates all view collections with changed fields.
+func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
+ collections, err := app.FindAllCollections(CollectionTypeView)
+ if err != nil {
+ return err
+ }
+
+ return app.RunInTransaction(func(txApp App) error {
+ var collectionErrors []error
+
+ for _, collection := range collections {
+ if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
+ continue
+ }
+
+ check := func() error {
+ // clone the existing fields for temp modifications
+ oldFields, err := collection.Fields.Clone()
+ if err != nil {
+ 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, json.Deterministic(true))
+ if err != nil {
+ return err
+ }
+
+ encodedOldFields, err := json.Marshal(oldFields, json.Deterministic(true))
+ if err != nil {
+ return err
+ }
+
+ if bytes.EqualFold(encodedNewFields, encodedOldFields) {
+ return nil // no changes
+ }
+
+ return txApp.Save(collection)
+ }
+
+ if err := check(); err != nil {
+ collectionErrors = append(
+ collectionErrors,
+ fmt.Errorf("[%s] %w", collection.Name, err),
+ )
+ }
+ }
+
+ return errors.Join(collectionErrors...)
+ })
+}
diff --git a/core/collection_query_test.go b/core/collection_query_test.go
new file mode 100644
index 0000000000..2bef7cfe8c
--- /dev/null
+++ b/core/collection_query_test.go
@@ -0,0 +1,474 @@
+package core_test
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func TestCollectionQuery(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ expected := "SELECT {{_collections}}.* FROM `_collections`"
+
+ sql := app.CollectionQuery().Build().SQL()
+ if sql != expected {
+ t.Errorf("Expected sql %s, got %s", expected, sql)
+ }
+}
+
+func TestReloadCachedCollections(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ err := app.ReloadCachedCollections()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cached := app.Store().Get(core.StoreKeyCachedCollections)
+
+ cachedCollections, ok := cached.([]*core.Collection)
+ if !ok {
+ t.Fatalf("Expected []*core.Collection, got %T", cached)
+ }
+
+ collections, err := app.FindAllCollections()
+ if err != nil {
+ t.Fatalf("Failed to retrieve all collections: %v", err)
+ }
+
+ if len(cachedCollections) != len(collections) {
+ t.Fatalf("Expected %d collections, got %d", len(collections), len(cachedCollections))
+ }
+
+ for _, c := range collections {
+ var exists bool
+ for _, cc := range cachedCollections {
+ if cc.Id == c.Id {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ t.Fatalf("The collections cache is missing collection %q", c.Name)
+ }
+ }
+}
+
+func TestFindAllCollections(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionTypes []string
+ expectTotal int
+ }{
+ {nil, 16},
+ {[]string{}, 16},
+ {[]string{""}, 16},
+ {[]string{"unknown"}, 0},
+ {[]string{"unknown", core.CollectionTypeAuth}, 4},
+ {[]string{core.CollectionTypeAuth, core.CollectionTypeView}, 7},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, strings.Join(s.collectionTypes, "_")), func(t *testing.T) {
+ collections, err := app.FindAllCollections(s.collectionTypes...)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(collections) != s.expectTotal {
+ t.Fatalf("Expected %d collections, got %d", s.expectTotal, len(collections))
+ }
+
+ expectedTypes := list.NonzeroUniques(s.collectionTypes)
+ if len(expectedTypes) > 0 {
+ for _, c := range collections {
+ if !slices.Contains(expectedTypes, c.Type) {
+ t.Fatalf("Unexpected collection type %s\n%v", c.Type, c)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestFindCollectionByNameOrId(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ nameOrId string
+ expectError bool
+ }{
+ {"", true},
+ {"missing", true},
+ {"wsmn24bux7wo113", false},
+ {"demo1", false},
+ {"DEMO1", false}, // case insensitive
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.nameOrId), func(t *testing.T) {
+ model, err := app.FindCollectionByNameOrId(s.nameOrId)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if model != nil && model.Id != s.nameOrId && !strings.EqualFold(model.Name, s.nameOrId) {
+ t.Fatalf("Expected model with identifier %s, got %v", s.nameOrId, model)
+ }
+ })
+ }
+}
+
+func TestFindCachedCollectionByNameOrId(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ totalQueries := 0
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ totalQueries++
+ }
+
+ run := func(withCache bool) {
+ scenarios := []struct {
+ nameOrId string
+ expectError bool
+ }{
+ {"", true},
+ {"missing", true},
+ {"wsmn24bux7wo113", false},
+ {"demo1", false},
+ {"DEMO1", false}, // case insensitive
+ }
+
+ var expectedTotalQueries int
+
+ if withCache {
+ err := app.ReloadCachedCollections()
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ app.Store().Reset(nil)
+ expectedTotalQueries = len(scenarios)
+ }
+
+ totalQueries = 0
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.nameOrId), func(t *testing.T) {
+ model, err := app.FindCachedCollectionByNameOrId(s.nameOrId)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if model != nil && model.Id != s.nameOrId && !strings.EqualFold(model.Name, s.nameOrId) {
+ t.Fatalf("Expected model with identifier %s, got %v", s.nameOrId, model)
+ }
+ })
+ }
+
+ if totalQueries != expectedTotalQueries {
+ t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
+ }
+ }
+
+ run(true)
+
+ run(false)
+}
+
+func TestFindCollectionReferences(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := app.FindCollectionReferences(
+ collection,
+ collection.Id,
+ // test whether "nonempty" exclude ids condition will be skipped
+ "",
+ "",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
+ }
+
+ expectedFields := []string{
+ "rel_one_no_cascade",
+ "rel_one_no_cascade_required",
+ "rel_one_cascade",
+ "rel_one_unique",
+ "rel_many_no_cascade",
+ "rel_many_no_cascade_required",
+ "rel_many_cascade",
+ "rel_many_unique",
+ }
+
+ for col, fields := range result {
+ if col.Name != "demo4" {
+ t.Fatalf("Expected collection demo4, got %s", col.Name)
+ }
+ if len(fields) != len(expectedFields) {
+ t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
+ }
+ for i, f := range fields {
+ if !slices.Contains(expectedFields, f.GetName()) {
+ t.Fatalf("[%d] Didn't expect field %v", i, f)
+ }
+ }
+ }
+}
+
+func TestFindCachedCollectionReferences(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ totalQueries := 0
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ totalQueries++
+ }
+
+ run := func(withCache bool) {
+ var expectedTotalQueries int
+
+ if withCache {
+ err := app.ReloadCachedCollections()
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ app.Store().Reset(nil)
+ expectedTotalQueries = 1
+ }
+
+ totalQueries = 0
+
+ result, err := app.FindCachedCollectionReferences(
+ collection,
+ collection.Id,
+ // test whether "nonempty" exclude ids condition will be skipped
+ "",
+ "",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != 1 {
+ t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
+ }
+
+ expectedFields := []string{
+ "rel_one_no_cascade",
+ "rel_one_no_cascade_required",
+ "rel_one_cascade",
+ "rel_one_unique",
+ "rel_many_no_cascade",
+ "rel_many_no_cascade_required",
+ "rel_many_cascade",
+ "rel_many_unique",
+ }
+
+ for col, fields := range result {
+ if col.Name != "demo4" {
+ t.Fatalf("Expected collection demo4, got %s", col.Name)
+ }
+ if len(fields) != len(expectedFields) {
+ t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
+ }
+ for i, f := range fields {
+ if !slices.Contains(expectedFields, f.GetName()) {
+ t.Fatalf("[%d] Didn't expect field %v", i, f)
+ }
+ }
+ }
+
+ if totalQueries != expectedTotalQueries {
+ t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
+ }
+ }
+
+ run(true)
+
+ run(false)
+}
+
+func TestIsCollectionNameUnique(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ excludeId string
+ expected bool
+ }{
+ {"", "", false},
+ {"demo1", "", false},
+ {"Demo1", "", false},
+ {"new", "", true},
+ {"demo1", "wsmn24bux7wo113", true},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
+ result := app.IsCollectionNameUnique(s.name, s.excludeId)
+ if result != s.expected {
+ t.Errorf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestFindCollectionTruncate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ countFiles := func(collectionId string) (int, error) {
+ entries, err := os.ReadDir(filepath.Join(app.DataDir(), "storage", collectionId))
+ return len(entries), err
+ }
+
+ t.Run("truncate view", func(t *testing.T) {
+ view2, err := app.FindCollectionByNameOrId("view2")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = app.TruncateCollection(view2)
+ if err == nil {
+ t.Fatalf("Expected truncate to fail because view collections can't be truncated")
+ }
+ })
+
+ t.Run("truncate failure", func(t *testing.T) {
+ demo3, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ originalTotalRecords, err := app.CountRecords(demo3)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ originalTotalFiles, err := countFiles(demo3.Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = app.TruncateCollection(demo3)
+ if err == nil {
+ t.Fatalf("Expected truncate to fail due to cascade delete failed required constraint")
+ }
+
+ // short delay to ensure that the file delete goroutine has been executed
+ time.Sleep(100 * time.Millisecond)
+
+ totalRecords, err := app.CountRecords(demo3)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if totalRecords != originalTotalRecords {
+ t.Fatalf("Expected %d records, got %d", originalTotalRecords, totalRecords)
+ }
+
+ totalFiles, err := countFiles(demo3.Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if totalFiles != originalTotalFiles {
+ t.Fatalf("Expected %d files, got %d", originalTotalFiles, totalFiles)
+ }
+ })
+
+ t.Run("truncate success", func(t *testing.T) {
+ demo5, err := app.FindCollectionByNameOrId("demo5")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = app.TruncateCollection(demo5)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // short delay to ensure that the file delete goroutine has been executed
+ time.Sleep(100 * time.Millisecond)
+
+ total, err := app.CountRecords(demo5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if total != 0 {
+ t.Fatalf("Expected all records to be deleted, got %v", total)
+ }
+
+ totalFiles, err := countFiles(demo5.Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if totalFiles != 0 {
+ t.Fatalf("Expected truncated record files to be deleted, got %d", totalFiles)
+ }
+
+ // try to truncate again (shouldn't return an error)
+ err = app.TruncateCollection(demo5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+}
diff --git a/core/collection_record_table_sync.go b/core/collection_record_table_sync.go
new file mode 100644
index 0000000000..93967c59fd
--- /dev/null
+++ b/core/collection_record_table_sync.go
@@ -0,0 +1,366 @@
+package core
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+// SyncRecordTableSchema compares the two provided collections
+// and applies the necessary related record table changes.
+//
+// If oldCollection is null, then only newCollection is used to create the record table.
+//
+// This method is automatically invoked as part of a collection create/update/delete operation.
+func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollection *Collection) error {
+ if newCollection.IsView() {
+ return nil // nothing to sync since views don't have records table
+ }
+
+ txErr := app.RunInTransaction(func(txApp App) error {
+ // create
+ // -----------------------------------------------------------
+ if oldCollection == nil || !app.HasTable(oldCollection.Name) {
+ tableName := newCollection.Name
+
+ fields := newCollection.Fields
+
+ cols := make(map[string]string, len(fields))
+
+ // add fields definition
+ for _, field := range fields {
+ cols[field.GetName()] = field.ColumnType(app)
+ }
+
+ // create table
+ if _, err := txApp.DB().CreateTable(tableName, cols).Execute(); err != nil {
+ return err
+ }
+
+ return createCollectionIndexes(txApp, newCollection)
+ }
+
+ // update
+ // -----------------------------------------------------------
+ oldTableName := oldCollection.Name
+ newTableName := newCollection.Name
+ oldFields := oldCollection.Fields
+ newFields := newCollection.Fields
+
+ needTableRename := !strings.EqualFold(oldTableName, newTableName)
+
+ var needIndexesUpdate bool
+ if needTableRename ||
+ oldFields.String() != newFields.String() ||
+ oldCollection.Indexes.String() != newCollection.Indexes.String() {
+ needIndexesUpdate = true
+ }
+
+ if needIndexesUpdate {
+ // drop old indexes (if any)
+ if err := dropCollectionIndexes(txApp, oldCollection); err != nil {
+ return err
+ }
+ }
+
+ // check for renamed table
+ if needTableRename {
+ _, err := txApp.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
+ if err != nil {
+ return err
+ }
+ }
+
+ // check for deleted columns
+ for _, oldField := range oldFields {
+ if f := newFields.GetById(oldField.GetId()); f != nil {
+ continue // exist
+ }
+
+ _, err := txApp.DB().DropColumn(newTableName, oldField.GetName()).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to drop column %s - %w", oldField.GetName(), err)
+ }
+ }
+
+ // check for new or renamed columns
+ toRename := map[string]string{}
+ for _, field := range newFields {
+ oldField := oldFields.GetById(field.GetId())
+ // Note:
+ // We are using a temporary column name when adding or renaming columns
+ // to ensure that there are no name collisions in case there is
+ // names switch/reuse of existing columns (eg. name, title -> title, name).
+ // This way we are always doing 1 more rename operation but it provides better less ambiguous experience.
+
+ if oldField == nil {
+ tempName := field.GetName() + security.PseudorandomString(5)
+ toRename[tempName] = field.GetName()
+
+ // add
+ _, err := txApp.DB().AddColumn(newTableName, tempName, field.ColumnType(txApp)).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to add column %s - %w", field.GetName(), err)
+ }
+ } else if oldField.GetName() != field.GetName() {
+ tempName := field.GetName() + security.PseudorandomString(5)
+ toRename[tempName] = field.GetName()
+
+ // rename
+ _, err := txApp.DB().RenameColumn(newTableName, oldField.GetName(), tempName).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to rename column %s - %w", oldField.GetName(), err)
+ }
+ }
+ }
+
+ // set the actual columns name
+ for tempName, actualName := range toRename {
+ _, err := txApp.DB().RenameColumn(newTableName, tempName, actualName).Execute()
+ if err != nil {
+ return err
+ }
+ }
+
+ if err := normalizeSingleVsMultipleFieldChanges(txApp, newCollection, oldCollection); err != nil {
+ return err
+ }
+
+ if needIndexesUpdate {
+ return createCollectionIndexes(txApp, newCollection)
+ }
+
+ return nil
+ })
+ if txErr != nil {
+ return txErr
+ }
+
+ // run optimize per the SQLite recommendations
+ // (https://www.sqlite.org/pragma.html#pragma_optimize)
+ _, optimizeErr := app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
+ if optimizeErr != nil {
+ app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
+ }
+
+ return nil
+}
+
+func normalizeSingleVsMultipleFieldChanges(app App, newCollection *Collection, oldCollection *Collection) error {
+ if newCollection.IsView() || oldCollection == nil {
+ return nil // view or not an update
+ }
+
+ return app.RunInTransaction(func(txApp App) error {
+ for _, newField := range newCollection.Fields {
+ // allow to continue even if there is no old field for the cases
+ // when a new field is added and there are already inserted data
+ var isOldMultiple bool
+ if oldField := oldCollection.Fields.GetById(newField.GetId()); oldField != nil {
+ if mv, ok := oldField.(MultiValuer); ok {
+ isOldMultiple = mv.IsMultiple()
+ }
+ }
+
+ var isNewMultiple bool
+ if mv, ok := newField.(MultiValuer); ok {
+ isNewMultiple = mv.IsMultiple()
+ }
+
+ if isOldMultiple == isNewMultiple {
+ continue // no change
+ }
+
+ // -------------------------------------------------------
+ // update the field column definition
+ // -------------------------------------------------------
+
+ // temporary drop all views to prevent reference errors during the columns renaming
+ // (this is used as an "alternative" to the writable_schema PRAGMA)
+ views := []struct {
+ Name string `db:"name"`
+ SQL string `db:"sql"`
+ }{}
+ err := txApp.DB().Select("name", "sql").
+ From("sqlite_master").
+ AndWhere(dbx.NewExp("sql is not null")).
+ AndWhere(dbx.HashExp{"type": "view"}).
+ All(&views)
+ if err != nil {
+ return err
+ }
+ for _, view := range views {
+ err = txApp.DeleteView(view.Name)
+ if err != nil {
+ return err
+ }
+ }
+
+ originalName := newField.GetName()
+ oldTempName := "_" + newField.GetName() + security.PseudorandomString(5)
+
+ // rename temporary the original column to something else to allow inserting a new one in its place
+ _, err = txApp.DB().RenameColumn(newCollection.Name, originalName, oldTempName).Execute()
+ if err != nil {
+ return err
+ }
+
+ // reinsert the field column with the new type
+ _, err = txApp.DB().AddColumn(newCollection.Name, originalName, newField.ColumnType(txApp)).Execute()
+ if err != nil {
+ return err
+ }
+
+ var copyQuery *dbx.Query
+
+ if !isOldMultiple && isNewMultiple {
+ // single -> multiple (convert to array)
+ copyQuery = txApp.DB().NewQuery(fmt.Sprintf(
+ `UPDATE {{%s}} set [[%s]] = (
+ CASE
+ WHEN COALESCE([[%s]], '') = ''
+ THEN '[]'
+ ELSE (
+ CASE
+ WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
+ THEN [[%s]]
+ ELSE json_array([[%s]])
+ END
+ )
+ END
+ )`,
+ newCollection.Name,
+ originalName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ ))
+ } else {
+ // multiple -> single (keep only the last element)
+ //
+ // note: for file fields the actual file objects are not
+ // deleted allowing additional custom handling via migration
+ copyQuery = txApp.DB().NewQuery(fmt.Sprintf(
+ `UPDATE {{%s}} set [[%s]] = (
+ CASE
+ WHEN COALESCE([[%s]], '[]') = '[]'
+ THEN ''
+ ELSE (
+ CASE
+ WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
+ THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
+ ELSE [[%s]]
+ END
+ )
+ END
+ )`,
+ newCollection.Name,
+ originalName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ oldTempName,
+ ))
+ }
+
+ // copy the normalized values
+ _, err = copyQuery.Execute()
+ if err != nil {
+ return err
+ }
+
+ // drop the original column
+ _, err = txApp.DB().DropColumn(newCollection.Name, oldTempName).Execute()
+ if err != nil {
+ return err
+ }
+
+ // restore views
+ for _, view := range views {
+ _, err = txApp.DB().NewQuery(view.SQL).Execute()
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ })
+}
+
+func dropCollectionIndexes(app App, collection *Collection) error {
+ if collection.IsView() {
+ return nil // views don't have indexes
+ }
+
+ return app.RunInTransaction(func(txApp App) error {
+ for _, raw := range collection.Indexes {
+ parsed := dbutils.ParseIndex(raw)
+
+ // note: don't check IsValid because the index table name may not be populated
+ // (https://github.com/pocketbase/pocketbase/issues/7689)
+ if parsed.IndexName == "" {
+ return fmt.Errorf("failed to dop index - missing index name: %s", raw)
+ }
+
+ _, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
+
+func createCollectionIndexes(app App, collection *Collection) error {
+ if collection.IsView() {
+ return nil // views don't have indexes
+ }
+
+ return app.RunInTransaction(func(txApp App) error {
+ // upsert new indexes
+ //
+ // note: we are returning validation errors because the indexes cannot be
+ // easily validated in a form, aka. before persisting the related
+ // collection record table changes
+ errs := validation.Errors{}
+ for i, idx := range collection.Indexes {
+ parsed := dbutils.ParseIndex(idx)
+
+ // ensure that the index is always for the current collection
+ parsed.TableName = collection.Name
+
+ if !parsed.IsValid() {
+ errs[strconv.Itoa(i)] = validation.NewError(
+ "validation_invalid_index_expression",
+ "Invalid CREATE INDEX expression.",
+ )
+ continue
+ }
+
+ if _, err := txApp.DB().NewQuery(parsed.Build()).Execute(); err != nil {
+ errs[strconv.Itoa(i)] = validation.NewError(
+ "validation_invalid_index_expression",
+ fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
+ )
+ continue
+ }
+ }
+
+ if len(errs) > 0 {
+ return validation.Errors{"indexes": errs}
+ }
+
+ return nil
+ })
+}
diff --git a/core/collection_record_table_sync_test.go b/core/collection_record_table_sync_test.go
new file mode 100644
index 0000000000..ab18e54318
--- /dev/null
+++ b/core/collection_record_table_sync_test.go
@@ -0,0 +1,342 @@
+package core_test
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestSyncRecordTableSchema(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ oldCollection, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ updatedCollection, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ updatedCollection.Name = "demo_renamed"
+ updatedCollection.Fields.RemoveByName("active")
+ updatedCollection.Fields.Add(&core.EmailField{
+ Name: "new_field",
+ })
+ updatedCollection.Fields.Add(&core.EmailField{
+ Id: updatedCollection.Fields.GetByName("title").GetId(),
+ Name: "title_renamed",
+ })
+ updatedCollection.Indexes = types.JSONArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
+
+ baseCol := core.NewBaseCollection("new_base")
+ baseCol.Fields.Add(&core.TextField{Name: "test"})
+
+ authCol := core.NewAuthCollection("new_auth")
+ authCol.Fields.Add(&core.TextField{Name: "test"})
+ authCol.AddIndex("idx_auth_test", false, "email, id", "")
+
+ scenarios := []struct {
+ name string
+ newCollection *core.Collection
+ oldCollection *core.Collection
+ expectedColumns []string
+ expectedIndexesCount int
+ }{
+ {
+ "new base collection",
+ baseCol,
+ nil,
+ []string{"id", "test"},
+ 0,
+ },
+ {
+ "new auth collection",
+ authCol,
+ nil,
+ []string{
+ "id", "test", "email", "verified",
+ "emailVisibility", "tokenKey", "password",
+ },
+ 3,
+ },
+ {
+ "no changes",
+ oldCollection,
+ oldCollection,
+ []string{"id", "created", "updated", "title", "active"},
+ 3,
+ },
+ {
+ "renamed table, deleted column, renamed columnd and new column",
+ updatedCollection,
+ oldCollection,
+ []string{"id", "created", "updated", "title_renamed", "new_field"},
+ 1,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := app.SyncRecordTableSchema(s.newCollection, s.oldCollection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !app.HasTable(s.newCollection.Name) {
+ t.Fatalf("Expected table %s to exist", s.newCollection.Name)
+ }
+
+ cols, _ := app.TableColumns(s.newCollection.Name)
+ if len(cols) != len(s.expectedColumns) {
+ t.Fatalf("Expected columns %v, got %v", s.expectedColumns, cols)
+ }
+
+ for _, col := range cols {
+ if !list.ExistInSlice(col, s.expectedColumns) {
+ t.Fatalf("Couldn't find column %s in %v", col, s.expectedColumns)
+ }
+ }
+
+ indexes, _ := app.TableIndexes(s.newCollection.Name)
+
+ if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
+ t.Fatalf("Expected %d indexes, got %d:\n%v", s.expectedIndexesCount, totalIndexes, indexes)
+ }
+ })
+ }
+}
+
+func getTotalViews(app core.App) (int, error) {
+ var total int
+
+ err := app.DB().Select("count(*)").
+ From("sqlite_master").
+ AndWhere(dbx.NewExp("sql is not null")).
+ AndWhere(dbx.HashExp{"type": "view"}).
+ Row(&total)
+
+ return total, err
+}
+
+func TestSingleVsMultipleValuesNormalization(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ beforeTotalViews, err := getTotalViews(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // mock field changes
+ collection.Fields.GetByName("select_one").(*core.SelectField).MaxSelect = 2
+ collection.Fields.GetByName("select_many").(*core.SelectField).MaxSelect = 1
+ collection.Fields.GetByName("file_one").(*core.FileField).MaxSelect = 2
+ collection.Fields.GetByName("file_many").(*core.FileField).MaxSelect = 1
+ collection.Fields.GetByName("rel_one").(*core.RelationField).MaxSelect = 2
+ collection.Fields.GetByName("rel_many").(*core.RelationField).MaxSelect = 1
+
+ // new multivaluer field to check whether the array normalization
+ // will be applied for already inserted data
+ collection.Fields.Add(&core.SelectField{
+ Name: "new_multiple",
+ Values: []string{"a", "b", "c"},
+ MaxSelect: 3,
+ })
+
+ if err := app.Save(collection); err != nil {
+ t.Fatal(err)
+ }
+
+ // ensure that the views were reinserted
+ afterTotalViews, err := getTotalViews(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if afterTotalViews != beforeTotalViews {
+ t.Fatalf("Expected total views %d, got %d", beforeTotalViews, afterTotalViews)
+ }
+
+ // check whether the columns DEFAULT definition was updated
+ // ---------------------------------------------------------------
+ tableInfo, err := app.TableInfo(collection.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tableInfoExpectations := map[string]string{
+ "select_one": `'[]'`,
+ "select_many": `''`,
+ "file_one": `'[]'`,
+ "file_many": `''`,
+ "rel_one": `'[]'`,
+ "rel_many": `''`,
+ "new_multiple": `'[]'`,
+ }
+ for col, dflt := range tableInfoExpectations {
+ t.Run("check default for "+col, func(t *testing.T) {
+ var row *core.TableInfoRow
+ for _, r := range tableInfo {
+ if r.Name == col {
+ row = r
+ break
+ }
+ }
+ if row == nil {
+ t.Fatalf("Missing info for column %q", col)
+ }
+
+ if v := row.DefaultValue.String; v != dflt {
+ t.Fatalf("Expected default value %q, got %q", dflt, v)
+ }
+ })
+ }
+
+ // check whether the values were normalized
+ // ---------------------------------------------------------------
+ type fieldsExpectation struct {
+ SelectOne string `db:"select_one"`
+ SelectMany string `db:"select_many"`
+ FileOne string `db:"file_one"`
+ FileMany string `db:"file_many"`
+ RelOne string `db:"rel_one"`
+ RelMany string `db:"rel_many"`
+ NewMultiple string `db:"new_multiple"`
+ }
+
+ fieldsScenarios := []struct {
+ recordId string
+ expected fieldsExpectation
+ }{
+ {
+ "imy661ixudk5izi",
+ fieldsExpectation{
+ SelectOne: `[]`,
+ SelectMany: ``,
+ FileOne: `[]`,
+ FileMany: ``,
+ RelOne: `[]`,
+ RelMany: ``,
+ NewMultiple: `[]`,
+ },
+ },
+ {
+ "al1h9ijdeojtsjy",
+ fieldsExpectation{
+ SelectOne: `["optionB"]`,
+ SelectMany: `optionB`,
+ FileOne: `["300_Jsjq7RdBgA.png"]`,
+ FileMany: ``,
+ RelOne: `["84nmscqy84lsi1t"]`,
+ RelMany: `oap640cot4yru2s`,
+ NewMultiple: `[]`,
+ },
+ },
+ {
+ "84nmscqy84lsi1t",
+ fieldsExpectation{
+ SelectOne: `["optionB"]`,
+ SelectMany: `optionC`,
+ FileOne: `["test_d61b33QdDU.txt"]`,
+ FileMany: `test_tC1Yc87DfC.txt`,
+ RelOne: `[]`,
+ RelMany: `oap640cot4yru2s`,
+ NewMultiple: `[]`,
+ },
+ },
+ }
+
+ for _, s := range fieldsScenarios {
+ t.Run("check fields for record "+s.recordId, func(t *testing.T) {
+ result := new(fieldsExpectation)
+
+ err := app.DB().Select(
+ "select_one",
+ "select_many",
+ "file_one",
+ "file_many",
+ "rel_one",
+ "rel_many",
+ "new_multiple",
+ ).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
+ if err != nil {
+ t.Fatalf("Failed to load record: %v", err)
+ }
+
+ encodedResult, err := json.Marshal(result, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to encode result: %v", err)
+ }
+
+ encodedExpectation, err := json.Marshal(s.expected, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to encode expectation: %v", err)
+ }
+
+ if !bytes.EqualFold(encodedExpectation, encodedResult) {
+ t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
+ }
+ })
+ }
+}
+
+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)
+ }
+}
diff --git a/core/collection_validate.go b/core/collection_validate.go
new file mode 100644
index 0000000000..a63b5039b6
--- /dev/null
+++ b/core/collection_validate.go
@@ -0,0 +1,698 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+var collectionNameRegex = regexp.MustCompile(`^\w+$`)
+
+func onCollectionValidate(e *CollectionEvent) error {
+ var original *Collection
+ if !e.Collection.IsNew() {
+ original = &Collection{}
+ if err := e.App.ModelQuery(original).Model(e.Collection.LastSavedPK(), original); err != nil {
+ return fmt.Errorf("failed to fetch old collection state: %w", err)
+ }
+ }
+
+ validator := newCollectionValidator(
+ e.Context,
+ e.App,
+ e.Collection,
+ original,
+ )
+
+ if err := validator.run(); err != nil {
+ return err
+ }
+
+ return e.Next()
+}
+
+func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
+ validator := &collectionValidator{
+ ctx: ctx,
+ app: app,
+ new: new,
+ original: original,
+ }
+
+ // load old/original collection
+ if validator.original == nil {
+ validator.original = NewCollection(validator.new.Type, "")
+ }
+
+ return validator
+}
+
+type collectionValidator struct {
+ original *Collection
+ new *Collection
+ app App
+ ctx context.Context
+}
+
+type optionsValidator interface {
+ validate(cv *collectionValidator) error
+}
+
+func (validator *collectionValidator) run() error {
+ if validator.original.IsNew() {
+ validator.new.updateGeneratedIdIfExists(validator.app)
+ }
+
+ // generate fields from the query (overwriting any explicit user defined fields)
+ if validator.new.IsView() {
+ validator.new.Fields, _ = validator.app.CreateViewFields(validator.new.ViewQuery)
+ }
+
+ // validate base fields
+ baseErr := validation.ValidateStruct(validator.new,
+ validation.Field(
+ &validator.new.Id,
+ validation.Required,
+ validation.When(
+ validator.original.IsNew(),
+ validation.Length(1, 100),
+ validation.Match(DefaultIdRegex),
+ validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
+ ).Else(
+ validation.By(validators.Equal(validator.original.Id)),
+ ),
+ ),
+ validation.Field(
+ &validator.new.System,
+ validation.By(validator.ensureNoSystemFlagChange),
+ ),
+ validation.Field(
+ &validator.new.Type,
+ validation.Required,
+ validation.In(
+ CollectionTypeBase,
+ CollectionTypeAuth,
+ CollectionTypeView,
+ ),
+ validation.By(validator.ensureNoTypeChange),
+ ),
+ validation.Field(
+ &validator.new.Name,
+ validation.Required,
+ validation.Length(1, 255),
+ validation.By(checkForVia),
+ validation.Match(collectionNameRegex),
+ validation.By(validator.ensureNoSystemNameChange),
+ validation.By(validator.checkUniqueName),
+ ),
+ validation.Field(
+ &validator.new.Fields,
+ validation.By(validator.checkFieldDuplicates),
+ validation.By(validator.checkMinFields),
+ validation.When(
+ !validator.new.IsView(),
+ validation.By(validator.ensureNoSystemFieldsChange),
+ validation.By(validator.ensureNoFieldsTypeChange),
+ ),
+ validation.When(validator.new.IsAuth(), validation.By(validator.checkReservedAuthKeys)),
+ validation.By(validator.checkFieldValidators),
+ ),
+ validation.Field(
+ &validator.new.ListRule,
+ validation.By(validator.checkRule),
+ validation.By(validator.ensureNoSystemRuleChange(validator.original.ListRule)),
+ ),
+ validation.Field(
+ &validator.new.ViewRule,
+ validation.By(validator.checkRule),
+ validation.By(validator.ensureNoSystemRuleChange(validator.original.ViewRule)),
+ ),
+ validation.Field(
+ &validator.new.CreateRule,
+ validation.When(validator.new.IsView(), validation.Nil),
+ validation.By(validator.checkRule),
+ validation.By(validator.ensureNoSystemRuleChange(validator.original.CreateRule)),
+ ),
+ validation.Field(
+ &validator.new.UpdateRule,
+ validation.When(validator.new.IsView(), validation.Nil),
+ validation.By(validator.checkRule),
+ validation.By(validator.ensureNoSystemRuleChange(validator.original.UpdateRule)),
+ ),
+ validation.Field(
+ &validator.new.DeleteRule,
+ validation.When(validator.new.IsView(), validation.Nil),
+ validation.By(validator.checkRule),
+ validation.By(validator.ensureNoSystemRuleChange(validator.original.DeleteRule)),
+ ),
+ validation.Field(&validator.new.Indexes, validation.By(validator.checkIndexes)),
+ )
+
+ optionsErr := validator.validateOptions()
+
+ return validators.JoinValidationErrors(baseErr, optionsErr)
+}
+
+func (validator *collectionValidator) checkUniqueName(value any) error {
+ v, _ := value.(string)
+
+ // ensure unique collection name
+ if !validator.app.IsCollectionNameUnique(v, validator.original.Id) {
+ return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
+ }
+
+ // ensure that the collection name doesn't collide with the id of any collection
+ dummyCollection := &Collection{}
+ if validator.app.ModelQuery(dummyCollection).Model(v, dummyCollection) == nil {
+ return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
+ }
+
+ // ensure that there is no existing internal table with the provided name
+ if validator.original.Name != v && // has changed
+ validator.app.IsCollectionNameUnique(v) && // is not a collection (in case it was presaved)
+ validator.app.HasTable(v) {
+ return validation.NewError("validation_collection_name_invalid", "The name shouldn't match with an existing internal table.")
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoSystemNameChange(value any) error {
+ v, _ := value.(string)
+
+ if !validator.original.IsNew() && validator.original.System && v != validator.original.Name {
+ return validation.NewError("validation_collection_system_name_change", "System collection name cannot be changed.")
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoSystemFlagChange(value any) error {
+ v, _ := value.(bool)
+
+ if !validator.original.IsNew() && v != validator.original.System {
+ return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoTypeChange(value any) error {
+ v, _ := value.(string)
+
+ if !validator.original.IsNew() && v != validator.original.Type {
+ return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoFieldsTypeChange(value any) error {
+ v, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ errs := validation.Errors{}
+
+ for i, field := range v {
+ oldField := validator.original.Fields.GetById(field.GetId())
+
+ if oldField != nil && oldField.Type() != field.Type() {
+ errs[strconv.Itoa(i)] = validation.NewError(
+ "validation_field_type_change",
+ "Field type cannot be changed.",
+ )
+ }
+ }
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) checkFieldDuplicates(value any) error {
+ fields, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ totalFields := len(fields)
+ ids := make([]string, 0, totalFields)
+ names := make([]string, 0, totalFields)
+
+ for i, field := range fields {
+ if list.ExistInSlice(field.GetId(), ids) {
+ return validation.Errors{
+ strconv.Itoa(i): validation.Errors{
+ "id": validation.NewError(
+ "validation_duplicated_field_id",
+ fmt.Sprintf("Duplicated or invalid field id %q", field.GetId()),
+ ),
+ },
+ }
+ }
+
+ // field names are used as db columns and should be case insensitive
+ nameLower := strings.ToLower(field.GetName())
+
+ if list.ExistInSlice(nameLower, names) {
+ return validation.Errors{
+ strconv.Itoa(i): validation.Errors{
+ "name": validation.NewError(
+ "validation_duplicated_field_name",
+ "Duplicated or invalid field name {{.fieldName}}",
+ ).SetParams(map[string]any{
+ "fieldName": field.GetName(),
+ }),
+ },
+ }
+ }
+
+ ids = append(ids, field.GetId())
+ names = append(names, nameLower)
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) checkFieldValidators(value any) error {
+ fields, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ errs := validation.Errors{}
+
+ for i, field := range fields {
+ if err := field.ValidateSettings(validator.ctx, validator.app, validator.new); err != nil {
+ errs[strconv.Itoa(i)] = err
+ }
+ }
+
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return nil
+}
+
+func (cv *collectionValidator) checkViewQuery(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ _, err := cv.app.DryRunView(v, 10)
+ if err != nil {
+ rawErr := err.Error()
+ if len(rawErr) > 500 {
+ // restrict just as an extra precaution
+ rawErr = rawErr[:500]
+ }
+
+ return validation.NewError("validation_invalid_view_query", "Invalid query - "+rawErr)
+ }
+
+ return nil
+}
+
+var reservedAuthKeys = []string{"passwordConfirm", "oldPassword"}
+
+func (cv *collectionValidator) checkReservedAuthKeys(value any) error {
+ fields, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if !cv.new.IsAuth() {
+ return nil // not an auth collection
+ }
+
+ errs := validation.Errors{}
+ for i, field := range fields {
+ if list.ExistInSlice(field.GetName(), reservedAuthKeys) {
+ errs[strconv.Itoa(i)] = validation.Errors{
+ "name": validation.NewError(
+ "validation_reserved_field_name",
+ "The field name is reserved and cannot be used.",
+ ),
+ }
+ }
+ }
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return nil
+}
+
+func (cv *collectionValidator) checkMinFields(value any) error {
+ fields, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if len(fields) == 0 {
+ return validation.ErrRequired
+ }
+
+ // all collections must have an "id" PK field
+ idField, _ := fields.GetByName(FieldNameId).(*TextField)
+ if idField == nil || !idField.PrimaryKey {
+ return validation.NewError("validation_missing_primary_key", `Missing or invalid "id" PK field.`)
+ }
+
+ switch cv.new.Type {
+ case CollectionTypeAuth:
+ passwordField, _ := fields.GetByName(FieldNamePassword).(*PasswordField)
+ if passwordField == nil {
+ return validation.NewError("validation_missing_password_field", `System "password" field is required.`)
+ }
+ if !passwordField.Hidden || !passwordField.System {
+ return validation.Errors{FieldNamePassword: ErrMustBeSystemAndHidden}
+ }
+
+ tokenKeyField, _ := fields.GetByName(FieldNameTokenKey).(*TextField)
+ if tokenKeyField == nil {
+ return validation.NewError("validation_missing_tokenKey_field", `System "tokenKey" field is required.`)
+ }
+ if !tokenKeyField.Hidden || !tokenKeyField.System {
+ return validation.Errors{FieldNameTokenKey: ErrMustBeSystemAndHidden}
+ }
+
+ emailField, _ := fields.GetByName(FieldNameEmail).(*EmailField)
+ if emailField == nil {
+ return validation.NewError("validation_missing_email_field", `System "email" field is required.`)
+ }
+ if !emailField.System {
+ return validation.Errors{FieldNameEmail: ErrMustBeSystem}
+ }
+
+ emailVisibilityField, _ := fields.GetByName(FieldNameEmailVisibility).(*BoolField)
+ if emailVisibilityField == nil {
+ return validation.NewError("validation_missing_emailVisibility_field", `System "emailVisibility" field is required.`)
+ }
+ if !emailVisibilityField.System {
+ return validation.Errors{FieldNameEmailVisibility: ErrMustBeSystem}
+ }
+
+ verifiedField, _ := fields.GetByName(FieldNameVerified).(*BoolField)
+ if verifiedField == nil {
+ return validation.NewError("validation_missing_verified_field", `System "verified" field is required.`)
+ }
+ if !verifiedField.System {
+ return validation.Errors{FieldNameVerified: ErrMustBeSystem}
+ }
+
+ return nil
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoSystemFieldsChange(value any) error {
+ fields, ok := value.(FieldsList)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if validator.original.IsNew() {
+ return nil // not an update
+ }
+
+ for _, oldField := range validator.original.Fields {
+ if !oldField.GetSystem() {
+ continue
+ }
+
+ newField := fields.GetById(oldField.GetId())
+
+ if newField == nil || oldField.GetName() != newField.GetName() {
+ return validation.NewError("validation_system_field_change", "System fields cannot be deleted or renamed.")
+ }
+ }
+
+ return nil
+}
+
+func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
+ names, ok := value.([]string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if len(names) == 0 {
+ return nil // nothing to check
+ }
+
+ for _, name := range names {
+ field := cv.new.Fields.GetByName(name)
+ if field == nil {
+ return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
+ SetParams(map[string]any{"fieldName": name})
+ }
+
+ if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
+ return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
+ SetParams(map[string]any{"fieldName": name})
+ }
+ }
+
+ return nil
+}
+
+// note: value could be either *string or string
+func (validator *collectionValidator) checkRule(value any) error {
+ var vStr string
+
+ v, ok := value.(*string)
+ if ok {
+ if v != nil {
+ vStr = *v
+ }
+ } else {
+ vStr, ok = value.(string)
+ }
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if vStr == "" {
+ return nil // nothing to check
+ }
+
+ r := NewRecordFieldResolver(validator.app, validator.new, &RequestInfo{}, true)
+ _, err := search.FilterData(vStr).BuildExpr(r)
+ if err != nil {
+ return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error())
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) ensureNoSystemRuleChange(oldRule *string) validation.RuleFunc {
+ return func(value any) error {
+ if validator.original.IsNew() || !validator.original.System {
+ return nil // not an update of a system collection
+ }
+
+ rule, ok := value.(*string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if (rule == nil && oldRule == nil) ||
+ (rule != nil && oldRule != nil && *rule == *oldRule) {
+ return nil
+ }
+
+ return validation.NewError("validation_collection_system_rule_change", "System collection API rule cannot be changed.")
+ }
+}
+
+func (cv *collectionValidator) checkIndexes(value any) error {
+ indexes, _ := value.(types.JSONArray[string])
+
+ if cv.new.IsView() && len(indexes) > 0 {
+ return validation.NewError(
+ "validation_indexes_not_supported",
+ "View collections don't support indexes.",
+ )
+ }
+
+ duplicatedNames := make(map[string]struct{}, len(indexes))
+ duplicatedDefinitions := make(map[string]struct{}, len(indexes))
+
+ for i, rawIndex := range indexes {
+ parsed := dbutils.ParseIndex(rawIndex)
+
+ // always set a table name because it is ignored anyway in order to keep it in sync with the collection name
+ parsed.TableName = "validator"
+
+ if !parsed.IsValid() {
+ return validation.Errors{
+ strconv.Itoa(i): validation.NewError(
+ "validation_invalid_index_expression",
+ "Invalid CREATE INDEX expression.",
+ ),
+ }
+ }
+
+ if _, isDuplicated := duplicatedNames[strings.ToLower(parsed.IndexName)]; isDuplicated {
+ return validation.Errors{
+ strconv.Itoa(i): validation.NewError(
+ "validation_duplicated_index_name",
+ "The index name already exists.",
+ ),
+ }
+ }
+ duplicatedNames[strings.ToLower(parsed.IndexName)] = struct{}{}
+
+ // ensure that the index name is not used in another collection
+ var usedTblName string
+ _ = cv.app.ConcurrentDB().Select("tbl_name").
+ From("sqlite_master").
+ AndWhere(dbx.HashExp{"type": "index"}).
+ AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
+ AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:newName})", dbx.Params{"newName": cv.new.Name})).
+ AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:indexName})", dbx.Params{"indexName": parsed.IndexName})).
+ Limit(1).
+ Row(&usedTblName)
+ if usedTblName != "" {
+ return validation.Errors{
+ strconv.Itoa(i): validation.NewError(
+ "validation_existing_index_name",
+ "The index name is already used in {{.usedTableName}} collection.",
+ ).SetParams(map[string]any{"usedTableName": usedTblName}),
+ }
+ }
+
+ // reset non-important identifiers
+ parsed.SchemaName = "validator"
+ parsed.IndexName = "validator"
+ parsedDef := parsed.Build()
+
+ if _, isDuplicated := duplicatedDefinitions[parsedDef]; isDuplicated {
+ return validation.Errors{
+ strconv.Itoa(i): validation.NewError(
+ "validation_duplicated_index_definition",
+ "The index definition already exists.",
+ ),
+ }
+ }
+ duplicatedDefinitions[parsedDef] = struct{}{}
+
+ // note: we don't check the index table name because it is always
+ // overwritten by the SyncRecordTableSchema to allow
+ // easier partial modifications (eg. changing only the collection name).
+ // if !strings.EqualFold(parsed.TableName, form.Name) {
+ // return validation.Errors{
+ // strconv.Itoa(i): validation.NewError(
+ // "validation_invalid_index_table",
+ // fmt.Sprintf("The index table must be the same as the collection name."),
+ // ),
+ // }
+ // }
+ }
+
+ // ensure that unique indexes on system fields are not changed or removed
+ if !cv.original.IsNew() {
+ OLD_INDEXES_LOOP:
+ for _, oldIndex := range cv.original.Indexes {
+ oldParsed := dbutils.ParseIndex(oldIndex)
+ if !oldParsed.Unique {
+ continue
+ }
+
+ // reset collate and sort since they are not important for the unique constraint
+ for i := range oldParsed.Columns {
+ oldParsed.Columns[i].Collate = ""
+ oldParsed.Columns[i].Sort = ""
+ }
+
+ oldParsedStr := oldParsed.Build()
+
+ for _, column := range oldParsed.Columns {
+ for _, f := range cv.original.Fields {
+ if !f.GetSystem() || !strings.EqualFold(column.Name, f.GetName()) {
+ continue
+ }
+
+ var hasMatch bool
+ for _, newIndex := range cv.new.Indexes {
+ newParsed := dbutils.ParseIndex(newIndex)
+
+ // exclude the non-important identifiers from the check
+ newParsed.SchemaName = oldParsed.SchemaName
+ newParsed.IndexName = oldParsed.IndexName
+ newParsed.TableName = oldParsed.TableName
+
+ // exclude partial constraints
+ newParsed.Where = oldParsed.Where
+
+ // reset collate and sort
+ for i := range newParsed.Columns {
+ newParsed.Columns[i].Collate = ""
+ newParsed.Columns[i].Sort = ""
+ }
+
+ if oldParsedStr == newParsed.Build() {
+ hasMatch = true
+ break
+ }
+ }
+
+ if !hasMatch {
+ return validation.NewError(
+ "validation_invalid_unique_system_field_index",
+ "Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
+ ).SetParams(map[string]any{"fieldName": f.GetName()})
+ }
+
+ continue OLD_INDEXES_LOOP
+ }
+ }
+ }
+ }
+
+ // check for required indexes
+ //
+ // note: this is in case the indexes were removed manually when creating/importing new auth collections
+ // and technically it is not necessary because on app.Save() the missing indexes will be reinserted by the system collection hook
+ if cv.new.IsAuth() {
+ requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
+ for _, name := range requiredNames {
+ if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
+ return validation.NewError(
+ "validation_missing_required_unique_index",
+ `Missing required unique index for field "{{.fieldName}}".`,
+ ).SetParams(map[string]any{"fieldName": name})
+ }
+ }
+ }
+
+ return nil
+}
+
+func (validator *collectionValidator) validateOptions() error {
+ switch validator.new.Type {
+ case CollectionTypeAuth:
+ return validator.new.collectionAuthOptions.validate(validator)
+ case CollectionTypeView:
+ return validator.new.collectionViewOptions.validate(validator)
+ }
+
+ return nil
+}
diff --git a/core/collection_validate_test.go b/core/collection_validate_test.go
new file mode 100644
index 0000000000..9f0ef34287
--- /dev/null
+++ b/core/collection_validate_test.go
@@ -0,0 +1,909 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestCollectionValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ collection func(app core.App) (*core.Collection, error)
+ expectedErrors []string
+ }{
+ {
+ name: "empty collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ return &core.Collection{}, nil
+ },
+ expectedErrors: []string{
+ "id", "name", "type", "fields", // no default fields because the type is unknown
+ },
+ },
+ {
+ name: "unknown type with all invalid fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := &core.Collection{}
+ c.Id = "invalid_id ?!@#$"
+ c.Name = "invalid_name ?!@#$"
+ c.Type = "invalid_type"
+ c.ListRule = types.Pointer("missing = '123'")
+ c.ViewRule = types.Pointer("missing = '123'")
+ c.CreateRule = types.Pointer("missing = '123'")
+ c.UpdateRule = types.Pointer("missing = '123'")
+ c.DeleteRule = types.Pointer("missing = '123'")
+ c.Indexes = []string{"create index '' on '' ()"}
+
+ // type specific fields
+ c.ViewQuery = "invalid" // should be ignored
+ c.AuthRule = types.Pointer("missing = '123'") // should be ignored
+
+ return c, nil
+ },
+ expectedErrors: []string{
+ "id", "name", "type", "indexes",
+ "listRule", "viewRule", "createRule", "updateRule", "deleteRule",
+ "fields", // no default fields because the type is unknown
+ },
+ },
+ {
+ name: "base with invalid fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("invalid_name ?!@#$")
+ c.Indexes = []string{"create index '' on '' ()"}
+
+ // type specific fields
+ c.ViewQuery = "invalid" // should be ignored
+ c.AuthRule = types.Pointer("missing = '123'") // should be ignored
+
+ return c, nil
+ },
+ expectedErrors: []string{"name", "indexes"},
+ },
+ {
+ name: "view with invalid fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("invalid_name ?!@#$")
+ c.Indexes = []string{"create index '' on '' ()"}
+
+ // type specific fields
+ c.ViewQuery = "invalid"
+ c.AuthRule = types.Pointer("missing = '123'") // should be ignored
+
+ return c, nil
+ },
+ expectedErrors: []string{"indexes", "name", "fields", "viewQuery"},
+ },
+ {
+ name: "auth with invalid fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("invalid_name ?!@#$")
+ c.Indexes = []string{"create index '' on '' ()"}
+
+ // type specific fields
+ c.ViewQuery = "invalid" // should be ignored
+ c.AuthRule = types.Pointer("missing = '123'")
+
+ return c, nil
+ },
+ expectedErrors: []string{"indexes", "name", "authRule"},
+ },
+
+ // type checks
+ {
+ name: "empty type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Type = ""
+ return c, nil
+ },
+ expectedErrors: []string{"type"},
+ },
+ {
+ name: "unknown type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Type = "unknown"
+ return c, nil
+ },
+ expectedErrors: []string{"type"},
+ },
+ {
+ name: "base type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "view type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("test")
+ c.ViewQuery = "select 1 as id"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "auth type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("test")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "changing type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("users")
+ c.Type = core.CollectionTypeBase
+ return c, nil
+ },
+ expectedErrors: []string{"type"},
+ },
+
+ // system checks
+ {
+ name: "change from system to regular",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ c.System = false
+ return c, nil
+ },
+ expectedErrors: []string{"system"},
+ },
+ {
+ name: "change from regular to system",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.System = true
+ return c, nil
+ },
+ expectedErrors: []string{"system"},
+ },
+ {
+ name: "create system",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_system")
+ c.System = true
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // id checks
+ {
+ name: "empty id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Id = ""
+ return c, nil
+ },
+ expectedErrors: []string{"id"},
+ },
+ {
+ name: "invalid id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Id = "!invalid"
+ return c, nil
+ },
+ expectedErrors: []string{"id"},
+ },
+ {
+ name: "existing id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Id = "_pb_users_auth_"
+ return c, nil
+ },
+ expectedErrors: []string{"id"},
+ },
+ {
+ name: "changing id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo3")
+ c.Id = "anything"
+ return c, nil
+ },
+ expectedErrors: []string{"id"},
+ },
+ {
+ name: "valid id",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test")
+ c.Id = "anything"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // name checks
+ {
+ name: "empty name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("")
+ c.Id = "test"
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "invalid name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("!invalid")
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "name with _via_",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("a_via_b")
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "create with existing collection name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("demo1")
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "create with existing internal table name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("_collections")
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "update with existing collection name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("users")
+ c.Name = "demo1"
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "update with existing internal table name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("users")
+ c.Name = "_collections"
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "system collection name change",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ c.Name = "superusers_new"
+ return c, nil
+ },
+ expectedErrors: []string{"name"},
+ },
+ {
+ name: "create with valid name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_col")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "update with valid name",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Name = "demo1_new"
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // rule checks
+ {
+ name: "invalid base rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new")
+ c.ListRule = types.Pointer("!invalid")
+ c.ViewRule = types.Pointer("missing = 123")
+ c.CreateRule = types.Pointer("id = 123 && missing = 456")
+ c.UpdateRule = types.Pointer("@request.body.missing:changed = false")
+ c.DeleteRule = types.Pointer("(id=123")
+ return c, nil
+ },
+ expectedErrors: []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
+ },
+ {
+ name: "valid base rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new")
+ c.Fields.Add(&core.TextField{Name: "f1"}) // dummy field to ensure that new fields can be referenced
+ c.ListRule = types.Pointer("")
+ c.ViewRule = types.Pointer("f1 = 123")
+ c.CreateRule = types.Pointer("id = 123 && f1 = 456")
+ c.UpdateRule = types.Pointer("(id = 123 && @request.body.f1:changed = false)")
+ c.DeleteRule = types.Pointer("f1 = 123")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "view with non-nil create/update/delete rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new")
+ c.ViewQuery = "select 1 as id, 'text' as f1"
+ c.ListRule = types.Pointer("id = 123")
+ c.ViewRule = types.Pointer("f1 = 456")
+ c.CreateRule = types.Pointer("")
+ c.UpdateRule = types.Pointer("")
+ c.DeleteRule = types.Pointer("")
+ return c, nil
+ },
+ expectedErrors: []string{"createRule", "updateRule", "deleteRule"},
+ },
+ {
+ name: "view with nil create/update/delete rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewViewCollection("new")
+ c.ViewQuery = "select 1 as id, 'text' as f1"
+ c.ListRule = types.Pointer("id = 1")
+ c.ViewRule = types.Pointer("f1 = 456")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "changing api rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("users")
+ c.Fields.Add(&core.TextField{Name: "f1"}) // dummy field to ensure that new fields can be referenced
+ c.ListRule = types.Pointer("id = 1")
+ c.ViewRule = types.Pointer("f1 = 456")
+ c.CreateRule = types.Pointer("id = 123 && f1 = 456")
+ c.UpdateRule = types.Pointer("(id = 123)")
+ c.DeleteRule = types.Pointer("f1 = 123")
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "changing system collection api rules",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ c.ListRule = types.Pointer("1 = 1")
+ c.ViewRule = types.Pointer("1 = 1")
+ c.CreateRule = types.Pointer("1 = 1")
+ c.UpdateRule = types.Pointer("1 = 1")
+ c.DeleteRule = types.Pointer("1 = 1")
+ c.ManageRule = types.Pointer("1 = 1")
+ c.AuthRule = types.Pointer("1 = 1")
+ return c, nil
+ },
+ expectedErrors: []string{
+ "listRule", "viewRule", "createRule", "updateRule",
+ "deleteRule", "manageRule", "authRule",
+ },
+ },
+
+ // indexes checks
+ {
+ name: "invalid index expression",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index invalid",
+ "create index idx_test_demo2 on anything (text)", // the name of table shouldn't matter
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "index name used in other table",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index `idx_test_demo1` on demo1 (id)",
+ "create index `__pb_USERS_auth__username_idx` on anything (text)", // should be case-insensitive
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "duplicated index names",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index idx_test_demo1 on demo1 (id)",
+ "create index idx_test_demo1 on anything (text)",
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "duplicated index definitions",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index idx_test_demo1 on demo1 (id)",
+ "create index idx_test_demo2 on demo1 (id)",
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "try to add index to a view collection",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("view1")
+ c.Indexes = []string{"create index idx_test_view1 on view1 (id)"}
+ return c, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "replace old with new indexes",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index idx_test_demo1 on demo1 (id)",
+ "create index idx_test_demo2 on anything (text)", // the name of table shouldn't matter
+ }
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "old + new indexes",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "CREATE INDEX `_wsmn24bux7wo113_created_idx` ON `demo1` (`created`)",
+ "create index idx_test_demo1 on anything (id)",
+ }
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "index for missing field",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ c.Indexes = []string{
+ "create index idx_test_demo1 on anything (missing)", // still valid because it is checked on db persist
+ }
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "auth collection with missing required unique indexes",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Indexes = []string{}
+ return c, nil
+ },
+ expectedErrors: []string{"indexes", "passwordAuth"},
+ },
+ {
+ name: "auth collection with non-unique required indexes",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Indexes = []string{
+ "create index test_idx1 on new_auth (tokenKey)",
+ "create index test_idx2 on new_auth (email)",
+ }
+ return c, nil
+ },
+ expectedErrors: []string{"indexes", "passwordAuth"},
+ },
+ {
+ name: "auth collection with unique required indexes",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Indexes = []string{
+ "create unique index test_idx1 on new_auth (tokenKey)",
+ "create unique index test_idx2 on new_auth (email)",
+ }
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "removing index on system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // mark the title field as system
+ demo2.Fields.GetByName("title").SetSystem(true)
+ if err = app.Save(demo2); err != nil {
+ return nil, err
+ }
+
+ // refresh
+ demo2, err = app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ demo2.RemoveIndex("idx_unique_demo2_title")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "changing partial constraint of existing index on system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // mark the title field as system
+ demo2.Fields.GetByName("title").SetSystem(true)
+ if err = app.Save(demo2); err != nil {
+ return nil, err
+ }
+
+ // refresh
+ demo2, err = app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // replace the index with a partial one
+ demo2.RemoveIndex("idx_unique_demo2_title")
+ demo2.AddIndex("idx_new_demo2_title", true, "title", "1 = 1")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "changing column sort and collate of existing index on system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // mark the title field as system
+ demo2.Fields.GetByName("title").SetSystem(true)
+ if err = app.Save(demo2); err != nil {
+ return nil, err
+ }
+
+ // refresh
+ demo2, err = app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // replace the index with a new one for the same column but with collate and sort
+ demo2.RemoveIndex("idx_unique_demo2_title")
+ demo2.AddIndex("idx_new_demo2_title", true, "title COLLATE test ASC", "")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "adding new column to index on system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // mark the title field as system
+ demo2.Fields.GetByName("title").SetSystem(true)
+ if err = app.Save(demo2); err != nil {
+ return nil, err
+ }
+
+ // refresh
+ demo2, err = app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // replace the index with a non-unique one
+ demo2.RemoveIndex("idx_unique_demo2_title")
+ demo2.AddIndex("idx_new_title", false, "title, id", "")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "changing index type on system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // mark the title field as system
+ demo2.Fields.GetByName("title").SetSystem(true)
+ if err = app.Save(demo2); err != nil {
+ return nil, err
+ }
+
+ // refresh
+ demo2, err = app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // replace the index with a non-unique one (partial constraints are ignored)
+ demo2.RemoveIndex("idx_unique_demo2_title")
+ demo2.AddIndex("idx_new_title", false, "title", "1=1")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{"indexes"},
+ },
+ {
+ name: "changing index on non-system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ demo2, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ return nil, err
+ }
+
+ // replace the index with a partial one
+ demo2.RemoveIndex("idx_demo2_active")
+ demo2.AddIndex("idx_demo2_active", true, "active", "1 = 1")
+
+ return demo2, nil
+ },
+ expectedErrors: []string{},
+ },
+
+ // fields list checks
+ {
+ name: "empty fields",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_auth")
+ c.Fields = nil // the minimum fields should auto added
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "no id primay key field",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_auth")
+ c.Fields = core.NewFieldsList(
+ &core.TextField{Name: "id"},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with id primay key field",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_auth")
+ c.Fields = core.NewFieldsList(
+ &core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "duplicated field names",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("new_auth")
+ c.Fields = core.NewFieldsList(
+ &core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
+ &core.TextField{Id: "f1", Name: "Test"}, // case-insensitive
+ &core.BoolField{Id: "f2", Name: "test"},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "changing field type",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("demo1")
+ f := c.Fields.GetByName("text")
+ c.Fields.Add(&core.BoolField{Id: f.GetId(), Name: f.GetName()})
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "renaming system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId(core.CollectionNameAuthOrigins)
+ f := c.Fields.GetByName("fingerprint")
+ f.SetName("fingerprint_new")
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "deleting system field",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId(core.CollectionNameAuthOrigins)
+ c.Fields.RemoveByName("fingerprint")
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "invalid field setting",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test_new")
+ c.Fields.Add(&core.TextField{Name: "f1", Min: -10})
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "valid field setting",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewBaseCollection("test_new")
+ c.Fields.Add(&core.TextField{Name: "f1", Min: 10})
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "fields view changes should be ignored",
+ collection: func(app core.App) (*core.Collection, error) {
+ c, _ := app.FindCollectionByNameOrId("view1")
+ c.Fields = nil
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "with reserved auth only field name (passwordConfirm)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "passwordConfirm"},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with reserved auth only field name (oldPassword)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "oldPassword"},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with invalid password auth field options (1)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "password", System: true, Hidden: true}, // should be PasswordField
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with valid password auth field options (2)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.PasswordField{Name: "password", System: true, Hidden: true},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "with invalid tokenKey auth field options (1)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "tokenKey", System: true}, // should be also hidden
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with valid tokenKey auth field options (2)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "tokenKey", System: true, Hidden: true},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "with invalid email auth field options (1)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "email", System: true}, // should be EmailField
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with valid email auth field options (2)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.EmailField{Name: "email", System: true},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ {
+ name: "with invalid verified auth field options (1)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.TextField{Name: "verified", System: true}, // should be BoolField
+ )
+ return c, nil
+ },
+ expectedErrors: []string{"fields"},
+ },
+ {
+ name: "with valid verified auth field options (2)",
+ collection: func(app core.App) (*core.Collection, error) {
+ c := core.NewAuthCollection("new_auth")
+ c.Fields.Add(
+ &core.BoolField{Name: "verified", System: true},
+ )
+ return c, nil
+ },
+ expectedErrors: []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := s.collection(app)
+ if err != nil {
+ t.Fatalf("Failed to retrieve test collection: %v", err)
+ }
+
+ result := app.Validate(collection)
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
diff --git a/core/db.go b/core/db.go
new file mode 100644
index 0000000000..d92f1cbdb4
--- /dev/null
+++ b/core/db.go
@@ -0,0 +1,499 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "hash/crc32"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+const (
+ idColumn string = "id"
+
+ // DefaultIdLength is the default length of the generated model id.
+ DefaultIdLength int = 15
+
+ // DefaultIdAlphabet is the default characters set used for generating the model id.
+ DefaultIdAlphabet string = "abcdefghijklmnopqrstuvwxyz0123456789"
+)
+
+// DefaultIdRegex specifies the default regex pattern for an id value.
+var DefaultIdRegex = regexp.MustCompile(`^\w+$`)
+
+// DBExporter defines an interface for custom DB data export.
+// Usually used as part of [App.Save].
+type DBExporter interface {
+ // DBExport returns a key-value map with the data to be used when saving the struct in the database.
+ DBExport(app App) (map[string]any, error)
+}
+
+// PreValidator defines an optional model interface for registering a
+// function that will run BEFORE firing the validation hooks (see [App.ValidateWithContext]).
+type PreValidator interface {
+ // PreValidate defines a function that runs BEFORE the validation hooks.
+ PreValidate(ctx context.Context, app App) error
+}
+
+// PostValidator defines an optional model interface for registering a
+// function that will run AFTER executing the validation hooks (see [App.ValidateWithContext]).
+type PostValidator interface {
+ // PostValidate defines a function that runs AFTER the successful
+ // execution of the validation hooks.
+ PostValidate(ctx context.Context, app App) error
+}
+
+// GenerateDefaultRandomId generates a default random id string
+// (note: the generated random string is not intended for security purposes).
+func GenerateDefaultRandomId() string {
+ return security.PseudorandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
+}
+
+// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
+func crc32Checksum(str string) string {
+ return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
+}
+
+// ModelQuery creates a new preconfigured select data.db query with preset
+// SELECT, FROM and other common fields based on the provided model.
+func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
+ return app.modelQuery(app.ConcurrentDB(), m)
+}
+
+// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
+// SELECT, FROM and other common fields based on the provided model.
+func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
+ return app.modelQuery(app.AuxConcurrentDB(), m)
+}
+
+func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
+ tableName := m.TableName()
+
+ return db.
+ Select("{{" + tableName + "}}.*").
+ From(tableName).
+ WithBuildHook(func(query *dbx.Query) {
+ query.WithExecHook(execLockRetry(app.config.QueryTimeout, defaultMaxLockRetries))
+ })
+}
+
+// Delete deletes the specified model from the regular app database.
+func (app *BaseApp) Delete(model Model) error {
+ return app.DeleteWithContext(context.Background(), model)
+}
+
+// Delete deletes the specified model from the regular app database
+// (the context could be used to limit the query execution).
+func (app *BaseApp) DeleteWithContext(ctx context.Context, model Model) error {
+ return app.delete(ctx, model, false)
+}
+
+// AuxDelete deletes the specified model from the auxiliary database.
+func (app *BaseApp) AuxDelete(model Model) error {
+ return app.AuxDeleteWithContext(context.Background(), model)
+}
+
+// AuxDeleteWithContext deletes the specified model from the auxiliary database
+// (the context could be used to limit the query execution).
+func (app *BaseApp) AuxDeleteWithContext(ctx context.Context, model Model) error {
+ return app.delete(ctx, model, true)
+}
+
+func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) error {
+ event := new(ModelEvent)
+ event.App = app
+ event.Type = ModelEventTypeDelete
+ event.Context = ctx
+ event.Model = model
+
+ deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
+ pk := cast.ToString(e.Model.LastSavedPK())
+ if pk == "" {
+ return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
+ }
+
+ // db write
+ return e.App.OnModelDeleteExecute().Trigger(event, func(e *ModelEvent) error {
+ var db dbx.Builder
+ if isForAuxDB {
+ db = e.App.AuxNonconcurrentDB()
+ } else {
+ db = e.App.NonconcurrentDB()
+ }
+
+ return baseLockRetry(func(attempt int) error {
+ _, err := db.Delete(e.Model.TableName(), dbx.HashExp{
+ idColumn: pk,
+ }).WithContext(e.Context).Execute()
+
+ return err
+ }, defaultMaxLockRetries)
+ })
+ })
+ if deleteErr != nil {
+ errEvent := &ModelErrorEvent{ModelEvent: *event, Error: deleteErr}
+ errEvent.App = app // replace with the initial app in case it was changed by the hook
+ hookErr := app.OnModelAfterDeleteError().Trigger(errEvent)
+ if hookErr != nil {
+ return errors.Join(deleteErr, hookErr)
+ }
+
+ return deleteErr
+ }
+
+ if app.txInfo != nil {
+ // execute later after the transaction has completed
+ app.txInfo.OnComplete(func(txErr error) error {
+ if app.txInfo != nil && app.txInfo.parent != nil {
+ event.App = app.txInfo.parent
+ }
+
+ if txErr != nil {
+ return app.OnModelAfterDeleteError().Trigger(&ModelErrorEvent{
+ ModelEvent: *event,
+ Error: txErr,
+ })
+ }
+
+ return app.OnModelAfterDeleteSuccess().Trigger(event)
+ })
+ } else if err := event.App.OnModelAfterDeleteSuccess().Trigger(event); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Save validates and saves the specified model into the regular app database.
+//
+// If you don't want to run validations, use [App.SaveNoValidate()].
+func (app *BaseApp) Save(model Model) error {
+ return app.SaveWithContext(context.Background(), model)
+}
+
+// SaveWithContext is the same as [App.Save()] but allows specifying a context to limit the db execution.
+//
+// If you don't want to run validations, use [App.SaveNoValidateWithContext()].
+func (app *BaseApp) SaveWithContext(ctx context.Context, model Model) error {
+ return app.save(ctx, model, true, false)
+}
+
+// SaveNoValidate saves the specified model into the regular app database without performing validations.
+//
+// If you want to also run validations before persisting, use [App.Save()].
+func (app *BaseApp) SaveNoValidate(model Model) error {
+ return app.SaveNoValidateWithContext(context.Background(), model)
+}
+
+// SaveNoValidateWithContext is the same as [App.SaveNoValidate()]
+// but allows specifying a context to limit the db execution.
+//
+// If you want to also run validations before persisting, use [App.SaveWithContext()].
+func (app *BaseApp) SaveNoValidateWithContext(ctx context.Context, model Model) error {
+ return app.save(ctx, model, false, false)
+}
+
+// AuxSave validates and saves the specified model into the auxiliary app database.
+//
+// If you don't want to run validations, use [App.AuxSaveNoValidate()].
+func (app *BaseApp) AuxSave(model Model) error {
+ return app.AuxSaveWithContext(context.Background(), model)
+}
+
+// AuxSaveWithContext is the same as [App.AuxSave()] but allows specifying a context to limit the db execution.
+//
+// If you don't want to run validations, use [App.AuxSaveNoValidateWithContext()].
+func (app *BaseApp) AuxSaveWithContext(ctx context.Context, model Model) error {
+ return app.save(ctx, model, true, true)
+}
+
+// AuxSaveNoValidate saves the specified model into the auxiliary app database without performing validations.
+//
+// If you want to also run validations before persisting, use [App.AuxSave()].
+func (app *BaseApp) AuxSaveNoValidate(model Model) error {
+ return app.AuxSaveNoValidateWithContext(context.Background(), model)
+}
+
+// AuxSaveNoValidateWithContext is the same as [App.AuxSaveNoValidate()]
+// but allows specifying a context to limit the db execution.
+//
+// If you want to also run validations before persisting, use [App.AuxSaveWithContext()].
+func (app *BaseApp) AuxSaveNoValidateWithContext(ctx context.Context, model Model) error {
+ return app.save(ctx, model, false, true)
+}
+
+// Validate triggers the OnModelValidate hook for the specified model.
+func (app *BaseApp) Validate(model Model) error {
+ return app.ValidateWithContext(context.Background(), model)
+}
+
+// ValidateWithContext is the same as Validate but allows specifying the ModelEvent context.
+func (app *BaseApp) ValidateWithContext(ctx context.Context, model Model) error {
+ if m, ok := model.(PreValidator); ok {
+ if err := m.PreValidate(ctx, app); err != nil {
+ return err
+ }
+ }
+
+ event := new(ModelEvent)
+ event.App = app
+ event.Context = ctx
+ event.Type = ModelEventTypeValidate
+ event.Model = model
+
+ return event.App.OnModelValidate().Trigger(event, func(e *ModelEvent) error {
+ if m, ok := e.Model.(PostValidator); ok {
+ if err := m.PostValidate(ctx, e.App); err != nil {
+ return err
+ }
+ }
+
+ return e.Next()
+ })
+}
+
+// -------------------------------------------------------------------
+
+func (app *BaseApp) save(ctx context.Context, model Model, withValidations bool, isForAuxDB bool) error {
+ if model.IsNew() {
+ return app.create(ctx, model, withValidations, isForAuxDB)
+ }
+
+ return app.update(ctx, model, withValidations, isForAuxDB)
+}
+
+func (app *BaseApp) create(ctx context.Context, model Model, withValidations bool, isForAuxDB bool) error {
+ event := new(ModelEvent)
+ event.App = app
+ event.Context = ctx
+ event.Type = ModelEventTypeCreate
+ event.Model = model
+
+ saveErr := app.OnModelCreate().Trigger(event, func(e *ModelEvent) error {
+ // run validations (if any)
+ if withValidations {
+ validateErr := e.App.ValidateWithContext(e.Context, e.Model)
+ if validateErr != nil {
+ return validateErr
+ }
+ }
+
+ // db write
+ return e.App.OnModelCreateExecute().Trigger(event, func(e *ModelEvent) error {
+ var db dbx.Builder
+ if isForAuxDB {
+ db = e.App.AuxNonconcurrentDB()
+ } else {
+ db = e.App.NonconcurrentDB()
+ }
+
+ dbErr := baseLockRetry(func(attempt int) error {
+ if m, ok := e.Model.(DBExporter); ok {
+ data, err := m.DBExport(e.App)
+ if err != nil {
+ return err
+ }
+
+ // manually add the id to the data if missing
+ if _, ok := data[idColumn]; !ok {
+ data[idColumn] = e.Model.PK()
+ }
+
+ if cast.ToString(data[idColumn]) == "" {
+ return errors.New("empty primary key is not allowed when using the DBExporter interface")
+ }
+
+ _, err = db.Insert(e.Model.TableName(), data).WithContext(e.Context).Execute()
+
+ return err
+ }
+
+ return db.Model(e.Model).WithContext(e.Context).Insert()
+ }, defaultMaxLockRetries)
+ if dbErr != nil {
+ return dbErr
+ }
+
+ e.Model.MarkAsNotNew()
+
+ return nil
+ })
+ })
+ if saveErr != nil {
+ event.Model.MarkAsNew() // reset "new" state
+
+ errEvent := &ModelErrorEvent{ModelEvent: *event, Error: saveErr}
+ errEvent.App = app // replace with the initial app in case it was changed by the hook
+ hookErr := app.OnModelAfterCreateError().Trigger(errEvent)
+ if hookErr != nil {
+ return errors.Join(saveErr, hookErr)
+ }
+
+ return saveErr
+ }
+
+ if app.txInfo != nil {
+ // execute later after the transaction has completed
+ app.txInfo.OnComplete(func(txErr error) error {
+ if app.txInfo != nil && app.txInfo.parent != nil {
+ event.App = app.txInfo.parent
+ }
+
+ if txErr != nil {
+ event.Model.MarkAsNew() // reset "new" state
+
+ return app.OnModelAfterCreateError().Trigger(&ModelErrorEvent{
+ ModelEvent: *event,
+ Error: txErr,
+ })
+ }
+
+ return app.OnModelAfterCreateSuccess().Trigger(event)
+ })
+ } else if err := event.App.OnModelAfterCreateSuccess().Trigger(event); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (app *BaseApp) update(ctx context.Context, model Model, withValidations bool, isForAuxDB bool) error {
+ event := new(ModelEvent)
+ event.App = app
+ event.Context = ctx
+ event.Type = ModelEventTypeUpdate
+ event.Model = model
+
+ saveErr := app.OnModelUpdate().Trigger(event, func(e *ModelEvent) error {
+ // run validations (if any)
+ if withValidations {
+ validateErr := e.App.ValidateWithContext(e.Context, e.Model)
+ if validateErr != nil {
+ return validateErr
+ }
+ }
+
+ // db write
+ return e.App.OnModelUpdateExecute().Trigger(event, func(e *ModelEvent) error {
+ var db dbx.Builder
+ if isForAuxDB {
+ db = e.App.AuxNonconcurrentDB()
+ } else {
+ db = e.App.NonconcurrentDB()
+ }
+
+ return baseLockRetry(func(attempt int) error {
+ if m, ok := e.Model.(DBExporter); ok {
+ data, err := m.DBExport(e.App)
+ if err != nil {
+ return err
+ }
+
+ // note: for now disallow primary key change for consistency with dbx.ModelQuery.Update()
+ if data[idColumn] != e.Model.LastSavedPK() {
+ return errors.New("primary key change is not allowed")
+ }
+
+ _, err = db.Update(e.Model.TableName(), data, dbx.HashExp{
+ idColumn: e.Model.LastSavedPK(),
+ }).WithContext(e.Context).Execute()
+
+ return err
+ }
+
+ return db.Model(e.Model).WithContext(e.Context).Update()
+ }, defaultMaxLockRetries)
+ })
+ })
+ if saveErr != nil {
+ errEvent := &ModelErrorEvent{ModelEvent: *event, Error: saveErr}
+ errEvent.App = app // replace with the initial app in case it was changed by the hook
+ hookErr := app.OnModelAfterUpdateError().Trigger(errEvent)
+ if hookErr != nil {
+ return errors.Join(saveErr, hookErr)
+ }
+
+ return saveErr
+ }
+
+ if app.txInfo != nil {
+ // execute later after the transaction has completed
+ app.txInfo.OnComplete(func(txErr error) error {
+ if app.txInfo != nil && app.txInfo.parent != nil {
+ event.App = app.txInfo.parent
+ }
+
+ if txErr != nil {
+ return app.OnModelAfterUpdateError().Trigger(&ModelErrorEvent{
+ ModelEvent: *event,
+ Error: txErr,
+ })
+ }
+
+ return app.OnModelAfterUpdateSuccess().Trigger(event)
+ })
+ } else if err := event.App.OnModelAfterUpdateSuccess().Trigger(event); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func validateCollectionId(app App, optTypes ...string) validation.RuleFunc {
+ return func(value any) error {
+ id, _ := value.(string)
+ if id == "" {
+ return nil
+ }
+
+ collection := &Collection{}
+ if err := app.ModelQuery(collection).Model(id, collection); err != nil {
+ return validation.NewError("validation_invalid_collection_id", "Missing or invalid collection.")
+ }
+
+ if len(optTypes) > 0 && !slices.Contains(optTypes, collection.Type) {
+ return validation.NewError(
+ "validation_invalid_collection_type",
+ fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
+ )
+ }
+
+ return nil
+ }
+}
+
+func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
+ return func(value any) error {
+ id, _ := value.(string)
+ if id == "" {
+ return nil
+ }
+
+ collection, err := app.FindCachedCollectionByNameOrId(collectionNameOrId)
+ if err != nil {
+ return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
+ }
+
+ var exists int
+
+ rowErr := app.ConcurrentDB().Select("(1)").
+ From(collection.Name).
+ AndWhere(dbx.HashExp{"id": id}).
+ Limit(1).
+ Row(&exists)
+
+ if rowErr != nil || exists == 0 {
+ return validation.NewError("validation_invalid_record", "Missing or invalid record.")
+ }
+
+ return nil
+ }
+}
diff --git a/core/db_builder.go b/core/db_builder.go
new file mode 100644
index 0000000000..021e9c912e
--- /dev/null
+++ b/core/db_builder.go
@@ -0,0 +1,187 @@
+package core
+
+import (
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/pocketbase/dbx"
+)
+
+var _ dbx.Builder = (*dualDBBuilder)(nil)
+
+// note: expects both builder to use the same driver
+type dualDBBuilder struct {
+ concurrentDB dbx.Builder
+ nonconcurrentDB dbx.Builder
+}
+
+// Select implements the [dbx.Builder.Select] interface method.
+func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
+ return b.concurrentDB.Select(cols...)
+}
+
+// Model implements the [dbx.Builder.Model] interface method.
+func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
+ return b.nonconcurrentDB.Model(data)
+}
+
+// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
+func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
+ return b.concurrentDB.GeneratePlaceholder(i)
+}
+
+// Quote implements the [dbx.Builder.Quote] interface method.
+func (b *dualDBBuilder) Quote(str string) string {
+ return b.concurrentDB.Quote(str)
+}
+
+// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
+func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
+ return b.concurrentDB.QuoteSimpleTableName(table)
+}
+
+// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
+func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
+ return b.concurrentDB.QuoteSimpleColumnName(col)
+}
+
+// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
+func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
+ return b.concurrentDB.QueryBuilder()
+}
+
+// Insert implements the [dbx.Builder.Insert] interface method.
+func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
+ return b.nonconcurrentDB.Insert(table, cols)
+}
+
+// Upsert implements the [dbx.Builder.Upsert] interface method.
+func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
+ return b.nonconcurrentDB.Upsert(table, cols, constraints...)
+}
+
+// Update implements the [dbx.Builder.Update] interface method.
+func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
+ return b.nonconcurrentDB.Update(table, cols, where)
+}
+
+// Delete implements the [dbx.Builder.Delete] interface method.
+func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
+ return b.nonconcurrentDB.Delete(table, where)
+}
+
+// CreateTable implements the [dbx.Builder.CreateTable] interface method.
+func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
+ return b.nonconcurrentDB.CreateTable(table, cols, options...)
+}
+
+// RenameTable implements the [dbx.Builder.RenameTable] interface method.
+func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
+ return b.nonconcurrentDB.RenameTable(oldName, newName)
+}
+
+// DropTable implements the [dbx.Builder.DropTable] interface method.
+func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
+ return b.nonconcurrentDB.DropTable(table)
+}
+
+// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
+func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
+ return b.nonconcurrentDB.TruncateTable(table)
+}
+
+// AddColumn implements the [dbx.Builder.AddColumn] interface method.
+func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
+ return b.nonconcurrentDB.AddColumn(table, col, typ)
+}
+
+// DropColumn implements the [dbx.Builder.DropColumn] interface method.
+func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
+ return b.nonconcurrentDB.DropColumn(table, col)
+}
+
+// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
+func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
+ return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
+}
+
+// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
+func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
+ return b.nonconcurrentDB.AlterColumn(table, col, typ)
+}
+
+// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
+func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
+ return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
+}
+
+// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
+func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
+ return b.nonconcurrentDB.DropPrimaryKey(table, name)
+}
+
+// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
+func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
+ return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
+}
+
+// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
+func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
+ return b.nonconcurrentDB.DropForeignKey(table, name)
+}
+
+// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
+func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
+ return b.nonconcurrentDB.CreateIndex(table, name, cols...)
+}
+
+// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
+func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
+ return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
+}
+
+// DropIndex implements the [dbx.Builder.DropIndex] interface method.
+func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
+ return b.nonconcurrentDB.DropIndex(table, name)
+}
+
+// NewQuery implements the [dbx.Builder.NewQuery] interface method by
+// routing the SELECT queries to the concurrent builder instance.
+func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
+ // note: technically INSERT/UPDATE/DELETE could also have CTE but since
+ // it is rare for now this scase is ignored to avoid unnecessary complicating the checks
+ trimmed := trimLeftSpaces(str)
+ if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
+ return b.concurrentDB.NewQuery(str)
+ }
+
+ return b.nonconcurrentDB.NewQuery(str)
+}
+
+var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
+
+// note: similar to strings.Space() but without the right trim because it is not needed in our case
+func trimLeftSpaces(str string) string {
+ start := 0
+ for ; start < len(str); start++ {
+ c := str[start]
+ if c >= utf8.RuneSelf {
+ return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
+ }
+ if asciiSpace[c] == 0 {
+ break
+ }
+ }
+
+ return str[start:]
+}
+
+// note: the prefix is expected to be ASCII
+func hasPrefixFold(str, prefix string) bool {
+ if len(str) < len(prefix) {
+ return false
+ }
+
+ return strings.EqualFold(str[:len(prefix)], prefix)
+}
diff --git a/core/db_cgo.go b/core/db_cgo.go
deleted file mode 100644
index 25d46083a1..0000000000
--- a/core/db_cgo.go
+++ /dev/null
@@ -1,50 +0,0 @@
-//go:build cgo
-
-package core
-
-import (
- "database/sql"
-
- "github.com/mattn/go-sqlite3"
- "github.com/pocketbase/dbx"
-)
-
-func init() {
- // Registers the sqlite3 driver with a ConnectHook so that we can
- // initialize the default PRAGMAs.
- //
- // Note 1: we don't define the PRAGMA as part of the dsn string
- // because not all pragmas are available.
- //
- // Note 2: the busy_timeout pragma must be first because
- // the connection needs to be set to block on busy before WAL mode
- // is set in case it hasn't been already set by another connection.
- sql.Register("pb_sqlite3",
- &sqlite3.SQLiteDriver{
- ConnectHook: func(conn *sqlite3.SQLiteConn) error {
- _, err := conn.Exec(`
- PRAGMA busy_timeout = 10000;
- PRAGMA journal_mode = WAL;
- PRAGMA journal_size_limit = 200000000;
- PRAGMA synchronous = NORMAL;
- PRAGMA foreign_keys = ON;
- PRAGMA temp_store = MEMORY;
- PRAGMA cache_size = -16000;
- `, nil)
-
- return err
- },
- },
- )
-
- dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
-}
-
-func connectDB(dbPath string) (*dbx.DB, error) {
- db, err := dbx.Open("pb_sqlite3", dbPath)
- if err != nil {
- return nil, err
- }
-
- return db, nil
-}
diff --git a/core/db_connect.go b/core/db_connect.go
new file mode 100644
index 0000000000..e190084868
--- /dev/null
+++ b/core/db_connect.go
@@ -0,0 +1,22 @@
+//go:build !no_default_driver
+
+package core
+
+import (
+ "github.com/pocketbase/dbx"
+ _ "modernc.org/sqlite"
+)
+
+func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
+ // Note: the busy_timeout pragma must be first because
+ // the connection needs to be set to block on busy before WAL mode
+ // is set in case it hasn't been already set by another connection.
+ pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)&_defensive=1"
+
+ db, err := dbx.Open("sqlite", dbPath+pragmas)
+ if err != nil {
+ return nil, err
+ }
+
+ return db, nil
+}
diff --git a/core/db_connect_nodefaultdriver.go b/core/db_connect_nodefaultdriver.go
new file mode 100644
index 0000000000..34e7a41883
--- /dev/null
+++ b/core/db_connect_nodefaultdriver.go
@@ -0,0 +1,9 @@
+//go:build no_default_driver
+
+package core
+
+import "github.com/pocketbase/dbx"
+
+func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
+ panic("DBConnect config option must be set when the no_default_driver tag is used!")
+}
diff --git a/core/db_model.go b/core/db_model.go
new file mode 100644
index 0000000000..9649e1ae13
--- /dev/null
+++ b/core/db_model.go
@@ -0,0 +1,59 @@
+package core
+
+// Model defines an interface with common methods that all db models should have.
+//
+// Note: for simplicity composite pk are not supported.
+type Model interface {
+ TableName() string
+ PK() any
+ LastSavedPK() any
+ IsNew() bool
+ MarkAsNew()
+ MarkAsNotNew()
+}
+
+// BaseModel defines a base struct that is intended to be embedded into other custom models.
+type BaseModel struct {
+ lastSavedPK string
+
+ // Id is the primary key of the model.
+ // It is usually autogenerated by the parent model implementation.
+ Id string `db:"id" json:"id" form:"id" xml:"id"`
+}
+
+// LastSavedPK returns the last saved primary key of the model.
+//
+// Its value is updated to the latest PK value after MarkAsNotNew() or PostScan() calls.
+func (m *BaseModel) LastSavedPK() any {
+ return m.lastSavedPK
+}
+
+func (m *BaseModel) PK() any {
+ return m.Id
+}
+
+// IsNew indicates what type of db query (insert or update)
+// should be used with the model instance.
+func (m *BaseModel) IsNew() bool {
+ return m.lastSavedPK == ""
+}
+
+// MarkAsNew clears the pk field and marks the current model as "new"
+// (aka. forces m.IsNew() to be true).
+func (m *BaseModel) MarkAsNew() {
+ m.lastSavedPK = ""
+}
+
+// MarkAsNew set the pk field to the Id value and marks the current model
+// as NOT "new" (aka. forces m.IsNew() to be false).
+func (m *BaseModel) MarkAsNotNew() {
+ m.lastSavedPK = m.Id
+}
+
+// PostScan implements the [dbx.PostScanner] interface.
+//
+// It is usually executed right after the model is populated with the db row values.
+func (m *BaseModel) PostScan() error {
+ m.MarkAsNotNew()
+ return nil
+}
diff --git a/core/db_model_test.go b/core/db_model_test.go
new file mode 100644
index 0000000000..1771a7782f
--- /dev/null
+++ b/core/db_model_test.go
@@ -0,0 +1,70 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func TestBaseModel(t *testing.T) {
+ id := "test_id"
+
+ m := core.BaseModel{Id: id}
+
+ if m.PK() != id {
+ t.Fatalf("[before PostScan] Expected PK %q, got %q", "", m.PK())
+ }
+
+ if m.LastSavedPK() != "" {
+ t.Fatalf("[before PostScan] Expected LastSavedPK %q, got %q", "", m.LastSavedPK())
+ }
+
+ if !m.IsNew() {
+ t.Fatalf("[before PostScan] Expected IsNew %v, got %v", true, m.IsNew())
+ }
+
+ if err := m.PostScan(); err != nil {
+ t.Fatal(err)
+ }
+
+ if m.PK() != id {
+ t.Fatalf("[after PostScan] Expected PK %q, got %q", "", m.PK())
+ }
+
+ if m.LastSavedPK() != id {
+ t.Fatalf("[after PostScan] Expected LastSavedPK %q, got %q", id, m.LastSavedPK())
+ }
+
+ if m.IsNew() {
+ t.Fatalf("[after PostScan] Expected IsNew %v, got %v", false, m.IsNew())
+ }
+
+ m.MarkAsNew()
+
+ if m.PK() != id {
+ t.Fatalf("[after MarkAsNew] Expected PK %q, got %q", id, m.PK())
+ }
+
+ if m.LastSavedPK() != "" {
+ t.Fatalf("[after MarkAsNew] Expected LastSavedPK %q, got %q", "", m.LastSavedPK())
+ }
+
+ if !m.IsNew() {
+ t.Fatalf("[after MarkAsNew] Expected IsNew %v, got %v", true, m.IsNew())
+ }
+
+ // mark as not new without id
+ m.MarkAsNotNew()
+
+ if m.PK() != id {
+ t.Fatalf("[after MarkAsNotNew] Expected PK %q, got %q", id, m.PK())
+ }
+
+ if m.LastSavedPK() != id {
+ t.Fatalf("[after MarkAsNotNew] Expected LastSavedPK %q, got %q", id, m.LastSavedPK())
+ }
+
+ if m.IsNew() {
+ t.Fatalf("[after MarkAsNotNew] Expected IsNew %v, got %v", false, m.IsNew())
+ }
+}
diff --git a/core/db_nocgo.go b/core/db_nocgo.go
deleted file mode 100644
index 98fe8c3ab9..0000000000
--- a/core/db_nocgo.go
+++ /dev/null
@@ -1,22 +0,0 @@
-//go:build !cgo
-
-package core
-
-import (
- "github.com/pocketbase/dbx"
- _ "modernc.org/sqlite"
-)
-
-func connectDB(dbPath string) (*dbx.DB, error) {
- // Note: the busy_timeout pragma must be first because
- // the connection needs to be set to block on busy before WAL mode
- // is set in case it hasn't been already set by another connection.
- pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
-
- db, err := dbx.Open("sqlite", dbPath+pragmas)
- if err != nil {
- return nil, err
- }
-
- return db, nil
-}
diff --git a/core/db_retry.go b/core/db_retry.go
new file mode 100644
index 0000000000..0c83d64111
--- /dev/null
+++ b/core/db_retry.go
@@ -0,0 +1,70 @@
+package core
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/pocketbase/dbx"
+)
+
+// default retries intervals (in ms)
+var defaultRetryIntervals = []int{50, 100, 150, 200, 300, 400, 500, 700, 1000}
+
+// default max retry attempts
+const defaultMaxLockRetries = 12
+
+func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
+ return func(q *dbx.Query, op func() error) error {
+ if q.Context() == nil {
+ cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer func() {
+ cancel()
+ //nolint:staticcheck
+ q.WithContext(nil) // reset
+ }()
+ q.WithContext(cancelCtx)
+ }
+
+ execErr := baseLockRetry(func(attempt int) error {
+ return op()
+ }, maxRetries)
+ if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
+ execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
+ }
+
+ return execErr
+ }
+}
+
+func baseLockRetry(op func(attempt int) error, maxRetries int) error {
+ attempt := 1
+
+Retry:
+ err := op(attempt)
+
+ if err != nil && attempt <= maxRetries {
+ errStr := err.Error()
+ // we are checking the error against the plain error texts since the codes could vary between drivers
+ if strings.Contains(errStr, "database is locked") ||
+ strings.Contains(errStr, "table is locked") {
+ // wait and retry
+ time.Sleep(getDefaultRetryInterval(attempt))
+ attempt++
+ goto Retry
+ }
+ }
+
+ return err
+}
+
+func getDefaultRetryInterval(attempt int) time.Duration {
+ if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
+ return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
+ }
+
+ return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
+}
diff --git a/core/db_retry_test.go b/core/db_retry_test.go
new file mode 100644
index 0000000000..838902f205
--- /dev/null
+++ b/core/db_retry_test.go
@@ -0,0 +1,66 @@
+package core
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestGetDefaultRetryInterval(t *testing.T) {
+ t.Parallel()
+
+ if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
+ t.Fatalf("Expected 1000ms, got %v", i)
+ }
+
+ if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
+ t.Fatalf("Expected 1000ms, got %v", i)
+ }
+
+ if i := getDefaultRetryInterval(3); i.Milliseconds() != 200 {
+ t.Fatalf("Expected 500ms, got %v", i)
+ }
+}
+
+func TestBaseLockRetry(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ err error
+ failUntilAttempt int
+ expectedAttempts int
+ }{
+ {nil, 3, 1},
+ {errors.New("test"), 3, 1},
+ {errors.New("database is locked"), 3, 3},
+ {errors.New("table is locked"), 3, 3},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.err), func(t *testing.T) {
+ lastAttempt := 0
+
+ err := baseLockRetry(func(attempt int) error {
+ lastAttempt = attempt
+
+ if attempt < s.failUntilAttempt {
+ return s.err
+ }
+
+ return nil
+ }, s.failUntilAttempt+2)
+
+ if lastAttempt != s.expectedAttempts {
+ t.Errorf("Expected lastAttempt to be %d, got %d", s.expectedAttempts, lastAttempt)
+ }
+
+ if s.failUntilAttempt == s.expectedAttempts && err != nil {
+ t.Fatalf("Expected nil, got err %v", err)
+ }
+
+ if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
+ t.Fatalf("Expected error %q, got nil", s.err)
+ }
+ })
+ }
+}
diff --git a/core/db_table.go b/core/db_table.go
new file mode 100644
index 0000000000..47237ed28d
--- /dev/null
+++ b/core/db_table.go
@@ -0,0 +1,137 @@
+package core
+
+import (
+ "database/sql"
+ "fmt"
+
+ "github.com/pocketbase/dbx"
+)
+
+// TableColumns returns all column names of a single table by its name.
+func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
+ columns := []string{}
+
+ err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
+ Bind(dbx.Params{"tableName": tableName}).
+ Column(&columns)
+
+ return columns, err
+}
+
+type TableInfoRow struct {
+ // the `db:"pk"` tag has special semantic so we cannot rename
+ // the original field without specifying a custom mapper
+ PK int
+
+ Index int `db:"cid"`
+ Name string `db:"name"`
+ Type string `db:"type"`
+ NotNull bool `db:"notnull"`
+ DefaultValue sql.NullString `db:"dflt_value"`
+}
+
+// TableInfo returns the "table_info" pragma result for the specified table.
+func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
+ info := []*TableInfoRow{}
+
+ err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
+ Bind(dbx.Params{"tableName": tableName}).
+ All(&info)
+ if err != nil {
+ return nil, err
+ }
+
+ // mattn/go-sqlite3 doesn't throw an error on invalid or missing table
+ // so we additionally have to check whether the loaded info result is nonempty
+ if len(info) == 0 {
+ return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
+ }
+
+ return info, nil
+}
+
+// TableIndexes returns a name grouped map with all non empty index of the specified table.
+//
+// Note: This method doesn't return an error on nonexisting table.
+func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
+ indexes := []struct {
+ Name string
+ Sql string
+ }{}
+
+ err := app.ConcurrentDB().Select("name", "sql").
+ From("sqlite_master").
+ AndWhere(dbx.NewExp("sql is not null")).
+ AndWhere(dbx.HashExp{
+ "type": "index",
+ "tbl_name": tableName,
+ }).
+ All(&indexes)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[string]string, len(indexes))
+
+ for _, idx := range indexes {
+ result[idx.Name] = idx.Sql
+ }
+
+ return result, nil
+}
+
+// DeleteTable drops the specified table.
+//
+// This method is a no-op if a table with the provided name doesn't exist.
+//
+// NB! Be aware that this method is vulnerable to SQL injection and the
+// "dangerousTableName" argument must come only from trusted input!
+func (app *BaseApp) DeleteTable(dangerousTableName string) error {
+ _, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
+ "DROP TABLE IF EXISTS {{%s}}",
+ dangerousTableName,
+ )).Execute()
+
+ return err
+}
+
+// HasTable checks if a table (or view) with the provided name exists (case insensitive).
+// in the data.db.
+func (app *BaseApp) HasTable(tableName string) bool {
+ return app.hasTable(app.ConcurrentDB(), tableName)
+}
+
+// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
+// in the auixiliary.db.
+func (app *BaseApp) AuxHasTable(tableName string) bool {
+ return app.hasTable(app.AuxConcurrentDB(), tableName)
+}
+
+func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
+ var exists int
+
+ err := db.Select("(1)").
+ From("sqlite_schema").
+ AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
+ AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
+ Limit(1).
+ Row(&exists)
+
+ return err == nil && exists > 0
+}
+
+// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
+func (app *BaseApp) Vacuum() error {
+ return app.vacuum(app.NonconcurrentDB())
+}
+
+// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
+func (app *BaseApp) AuxVacuum() error {
+ return app.vacuum(app.AuxNonconcurrentDB())
+}
+
+func (app *BaseApp) vacuum(db dbx.Builder) error {
+ _, err := db.NewQuery("VACUUM").Execute()
+
+ return err
+}
diff --git a/core/db_table_test.go b/core/db_table_test.go
new file mode 100644
index 0000000000..2731c8b84b
--- /dev/null
+++ b/core/db_table_test.go
@@ -0,0 +1,254 @@
+package core_test
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json/v2"
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestHasTable(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expected bool
+ }{
+ {"", false},
+ {"test", false},
+ {core.CollectionNameSuperusers, true},
+ {"demo3", true},
+ {"DEMO3", true}, // table names are case insensitives by default
+ {"view1", true}, // view
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.tableName, func(t *testing.T) {
+ result := app.HasTable(s.tableName)
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestAuxHasTable(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expected bool
+ }{
+ {"", false},
+ {"test", false},
+ {"_lOGS", true}, // table names are case insensitives by default
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.tableName, func(t *testing.T) {
+ result := app.AuxHasTable(s.tableName)
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestTableColumns(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expected []string
+ }{
+ {"", nil},
+ {"_params", []string{"id", "value", "created", "updated"}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
+ columns, _ := app.TableColumns(s.tableName)
+
+ if len(columns) != len(s.expected) {
+ t.Fatalf("Expected columns %v, got %v", s.expected, columns)
+ }
+
+ for _, c := range columns {
+ if !slices.Contains(s.expected, c) {
+ t.Errorf("Didn't expect column %s", c)
+ }
+ }
+ })
+ }
+}
+
+func TestTableInfo(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expected string
+ }{
+ {"", "null"},
+ {"missing", "null"},
+ {
+ "_params",
+ `[{"PK":0,"Index":0,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":{"String":"''","Valid":true}},{"PK":1,"Index":1,"Name":"id","Type":"TEXT","NotNull":true,"DefaultValue":{"String":"'r'||lower(hex(randomblob(7)))","Valid":true}},{"PK":0,"Index":2,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":{"String":"''","Valid":true}},{"PK":0,"Index":3,"Name":"value","Type":"JSON","NotNull":false,"DefaultValue":{"String":"NULL","Valid":true}}]`,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
+ rows, _ := app.TableInfo(s.tableName)
+
+ raw, err := json.Marshal(
+ rows,
+ json.Deterministic(true),
+ json.FormatNilSliceAsNull(true),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if str := string(raw); str != s.expected {
+ t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str)
+ }
+ })
+ }
+}
+
+func TestTableIndexes(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expected []string
+ }{
+ {"", nil},
+ {"missing", nil},
+ {
+ core.CollectionNameSuperusers,
+ []string{"idx_email__pbc_3323866339", "idx_tokenKey__pbc_3323866339"},
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
+ indexes, _ := app.TableIndexes(s.tableName)
+
+ if len(indexes) != len(s.expected) {
+ t.Fatalf("Expected %d indexes, got %d\n%v", len(s.expected), len(indexes), indexes)
+ }
+
+ for _, name := range s.expected {
+ if v, ok := indexes[name]; !ok || v == "" {
+ t.Fatalf("Expected non-empty index %q in \n%v", name, indexes)
+ }
+ }
+ })
+ }
+}
+
+func TestDeleteTable(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ tableName string
+ expectError bool
+ }{
+ {"", true},
+ {"test", false}, // missing tables are ignored
+ {"_admins", false},
+ {"demo3", false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
+ err := app.DeleteTable(s.tableName)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
+ }
+ })
+ }
+}
+
+func TestVacuum(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ calledQueries := []string{}
+ app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+
+ if err := app.Vacuum(); err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(calledQueries); total != 1 {
+ t.Fatalf("Expected 1 query, got %d", total)
+ }
+
+ if calledQueries[0] != "VACUUM" {
+ t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
+ }
+}
+
+func TestAuxVacuum(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ calledQueries := []string{}
+ app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+
+ if err := app.AuxVacuum(); err != nil {
+ t.Fatal(err)
+ }
+
+ if total := len(calledQueries); total != 1 {
+ t.Fatalf("Expected 1 query, got %d", total)
+ }
+
+ if calledQueries[0] != "VACUUM" {
+ t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
+ }
+}
diff --git a/core/db_test.go b/core/db_test.go
new file mode 100644
index 0000000000..d274b7efad
--- /dev/null
+++ b/core/db_test.go
@@ -0,0 +1,113 @@
+package core_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestGenerateDefaultRandomId(t *testing.T) {
+ t.Parallel()
+
+ id1 := core.GenerateDefaultRandomId()
+ id2 := core.GenerateDefaultRandomId()
+
+ if id1 == id2 {
+ t.Fatalf("Expected id1 and id2 to differ, got %q", id1)
+ }
+
+ if l := len(id1); l != 15 {
+ t.Fatalf("Expected id1 length %d, got %d", 15, l)
+ }
+
+ if l := len(id2); l != 15 {
+ t.Fatalf("Expected id2 length %d, got %d", 15, l)
+ }
+}
+
+func TestModelQuery(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ modelsQuery := app.ModelQuery(&core.Collection{})
+ logsModelQuery := app.AuxModelQuery(&core.Collection{})
+
+ if app.ConcurrentDB() == modelsQuery.Info().Builder {
+ t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
+ }
+
+ if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
+ t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
+ }
+
+ expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
+ for i, q := range []*dbx.SelectQuery{modelsQuery, logsModelQuery} {
+ sql := q.Build().SQL()
+ if sql != expectedSQL {
+ t.Fatalf("[%d] Expected select\n%s\ngot\n%s", i, expectedSQL, sql)
+ }
+ }
+}
+
+func TestValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ u := &mockSuperusers{}
+
+ testErr := errors.New("test")
+
+ app.OnModelValidate().BindFunc(func(e *core.ModelEvent) error {
+ return testErr
+ })
+
+ err := app.Validate(u)
+ if err != testErr {
+ t.Fatalf("Expected error %v, got %v", testErr, err)
+ }
+}
+
+func TestValidateWithContext(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ u := &mockSuperusers{}
+
+ testErr := errors.New("test")
+
+ app.OnModelValidate().BindFunc(func(e *core.ModelEvent) error {
+ if v := e.Context.Value("test"); v != 123 {
+ t.Fatalf("Expected 'test' context value %#v, got %#v", 123, v)
+ }
+ return testErr
+ })
+
+ //nolint:staticcheck
+ ctx := context.WithValue(context.Background(), "test", 123)
+
+ err := app.ValidateWithContext(ctx, u)
+ if err != testErr {
+ t.Fatalf("Expected error %v, got %v", testErr, err)
+ }
+}
+
+// -------------------------------------------------------------------
+
+type mockSuperusers struct {
+ core.BaseModel
+ Email string `db:"email"`
+}
+
+func (m *mockSuperusers) TableName() string {
+ return core.CollectionNameSuperusers
+}
diff --git a/core/db_tx.go b/core/db_tx.go
new file mode 100644
index 0000000000..3f08228a7c
--- /dev/null
+++ b/core/db_tx.go
@@ -0,0 +1,112 @@
+package core
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+
+ "github.com/pocketbase/dbx"
+)
+
+// RunInTransaction wraps fn into a transaction for the regular app database.
+//
+// It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
+func (app *BaseApp) RunInTransaction(fn func(txApp App) error) error {
+ return app.runInTransaction(app.NonconcurrentDB(), fn, false)
+}
+
+// AuxRunInTransaction wraps fn into a transaction for the auxiliary app database.
+//
+// It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
+func (app *BaseApp) AuxRunInTransaction(fn func(txApp App) error) error {
+ return app.runInTransaction(app.AuxNonconcurrentDB(), fn, true)
+}
+
+func (app *BaseApp) runInTransaction(db dbx.Builder, fn func(txApp App) error, isForAuxDB bool) error {
+ switch txOrDB := db.(type) {
+ case *dbx.Tx:
+ // run as part of the already existing transaction
+ return fn(app)
+ case *dbx.DB:
+ var txApp *BaseApp
+ txErr := txOrDB.Transactional(func(tx *dbx.Tx) error {
+ txApp = app.createTxApp(tx, isForAuxDB)
+ return fn(txApp)
+ })
+
+ // execute all after event calls on transaction complete
+ if txApp != nil && txApp.txInfo != nil {
+ afterFuncErr := txApp.txInfo.runAfterFuncs(txErr)
+ if afterFuncErr != nil {
+ return errors.Join(txErr, afterFuncErr)
+ }
+ }
+
+ return txErr
+ default:
+ return errors.New("failed to start transaction (unknown db type)")
+ }
+}
+
+// createTxApp shallow clones the current app and assigns a new tx state.
+func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
+ clone := *app
+
+ if isForAuxDB {
+ clone.auxConcurrentDB = tx
+ clone.auxNonconcurrentDB = tx
+ } else {
+ clone.concurrentDB = tx
+ clone.nonconcurrentDB = tx
+ }
+
+ clone.txInfo = &TxAppInfo{
+ parent: app,
+ isForAuxDB: isForAuxDB,
+ }
+
+ return &clone
+}
+
+// TxAppInfo represents an active transaction context associated to an existing app instance.
+type TxAppInfo struct {
+ parent *BaseApp
+ afterFuncs []func(txErr error) error
+ mu sync.Mutex
+ isForAuxDB bool
+}
+
+// OnComplete registers the provided callback that will be invoked
+// once the related transaction ends (either completes successfully or rollbacked with an error).
+//
+// The callback receives the transaction error (if any) as its argument.
+// Any additional errors returned by the OnComplete callbacks will be
+// joined together with txErr when returning the final transaction result.
+func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ a.afterFuncs = append(a.afterFuncs, fn)
+}
+
+// note: can be called only once because TxAppInfo is cleared
+func (a *TxAppInfo) runAfterFuncs(txErr error) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ var errs []error
+
+ for _, call := range a.afterFuncs {
+ if err := call(txErr); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ a.afterFuncs = nil
+
+ if len(errs) > 0 {
+ return fmt.Errorf("transaction afterFunc errors: %w", errors.Join(errs...))
+ }
+
+ return nil
+}
diff --git a/core/db_tx_test.go b/core/db_tx_test.go
new file mode 100644
index 0000000000..d83f126860
--- /dev/null
+++ b/core/db_tx_test.go
@@ -0,0 +1,413 @@
+package core_test
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRunInTransaction(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ t.Run("failed nested transaction", func(t *testing.T) {
+ app.RunInTransaction(func(txApp core.App) error {
+ superuser, _ := txApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+
+ return txApp.RunInTransaction(func(tx2Dao core.App) error {
+ if err := tx2Dao.Delete(superuser); err != nil {
+ t.Fatal(err)
+ }
+ return errors.New("test error")
+ })
+ })
+
+ // superuser should still exist
+ superuser, _ := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if superuser == nil {
+ t.Fatal("Expected superuser test@example.com to not be deleted")
+ }
+ })
+
+ t.Run("successful nested transaction", func(t *testing.T) {
+ app.RunInTransaction(func(txApp core.App) error {
+ superuser, _ := txApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+
+ return txApp.RunInTransaction(func(tx2Dao core.App) error {
+ return tx2Dao.Delete(superuser)
+ })
+ })
+
+ // superuser should have been deleted
+ superuser, _ := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if superuser != nil {
+ t.Fatalf("Expected superuser test@example.com to be deleted, found %v", superuser)
+ }
+ })
+}
+
+func TestTransactionHooksCallsOnFailure(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ createHookCalls := 0
+ updateHookCalls := 0
+ deleteHookCalls := 0
+ afterCreateHookCalls := 0
+ afterUpdateHookCalls := 0
+ afterDeleteHookCalls := 0
+
+ app.OnModelCreate().BindFunc(func(e *core.ModelEvent) error {
+ createHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelUpdate().BindFunc(func(e *core.ModelEvent) error {
+ updateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelDelete().BindFunc(func(e *core.ModelEvent) error {
+ deleteHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
+ afterCreateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
+ afterUpdateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
+ afterDeleteHookCalls++
+ return e.Next()
+ })
+
+ existingModel, _ := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+
+ app.RunInTransaction(func(txApp1 core.App) error {
+ return txApp1.RunInTransaction(func(txApp2 core.App) error {
+ // test create
+ // ---
+ newModel := core.NewRecord(existingModel.Collection())
+ newModel.SetEmail("test_new1@example.com")
+ newModel.SetPassword("1234567890")
+ if err := txApp2.Save(newModel); err != nil {
+ t.Fatal(err)
+ }
+
+ // test update (twice)
+ // ---
+ if err := txApp2.Save(existingModel); err != nil {
+ t.Fatal(err)
+ }
+ if err := txApp2.Save(existingModel); err != nil {
+ t.Fatal(err)
+ }
+
+ // test delete
+ // ---
+ if err := txApp2.Delete(newModel); err != nil {
+ t.Fatal(err)
+ }
+
+ return errors.New("test_tx_error")
+ })
+ })
+
+ if createHookCalls != 1 {
+ t.Errorf("Expected createHookCalls to be called 1 time, got %d", createHookCalls)
+ }
+ if updateHookCalls != 2 {
+ t.Errorf("Expected updateHookCalls to be called 2 times, got %d", updateHookCalls)
+ }
+ if deleteHookCalls != 1 {
+ t.Errorf("Expected deleteHookCalls to be called 1 time, got %d", deleteHookCalls)
+ }
+ if afterCreateHookCalls != 0 {
+ t.Errorf("Expected afterCreateHookCalls to be called 0 times, got %d", afterCreateHookCalls)
+ }
+ if afterUpdateHookCalls != 0 {
+ t.Errorf("Expected afterUpdateHookCalls to be called 0 times, got %d", afterUpdateHookCalls)
+ }
+ if afterDeleteHookCalls != 0 {
+ t.Errorf("Expected afterDeleteHookCalls to be called 0 times, got %d", afterDeleteHookCalls)
+ }
+}
+
+func TestTransactionHooksCallsOnSuccess(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ createHookCalls := 0
+ updateHookCalls := 0
+ deleteHookCalls := 0
+ afterCreateHookCalls := 0
+ afterUpdateHookCalls := 0
+ afterDeleteHookCalls := 0
+
+ app.OnModelCreate().BindFunc(func(e *core.ModelEvent) error {
+ createHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelUpdate().BindFunc(func(e *core.ModelEvent) error {
+ updateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelDelete().BindFunc(func(e *core.ModelEvent) error {
+ deleteHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ afterCreateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ afterUpdateHookCalls++
+ return e.Next()
+ })
+
+ app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ afterDeleteHookCalls++
+ return e.Next()
+ })
+
+ existingModel, _ := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+
+ app.RunInTransaction(func(txApp1 core.App) error {
+ return txApp1.RunInTransaction(func(txApp2 core.App) error {
+ // test create
+ // ---
+ newModel := core.NewRecord(existingModel.Collection())
+ newModel.SetEmail("test_new1@example.com")
+ newModel.SetPassword("1234567890")
+ if err := txApp2.Save(newModel); err != nil {
+ t.Fatal(err)
+ }
+
+ // test update (twice)
+ // ---
+ if err := txApp2.Save(existingModel); err != nil {
+ t.Fatal(err)
+ }
+ if err := txApp2.Save(existingModel); err != nil {
+ t.Fatal(err)
+ }
+
+ // test delete
+ // ---
+ if err := txApp2.Delete(newModel); err != nil {
+ t.Fatal(err)
+ }
+
+ return nil
+ })
+ })
+
+ if createHookCalls != 1 {
+ t.Errorf("Expected createHookCalls to be called 1 time, got %d", createHookCalls)
+ }
+ if updateHookCalls != 2 {
+ t.Errorf("Expected updateHookCalls to be called 2 times, got %d", updateHookCalls)
+ }
+ if deleteHookCalls != 1 {
+ t.Errorf("Expected deleteHookCalls to be called 1 time, got %d", deleteHookCalls)
+ }
+ if afterCreateHookCalls != 1 {
+ t.Errorf("Expected afterCreateHookCalls to be called 1 time, got %d", afterCreateHookCalls)
+ }
+ if afterUpdateHookCalls != 2 {
+ t.Errorf("Expected afterUpdateHookCalls to be called 2 times, got %d", afterUpdateHookCalls)
+ }
+ if afterDeleteHookCalls != 1 {
+ t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
+ }
+}
+
+func TestTransactionFromInnerCreateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
+ originalApp := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() {
+ e.App = originalApp
+ }()
+
+ nextErr := e.Next()
+
+ return nextErr
+ })
+ })
+
+ app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ // perform a db query with the app instance to ensure that it is still valid
+ _, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
+ if err != nil {
+ t.Fatalf("Failed to perform a db query after tx success: %v", err)
+ }
+
+ return e.Next()
+ })
+
+ collection, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record := core.NewRecord(collection)
+
+ record.Set("title", "test_inner_tx")
+
+ if err = app.Save(record); err != nil {
+ t.Fatalf("Create failed: %v", err)
+ }
+
+ expectedHookCalls := map[string]int{
+ "OnRecordCreateExecute": 1,
+ "OnRecordAfterCreateSuccess": 1,
+ }
+ for k, total := range expectedHookCalls {
+ if found, ok := app.EventCalls[k]; !ok || total != found {
+ t.Fatalf("Expected %q %d calls, got %d", k, total, found)
+ }
+ }
+}
+
+func TestTransactionFromInnerUpdateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
+ originalApp := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() {
+ e.App = originalApp
+ }()
+
+ nextErr := e.Next()
+
+ return nextErr
+ })
+ })
+
+ app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ // perform a db query with the app instance to ensure that it is still valid
+ _, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
+ if err != nil {
+ t.Fatalf("Failed to perform a db query after tx success: %v", err)
+ }
+
+ return e.Next()
+ })
+
+ existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err = app.Save(existingModel); err != nil {
+ t.Fatalf("Update failed: %v", err)
+ }
+
+ expectedHookCalls := map[string]int{
+ "OnRecordUpdateExecute": 1,
+ "OnRecordAfterUpdateSuccess": 1,
+ }
+ for k, total := range expectedHookCalls {
+ if found, ok := app.EventCalls[k]; !ok || total != found {
+ t.Fatalf("Expected %q %d calls, got %d", k, total, found)
+ }
+ }
+}
+
+func TestTransactionFromInnerDeleteHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
+ originalApp := e.App
+ return e.App.RunInTransaction(func(txApp core.App) error {
+ e.App = txApp
+ defer func() {
+ e.App = originalApp
+ }()
+
+ nextErr := e.Next()
+
+ return nextErr
+ })
+ })
+
+ app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
+ if e.App.IsTransactional() {
+ t.Fatal("Expected e.App to be non-transactional")
+ }
+
+ // perform a db query with the app instance to ensure that it is still valid
+ _, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
+ if err != nil {
+ t.Fatalf("Failed to perform a db query after tx success: %v", err)
+ }
+
+ return e.Next()
+ })
+
+ existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err = app.Delete(existingModel); err != nil {
+ t.Fatalf("Delete failed: %v", err)
+ }
+
+ expectedHookCalls := map[string]int{
+ "OnRecordDeleteExecute": 1,
+ "OnRecordAfterDeleteSuccess": 1,
+ }
+ for k, total := range expectedHookCalls {
+ if found, ok := app.EventCalls[k]; !ok || total != found {
+ t.Fatalf("Expected %q %d calls, got %d", k, total, found)
+ }
+ }
+}
diff --git a/core/event_request.go b/core/event_request.go
new file mode 100644
index 0000000000..11a80f7bb8
--- /dev/null
+++ b/core/event_request.go
@@ -0,0 +1,197 @@
+package core
+
+import (
+ "maps"
+ "net/netip"
+ "strings"
+ "sync"
+
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+// Common request store keys used by the middlewares and api handlers.
+const (
+ RequestEventKeyInfoContext = "infoContext"
+)
+
+// RequestEvent defines the PocketBase router handler event.
+type RequestEvent struct {
+ App App
+
+ cachedRequestInfo *RequestInfo
+
+ Auth *Record
+
+ router.Event
+
+ mu sync.Mutex
+}
+
+// RealIP returns the "real" IP address from the configured trusted proxy headers.
+//
+// If Settings.TrustedProxy is not configured or the found IP is empty,
+// it fallbacks to e.RemoteIP().
+//
+// NB!
+// Be careful when used in a security critical context as it relies on
+// the trusted proxy to be properly configured and your app to be accessible only through it.
+// If you are not sure, use e.RemoteIP().
+func (e *RequestEvent) RealIP() string {
+ settings := e.App.Settings()
+
+ for _, h := range settings.TrustedProxy.Headers {
+ headerValues := e.Request.Header.Values(h)
+ if len(headerValues) == 0 {
+ continue
+ }
+
+ // extract the last header value as it is expected to be the one controlled by the proxy
+ ipsList := headerValues[len(headerValues)-1]
+ if ipsList == "" {
+ continue
+ }
+
+ ips := strings.Split(ipsList, ",")
+
+ if settings.TrustedProxy.UseLeftmostIP {
+ for _, ip := range ips {
+ parsed, err := netip.ParseAddr(strings.TrimSpace(ip))
+ if err == nil {
+ return parsed.StringExpanded()
+ }
+ }
+ } else {
+ for i := len(ips) - 1; i >= 0; i-- {
+ parsed, err := netip.ParseAddr(strings.TrimSpace(ips[i]))
+ if err == nil {
+ return parsed.StringExpanded()
+ }
+ }
+ }
+ }
+
+ return e.RemoteIP()
+}
+
+// HasSuperuserAuth checks whether the current RequestEvent has superuser authentication loaded.
+func (e *RequestEvent) HasSuperuserAuth() bool {
+ return e.Auth != nil && e.Auth.IsSuperuser()
+}
+
+// RequestInfo parses the current request into RequestInfo instance.
+//
+// Note that the returned result is cached to avoid copying the request data multiple times
+// but the auth state and other common store items are always refreshed in case they were changed by another handler.
+func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ if e.cachedRequestInfo != nil {
+ e.cachedRequestInfo.Auth = e.Auth
+
+ infoCtx, _ := e.Get(RequestEventKeyInfoContext).(string)
+ if infoCtx != "" {
+ e.cachedRequestInfo.Context = infoCtx
+ } else {
+ e.cachedRequestInfo.Context = RequestInfoContextDefault
+ }
+ } else {
+ // (re)init e.cachedRequestInfo based on the current request event
+ if err := e.initRequestInfo(); err != nil {
+ return nil, err
+ }
+ }
+
+ return e.cachedRequestInfo, nil
+}
+
+func (e *RequestEvent) initRequestInfo() error {
+ infoCtx, _ := e.Get(RequestEventKeyInfoContext).(string)
+ if infoCtx == "" {
+ infoCtx = RequestInfoContextDefault
+ }
+
+ info := &RequestInfo{
+ Context: infoCtx,
+ Method: e.Request.Method,
+ Query: map[string]string{},
+ Headers: map[string]string{},
+ Body: map[string]any{},
+ }
+
+ if err := e.BindBody(&info.Body); err != nil {
+ return err
+ }
+
+ // extract the first value of all query params
+ query := e.Request.URL.Query()
+ for k, v := range query {
+ if len(v) > 0 {
+ info.Query[k] = v[0]
+ }
+ }
+
+ // extract the first value of all headers and normalizes the keys
+ // ("X-Token" is converted to "x_token")
+ for k, v := range e.Request.Header {
+ if len(v) > 0 {
+ info.Headers[inflector.Snakecase(k)] = v[0]
+ }
+ }
+
+ info.Auth = e.Auth
+
+ e.cachedRequestInfo = info
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+const (
+ RequestInfoContextDefault = "default"
+ RequestInfoContextExpand = "expand"
+ RequestInfoContextRealtime = "realtime"
+ RequestInfoContextProtectedFile = "protectedFile"
+ RequestInfoContextBatch = "batch"
+ RequestInfoContextOAuth2 = "oauth2"
+ RequestInfoContextOTP = "otp"
+ RequestInfoContextPasswordAuth = "password"
+)
+
+// RequestInfo defines a HTTP request data struct, usually used
+// as part of the `@request.*` filter resolver.
+//
+// The Query and Headers fields contains only the first value for each found entry.
+type RequestInfo struct {
+ Query map[string]string `json:"query"`
+ Headers map[string]string `json:"headers"`
+ Body map[string]any `json:"body"`
+ Auth *Record `json:"auth"`
+ Method string `json:"method"`
+ Context string `json:"context"`
+}
+
+// HasSuperuserAuth checks whether the current RequestInfo instance
+// has superuser authentication loaded.
+func (info *RequestInfo) HasSuperuserAuth() bool {
+ return info.Auth != nil && info.Auth.IsSuperuser()
+}
+
+// Clone creates a new shallow copy of the current RequestInfo and its Auth record (if any).
+func (info *RequestInfo) Clone() *RequestInfo {
+ clone := &RequestInfo{
+ Method: info.Method,
+ Context: info.Context,
+ Query: maps.Clone(info.Query),
+ Body: maps.Clone(info.Body),
+ Headers: maps.Clone(info.Headers),
+ }
+
+ if info.Auth != nil {
+ clone.Auth = info.Auth.Fresh()
+ }
+
+ return clone
+}
diff --git a/core/event_request_batch.go b/core/event_request_batch.go
new file mode 100644
index 0000000000..807d1da2d2
--- /dev/null
+++ b/core/event_request_batch.go
@@ -0,0 +1,33 @@
+package core
+
+import (
+ "net/http"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/hook"
+)
+
+type BatchRequestEvent struct {
+ hook.Event
+ *RequestEvent
+
+ Batch []*InternalRequest
+}
+
+type InternalRequest struct {
+ // note: for uploading files the value must be either *filesystem.File or []*filesystem.File
+ Body map[string]any `form:"body" json:"body"`
+
+ Headers map[string]string `form:"headers" json:"headers"`
+
+ Method string `form:"method" json:"method"`
+
+ URL string `form:"url" json:"url"`
+}
+
+func (br InternalRequest) Validate() error {
+ return validation.ValidateStruct(&br,
+ validation.Field(&br.Method, validation.Required, validation.In(http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)),
+ validation.Field(&br.URL, validation.Required, validation.Length(0, 2000)),
+ )
+}
diff --git a/core/event_request_batch_test.go b/core/event_request_batch_test.go
new file mode 100644
index 0000000000..6feaf40de1
--- /dev/null
+++ b/core/event_request_batch_test.go
@@ -0,0 +1,74 @@
+package core_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestInternalRequestValidate(t *testing.T) {
+ scenarios := []struct {
+ name string
+ request core.InternalRequest
+ expectedErrors []string
+ }{
+ {
+ "empty struct",
+ core.InternalRequest{},
+ []string{"method", "url"},
+ },
+
+ // method
+ {
+ "GET method",
+ core.InternalRequest{URL: "test", Method: http.MethodGet},
+ []string{},
+ },
+ {
+ "POST method",
+ core.InternalRequest{URL: "test", Method: http.MethodPost},
+ []string{},
+ },
+ {
+ "PUT method",
+ core.InternalRequest{URL: "test", Method: http.MethodPut},
+ []string{},
+ },
+ {
+ "PATCH method",
+ core.InternalRequest{URL: "test", Method: http.MethodPatch},
+ []string{},
+ },
+ {
+ "DELETE method",
+ core.InternalRequest{URL: "test", Method: http.MethodDelete},
+ []string{},
+ },
+ {
+ "unknown method",
+ core.InternalRequest{URL: "test", Method: "unknown"},
+ []string{"method"},
+ },
+
+ // url
+ {
+ "url <= 2000",
+ core.InternalRequest{URL: strings.Repeat("a", 2000), Method: http.MethodGet},
+ []string{},
+ },
+ {
+ "url > 2000",
+ core.InternalRequest{URL: strings.Repeat("a", 2001), Method: http.MethodGet},
+ []string{"url"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ tests.TestValidationErrors(t, s.request.Validate(), s.expectedErrors)
+ })
+ }
+}
diff --git a/core/event_request_test.go b/core/event_request_test.go
new file mode 100644
index 0000000000..26a331a49a
--- /dev/null
+++ b/core/event_request_test.go
@@ -0,0 +1,334 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestEventRequestRealIP(t *testing.T) {
+ t.Parallel()
+
+ headers := map[string][]string{
+ "CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
+ "Fly-Client-IP": {"1.2.3.4", "1.1.1.2"},
+ "X-Real-IP": {"1.2.3.4", "1.1.1.3,1.1.1.4"},
+ "X-Forwarded-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
+ }
+
+ scenarios := []struct {
+ name string
+ headers map[string][]string
+ trustedHeaders []string
+ useLeftmostIP bool
+ expected string
+ }{
+ {
+ "no trusted headers",
+ headers,
+ nil,
+ false,
+ "127.0.0.1",
+ },
+ {
+ "non-matching trusted header",
+ headers,
+ []string{"header1", "header2"},
+ false,
+ "127.0.0.1",
+ },
+ {
+ "trusted X-Real-IP (rightmost)",
+ headers,
+ []string{"header1", "x-real-ip", "x-forwarded-for"},
+ false,
+ "1.1.1.4",
+ },
+ {
+ "trusted X-Real-IP (leftmost)",
+ headers,
+ []string{"header1", "x-real-ip", "x-forwarded-for"},
+ true,
+ "1.1.1.3",
+ },
+ {
+ "trusted X-Forwarded-For (rightmost)",
+ headers,
+ []string{"header1", "x-forwarded-for"},
+ false,
+ "1.1.1.6",
+ },
+ {
+ "trusted X-Forwarded-For (leftmost)",
+ headers,
+ []string{"header1", "x-forwarded-for"},
+ true,
+ "1.1.1.5",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, err := tests.NewTestApp()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer app.Cleanup()
+
+ app.Settings().TrustedProxy.Headers = s.trustedHeaders
+ app.Settings().TrustedProxy.UseLeftmostIP = s.useLeftmostIP
+
+ event := core.RequestEvent{}
+ event.App = app
+
+ event.Request, err = http.NewRequest(http.MethodGet, "/", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ event.Request.RemoteAddr = "127.0.0.1:80" // fallback
+
+ for k, values := range s.headers {
+ for _, v := range values {
+ event.Request.Header.Add(k, v)
+ }
+ }
+
+ result := event.RealIP()
+
+ if result != s.expected {
+ t.Fatalf("Expected ip %q, got %q", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestEventRequestHasSuperUserAuth(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ record *core.Record
+ expected bool
+ }{
+ {"nil record", nil, false},
+ {"regular user record", user, false},
+ {"superuser record", superuser, true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ e := core.RequestEvent{}
+ e.Auth = s.record
+
+ result := e.HasSuperuserAuth()
+
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRequestEventRequestInfo(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ userCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1 := core.NewRecord(userCol)
+ user1.Id = "user1"
+ user1.SetEmail("test1@example.com")
+
+ user2 := core.NewRecord(userCol)
+ user2.Id = "user2"
+ user2.SetEmail("test2@example.com")
+
+ testBody := `{"a":123,"b":"test"}`
+
+ event := core.RequestEvent{}
+ event.Request, err = http.NewRequest("POST", "/test?q1=123&q2=456", strings.NewReader(testBody))
+ if err != nil {
+ t.Fatal(err)
+ }
+ event.Request.Header.Add("content-type", "application/json")
+ event.Request.Header.Add("x-test", "test")
+ event.Set(core.RequestEventKeyInfoContext, "test")
+ event.Auth = user1
+
+ t.Run("init", func(t *testing.T) {
+ info, err := event.RequestInfo()
+ if err != nil {
+ t.Fatalf("Failed to resolve request info: %v", err)
+ }
+
+ raw, err := json.Marshal(info, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to serialize request info: %v", err)
+ }
+ rawStr := string(raw)
+
+ expected := `{"query":{"q1":"123","q2":"456"},"headers":{"content_type":"application/json","x_test":"test"},"body":{"a":123,"b":"test"},"auth":{"avatar":"","collectionId":"_pb_users_auth_","collectionName":"users","created":"","emailVisibility":false,"file":[],"id":"user1","name":"","rel":"","updated":"","username":"","verified":false},"method":"POST","context":"test"}`
+
+ if expected != rawStr {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
+ }
+ })
+
+ t.Run("change user and context", func(t *testing.T) {
+ event.Set(core.RequestEventKeyInfoContext, "test2")
+ event.Auth = user2
+
+ info, err := event.RequestInfo()
+ if err != nil {
+ t.Fatalf("Failed to resolve request info: %v", err)
+ }
+
+ raw, err := json.Marshal(info, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to serialize request info: %v", err)
+ }
+ rawStr := string(raw)
+
+ expected := `{"query":{"q1":"123","q2":"456"},"headers":{"content_type":"application/json","x_test":"test"},"body":{"a":123,"b":"test"},"auth":{"avatar":"","collectionId":"_pb_users_auth_","collectionName":"users","created":"","emailVisibility":false,"file":[],"id":"user2","name":"","rel":"","updated":"","username":"","verified":false},"method":"POST","context":"test2"}`
+
+ if expected != rawStr {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
+ }
+ })
+}
+
+func TestRequestInfoHasSuperuserAuth(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ event := core.RequestEvent{}
+ event.Request, err = http.NewRequest("POST", "/test?q1=123&q2=456", strings.NewReader(`{"a":123,"b":"test"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ event.Request.Header.Add("content-type", "application/json")
+
+ scenarios := []struct {
+ name string
+ record *core.Record
+ expected bool
+ }{
+ {"nil record", nil, false},
+ {"regular user record", user, false},
+ {"superuser record", superuser, true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ event.Auth = s.record
+
+ info, err := event.RequestInfo()
+ if err != nil {
+ t.Fatalf("Failed to resolve request info: %v", err)
+ }
+
+ result := info.HasSuperuserAuth()
+
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRequestInfoClone(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ userCol, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user := core.NewRecord(userCol)
+ user.Id = "user1"
+ user.SetEmail("test1@example.com")
+
+ event := core.RequestEvent{}
+ event.Request, err = http.NewRequest("POST", "/test?q1=123&q2=456", strings.NewReader(`{"a":123,"b":"test"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ event.Request.Header.Add("content-type", "application/json")
+ event.Auth = user
+
+ info, err := event.RequestInfo()
+ if err != nil {
+ t.Fatalf("Failed to resolve request info: %v", err)
+ }
+
+ clone := info.Clone()
+
+ // modify the clone fields to ensure that it is a shallow copy
+ clone.Headers["new_header"] = "test"
+ clone.Query["new_query"] = "test"
+ clone.Body["new_body"] = "test"
+ clone.Auth.Id = "user2" // should be a Fresh copy of the record
+
+ // check the original data
+ // ---
+ originalRaw, err := json.Marshal(info, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to serialize original request info: %v", err)
+ }
+ originalRawStr := string(originalRaw)
+
+ expectedRawStr := `{"query":{"q1":"123","q2":"456"},"headers":{"content_type":"application/json"},"body":{"a":123,"b":"test"},"auth":{"avatar":"","collectionId":"_pb_users_auth_","collectionName":"users","created":"","emailVisibility":false,"file":[],"id":"user1","name":"","rel":"","updated":"","username":"","verified":false},"method":"POST","context":"default"}`
+ if expectedRawStr != originalRawStr {
+ t.Fatalf("Expected original info\n%v\ngot\n%v", expectedRawStr, originalRawStr)
+ }
+
+ // check the clone data
+ // ---
+ cloneRaw, err := json.Marshal(clone, json.Deterministic(true))
+ if err != nil {
+ t.Fatalf("Failed to serialize clone request info: %v", err)
+ }
+ cloneRawStr := string(cloneRaw)
+
+ expectedCloneStr := `{"query":{"new_query":"test","q1":"123","q2":"456"},"headers":{"content_type":"application/json","new_header":"test"},"body":{"a":123,"b":"test","new_body":"test"},"auth":{"avatar":"","collectionId":"_pb_users_auth_","collectionName":"users","created":"","emailVisibility":false,"file":[],"id":"user2","name":"","rel":"","updated":"","username":"","verified":false},"method":"POST","context":"default"}`
+ if expectedCloneStr != cloneRawStr {
+ t.Fatalf("Expected clone info\n%v\ngot\n%v", expectedCloneStr, cloneRawStr)
+ }
+}
diff --git a/core/events.go b/core/events.go
index 6e18f091c4..81b95955ee 100644
--- a/core/events.go
+++ b/core/events.go
@@ -1,49 +1,68 @@
package core
import (
+ "context"
+ "io/fs"
+ "net"
"net/http"
"time"
- "github.com/labstack/echo/v5"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"golang.org/x/crypto/acme/autocert"
)
-var (
- _ hook.Tagger = (*BaseModelEvent)(nil)
- _ hook.Tagger = (*BaseCollectionEvent)(nil)
-)
+type HookTagger interface {
+ HookTags() []string
+}
+
+// -------------------------------------------------------------------
-type BaseModelEvent struct {
- Model models.Model
+type baseModelEventData struct {
+ Model Model
}
-func (e *BaseModelEvent) Tags() []string {
+func (e *baseModelEventData) Tags() []string {
if e.Model == nil {
return nil
}
- if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
- return []string{r.Collection().Id, r.Collection().Name}
+ if ht, ok := e.Model.(HookTagger); ok {
+ return ht.HookTags()
}
return []string{e.Model.TableName()}
}
-type BaseCollectionEvent struct {
- Collection *models.Collection
+// -------------------------------------------------------------------
+
+type baseRecordEventData struct {
+ Record *Record
+}
+
+func (e *baseRecordEventData) Tags() []string {
+ if e.Record == nil {
+ return nil
+ }
+
+ return e.Record.HookTags()
+}
+
+// -------------------------------------------------------------------
+
+type baseCollectionEventData struct {
+ Collection *Collection
}
-func (e *BaseCollectionEvent) Tags() []string {
+// @todo consider storing the original collection name and use that as a tag
+// to avoid the ambiguity when the collection is being modified (#7613);
+// for new collection also maybe return empty tags?
+func (e *baseCollectionEventData) Tags() []string {
if e.Collection == nil {
return nil
}
@@ -62,355 +81,569 @@ func (e *BaseCollectionEvent) Tags() []string {
}
// -------------------------------------------------------------------
-// Serve events data
+// App events data
// -------------------------------------------------------------------
type BootstrapEvent struct {
+ hook.Event
App App
}
type TerminateEvent struct {
- App App
+ hook.Event
+ App App
+ IsRestart bool
+}
+
+type BackupEvent struct {
+ hook.Event
+ App App
+ Context context.Context
+ Name string // the name of the backup to create/restore.
+ Exclude []string // list of dir entries to exclude from the backup create/restore.
}
type ServeEvent struct {
+ hook.Event
App App
- Router *echo.Echo
+ Router *router.Router[*RequestEvent]
Server *http.Server
CertManager *autocert.Manager
-}
-type ApiErrorEvent struct {
- HttpContext echo.Context
- Error error
+ // Listener allow specifying a custom network listener.
+ //
+ // Leave it nil to use the default net.Listen("tcp", e.Server.Addr).
+ Listener net.Listener
+
+ // InstallerFunc is the "installer" function that is called after
+ // successful server tcp bind but only if there is no explicit
+ // superuser record created yet.
+ //
+ // It runs in a separate goroutine and its default value is [apis.DefaultInstallerFunc].
+ //
+ // It receives a system superuser record as argument that you can use to generate
+ // a short-lived auth token (e.g. systemSuperuser.NewStaticAuthToken(30 * time.Minute))
+ // and concatenate it as query param for your installer page
+ // (if you are using the client-side SDKs, you can then load the
+ // token with pb.authStore.save(token) and perform any Web API request
+ // e.g. creating a new superuser).
+ //
+ // Set it to nil if you want to skip the installer.
+ InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
+
+ // @todo experimental
+ //
+ // UIExtensions is a list with the superuser UI extensions.
+ UIExtensions []UIExtension
+}
+
+type UIExtension struct {
+ // Name is the name of the extension.
+ // It is also used as path segment for the registered public extension endpoint
+ // (e.g. /_/extensions/{name}/*)
+ Name string
+
+ // FS is the extension file system.
+ FS fs.FS
}
// -------------------------------------------------------------------
-// Model DAO events data
+// Settings events data
// -------------------------------------------------------------------
-type ModelEvent struct {
- BaseModelEvent
+type SettingsListRequestEvent struct {
+ hook.Event
+ *RequestEvent
+
+ Settings *Settings
+}
+
+type SettingsUpdateRequestEvent struct {
+ hook.Event
+ *RequestEvent
- Dao *daos.Dao
+ OldSettings *Settings
+ NewSettings *Settings
+}
+
+type SettingsReloadEvent struct {
+ hook.Event
+ App App
}
// -------------------------------------------------------------------
// Mailer events data
// -------------------------------------------------------------------
-type MailerRecordEvent struct {
- BaseCollectionEvent
+type MailerEvent struct {
+ hook.Event
+ App App
- MailClient mailer.Mailer
- Message *mailer.Message
- Record *models.Record
- Meta map[string]any
+ Mailer mailer.Mailer
+ Message *mailer.Message
}
-type MailerAdminEvent struct {
- MailClient mailer.Mailer
- Message *mailer.Message
- Admin *models.Admin
- Meta map[string]any
+type MailerRecordEvent struct {
+ MailerEvent
+ baseRecordEventData
+ Meta map[string]any
}
// -------------------------------------------------------------------
-// Realtime API events data
+// Filesystem events data
// -------------------------------------------------------------------
-type RealtimeConnectEvent struct {
- HttpContext echo.Context
- Client subscriptions.Client
- IdleTimeout time.Duration
-}
+type FilesystemNewWriterEvent struct {
+ hook.Event
+ *filesystem.NewWriterEvent
-type RealtimeDisconnectEvent struct {
- HttpContext echo.Context
- Client subscriptions.Client
+ App App
}
-type RealtimeMessageEvent struct {
- HttpContext echo.Context
- Client subscriptions.Client
- Message *subscriptions.Message
-}
+type FilesystemDeleteEvent struct {
+ hook.Event
+ *filesystem.DeleteEvent
-type RealtimeSubscribeEvent struct {
- HttpContext echo.Context
- Client subscriptions.Client
- Subscriptions []string
+ App App
}
// -------------------------------------------------------------------
-// Settings API events data
+// Model events data
// -------------------------------------------------------------------
-type SettingsListEvent struct {
- HttpContext echo.Context
- RedactedSettings *settings.Settings
+const (
+ ModelEventTypeCreate = "create"
+ ModelEventTypeUpdate = "update"
+ ModelEventTypeDelete = "delete"
+ ModelEventTypeValidate = "validate"
+)
+
+type ModelEvent struct {
+ hook.Event
+ App App
+ baseModelEventData
+ Context context.Context
+
+ // Could be any of the ModelEventType* constants, like:
+ // - create
+ // - update
+ // - delete
+ // - validate
+ Type string
}
-type SettingsUpdateEvent struct {
- HttpContext echo.Context
- OldSettings *settings.Settings
- NewSettings *settings.Settings
+type ModelErrorEvent struct {
+ Error error
+ ModelEvent
}
// -------------------------------------------------------------------
-// Record CRUD API events data
+// Record events data
// -------------------------------------------------------------------
-type RecordsListEvent struct {
- BaseCollectionEvent
-
- HttpContext echo.Context
- Records []*models.Record
- Result *search.Result
-}
+type RecordEvent struct {
+ hook.Event
+ App App
+ baseRecordEventData
+ Context context.Context
+
+ // Could be any of the ModelEventType* constants, like:
+ // - create
+ // - update
+ // - delete
+ // - validate
+ Type string
+}
+
+type RecordErrorEvent struct {
+ Error error
+ RecordEvent
+}
+
+func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
+ me.App = re.App
+ me.Context = re.Context
+ me.Type = re.Type
+
+ // @todo enable if after profiling doesn't have significant impact
+ // skip for now to avoid excessive checks and assume that the
+ // Model and the Record fields still points to the same instance
+ //
+ // if _, ok := me.Model.(*Record); ok {
+ // me.Model = re.Record
+ // } else if proxy, ok := me.Model.(RecordProxy); ok {
+ // proxy.SetProxyRecord(re.Record)
+ // }
+}
+
+func syncRecordEventWithModelEvent(re *RecordEvent, me *ModelEvent) {
+ re.App = me.App
+ re.Context = me.Context
+ re.Type = me.Type
+}
+
+func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
+ record, ok := me.Model.(*Record)
+ if !ok {
+ proxy, ok := me.Model.(RecordProxy)
+ if !ok {
+ return nil, false
+ }
+ record = proxy.ProxyRecord()
+ }
-type RecordViewEvent struct {
- BaseCollectionEvent
+ re := new(RecordEvent)
+ re.App = me.App
+ re.Context = me.Context
+ re.Type = me.Type
+ re.Record = record
- HttpContext echo.Context
- Record *models.Record
+ return re, true
}
-type RecordCreateEvent struct {
- BaseCollectionEvent
-
- HttpContext echo.Context
- Record *models.Record
- UploadedFiles map[string][]*filesystem.File
-}
+func newRecordErrorEventFromModelErrorEvent(me *ModelErrorEvent) (*RecordErrorEvent, bool) {
+ recordEvent, ok := newRecordEventFromModelEvent(&me.ModelEvent)
+ if !ok {
+ return nil, false
+ }
-type RecordUpdateEvent struct {
- BaseCollectionEvent
+ re := new(RecordErrorEvent)
+ re.RecordEvent = *recordEvent
+ re.Error = me.Error
- HttpContext echo.Context
- Record *models.Record
- UploadedFiles map[string][]*filesystem.File
+ return re, true
}
-type RecordDeleteEvent struct {
- BaseCollectionEvent
+func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErrorEvent) {
+ syncModelEventWithRecordEvent(&me.ModelEvent, &re.RecordEvent)
+ me.Error = re.Error
+}
- HttpContext echo.Context
- Record *models.Record
+func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
+ syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
+ re.Error = me.Error
}
// -------------------------------------------------------------------
-// Auth Record API events data
+// Collection events data
// -------------------------------------------------------------------
-type RecordAuthEvent struct {
- BaseCollectionEvent
+type CollectionEvent struct {
+ hook.Event
+ App App
+ baseCollectionEventData
+ Context context.Context
- HttpContext echo.Context
- Record *models.Record
- Token string
- Meta any
+ // Could be any of the ModelEventType* constants, like:
+ // - create
+ // - update
+ // - delete
+ // - validate
+ Type string
}
-type RecordAuthWithPasswordEvent struct {
- BaseCollectionEvent
+type CollectionErrorEvent struct {
+ Error error
+ CollectionEvent
+}
- HttpContext echo.Context
- Record *models.Record
- Identity string
- Password string
+func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
+ me.App = ce.App
+ me.Context = ce.Context
+ me.Type = ce.Type
+ me.Model = ce.Collection
}
-type RecordAuthWithOAuth2Event struct {
- BaseCollectionEvent
+func syncCollectionEventWithModelEvent(ce *CollectionEvent, me *ModelEvent) {
+ ce.App = me.App
+ ce.Context = me.Context
+ ce.Type = me.Type
+ if c, ok := me.Model.(*Collection); ok {
+ ce.Collection = c
+ }
+}
- HttpContext echo.Context
- ProviderName string
- ProviderClient auth.Provider
- Record *models.Record
- OAuth2User *auth.AuthUser
- IsNewRecord bool
+func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
+ record, ok := me.Model.(*Collection)
+ if !ok {
+ return nil, false
+ }
+
+ ce := new(CollectionEvent)
+ ce.App = me.App
+ ce.Context = me.Context
+ ce.Type = me.Type
+ ce.Collection = record
+
+ return ce, true
}
-type RecordAuthRefreshEvent struct {
- BaseCollectionEvent
+func newCollectionErrorEventFromModelErrorEvent(me *ModelErrorEvent) (*CollectionErrorEvent, bool) {
+ collectionevent, ok := newCollectionEventFromModelEvent(&me.ModelEvent)
+ if !ok {
+ return nil, false
+ }
+
+ ce := new(CollectionErrorEvent)
+ ce.CollectionEvent = *collectionevent
+ ce.Error = me.Error
- HttpContext echo.Context
- Record *models.Record
+ return ce, true
}
-type RecordRequestPasswordResetEvent struct {
- BaseCollectionEvent
+func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *CollectionErrorEvent) {
+ syncModelEventWithCollectionEvent(&me.ModelEvent, &ce.CollectionEvent)
+ me.Error = ce.Error
+}
- HttpContext echo.Context
- Record *models.Record
+func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
+ syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
+ ce.Error = me.Error
}
-type RecordConfirmPasswordResetEvent struct {
- BaseCollectionEvent
+// -------------------------------------------------------------------
+// File API events data
+// -------------------------------------------------------------------
- HttpContext echo.Context
- Record *models.Record
+type FileTokenRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseRecordEventData
+
+ Token string
}
-type RecordRequestVerificationEvent struct {
- BaseCollectionEvent
+type FileDownloadRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
+ FileField *FileField
+ ServedPath string
+ ServedName string
- HttpContext echo.Context
- Record *models.Record
+ // ThumbError indicates the a thumb wasn't able to be generated
+ // (e.g. because it didn't satisfy the support image formats or it timed out).
+ //
+ // Note that PocketBase fallbacks to the original file in case of a thumb error,
+ // but developers can check the field and provide their own custom thumb generation if necessary.
+ ThumbError error
}
-type RecordConfirmVerificationEvent struct {
- BaseCollectionEvent
+// -------------------------------------------------------------------
+// Collection API events data
+// -------------------------------------------------------------------
- HttpContext echo.Context
- Record *models.Record
+type CollectionsListRequestEvent struct {
+ hook.Event
+ *RequestEvent
+
+ Collections []*Collection
+ Result *search.Result
}
-type RecordRequestEmailChangeEvent struct {
- BaseCollectionEvent
+type CollectionsImportRequestEvent struct {
+ hook.Event
+ *RequestEvent
- HttpContext echo.Context
- Record *models.Record
+ CollectionsData []map[string]any
+ DeleteMissing bool
}
-type RecordConfirmEmailChangeEvent struct {
- BaseCollectionEvent
+type CollectionRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+}
+
+// -------------------------------------------------------------------
+// Realtime API events data
+// -------------------------------------------------------------------
+
+type RealtimeConnectRequestEvent struct {
+ hook.Event
+ *RequestEvent
+
+ Client subscriptions.Client
- HttpContext echo.Context
- Record *models.Record
+ // IdleTimeout specifies the max duration to wait for a new message
+ // before closing the connection.
+ //
+ // Modifying the value after the connection has been established has no effect.
+ //
+ // Defaults to 5 minutes.
+ IdleTimeout time.Duration
+
+ // MaxTimeout specifies the maximum duration a realtime connection
+ // can remain open (including even if there are ongoing messages).
+ //
+ // Once the specified duration expires, the current connection will
+ // be terminated, until a client reconnect is issued (if the client is still active).
+ //
+ // Modifying the value after the connection has been established has no effect.
+ //
+ // Defaults to 30 minutes.
+ MaxTimeout time.Duration
}
-type RecordListExternalAuthsEvent struct {
- BaseCollectionEvent
+type RealtimeMessageEvent struct {
+ hook.Event
+ *RequestEvent
- HttpContext echo.Context
- Record *models.Record
- ExternalAuths []*models.ExternalAuth
+ Client subscriptions.Client
+ Message *subscriptions.Message
}
-type RecordUnlinkExternalAuthEvent struct {
- BaseCollectionEvent
+type RealtimeSubscribeRequestEvent struct {
+ hook.Event
+ *RequestEvent
- HttpContext echo.Context
- Record *models.Record
- ExternalAuth *models.ExternalAuth
+ Client subscriptions.Client
+ Subscriptions []string
}
// -------------------------------------------------------------------
-// Admin API events data
+// Record CRUD API events data
// -------------------------------------------------------------------
-type AdminsListEvent struct {
- HttpContext echo.Context
- Admins []*models.Admin
- Result *search.Result
-}
+type RecordsListRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
-type AdminViewEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
+ // @todo consider removing and maybe add as generic to the search.Result?
+ Records []*Record
+ Result *search.Result
}
-type AdminCreateEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
-}
+type RecordRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
-type AdminUpdateEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
+ Record *Record
}
-type AdminDeleteEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
-}
+type RecordEnrichEvent struct {
+ hook.Event
+ App App
+ baseRecordEventData
-type AdminAuthEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
- Token string
+ RequestInfo *RequestInfo
}
-type AdminAuthWithPasswordEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
- Identity string
- Password string
-}
+// -------------------------------------------------------------------
+// Auth Record API events data
+// -------------------------------------------------------------------
-type AdminAuthRefreshEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
+type RecordCreateOTPRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
+ Password string
}
-type AdminRequestPasswordResetEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
+type RecordAuthWithOTPRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
+ OTP *OTP
}
-type AdminConfirmPasswordResetEvent struct {
- HttpContext echo.Context
- Admin *models.Admin
+type RecordAuthRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
+ Token string
+ Meta any
+ AuthMethod string
}
-// -------------------------------------------------------------------
-// Collection API events data
-// -------------------------------------------------------------------
+type RecordAuthWithPasswordRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
-type CollectionsListEvent struct {
- HttpContext echo.Context
- Collections []*models.Collection
- Result *search.Result
+ Record *Record
+ Identity string
+ IdentityField string
+ Password string
}
-type CollectionViewEvent struct {
- BaseCollectionEvent
+type RecordAuthWithOAuth2RequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
+ ProviderName string
+ ProviderClient auth.Provider
+ Record *Record
+ OAuth2User *auth.AuthUser
+ CreateData map[string]any
+ IsNewRecord bool
}
-type CollectionCreateEvent struct {
- BaseCollectionEvent
+type RecordAuthRefreshRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
+ Record *Record
}
-type CollectionUpdateEvent struct {
- BaseCollectionEvent
+type RecordRequestPasswordResetRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
+ Record *Record
}
-type CollectionDeleteEvent struct {
- BaseCollectionEvent
+type RecordConfirmPasswordResetRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
+ Record *Record
}
-type CollectionsImportEvent struct {
- HttpContext echo.Context
- Collections []*models.Collection
+type RecordRequestVerificationRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
}
-// -------------------------------------------------------------------
-// File API events data
-// -------------------------------------------------------------------
+type RecordConfirmVerificationRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
+
+ Record *Record
+}
-type FileTokenEvent struct {
- BaseModelEvent
+type RecordRequestEmailChangeRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
- Token string
+ Record *Record
+ NewEmail string
}
-type FileDownloadEvent struct {
- BaseCollectionEvent
+type RecordConfirmEmailChangeRequestEvent struct {
+ hook.Event
+ *RequestEvent
+ baseCollectionEventData
- HttpContext echo.Context
- Record *models.Record
- FileField *schema.SchemaField
- ServedPath string
- ServedName string
+ Record *Record
+ NewEmail string
}
diff --git a/core/events_test.go b/core/events_test.go
deleted file mode 100644
index d4ee5ebc2e..0000000000
--- a/core/events_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package core_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-func TestBaseCollectionEventTags(t *testing.T) {
- c1 := new(models.Collection)
-
- c2 := new(models.Collection)
- c2.Id = "a"
-
- c3 := new(models.Collection)
- c3.Name = "b"
-
- c4 := new(models.Collection)
- c4.Id = "a"
- c4.Name = "b"
-
- scenarios := []struct {
- collection *models.Collection
- expectedTags []string
- }{
- {c1, []string{}},
- {c2, []string{"a"}},
- {c3, []string{"b"}},
- {c4, []string{"a", "b"}},
- }
-
- for i, s := range scenarios {
- event := new(core.BaseCollectionEvent)
- event.Collection = s.collection
-
- tags := event.Tags()
-
- if len(s.expectedTags) != len(tags) {
- t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
- }
-
- for _, tag := range s.expectedTags {
- if !list.ExistInSlice(tag, tags) {
- t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
- }
- }
- }
-}
-
-func TestModelEventTags(t *testing.T) {
- m1 := new(models.Admin)
-
- c := new(models.Collection)
- c.Id = "a"
- c.Name = "b"
- m2 := models.NewRecord(c)
-
- scenarios := []struct {
- model models.Model
- expectedTags []string
- }{
- {m1, []string{"_admins"}},
- {m2, []string{"a", "b"}},
- }
-
- for i, s := range scenarios {
- event := new(core.ModelEvent)
- event.Model = s.model
-
- tags := event.Tags()
-
- if len(s.expectedTags) != len(tags) {
- t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
- }
-
- for _, tag := range s.expectedTags {
- if !list.ExistInSlice(tag, tags) {
- t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
- }
- }
- }
-}
diff --git a/core/external_auth_model.go b/core/external_auth_model.go
new file mode 100644
index 0000000000..1b767d79d7
--- /dev/null
+++ b/core/external_auth_model.go
@@ -0,0 +1,178 @@
+package core
+
+import (
+ "context"
+ "errors"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/auth"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+var (
+ _ Model = (*ExternalAuth)(nil)
+ _ PreValidator = (*ExternalAuth)(nil)
+ _ RecordProxy = (*ExternalAuth)(nil)
+)
+
+const CollectionNameExternalAuths = "_externalAuths"
+
+// ExternalAuth defines a Record proxy for working with the externalAuths collection.
+type ExternalAuth struct {
+ *Record
+}
+
+// NewExternalAuth instantiates and returns a new blank *ExternalAuth model.
+//
+// Example usage:
+//
+// ea := core.NewExternalAuth(app)
+// ea.SetRecordRef(user.Id)
+// ea.SetCollectionRef(user.Collection().Id)
+// ea.SetProvider("google")
+// ea.SetProviderId("...")
+// app.Save(ea)
+func NewExternalAuth(app App) *ExternalAuth {
+ m := &ExternalAuth{}
+
+ c, err := app.FindCachedCollectionByNameOrId(CollectionNameExternalAuths)
+ if err != nil {
+ // this is just to make tests easier since it is a system collection and it is expected to be always accessible
+ // (note: the loaded record is further checked on ExternalAuth.PreValidate())
+ c = NewBaseCollection("@__invalid__")
+ }
+
+ m.Record = NewRecord(c)
+
+ return m
+}
+
+// PreValidate implements the [PreValidator] interface and checks
+// whether the proxy is properly loaded.
+func (m *ExternalAuth) PreValidate(ctx context.Context, app App) error {
+ if m.Record == nil || m.Record.Collection().Name != CollectionNameExternalAuths {
+ return errors.New("missing or invalid ExternalAuth ProxyRecord")
+ }
+
+ return nil
+}
+
+// ProxyRecord returns the proxied Record model.
+func (m *ExternalAuth) ProxyRecord() *Record {
+ return m.Record
+}
+
+// SetProxyRecord loads the specified record model into the current proxy.
+func (m *ExternalAuth) SetProxyRecord(record *Record) {
+ m.Record = record
+}
+
+// CollectionRef returns the "collectionRef" field value.
+func (m *ExternalAuth) CollectionRef() string {
+ return m.GetString("collectionRef")
+}
+
+// SetCollectionRef updates the "collectionRef" record field value.
+func (m *ExternalAuth) SetCollectionRef(collectionId string) {
+ m.Set("collectionRef", collectionId)
+}
+
+// RecordRef returns the "recordRef" record field value.
+func (m *ExternalAuth) RecordRef() string {
+ return m.GetString("recordRef")
+}
+
+// SetRecordRef updates the "recordRef" record field value.
+func (m *ExternalAuth) SetRecordRef(recordId string) {
+ m.Set("recordRef", recordId)
+}
+
+// Provider returns the "provider" record field value.
+func (m *ExternalAuth) Provider() string {
+ return m.GetString("provider")
+}
+
+// SetProvider updates the "provider" record field value.
+func (m *ExternalAuth) SetProvider(provider string) {
+ m.Set("provider", provider)
+}
+
+// Provider returns the "providerId" record field value.
+func (m *ExternalAuth) ProviderId() string {
+ return m.GetString("providerId")
+}
+
+// SetProvider updates the "providerId" record field value.
+func (m *ExternalAuth) SetProviderId(providerId string) {
+ m.Set("providerId", providerId)
+}
+
+// Created returns the "created" record field value.
+func (m *ExternalAuth) Created() types.DateTime {
+ return m.GetDateTime("created")
+}
+
+// Updated returns the "updated" record field value.
+func (m *ExternalAuth) Updated() types.DateTime {
+ return m.GetDateTime("updated")
+}
+
+func (app *BaseApp) registerExternalAuthHooks() {
+ recordRefHooks[*ExternalAuth](app, CollectionNameExternalAuths, CollectionTypeAuth)
+
+ app.OnRecordValidate(CollectionNameExternalAuths).Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ providerNames := make([]any, 0, len(auth.Providers))
+ for name := range auth.Providers {
+ providerNames = append(providerNames, name)
+ }
+
+ provider := e.Record.GetString("provider")
+ if err := validation.Validate(provider, validation.Required, validation.In(providerNames...)); err != nil {
+ return validation.Errors{"provider": err}
+ }
+
+ return e.Next()
+ },
+ Priority: 99,
+ })
+
+ // delete all pre-existing external auths on verified upgrade
+ app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ if !e.Record.Collection().IsAuth() {
+ return e.Next()
+ }
+
+ hasUpgradedVerified := !e.Record.Original().IsNew() && !e.Record.Original().Verified() && e.Record.Verified()
+
+ if !hasUpgradedVerified {
+ return e.Next()
+ }
+
+ originalApp := e.App
+ return e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+ defer func() { e.App = originalApp }()
+
+ externalAuths, err := txApp.FindAllExternalAuthsByRecord(e.Record)
+ if err != nil {
+ return err
+ }
+ if len(externalAuths) > 0 {
+ // delete all pre-existing external auths
+ if err := txApp.DeleteAllExternalAuthsByRecord(e.Record); err != nil {
+ return err
+ }
+
+ // force refresh tokens reset (if not already)
+ e.Record.RefreshTokenKey()
+ }
+
+ return e.Next()
+ })
+ },
+ Priority: 99,
+ })
+}
diff --git a/core/external_auth_model_test.go b/core/external_auth_model_test.go
new file mode 100644
index 0000000000..46f7be7b96
--- /dev/null
+++ b/core/external_auth_model_test.go
@@ -0,0 +1,411 @@
+package core_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNewExternalAuth(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ if ea.Collection().Name != core.CollectionNameExternalAuths {
+ t.Fatalf("Expected record with %q collection, got %q", core.CollectionNameExternalAuths, ea.Collection().Name)
+ }
+}
+
+func TestExternalAuthProxyRecord(t *testing.T) {
+ t.Parallel()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.Id = "test_id"
+
+ ea := core.ExternalAuth{}
+ ea.SetProxyRecord(record)
+
+ if ea.ProxyRecord() == nil || ea.ProxyRecord().Id != record.Id {
+ t.Fatalf("Expected proxy record with id %q, got %v", record.Id, ea.ProxyRecord())
+ }
+}
+
+func TestExternalAuthRecordRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ ea.SetRecordRef(testValue)
+
+ if v := ea.RecordRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := ea.GetString("recordRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestExternalAuthCollectionRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ ea.SetCollectionRef(testValue)
+
+ if v := ea.CollectionRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := ea.GetString("collectionRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestExternalAuthProvider(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ ea.SetProvider(testValue)
+
+ if v := ea.Provider(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := ea.GetString("provider"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestExternalAuthProviderId(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ ea.SetProviderId(testValue)
+
+ if v := ea.ProviderId(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := ea.GetString("providerId"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestExternalAuthCreated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ if v := ea.Created().String(); v != "" {
+ t.Fatalf("Expected empty created, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ ea.SetRaw("created", now)
+
+ if v := ea.Created().String(); v != now.String() {
+ t.Fatalf("Expected %q created, got %q", now.String(), v)
+ }
+}
+
+func TestExternalAuthUpdated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ ea := core.NewExternalAuth(app)
+
+ if v := ea.Updated().String(); v != "" {
+ t.Fatalf("Expected empty updated, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ ea.SetRaw("updated", now)
+
+ if v := ea.Updated().String(); v != now.String() {
+ t.Fatalf("Expected %q updated, got %q", now.String(), v)
+ }
+}
+
+func TestExternalAuthPreValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ externalAuthsCol, err := app.FindCollectionByNameOrId(core.CollectionNameExternalAuths)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("no proxy record", func(t *testing.T) {
+ externalAuth := &core.ExternalAuth{}
+
+ if err := app.Validate(externalAuth); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("non-ExternalAuth collection", func(t *testing.T) {
+ externalAuth := &core.ExternalAuth{}
+ externalAuth.SetProxyRecord(core.NewRecord(core.NewBaseCollection("invalid")))
+ externalAuth.SetRecordRef(user.Id)
+ externalAuth.SetCollectionRef(user.Collection().Id)
+ externalAuth.SetProvider("gitlab")
+ externalAuth.SetProviderId("test123")
+
+ if err := app.Validate(externalAuth); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("ExternalAuth collection", func(t *testing.T) {
+ externalAuth := &core.ExternalAuth{}
+ externalAuth.SetProxyRecord(core.NewRecord(externalAuthsCol))
+ externalAuth.SetRecordRef(user.Id)
+ externalAuth.SetCollectionRef(user.Collection().Id)
+ externalAuth.SetProvider("gitlab")
+ externalAuth.SetProviderId("test123")
+
+ if err := app.Validate(externalAuth); err != nil {
+ t.Fatalf("Expected nil validation error, got %v", err)
+ }
+ })
+}
+
+func TestExternalAuthValidateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ externalAuth func() *core.ExternalAuth
+ expectErrors []string
+ }{
+ {
+ "empty",
+ func() *core.ExternalAuth {
+ return core.NewExternalAuth(app)
+ },
+ []string{"collectionRef", "recordRef", "provider", "providerId"},
+ },
+ {
+ "non-auth collection",
+ func() *core.ExternalAuth {
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(demo1.Collection().Id)
+ ea.SetRecordRef(demo1.Id)
+ ea.SetProvider("gitlab")
+ ea.SetProviderId("test123")
+ return ea
+ },
+ []string{"collectionRef"},
+ },
+ {
+ "disabled provider",
+ func() *core.ExternalAuth {
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef("missing")
+ ea.SetProvider("apple")
+ ea.SetProviderId("test123")
+ return ea
+ },
+ []string{"recordRef"},
+ },
+ {
+ "missing record id",
+ func() *core.ExternalAuth {
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef("missing")
+ ea.SetProvider("gitlab")
+ ea.SetProviderId("test123")
+ return ea
+ },
+ []string{"recordRef"},
+ },
+ {
+ "valid ref",
+ func() *core.ExternalAuth {
+ ea := core.NewExternalAuth(app)
+ ea.SetCollectionRef(user.Collection().Id)
+ ea.SetRecordRef(user.Id)
+ ea.SetProvider("gitlab")
+ ea.SetProviderId("test123")
+ return ea
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := app.Validate(s.externalAuth())
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestExternalAuthClearOnVerfiedUpgrade(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ t.Run("unverified->no changes", func(t *testing.T) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected user to be unverified")
+ }
+
+ beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(beforeAuths) == 0 {
+ t.Fatalf("Expected at least one external auth (%v)", err)
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ if oldTokenKey != user.TokenKey() {
+ t.Fatal("Expected tokenKey to remain unchanged")
+ }
+
+ afterAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(afterAuths) != len(beforeAuths) {
+ t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
+ }
+ })
+
+ t.Run("unverified->verified", func(t *testing.T) {
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if user.Verified() {
+ t.Fatal("Expected user to be unverified")
+ }
+
+ externalAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(externalAuths) == 0 {
+ t.Fatalf("Expected at least one external auth (%v)", err)
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ user.SetVerified(true)
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ if oldTokenKey == user.TokenKey() {
+ t.Fatal("Expected tokenKey to be renewed")
+ }
+
+ externalAuths, err = app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(externalAuths) != 0 {
+ t.Fatalf("Expected all user external auths to be deleted, found %d (%v)", len(externalAuths), err)
+ }
+ })
+
+ t.Run("verified->no changes", func(t *testing.T) {
+ user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !user.Verified() {
+ t.Fatal("Expected user to be verified")
+ }
+
+ beforeAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(beforeAuths) == 0 {
+ t.Fatalf("Expected at least one external auth (%v)", err)
+ }
+
+ oldTokenKey := user.TokenKey()
+
+ if err = app.Save(user); err != nil {
+ t.Fatal(err)
+ }
+
+ if oldTokenKey != user.TokenKey() {
+ t.Fatal("Expected tokenKey to remain unchanged")
+ }
+
+ afterAuths, err := app.FindAllExternalAuthsByRecord(user)
+ if err != nil || len(afterAuths) != len(beforeAuths) {
+ t.Fatalf("Expected %d external auths, found %d (%v)", len(afterAuths), len(beforeAuths), err)
+ }
+ })
+}
diff --git a/core/external_auth_query.go b/core/external_auth_query.go
new file mode 100644
index 0000000000..b5cb4cec16
--- /dev/null
+++ b/core/external_auth_query.go
@@ -0,0 +1,85 @@
+package core
+
+import (
+ "errors"
+
+ "github.com/pocketbase/dbx"
+)
+
+// FindAllExternalAuthsByRecord returns all ExternalAuth models
+// linked to the provided auth record.
+func (app *BaseApp) FindAllExternalAuthsByRecord(authRecord *Record) ([]*ExternalAuth, error) {
+ auths := []*ExternalAuth{}
+
+ err := app.RecordQuery(CollectionNameExternalAuths).
+ AndWhere(dbx.HashExp{
+ "collectionRef": authRecord.Collection().Id,
+ "recordRef": authRecord.Id,
+ }).
+ OrderBy("created DESC").
+ All(&auths)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return auths, nil
+}
+
+// FindAllExternalAuthsByCollection returns all ExternalAuth models
+// linked to the provided auth collection.
+func (app *BaseApp) FindAllExternalAuthsByCollection(collection *Collection) ([]*ExternalAuth, error) {
+ auths := []*ExternalAuth{}
+
+ err := app.RecordQuery(CollectionNameExternalAuths).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ OrderBy("created DESC").
+ All(&auths)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return auths, nil
+}
+
+// FindFirstExternalAuthByExpr returns the first available (the most recent created)
+// ExternalAuth model that satisfies the non-nil expression.
+func (app *BaseApp) FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error) {
+ model := &ExternalAuth{}
+
+ err := app.RecordQuery(CollectionNameExternalAuths).
+ AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
+ AndWhere(expr).
+ OrderBy("created DESC").
+ Limit(1).
+ One(model)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return model, nil
+}
+
+// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
+//
+// Returns a combined error with the failed deletes.
+func (app *BaseApp) DeleteAllExternalAuthsByRecord(authRecord *Record) error {
+ models, err := app.FindAllExternalAuthsByRecord(authRecord)
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ for _, m := range models {
+ if err := app.Delete(m); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
diff --git a/core/external_auth_query_test.go b/core/external_auth_query_test.go
new file mode 100644
index 0000000000..ccc97cb4a8
--- /dev/null
+++ b/core/external_auth_query_test.go
@@ -0,0 +1,242 @@
+package core_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestFindAllExternalAuthsByRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser1, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user3, err := app.FindAuthRecordByEmail("users", "test3@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1, err := app.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ expected []string
+ }{
+ {demo1, nil},
+ {superuser1, nil},
+ {client1, []string{"f1z5b3843pzc964"}},
+ {user1, []string{"clmflokuq1xl341", "dlmflokuq1xl342"}},
+ {user2, nil},
+ {user3, []string{"5eto7nmys833164"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.record.Collection().Name+"_"+s.record.Id, func(t *testing.T) {
+ result, err := app.FindAllExternalAuthsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total models %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAllExternalAuthsByCollection(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ clients, err := app.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ collection *core.Collection
+ expected []string
+ }{
+ {demo1, nil},
+ {superusers, nil},
+ {clients, []string{
+ "f1z5b3843pzc964",
+ }},
+ {users, []string{
+ "5eto7nmys833164",
+ "clmflokuq1xl341",
+ "dlmflokuq1xl342",
+ }},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.collection.Name, func(t *testing.T) {
+ result, err := app.FindAllExternalAuthsByCollection(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total models %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindFirstExternalAuthByExpr(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ expr dbx.Expression
+ expectedId string
+ }{
+ {dbx.HashExp{"collectionRef": "invalid"}, ""},
+ {dbx.HashExp{"collectionRef": "_pb_users_auth_"}, "5eto7nmys833164"},
+ {dbx.HashExp{"collectionRef": "_pb_users_auth_", "provider": "gitlab"}, "dlmflokuq1xl342"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
+ result, err := app.FindFirstExternalAuthByExpr(s.expr)
+
+ hasErr := err != nil
+ expectErr := s.expectedId == ""
+ if hasErr != expectErr {
+ t.Fatalf("Expected hasErr %v, got %v", expectErr, hasErr)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if result.Id != s.expectedId {
+ t.Errorf("Expected id %q, got %q", s.expectedId, result.Id)
+ }
+ })
+ }
+}
+
+func TestDeleteAllExternalAuthsByRecord(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client1, err := testApp.FindAuthRecordByEmail("clients", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client2, err := testApp.FindAuthRecordByEmail("clients", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ deletedIds []string
+ }{
+ {demo1, nil}, // non-auth record
+ {user1, []string{"dlmflokuq1xl342", "clmflokuq1xl341"}},
+ {client1, []string{"f1z5b3843pzc964"}},
+ {client2, nil},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ deletedIds := []string{}
+ app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ err := app.DeleteAllExternalAuthsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(s.deletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
+ }
+
+ for _, id := range s.deletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ })
+ }
+}
diff --git a/core/field.go b/core/field.go
new file mode 100644
index 0000000000..0c12ba8bd9
--- /dev/null
+++ b/core/field.go
@@ -0,0 +1,272 @@
+package core
+
+import (
+ "context"
+ "database/sql/driver"
+ "regexp"
+ "strings"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+var fieldNameRegex = regexp.MustCompile(`^\w+$`)
+
+const maxSafeJSONInt int64 = 1<<53 - 1
+
+// Commonly used field names.
+const (
+ FieldNameId = "id"
+ FieldNameCollectionId = "collectionId"
+ FieldNameCollectionName = "collectionName"
+ FieldNameExpand = "expand"
+ FieldNameEmail = "email"
+ FieldNameEmailVisibility = "emailVisibility"
+ FieldNameVerified = "verified"
+ FieldNameTokenKey = "tokenKey"
+ FieldNamePassword = "password"
+)
+
+// SystemFields returns special internal field names that are usually readonly.
+var SystemDynamicFieldNames = []string{
+ FieldNameCollectionId,
+ FieldNameCollectionName,
+ FieldNameExpand,
+}
+
+// Common RecordInterceptor action names.
+const (
+ InterceptorActionValidate = "validate"
+ InterceptorActionDelete = "delete"
+ InterceptorActionDeleteExecute = "deleteExecute"
+ InterceptorActionAfterDelete = "afterDelete"
+ InterceptorActionAfterDeleteError = "afterDeleteError"
+ InterceptorActionCreate = "create"
+ InterceptorActionCreateExecute = "createExecute"
+ InterceptorActionAfterCreate = "afterCreate"
+ InterceptorActionAfterCreateError = "afterCreateFailure"
+ InterceptorActionUpdate = "update"
+ InterceptorActionUpdateExecute = "updateExecute"
+ InterceptorActionAfterUpdate = "afterUpdate"
+ InterceptorActionAfterUpdateError = "afterUpdateError"
+)
+
+// Common field errors.
+var (
+ ErrUnknownField = validation.NewError("validation_unknown_field", "Unknown or invalid field.")
+ ErrInvalidFieldValue = validation.NewError("validation_invalid_field_value", "Invalid field value.")
+ ErrMustBeSystemAndHidden = validation.NewError("validation_must_be_system_and_hidden", `The field must be marked as "System" and "Hidden".`)
+ ErrMustBeSystem = validation.NewError("validation_must_be_system", `The field must be marked as "System".`)
+)
+
+// FieldFactoryFunc defines a simple function to construct a specific Field instance.
+type FieldFactoryFunc func() Field
+
+// Fields holds all available collection fields.
+var Fields = map[string]FieldFactoryFunc{}
+
+// Field defines a common interface that all Collection fields should implement.
+type Field interface {
+ // note: the getters has an explicit "Get" prefix to avoid conflicts with their related field members
+
+ // GetId returns the field id.
+ GetId() string
+
+ // SetId changes the field id.
+ SetId(id string)
+
+ // GetName returns the field name.
+ GetName() string
+
+ // SetName changes the field name.
+ SetName(name string)
+
+ // GetSystem returns the field system flag state.
+ GetSystem() bool
+
+ // SetSystem changes the field system flag state.
+ SetSystem(system bool)
+
+ // GetHidden returns the field hidden flag state.
+ GetHidden() bool
+
+ // SetHidden changes the field hidden flag state.
+ SetHidden(hidden bool)
+
+ // Type returns the unique type of the field.
+ Type() string
+
+ // ColumnType returns the DB column definition of the field.
+ ColumnType(app App) string
+
+ // PrepareValue returns a properly formatted field value based on the provided raw one.
+ //
+ // This method is also called on record construction to initialize its default field value.
+ PrepareValue(record *Record, raw any) (any, error)
+
+ // ValidateSettings validates the current field value associated with the provided record.
+ ValidateValue(ctx context.Context, app App, record *Record) error
+
+ // ValidateSettings validates the current field settings.
+ ValidateSettings(ctx context.Context, app App, collection *Collection) error
+}
+
+// MaxBodySizeCalculator defines an optional field interface for
+// specifying the max size of a field value.
+type MaxBodySizeCalculator interface {
+ // CalculateMaxBodySize returns the approximate max body size of a field value.
+ CalculateMaxBodySize() int64
+}
+
+type (
+ SetterFunc func(record *Record, raw any)
+
+ // SetterFinder defines a field interface for registering custom field value setters.
+ SetterFinder interface {
+ // FindSetter returns a single field value setter function
+ // by performing pattern-like field matching using the specified key.
+ //
+ // The key is usually just the field name but it could also
+ // contains "modifier" characters based on which you can perform custom set operations
+ // (ex. "users+" could be mapped to a function that will append new user to the existing field value).
+ //
+ // Return nil if you want to fallback to the default field value setter.
+ FindSetter(key string) SetterFunc
+ }
+)
+
+type (
+ GetterFunc func(record *Record) any
+
+ // GetterFinder defines a field interface for registering custom field value getters.
+ GetterFinder interface {
+ // FindGetter returns a single field value getter function
+ // by performing pattern-like field matching using the specified key.
+ //
+ // The key is usually just the field name but it could also
+ // contains "modifier" characters based on which you can perform custom get operations
+ // (ex. "description:excerpt" could be mapped to a function that will return an excerpt of the current field value).
+ //
+ // Return nil if you want to fallback to the default field value setter.
+ FindGetter(key string) GetterFunc
+ }
+)
+
+// DriverValuer defines a Field interface for exporting and formatting
+// a field value for the database.
+type DriverValuer interface {
+ // DriverValue exports a single field value for persistence in the database.
+ DriverValue(record *Record) (driver.Value, error)
+}
+
+// MultiValuer defines a field interface that every multi-valued (eg. with MaxSelect) field has.
+type MultiValuer interface {
+ // IsMultiple checks whether the field is configured to support multiple or single values.
+ IsMultiple() bool
+}
+
+// RecordInterceptor defines a field interface for reacting to various
+// Record related operations (create, delete, validate, etc.).
+type RecordInterceptor interface {
+ // Interceptor is invoked when a specific record action occurs
+ // allowing you to perform extra validations and normalization
+ // (ex. uploading or deleting files).
+ //
+ // Note that users must call actionFunc() manually if they want to
+ // execute the specific record action.
+ Intercept(
+ ctx context.Context,
+ app App,
+ record *Record,
+ actionName string,
+ actionFunc func() error,
+ ) error
+}
+
+// DefaultFieldHelpValidationRule performs base validation on a field's "help" value.
+func DefaultFieldHelpValidationRule(value any) error {
+ v, ok := value.(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ rules := []validation.Rule{
+ validation.Length(1, 300),
+ }
+
+ for _, r := range rules {
+ if err := r.Validate(v); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// DefaultFieldIdValidationRule performs base validation on a field id value.
+func DefaultFieldIdValidationRule(value any) error {
+ v, ok := value.(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ rules := []validation.Rule{
+ validation.Required,
+ validation.Length(1, 100),
+ }
+
+ for _, r := range rules {
+ if err := r.Validate(v); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// exclude special filter and system literals
+var excludeNames = append([]any{
+ "null", "true", "false", "_rowid_",
+}, list.ToInterfaceSlice(SystemDynamicFieldNames)...)
+
+// DefaultFieldIdValidationRule performs base validation on a field name value.
+func DefaultFieldNameValidationRule(value any) error {
+ v, ok := value.(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ rules := []validation.Rule{
+ validation.Required,
+ validation.Length(1, 100),
+ validation.Match(fieldNameRegex),
+ validation.NotIn(excludeNames...),
+ validation.By(checkForVia),
+ }
+
+ for _, r := range rules {
+ if err := r.Validate(v); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func checkForVia(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil
+ }
+
+ if strings.Contains(strings.ToLower(v), "_via_") {
+ return validation.NewError("validation_found_via", `The value cannot contain "_via_".`)
+ }
+
+ return nil
+}
+
+func noopSetter(record *Record, raw any) {
+ // do nothing
+}
diff --git a/core/field_autodate.go b/core/field_autodate.go
new file mode 100644
index 0000000000..a0535ccfda
--- /dev/null
+++ b/core/field_autodate.go
@@ -0,0 +1,215 @@
+package core
+
+import (
+ "context"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeAutodate] = func() Field {
+ return &AutodateField{}
+ }
+}
+
+const FieldTypeAutodate = "autodate"
+
+// used to keep track of the last set autodate value
+const autodateLastKnownPrefix = internalCustomFieldKeyPrefix + "_last_autodate_"
+
+var (
+ _ Field = (*AutodateField)(nil)
+ _ SetterFinder = (*AutodateField)(nil)
+ _ RecordInterceptor = (*AutodateField)(nil)
+)
+
+// AutodateField defines an "autodate" type field, aka.
+// field which datetime value could be auto set on record create/update.
+//
+// This field is usually used for defining timestamp fields like "created" and "updated".
+//
+// Requires either both or at least one of the OnCreate or OnUpdate options to be set.
+type AutodateField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // OnCreate auto sets the current datetime as field value on record create.
+ OnCreate bool `form:"onCreate" json:"onCreate"`
+
+ // OnUpdate auto sets the current datetime as field value on record update.
+ OnUpdate bool `form:"onUpdate" json:"onUpdate"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *AutodateField) Type() string {
+ return FieldTypeAutodate
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *AutodateField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *AutodateField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *AutodateField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *AutodateField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *AutodateField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *AutodateField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *AutodateField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *AutodateField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *AutodateField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL" // note: sqlite doesn't allow adding new columns with non-constant defaults
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *AutodateField) PrepareValue(record *Record, raw any) (any, error) {
+ val, _ := types.ParseDateTime(raw)
+ return val, nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *AutodateField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *AutodateField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ oldOnCreate := f.OnCreate
+ oldOnUpdate := f.OnUpdate
+
+ oldCollection, _ := app.FindCollectionByNameOrId(collection.Id)
+ if oldCollection != nil {
+ oldField, ok := oldCollection.Fields.GetById(f.Id).(*AutodateField)
+ if ok && oldField != nil {
+ oldOnCreate = oldField.OnCreate
+ oldOnUpdate = oldField.OnUpdate
+ }
+ }
+
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(
+ &f.OnCreate,
+ validation.When(f.System, validation.By(validators.Equal(oldOnCreate))),
+ validation.Required.Error("either onCreate or onUpdate must be enabled").When(!f.OnUpdate),
+ ),
+ validation.Field(
+ &f.OnUpdate,
+ validation.When(f.System, validation.By(validators.Equal(oldOnUpdate))),
+ validation.Required.Error("either onCreate or onUpdate must be enabled").When(!f.OnCreate),
+ ),
+ )
+}
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *AutodateField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ // return noopSetter to disallow updating the value with record.Set()
+ return noopSetter
+ default:
+ return nil
+ }
+}
+
+// Intercept implements the [RecordInterceptor] interface.
+func (f *AutodateField) Intercept(
+ ctx context.Context,
+ app App,
+ record *Record,
+ actionName string,
+ actionFunc func() error,
+) error {
+ switch actionName {
+ case InterceptorActionCreateExecute:
+ // ignore if a date different from the old one was manually set with SetRaw
+ if f.OnCreate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
+ now := types.NowDateTime()
+ record.SetRaw(f.Name, now)
+ record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
+ }
+
+ if err := actionFunc(); err != nil {
+ return err
+ }
+
+ record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
+
+ return nil
+ case InterceptorActionUpdateExecute:
+ // ignore if a date different from the old one was manually set with SetRaw
+ if f.OnUpdate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
+ now := types.NowDateTime()
+ record.SetRaw(f.Name, now)
+ record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
+ }
+
+ if err := actionFunc(); err != nil {
+ return err
+ }
+
+ record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
+
+ return nil
+ default:
+ return actionFunc()
+ }
+}
+
+func (f *AutodateField) getLastKnownValue(record *Record) types.DateTime {
+ v := record.GetDateTime(autodateLastKnownPrefix + f.Name)
+ if !v.IsZero() {
+ return v
+ }
+
+ return record.Original().GetDateTime(f.Name)
+}
diff --git a/core/field_autodate_test.go b/core/field_autodate_test.go
new file mode 100644
index 0000000000..8861ae6eda
--- /dev/null
+++ b/core/field_autodate_test.go
@@ -0,0 +1,441 @@
+package core_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestAutodateFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeAutodate)
+}
+
+func TestAutodateFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.AutodateField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestAutodateFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.AutodateField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"invalid", ""},
+ {"2024-01-01 00:11:22.345Z", "2024-01-01 00:11:22.345Z"},
+ {time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC), "2024-01-02 03:04:05.000Z"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vDate, ok := v.(types.DateTime)
+ if !ok {
+ t.Fatalf("Expected types.DateTime instance, got %T", v)
+ }
+
+ if vDate.String() != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestAutodateFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.AutodateField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.AutodateField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ false,
+ },
+ {
+ "missing field value",
+ &core.AutodateField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("abc", true)
+ return record
+ },
+ false,
+ },
+ {
+ "existing field value",
+ &core.AutodateField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.NowDateTime())
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestAutodateFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeAutodate)
+ testDefaultFieldNameValidation(t, core.FieldTypeAutodate)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ field func() *core.AutodateField
+ expectErrors []string
+ }{
+ {
+ "empty onCreate and onUpdate",
+ func() *core.AutodateField {
+ return &core.AutodateField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{"onCreate", "onUpdate"},
+ },
+ {
+ "with onCreate",
+ func() *core.AutodateField {
+ return &core.AutodateField{
+ Id: "test",
+ Name: "test",
+ OnCreate: true,
+ }
+ },
+ []string{},
+ },
+ {
+ "with onUpdate",
+ func() *core.AutodateField {
+ return &core.AutodateField{
+ Id: "test",
+ Name: "test",
+ OnUpdate: true,
+ }
+ },
+ []string{},
+ },
+ {
+ "change of a system autodate field",
+ func() *core.AutodateField {
+ created := superusers.Fields.GetByName("created").(*core.AutodateField)
+ created.OnCreate = !created.OnCreate
+ created.OnUpdate = !created.OnUpdate
+ return created
+ },
+ []string{"onCreate", "onUpdate"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, superusers)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestAutodateFieldFindSetter(t *testing.T) {
+ field := &core.AutodateField{Name: "test"}
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(field)
+
+ initialDate, err := types.ParseDateTime("2024-01-02 03:04:05.789Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw("test", initialDate)
+
+ t.Run("no matching setter", func(t *testing.T) {
+ f := field.FindSetter("abc")
+ if f != nil {
+ t.Fatal("Expected nil setter")
+ }
+ })
+
+ t.Run("matching setter", func(t *testing.T) {
+ f := field.FindSetter("test")
+ if f == nil {
+ t.Fatal("Expected non-nil setter")
+ }
+
+ f(record, types.NowDateTime()) // should be ignored
+
+ if v := record.GetString("test"); v != "2024-01-02 03:04:05.789Z" {
+ t.Fatalf("Expected no value change, got %q", v)
+ }
+ })
+}
+
+func cutMilliseconds(datetime string) string {
+ if len(datetime) > 19 {
+ return datetime[:19]
+ }
+ return datetime
+}
+
+func TestAutodateFieldIntercept(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ initialDate, err := types.ParseDateTime("2024-01-02 03:04:05.789Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ actionName string
+ field *core.AutodateField
+ record func() *core.Record
+ expected string
+ }{
+ {
+ "non-matching action",
+ "test",
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "",
+ },
+ {
+ "create with zero value (disabled onCreate)",
+ core.InterceptorActionCreateExecute,
+ &core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "",
+ },
+ {
+ "create with zero value",
+ core.InterceptorActionCreateExecute,
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "{NOW}",
+ },
+ {
+ "create with non-zero value",
+ core.InterceptorActionCreateExecute,
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", initialDate)
+ return record
+ },
+ initialDate.String(),
+ },
+ {
+ "update with zero value (disabled onUpdate)",
+ core.InterceptorActionUpdateExecute,
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "",
+ },
+ {
+ "update with zero value",
+ core.InterceptorActionUpdateExecute,
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "{NOW}",
+ },
+ {
+ "update with non-zero value",
+ core.InterceptorActionUpdateExecute,
+ &core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", initialDate)
+ return record
+ },
+ initialDate.String(),
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ actionCalls := 0
+ record := s.record()
+
+ now := types.NowDateTime().String()
+ err := s.field.Intercept(context.Background(), app, record, s.actionName, func() error {
+ actionCalls++
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if actionCalls != 1 {
+ t.Fatalf("Expected actionCalls %d, got %d", 1, actionCalls)
+ }
+
+ expected := cutMilliseconds(strings.ReplaceAll(s.expected, "{NOW}", now))
+
+ v := cutMilliseconds(record.GetString(s.field.GetName()))
+ if v != expected {
+ t.Fatalf("Expected value %q, got %q", expected, v)
+ }
+ })
+ }
+}
+
+func TestAutodateRecordResave(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record, err := app.FindRecordById(collection, "llvuca81nly1qls")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ lastUpdated := record.GetDateTime("updated")
+
+ // save with autogenerated date
+ err = app.Save(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ newUpdated := record.GetDateTime("updated")
+ if newUpdated.Equal(lastUpdated) {
+ t.Fatalf("[0] Expected updated to change, got %v", newUpdated)
+ }
+ lastUpdated = newUpdated
+
+ // save with custom date
+ manualUpdated := lastUpdated.Add(-1 * time.Minute)
+ record.SetRaw("updated", manualUpdated)
+ err = app.Save(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ newUpdated = record.GetDateTime("updated")
+ if !newUpdated.Equal(manualUpdated) {
+ t.Fatalf("[1] Expected updated to be the manual set date %v, got %v", manualUpdated, newUpdated)
+ }
+ lastUpdated = newUpdated
+
+ // save again with autogenerated date
+ err = app.Save(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ newUpdated = record.GetDateTime("updated")
+ if newUpdated.Equal(lastUpdated) {
+ t.Fatalf("[2] Expected updated to change, got %v", newUpdated)
+ }
+ lastUpdated = newUpdated
+
+ // simulate save failure
+ app.OnRecordUpdateExecute(collection.Id).Bind(&hook.Handler[*core.RecordEvent]{
+ Id: "test_failure",
+ Func: func(*core.RecordEvent) error {
+ return errors.New("test")
+ },
+ Priority: 9999999999, // as latest as possible
+ })
+
+ // save again with autogenerated date (should fail)
+ err = app.Save(record)
+ if err == nil {
+ t.Fatal("Expected save failure")
+ }
+
+ // updated should still be set even after save failure
+ newUpdated = record.GetDateTime("updated")
+ if newUpdated.Equal(lastUpdated) {
+ t.Fatalf("[3] Expected updated to change, got %v", newUpdated)
+ }
+ lastUpdated = newUpdated
+
+ // cleanup the error and resave again
+ app.OnRecordUpdateExecute(collection.Id).Unbind("test_failure")
+
+ err = app.Save(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ newUpdated = record.GetDateTime("updated")
+ if newUpdated.Equal(lastUpdated) {
+ t.Fatalf("[4] Expected updated to change, got %v", newUpdated)
+ }
+}
diff --git a/core/field_bool.go b/core/field_bool.go
new file mode 100644
index 0000000000..72ecc41e02
--- /dev/null
+++ b/core/field_bool.go
@@ -0,0 +1,129 @@
+package core
+
+import (
+ "context"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeBool] = func() Field {
+ return &BoolField{}
+ }
+}
+
+const FieldTypeBool = "bool"
+
+var _ Field = (*BoolField)(nil)
+
+// BoolField defines "bool" type field to store a single true/false value.
+//
+// The respective zero record field value is false.
+type BoolField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Required will require the field value to be always "true".
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *BoolField) Type() string {
+ return FieldTypeBool
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *BoolField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *BoolField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *BoolField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *BoolField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *BoolField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *BoolField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *BoolField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *BoolField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *BoolField) ColumnType(app App) string {
+ return "BOOLEAN DEFAULT FALSE NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *BoolField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToBool(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *BoolField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ v, ok := record.GetRaw(f.Name).(bool)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if f.Required {
+ return validation.Required.Validate(v)
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ )
+}
diff --git a/core/field_bool_test.go b/core/field_bool_test.go
new file mode 100644
index 0000000000..1c45428ffc
--- /dev/null
+++ b/core/field_bool_test.go
@@ -0,0 +1,151 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestBoolFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeBool)
+}
+
+func TestBoolFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.BoolField{}
+
+ expected := "BOOLEAN DEFAULT FALSE NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestBoolFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.BoolField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected bool
+ }{
+ {"", false},
+ {"f", false},
+ {"t", true},
+ {1, true},
+ {0, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestBoolFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.BoolField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.BoolField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "missing field value (non-required)",
+ &core.BoolField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("abc", true)
+ return record
+ },
+ true, // because of failed nil.(bool) cast
+ },
+ {
+ "missing field value (required)",
+ &core.BoolField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("abc", true)
+ return record
+ },
+ true,
+ },
+ {
+ "false field value (non-required)",
+ &core.BoolField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", false)
+ return record
+ },
+ false,
+ },
+ {
+ "false field value (required)",
+ &core.BoolField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", false)
+ return record
+ },
+ true,
+ },
+ {
+ "true field value (required)",
+ &core.BoolField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", true)
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestBoolFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeBool)
+ testDefaultFieldNameValidation(t, core.FieldTypeBool)
+ testDefaultFieldHelpValidation[core.BoolField](t)
+}
diff --git a/core/field_date.go b/core/field_date.go
new file mode 100644
index 0000000000..4c1b41d504
--- /dev/null
+++ b/core/field_date.go
@@ -0,0 +1,179 @@
+package core
+
+import (
+ "context"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeDate] = func() Field {
+ return &DateField{}
+ }
+}
+
+const FieldTypeDate = "date"
+
+var _ Field = (*DateField)(nil)
+
+// DateField defines "date" type field to store a single [types.DateTime] value.
+//
+// The respective zero record field value is the zero [types.DateTime].
+type DateField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Min specifies the min allowed field value.
+ //
+ // Leave it empty to skip the validator.
+ Min types.DateTime `form:"min" json:"min"`
+
+ // Max specifies the max allowed field value.
+ //
+ // Leave it empty to skip the validator.
+ Max types.DateTime `form:"max" json:"max"`
+
+ // Required will require the field value to be non-zero [types.DateTime].
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *DateField) Type() string {
+ return FieldTypeDate
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *DateField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *DateField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *DateField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *DateField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *DateField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *DateField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *DateField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *DateField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *DateField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *DateField) PrepareValue(record *Record, raw any) (any, error) {
+ // ignore scan errors since the format may change between versions
+ // and to allow running db adjusting migrations
+ val, _ := types.ParseDateTime(raw)
+ return val, nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *DateField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(types.DateTime)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if val.IsZero() {
+ if f.Required {
+ return validation.ErrRequired
+ }
+ return nil // nothing to check
+ }
+
+ if !f.Min.IsZero() {
+ if err := validation.Min(f.Min.Time()).Validate(val.Time()); err != nil {
+ return err
+ }
+ }
+
+ if !f.Max.IsZero() {
+ if err := validation.Max(f.Max.Time()).Validate(val.Time()); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))),
+ )
+}
+
+func (f *DateField) checkRange(min types.DateTime, max types.DateTime) validation.RuleFunc {
+ return func(value any) error {
+ v, _ := value.(types.DateTime)
+ if v.IsZero() {
+ return nil // nothing to check
+ }
+
+ dr := validation.Date(types.DefaultDateLayout)
+
+ if !min.IsZero() {
+ dr.Min(min.Time())
+ }
+
+ if !max.IsZero() {
+ dr.Max(max.Time())
+ }
+
+ return dr.Validate(v.String())
+ }
+}
diff --git a/core/field_date_test.go b/core/field_date_test.go
new file mode 100644
index 0000000000..27cacf4dea
--- /dev/null
+++ b/core/field_date_test.go
@@ -0,0 +1,230 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestDateFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeDate)
+}
+
+func TestDateFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.DateField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestDateFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.DateField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"invalid", ""},
+ {"2024-01-01 00:11:22.345Z", "2024-01-01 00:11:22.345Z"},
+ {time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC), "2024-01-02 03:04:05.000Z"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vDate, ok := v.(types.DateTime)
+ if !ok {
+ t.Fatalf("Expected types.DateTime instance, got %T", v)
+ }
+
+ if vDate.String() != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestDateFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.DateField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.DateField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.DateField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.DateTime{})
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.DateField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.DateTime{})
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.DateField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.NowDateTime())
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestDateFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeDate)
+ testDefaultFieldNameValidation(t, core.FieldTypeDate)
+ testDefaultFieldHelpValidation[core.DateField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.DateField
+ expectErrors []string
+ }{
+ {
+ "zero Min/Max",
+ func() *core.DateField {
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "non-empty Min with empty Max",
+ func() *core.DateField {
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ Min: types.NowDateTime(),
+ }
+ },
+ []string{},
+ },
+ {
+ "empty Min non-empty Max",
+ func() *core.DateField {
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ Max: types.NowDateTime(),
+ }
+ },
+ []string{},
+ },
+ {
+ "Min = Max",
+ func() *core.DateField {
+ date := types.NowDateTime()
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ Min: date,
+ Max: date,
+ }
+ },
+ []string{},
+ },
+ {
+ "Min > Max",
+ func() *core.DateField {
+ min := types.NowDateTime()
+ max := min.Add(-5 * time.Second)
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ Min: min,
+ Max: max,
+ }
+ },
+ []string{},
+ },
+ {
+ "Min < Max",
+ func() *core.DateField {
+ max := types.NowDateTime()
+ min := max.Add(-5 * time.Second)
+ return &core.DateField{
+ Id: "test",
+ Name: "test",
+ Min: min,
+ Max: max,
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
diff --git a/core/field_editor.go b/core/field_editor.go
new file mode 100644
index 0000000000..259747892a
--- /dev/null
+++ b/core/field_editor.go
@@ -0,0 +1,167 @@
+package core
+
+import (
+ "context"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeEditor] = func() Field {
+ return &EditorField{}
+ }
+}
+
+const FieldTypeEditor = "editor"
+
+const DefaultEditorFieldMaxSize int64 = 5 << 20
+
+var (
+ _ Field = (*EditorField)(nil)
+ _ MaxBodySizeCalculator = (*EditorField)(nil)
+)
+
+// EditorField defines "editor" type field to store HTML formatted text.
+//
+// The respective zero record field value is empty string.
+type EditorField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
+ //
+ // If zero, a default limit of ~5MB is applied.
+ MaxSize int64 `form:"maxSize" json:"maxSize"`
+
+ // ConvertURLs is usually used to instruct the editor whether to
+ // apply url conversion (eg. stripping the domain name in case the
+ // urls are using the same domain as the one where the editor is loaded).
+ //
+ // (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls)
+ ConvertURLs bool `form:"convertURLs" json:"convertURLs"`
+
+ // Required will require the field value to be non-empty string.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *EditorField) Type() string {
+ return FieldTypeEditor
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *EditorField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *EditorField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *EditorField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *EditorField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *EditorField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *EditorField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *EditorField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *EditorField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *EditorField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *EditorField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToString(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *EditorField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if f.Required {
+ if err := validation.Required.Validate(val); err != nil {
+ return err
+ }
+ }
+
+ maxSize := f.CalculateMaxBodySize()
+
+ if int64(len(val)) > maxSize {
+ return validation.NewError(
+ "validation_content_size_limit",
+ "The maximum allowed content size is {{.maxSize}} bytes",
+ ).SetParams(map[string]any{"maxSize": maxSize})
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ )
+}
+
+// CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface.
+func (f *EditorField) CalculateMaxBodySize() int64 {
+ if f.MaxSize <= 0 {
+ return DefaultEditorFieldMaxSize
+ }
+
+ return f.MaxSize
+}
diff --git a/core/field_editor_test.go b/core/field_editor_test.go
new file mode 100644
index 0000000000..2b29ffd52f
--- /dev/null
+++ b/core/field_editor_test.go
@@ -0,0 +1,253 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestEditorFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeEditor)
+}
+
+func TestEditorFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.EditorField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestEditorFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.EditorField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ {123.456, "123.456"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vStr, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+
+ if vStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestEditorFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.EditorField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.EditorField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.EditorField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.EditorField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.EditorField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc")
+ return record
+ },
+ false,
+ },
+ {
+ "> default MaxSize",
+ &core.EditorField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", strings.Repeat("a", 1+(5<<20)))
+ return record
+ },
+ true,
+ },
+ {
+ "> MaxSize",
+ &core.EditorField{Name: "test", Required: true, MaxSize: 5},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abcdef")
+ return record
+ },
+ true,
+ },
+ {
+ "<= MaxSize",
+ &core.EditorField{Name: "test", Required: true, MaxSize: 5},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abcde")
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestEditorFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeEditor)
+ testDefaultFieldNameValidation(t, core.FieldTypeEditor)
+ testDefaultFieldHelpValidation[core.EditorField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.EditorField
+ expectErrors []string
+ }{
+ {
+ "< 0 MaxSize",
+ func() *core.EditorField {
+ return &core.EditorField{
+ Id: "test",
+ Name: "test",
+ MaxSize: -1,
+ }
+ },
+ []string{"maxSize"},
+ },
+ {
+ "= 0 MaxSize",
+ func() *core.EditorField {
+ return &core.EditorField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "> 0 MaxSize",
+ func() *core.EditorField {
+ return &core.EditorField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ }
+ },
+ []string{},
+ },
+ {
+ "MaxSize > safe json int",
+ func() *core.EditorField {
+ return &core.EditorField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1 << 53,
+ }
+ },
+ []string{"maxSize"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestEditorFieldCalculateMaxBodySize(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ scenarios := []struct {
+ field *core.EditorField
+ expected int64
+ }{
+ {&core.EditorField{}, core.DefaultEditorFieldMaxSize},
+ {&core.EditorField{MaxSize: 10}, 10},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.field.MaxSize), func(t *testing.T) {
+ result := s.field.CalculateMaxBodySize()
+
+ if result != s.expected {
+ t.Fatalf("Expected %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/field_email.go b/core/field_email.go
new file mode 100644
index 0000000000..d2596ab8ad
--- /dev/null
+++ b/core/field_email.go
@@ -0,0 +1,172 @@
+package core
+
+import (
+ "context"
+ "slices"
+ "strings"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeEmail] = func() Field {
+ return &EmailField{}
+ }
+}
+
+const FieldTypeEmail = "email"
+
+var _ Field = (*EmailField)(nil)
+
+// EmailField defines "email" type field for storing a single email string address.
+//
+// The respective zero record field value is empty string.
+type EmailField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // ExceptDomains will require the email domain to NOT be included in the listed ones.
+ //
+ // This validator can be set only if OnlyDomains is empty.
+ ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
+
+ // OnlyDomains will require the email domain to be included in the listed ones.
+ //
+ // This validator can be set only if ExceptDomains is empty.
+ OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
+
+ // Required will require the field value to be non-empty email string.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *EmailField) Type() string {
+ return FieldTypeEmail
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *EmailField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *EmailField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *EmailField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *EmailField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *EmailField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *EmailField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *EmailField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *EmailField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *EmailField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *EmailField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToString(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *EmailField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if f.Required {
+ if err := validation.Required.Validate(val); err != nil {
+ return err
+ }
+ }
+
+ if val == "" {
+ return nil // nothing to check
+ }
+
+ if err := is.EmailFormat.Validate(val); err != nil {
+ return err
+ }
+
+ domain := val[strings.LastIndex(val, "@")+1:]
+
+ // only domains check
+ if len(f.OnlyDomains) > 0 && !slices.Contains(f.OnlyDomains, domain) {
+ return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
+ }
+
+ // except domains check
+ if len(f.ExceptDomains) > 0 && slices.Contains(f.ExceptDomains, domain) {
+ return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(
+ &f.ExceptDomains,
+ validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ validation.Field(
+ &f.OnlyDomains,
+ validation.When(len(f.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ )
+}
diff --git a/core/field_email_test.go b/core/field_email_test.go
new file mode 100644
index 0000000000..0cff54ddd7
--- /dev/null
+++ b/core/field_email_test.go
@@ -0,0 +1,272 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestEmailFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeEmail)
+}
+
+func TestEmailFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.EmailField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestEmailFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.EmailField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ {123.456, "123.456"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vStr, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+
+ if vStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestEmailFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.EmailField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.EmailField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.EmailField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.EmailField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.EmailField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "test@example.com")
+ return record
+ },
+ false,
+ },
+ {
+ "invalid email",
+ &core.EmailField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "invalid")
+ return record
+ },
+ true,
+ },
+ {
+ "failed onlyDomains",
+ &core.EmailField{Name: "test", OnlyDomains: []string{"example.org", "example.net"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "test@example.com")
+ return record
+ },
+ true,
+ },
+ {
+ "success onlyDomains",
+ &core.EmailField{Name: "test", OnlyDomains: []string{"example.org", "example.com"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "test@example.com")
+ return record
+ },
+ false,
+ },
+ {
+ "failed exceptDomains",
+ &core.EmailField{Name: "test", ExceptDomains: []string{"example.org", "example.com"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "test@example.com")
+ return record
+ },
+ true,
+ },
+ {
+ "success exceptDomains",
+ &core.EmailField{Name: "test", ExceptDomains: []string{"example.org", "example.net"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "test@example.com")
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestEmailFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeEmail)
+ testDefaultFieldNameValidation(t, core.FieldTypeEmail)
+ testDefaultFieldHelpValidation[core.EmailField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.EmailField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "both onlyDomains and exceptDomains",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com"},
+ ExceptDomains: []string{"example.org"},
+ }
+ },
+ []string{"onlyDomains", "exceptDomains"},
+ },
+ {
+ "invalid onlyDomains",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com", "invalid"},
+ }
+ },
+ []string{"onlyDomains"},
+ },
+ {
+ "valid onlyDomains",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com", "example.org"},
+ }
+ },
+ []string{},
+ },
+ {
+ "invalid exceptDomains",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ ExceptDomains: []string{"example.com", "invalid"},
+ }
+ },
+ []string{"exceptDomains"},
+ },
+ {
+ "valid exceptDomains",
+ func() *core.EmailField {
+ return &core.EmailField{
+ Id: "test",
+ Name: "test",
+ ExceptDomains: []string{"example.com", "example.org"},
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
diff --git a/core/field_file.go b/core/field_file.go
new file mode 100644
index 0000000000..f4b6baa7e5
--- /dev/null
+++ b/core/field_file.go
@@ -0,0 +1,829 @@
+package core
+
+import (
+ "context"
+ "database/sql/driver"
+ "errors"
+ "fmt"
+ "log"
+ "regexp"
+ "strings"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeFile] = func() Field {
+ return &FileField{}
+ }
+}
+
+const FieldTypeFile = "file"
+
+const DefaultFileFieldMaxSize int64 = 5 << 20
+
+var looseFilenameRegex = regexp.MustCompile(`^[^\./\\][^/\\]+$`)
+
+const (
+ deletedFilesPrefix = internalCustomFieldKeyPrefix + "_deletedFilesPrefix_"
+ uploadedFilesPrefix = internalCustomFieldKeyPrefix + "_uploadedFilesPrefix_"
+)
+
+var (
+ _ Field = (*FileField)(nil)
+ _ MultiValuer = (*FileField)(nil)
+ _ DriverValuer = (*FileField)(nil)
+ _ GetterFinder = (*FileField)(nil)
+ _ SetterFinder = (*FileField)(nil)
+ _ RecordInterceptor = (*FileField)(nil)
+ _ MaxBodySizeCalculator = (*FileField)(nil)
+)
+
+// FileField defines "file" type field for managing record file(s).
+//
+// Only the file name is stored as part of the record value.
+// New files (aka. files to upload) are expected to be of *filesystem.File.
+//
+// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
+//
+// If MaxSelect is > 1, then the field value is expected to be a slice of record ids.
+//
+// The respective zero record field value is either empty string (single) or empty string slice (multiple).
+//
+// ---
+//
+// The following additional setter keys are available:
+//
+// - "fieldName+" - append one or more files to the existing record one. For example:
+//
+// // []string{"old1.txt", "old2.txt", "new1_ajkvass.txt", "new2_klhfnwd.txt"}
+// record.Set("documents+", []*filesystem.File{new1, new2})
+//
+// - "+fieldName" - prepend one or more files to the existing record one. For example:
+//
+// // []string{"new1_ajkvass.txt", "new2_klhfnwd.txt", "old1.txt", "old2.txt",}
+// record.Set("+documents", []*filesystem.File{new1, new2})
+//
+// - "fieldName-" - subtract/delete one or more files from the existing record one. For example:
+//
+// // []string{"old2.txt",}
+// record.Set("documents-", "old1.txt")
+type FileField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
+ //
+ // If zero, a default limit of 5MB is applied.
+ MaxSize int64 `form:"maxSize" json:"maxSize"`
+
+ // MaxSelect specifies the max allowed files.
+ //
+ // For multiple files the value must be > 1, otherwise fallbacks to single (default).
+ MaxSelect int `form:"maxSelect" json:"maxSelect"`
+
+ // MimeTypes specifies an optional list of the allowed file mime types.
+ //
+ // Leave it empty to disable the validator.
+ MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
+
+ // Thumbs specifies an optional list of the supported thumbs for image based files.
+ //
+ // Each entry must be in one of the following formats:
+ //
+ // - WxH (eg. 100x300) - crop to WxH viewbox (from center)
+ // - WxHt (eg. 100x300t) - crop to WxH viewbox (from top)
+ // - WxHb (eg. 100x300b) - crop to WxH viewbox (from bottom)
+ // - WxHf (eg. 100x300f) - fit inside a WxH viewbox (without cropping)
+ // - 0xH (eg. 0x300) - resize to H height preserving the aspect ratio
+ // - Wx0 (eg. 100x0) - resize to W width preserving the aspect ratio
+ Thumbs []string `form:"thumbs" json:"thumbs"`
+
+ // Protected will require the users to provide a special file token to access the file.
+ //
+ // Note that by default all files are publicly accessible.
+ //
+ // For the majority of the cases this is fine because by default
+ // all file names have random part appended to their name which
+ // need to be known by the user before accessing the file.
+ Protected bool `form:"protected" json:"protected"`
+
+ // Required will require the field value to have at least one file.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *FileField) Type() string {
+ return FieldTypeFile
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *FileField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *FileField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *FileField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *FileField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *FileField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *FileField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *FileField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *FileField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// IsMultiple implements MultiValuer interface and checks whether the
+// current field options support multiple values.
+func (f *FileField) IsMultiple() bool {
+ return f.MaxSelect > 1
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *FileField) ColumnType(app App) string {
+ if f.IsMultiple() {
+ return "JSON DEFAULT '[]' NOT NULL"
+ }
+
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *FileField) PrepareValue(record *Record, raw any) (any, error) {
+ return f.normalizeValue(raw), nil
+}
+
+// DriverValue implements the [DriverValuer] interface.
+func (f *FileField) DriverValue(record *Record) (driver.Value, error) {
+ files := f.toSliceValue(record.GetRaw(f.Name))
+
+ if f.IsMultiple() {
+ ja := make(types.JSONArray[string], len(files))
+ for i, v := range files {
+ ja[i] = f.getFileName(v)
+ }
+ return ja, nil
+ }
+
+ if len(files) == 0 {
+ return "", nil
+ }
+
+ return f.getFileName(files[len(files)-1]), nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ validation.Field(&f.Thumbs, validation.Each(
+ validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
+ validation.Match(filesystem.ThumbSizeRegex),
+ )),
+ )
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *FileField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ files := f.toSliceValue(record.GetRaw(f.Name))
+ if len(files) == 0 {
+ if f.Required {
+ return validation.ErrRequired
+ }
+ return nil // nothing to check
+ }
+
+ // validate existing and disallow new plain string filenames submission
+ // (new files must be *filesystem.File)
+ // ---
+ oldExistingStrings := f.toSliceValue(f.getLatestOldValue(app, record))
+ existingStrings := list.ToInterfaceSlice(f.extractPlainStrings(files))
+ addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
+
+ if len(addedStrings) > 0 {
+ invalidFiles := make([]string, len(addedStrings))
+ for i, invalid := range addedStrings {
+ invalidStr := cast.ToString(invalid)
+ if len(invalidStr) > 250 {
+ invalidStr = invalidStr[:250]
+ }
+ invalidFiles[i] = invalidStr
+ }
+
+ return validation.NewError("validation_invalid_file", "Invalid new files: {{.invalidFiles}}.").
+ SetParams(map[string]any{"invalidFiles": invalidFiles})
+ }
+
+ maxSelect := f.maxSelect()
+ if len(files) > maxSelect {
+ return validation.NewError("validation_too_many_files", "The maximum allowed files is {{.maxSelect}}").
+ SetParams(map[string]any{"maxSelect": maxSelect})
+ }
+
+ // validate uploaded
+ // ---
+ uploads := f.extractUploadableFiles(files)
+ for _, upload := range uploads {
+ // loosely check the filename just in case it was manually changed after the normalization
+ err := validation.Length(1, 150).Validate(upload.Name)
+ if err != nil {
+ return err
+ }
+ err = validation.Match(looseFilenameRegex).Validate(upload.Name)
+ if err != nil {
+ return err
+ }
+
+ // check size
+ err = validators.UploadedFileSize(f.maxSize())(upload)
+ if err != nil {
+ return err
+ }
+
+ // check type
+ if len(f.MimeTypes) > 0 {
+ err = validators.UploadedFileMimeType(f.MimeTypes)(upload)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func (f *FileField) maxSize() int64 {
+ if f.MaxSize <= 0 {
+ return DefaultFileFieldMaxSize
+ }
+
+ return f.MaxSize
+}
+
+func (f *FileField) maxSelect() int {
+ if f.MaxSelect <= 1 {
+ return 1
+ }
+
+ return f.MaxSelect
+}
+
+// CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface.
+func (f *FileField) CalculateMaxBodySize() int64 {
+ return f.maxSize() * int64(f.maxSelect())
+}
+
+// Interceptors
+// -------------------------------------------------------------------
+
+// Intercept implements the [RecordInterceptor] interface.
+//
+// note: files delete after records deletion is handled globally by the app FileManager hook
+func (f *FileField) Intercept(
+ ctx context.Context,
+ app App,
+ record *Record,
+ actionName string,
+ actionFunc func() error,
+) error {
+ switch actionName {
+ case InterceptorActionCreateExecute, InterceptorActionUpdateExecute:
+ oldValue := f.getLatestOldValue(app, record)
+
+ err := f.processFilesToUpload(ctx, app, record)
+ if err != nil {
+ return err
+ }
+
+ err = actionFunc()
+ if err != nil {
+ return errors.Join(err, f.afterRecordExecuteFailure(newContextIfInvalid(ctx), app, record))
+ }
+
+ f.rememberFilesToDelete(app, record, oldValue)
+
+ f.afterRecordExecuteSuccess(newContextIfInvalid(ctx), app, record)
+
+ return nil
+ case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
+ // when in transaction we assume that the error was handled by afterRecordExecuteFailure
+ if app.IsTransactional() {
+ return actionFunc()
+ }
+
+ failedToDelete, deleteErr := f.deleteNewlyUploadedFiles(newContextIfInvalid(ctx), app, record)
+ if deleteErr != nil {
+ app.Logger().Warn(
+ "Failed to cleanup all new files after record commit failure",
+ "error", deleteErr,
+ "failedToDelete", failedToDelete,
+ )
+ }
+
+ record.SetRaw(deletedFilesPrefix+f.Name, nil)
+
+ if record.IsNew() {
+ // try to delete the record directory if there are no other files
+ //
+ // note: executed only on create failure to avoid accidentally
+ // deleting a concurrently updating directory due to the
+ // eventual consistent nature of some storage providers
+ err := f.deleteEmptyRecordDir(newContextIfInvalid(ctx), app, record)
+ if err != nil {
+ app.Logger().Warn("Failed to delete empty dir after new record commit failure", "error", err)
+ }
+ }
+
+ return actionFunc()
+ case InterceptorActionAfterCreate, InterceptorActionAfterUpdate:
+ record.SetRaw(uploadedFilesPrefix+f.Name, nil)
+
+ err := f.processFilesToDelete(ctx, app, record)
+ if err != nil {
+ return err
+ }
+
+ return actionFunc()
+ default:
+ return actionFunc()
+ }
+}
+func (f *FileField) getLatestOldValue(app App, record *Record) any {
+ if !record.IsNew() {
+ latestOriginal, err := app.FindRecordById(record.Collection(), cast.ToString(record.LastSavedPK()))
+ if err == nil {
+ return latestOriginal.GetRaw(f.Name)
+ }
+ }
+
+ return record.Original().GetRaw(f.Name)
+}
+
+func (f *FileField) afterRecordExecuteSuccess(ctx context.Context, app App, record *Record) {
+ uploaded, _ := record.GetRaw(uploadedFilesPrefix + f.Name).([]*filesystem.File)
+
+ // replace the uploaded file objects with their plain string names
+ newValue := f.toSliceValue(record.GetRaw(f.Name))
+ for i, v := range newValue {
+ if file, ok := v.(*filesystem.File); ok {
+ uploaded = append(uploaded, file)
+ newValue[i] = file.Name
+ }
+ }
+ f.setValue(record, newValue)
+
+ record.SetRaw(uploadedFilesPrefix+f.Name, uploaded)
+}
+
+func (f *FileField) afterRecordExecuteFailure(ctx context.Context, app App, record *Record) error {
+ uploaded := f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
+
+ toDelete := make([]string, len(uploaded))
+ for i, file := range uploaded {
+ toDelete[i] = file.Name
+ }
+
+ // delete previously uploaded files
+ failedToDelete, deleteErr := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(toDelete))
+
+ if len(failedToDelete) > 0 {
+ app.Logger().Warn(
+ "Failed to cleanup the new uploaded file after record db write failure",
+ "error", deleteErr,
+ "failedToDelete", failedToDelete,
+ )
+ }
+
+ return deleteErr
+}
+
+func (f *FileField) deleteEmptyRecordDir(ctx context.Context, app App, record *Record) error {
+ fsys, err := app.NewFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+ fsys.SetContext(newContextIfInvalid(ctx))
+
+ dir := record.BaseFilesPath()
+
+ if !fsys.IsEmptyDir(dir) {
+ return nil // no-op
+ }
+
+ err = fsys.Delete(dir)
+ if err != nil && !errors.Is(err, filesystem.ErrNotFound) {
+ return err
+ }
+
+ return nil
+}
+
+func (f *FileField) processFilesToDelete(ctx context.Context, app App, record *Record) error {
+ markedForDelete, _ := record.GetRaw(deletedFilesPrefix + f.Name).([]string)
+ if len(markedForDelete) == 0 {
+ return nil
+ }
+
+ old := list.ToInterfaceSlice(markedForDelete)
+ new := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(record.GetRaw(f.Name))))
+ diff := f.excludeFiles(old, new)
+
+ toDelete := make([]string, len(diff))
+ for i, del := range diff {
+ toDelete[i] = f.getFileName(del)
+ }
+
+ failedToDelete, err := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(toDelete))
+
+ record.SetRaw(deletedFilesPrefix+f.Name, failedToDelete)
+
+ return err
+}
+
+func (f *FileField) rememberFilesToDelete(app App, record *Record, oldValue any) {
+ old := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(oldValue)))
+ new := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(record.GetRaw(f.Name))))
+ diff := f.excludeFiles(old, new)
+
+ toDelete, _ := record.GetRaw(deletedFilesPrefix + f.Name).([]string)
+
+ for _, del := range diff {
+ toDelete = append(toDelete, f.getFileName(del))
+ }
+
+ record.SetRaw(deletedFilesPrefix+f.Name, toDelete)
+}
+
+func (f *FileField) processFilesToUpload(ctx context.Context, app App, record *Record) error {
+ uploads := f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
+ if len(uploads) == 0 {
+ return nil
+ }
+
+ if record.Id == "" {
+ return errors.New("uploading files requires the record to have a valid nonempty id")
+ }
+
+ fsys, err := app.NewFilesystem()
+ if err != nil {
+ return err
+ }
+ defer fsys.Close()
+ fsys.SetContext(ctx)
+
+ var failed []error // list of upload errors
+ var succeeded []string // list of uploaded file names
+
+ for _, upload := range uploads {
+ path := record.BaseFilesPath() + "/" + upload.Name
+ if err := fsys.UploadFile(upload, path); err == nil {
+ succeeded = append(succeeded, upload.Name)
+ } else {
+ failed = append(failed, fmt.Errorf("%q: %w", upload.Name, err))
+ break // for now stop on the first error since we currently don't allow partial uploads
+ }
+ }
+
+ if len(failed) > 0 {
+ // cleanup - try to delete the successfully uploaded files (if any)
+ _, cleanupErr := f.deleteFilesByNamesList(newContextIfInvalid(ctx), app, record, succeeded)
+
+ failed = append(failed, cleanupErr)
+
+ return fmt.Errorf("failed to upload all files: %w", errors.Join(failed...))
+ }
+
+ return nil
+}
+
+func (f *FileField) deleteNewlyUploadedFiles(ctx context.Context, app App, record *Record) ([]string, error) {
+ uploaded, _ := record.GetRaw(uploadedFilesPrefix + f.Name).([]*filesystem.File)
+ if len(uploaded) == 0 {
+ return nil, nil
+ }
+
+ names := make([]string, len(uploaded))
+ for i, file := range uploaded {
+ names[i] = file.Name
+ }
+
+ failed, err := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(names))
+ if err != nil {
+ return failed, err
+ }
+
+ record.SetRaw(uploadedFilesPrefix+f.Name, nil)
+
+ return nil, nil
+}
+
+// deleteFiles deletes a list of record files by their names.
+// Returns the failed/remaining files.
+func (f *FileField) deleteFilesByNamesList(ctx context.Context, app App, record *Record, filenames []string) ([]string, error) {
+ if len(filenames) == 0 {
+ return nil, nil // nothing to delete
+ }
+
+ if record.Id == "" {
+ return filenames, errors.New("the record doesn't have an id")
+ }
+
+ fsys, err := app.NewFilesystem()
+ if err != nil {
+ return filenames, err
+ }
+ defer fsys.Close()
+ fsys.SetContext(ctx)
+
+ var failures []error
+
+ for i := len(filenames) - 1; i >= 0; i-- {
+ filename := filenames[i]
+ if filename == "" || strings.ContainsAny(filename, "/\\") {
+ continue // empty or not a plain filename
+ }
+
+ path := record.BaseFilesPath() + "/" + filename
+
+ err := fsys.Delete(path)
+ if err != nil && !errors.Is(err, filesystem.ErrNotFound) {
+ // store the delete error
+ failures = append(failures, fmt.Errorf("file %d (%q): %w", i, filename, err))
+ } else {
+ // remove the deleted file from the list
+ filenames = append(filenames[:i], filenames[i+1:]...)
+
+ // try to delete the related file thumbs (if any)
+ thumbsErr := fsys.DeletePrefix(record.BaseFilesPath() + "/thumbs_" + filename + "/")
+ if len(thumbsErr) > 0 {
+ app.Logger().Warn("Failed to delete file thumbs", "error", errors.Join(thumbsErr...))
+ }
+ }
+ }
+
+ if len(failures) > 0 {
+ return filenames, fmt.Errorf("failed to delete all files: %w", errors.Join(failures...))
+ }
+
+ return nil, nil
+}
+
+// newContextIfInvalid returns a new Background context if the provided one was cancelled.
+func newContextIfInvalid(ctx context.Context) context.Context {
+ if ctx.Err() == nil {
+ return ctx
+ }
+
+ return context.Background()
+}
+
+// -------------------------------------------------------------------
+
+// FindGetter implements the [GetterFinder] interface.
+func (f *FileField) FindGetter(key string) GetterFunc {
+ switch key {
+ case f.Name:
+ return func(record *Record) any {
+ return record.GetRaw(f.Name)
+ }
+ case f.Name + ":unsaved":
+ return func(record *Record) any {
+ return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
+ }
+ case f.Name + ":uploaded":
+ // deprecated
+ log.Println("[file field getter] please replace :uploaded with :unsaved")
+ return func(record *Record) any {
+ return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
+ }
+ default:
+ return nil
+ }
+}
+
+// -------------------------------------------------------------------
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *FileField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return f.setValue
+ case "+" + f.Name:
+ return f.prependValue
+ case f.Name + "+":
+ return f.appendValue
+ case f.Name + "-":
+ return f.subtractValue
+ default:
+ return nil
+ }
+}
+
+func (f *FileField) setValue(record *Record, raw any) {
+ val := f.normalizeValue(raw)
+
+ record.SetRaw(f.Name, val)
+}
+
+func (f *FileField) prependValue(record *Record, toPrepend any) {
+ files := f.toSliceValue(record.GetRaw(f.Name))
+ prepends := f.toSliceValue(toPrepend)
+
+ if len(prepends) > 0 {
+ files = append(prepends, files...)
+ }
+
+ f.setValue(record, files)
+}
+
+func (f *FileField) appendValue(record *Record, toAppend any) {
+ files := f.toSliceValue(record.GetRaw(f.Name))
+ appends := f.toSliceValue(toAppend)
+
+ if len(appends) > 0 {
+ files = append(files, appends...)
+ }
+
+ f.setValue(record, files)
+}
+
+func (f *FileField) subtractValue(record *Record, toRemove any) {
+ files := f.excludeFiles(
+ f.toSliceValue(record.GetRaw(f.Name)),
+ f.toSliceValue(toRemove),
+ )
+
+ f.setValue(record, files)
+}
+
+func (f *FileField) normalizeValue(raw any) any {
+ files := f.toSliceValue(raw)
+
+ if f.IsMultiple() {
+ return files
+ }
+
+ if len(files) > 0 {
+ return files[len(files)-1] // the last selected
+ }
+
+ return ""
+}
+
+func (f *FileField) toSliceValue(raw any) []any {
+ var result []any
+
+ switch value := raw.(type) {
+ case nil:
+ // nothing to cast
+ case *filesystem.File:
+ if value != nil {
+ result = append(result, value)
+ }
+ case filesystem.File:
+ result = append(result, &value)
+ case []*filesystem.File:
+ for _, v := range value {
+ if v != nil {
+ result = append(result, v)
+ }
+ }
+ case []filesystem.File:
+ for _, v := range value {
+ result = append(result, &v)
+ }
+ case []any:
+ for _, v := range value {
+ casted := f.toSliceValue(v)
+ if len(casted) == 1 {
+ result = append(result, casted[0])
+ }
+ }
+ default:
+ result = list.ToInterfaceSlice(list.ToUniqueStringSlice(value))
+ }
+
+ return f.uniqueFiles(result)
+}
+
+func (f *FileField) uniqueFiles(files []any) []any {
+ found := make(map[string]struct{}, len(files))
+ result := make([]any, 0, len(files))
+
+ for _, fv := range files {
+ name := f.getFileName(fv)
+ if _, ok := found[name]; !ok {
+ result = append(result, fv)
+ found[name] = struct{}{}
+ }
+ }
+
+ return result
+}
+
+func (f *FileField) extractPlainStrings(files []any) []string {
+ result := []string{}
+
+ for _, raw := range files {
+ if f, ok := raw.(string); ok {
+ result = append(result, f)
+ }
+ }
+
+ return result
+}
+
+func (f *FileField) extractUploadableFiles(files []any) []*filesystem.File {
+ result := []*filesystem.File{}
+
+ for _, raw := range files {
+ if upload, ok := raw.(*filesystem.File); ok {
+ result = append(result, upload)
+ }
+ }
+
+ return result
+}
+
+func (f *FileField) excludeFiles(base []any, toExclude []any) []any {
+ result := make([]any, 0, len(base))
+
+SUBTRACT_LOOP:
+ for _, fv := range base {
+ for _, exclude := range toExclude {
+ if f.getFileName(exclude) == f.getFileName(fv) {
+ continue SUBTRACT_LOOP // skip
+ }
+ }
+
+ result = append(result, fv)
+ }
+
+ return result
+}
+
+func (f *FileField) getFileName(file any) string {
+ switch v := file.(type) {
+ case string:
+ return v
+ case *filesystem.File:
+ return v.Name
+ default:
+ return ""
+ }
+}
diff --git a/core/field_file_test.go b/core/field_file_test.go
new file mode 100644
index 0000000000..472fc36830
--- /dev/null
+++ b/core/field_file_test.go
@@ -0,0 +1,1157 @@
+package core_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestFileFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeFile)
+}
+
+func TestFileFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field *core.FileField
+ expected string
+ }{
+ {
+ "single (zero)",
+ &core.FileField{},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "single",
+ &core.FileField{MaxSelect: 1},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "multiple",
+ &core.FileField{MaxSelect: 2},
+ "JSON DEFAULT '[]' NOT NULL",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.ColumnType(app); v != s.expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestFileFieldIsMultiple(t *testing.T) {
+ scenarios := []struct {
+ name string
+ field *core.FileField
+ expected bool
+ }{
+ {
+ "zero",
+ &core.FileField{},
+ false,
+ },
+ {
+ "single",
+ &core.FileField{MaxSelect: 1},
+ false,
+ },
+ {
+ "multiple",
+ &core.FileField{MaxSelect: 2},
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.IsMultiple(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestFileFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "test1.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1Raw, err := json.Marshal(f1, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var nilFile *filesystem.File
+
+ scenarios := []struct {
+ raw any
+ field *core.FileField
+ expected string
+ }{
+ // single
+ {nil, &core.FileField{MaxSelect: 1}, `""`},
+ {"", &core.FileField{MaxSelect: 1}, `""`},
+ {123, &core.FileField{MaxSelect: 1}, `"123"`},
+ {"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)},
+ {nilFile, &core.FileField{MaxSelect: 1}, `""`},
+ {[]string{}, &core.FileField{MaxSelect: 1}, `""`},
+ {[]string{"a", "b"}, &core.FileField{MaxSelect: 1}, `"b"`},
+
+ // multiple
+ {nil, &core.FileField{MaxSelect: 2}, `[]`},
+ {"", &core.FileField{MaxSelect: 2}, `[]`},
+ {123, &core.FileField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.FileField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.FileField{MaxSelect: 2}, `["a"]`},
+ {[]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) + `]`},
+ {[]any{nilFile, f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
+ {[]string{}, &core.FileField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.FileField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ v, err := s.field.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestFileFieldDriverValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ raw any
+ field *core.FileField
+ expected string
+ }{
+ // single
+ {nil, &core.FileField{MaxSelect: 1}, `""`},
+ {"", &core.FileField{MaxSelect: 1}, `""`},
+ {123, &core.FileField{MaxSelect: 1}, `"123"`},
+ {"a", &core.FileField{MaxSelect: 1}, `"a"`},
+ {`["a"]`, &core.FileField{MaxSelect: 1}, `"a"`},
+ {f1, &core.FileField{MaxSelect: 1}, `"` + f1.Name + `"`},
+ {[]string{}, &core.FileField{MaxSelect: 1}, `""`},
+ {[]string{"a", "b"}, &core.FileField{MaxSelect: 1}, `"b"`},
+
+ // multiple
+ {nil, &core.FileField{MaxSelect: 2}, `[]`},
+ {"", &core.FileField{MaxSelect: 2}, `[]`},
+ {123, &core.FileField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.FileField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.FileField{MaxSelect: 2}, `["a"]`},
+ {[]any{"a", f1}, &core.FileField{MaxSelect: 2}, `["a","` + f1.Name + `"]`},
+ {[]string{}, &core.FileField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.FileField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.SetRaw(s.field.GetName(), s.raw)
+
+ v, err := s.field.DriverValue(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if s.field.IsMultiple() {
+ _, ok := v.(types.JSONArray[string])
+ if !ok {
+ t.Fatalf("Expected types.JSONArray value, got %T", v)
+ }
+ } else {
+ _, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string value, got %T", v)
+ }
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestFileFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "test1.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f2, err := filesystem.NewFileFromBytes([]byte("test"), "test2.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f3, err := filesystem.NewFileFromBytes([]byte("test_abc"), "test3.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f4, err := filesystem.NewFileFromBytes(make([]byte, core.DefaultFileFieldMaxSize+1), "test4.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f5, err := filesystem.NewFileFromBytes(make([]byte, core.DefaultFileFieldMaxSize), "test5.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ field *core.FileField
+ record func() *core.Record
+ expectError bool
+ }{
+ // single
+ {
+ "zero field value (not required)",
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 1, Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "new plain filename", // new files must be *filesystem.File
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "a")
+ return record
+ },
+ true,
+ },
+ {
+ "new file",
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", f1)
+ return record
+ },
+ false,
+ },
+ {
+ "new files > MaxSelect",
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2})
+ return record
+ },
+ true,
+ },
+ {
+ "new files <= MaxSelect",
+ &core.FileField{Name: "test", MaxSize: 9999, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2})
+ return record
+ },
+ false,
+ },
+ {
+ "> default MaxSize",
+ &core.FileField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", f4)
+ return record
+ },
+ true,
+ },
+ {
+ "<= default MaxSize",
+ &core.FileField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", f5)
+ return record
+ },
+ false,
+ },
+ {
+ "> MaxSize",
+ &core.FileField{Name: "test", MaxSize: 4, MaxSelect: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2, f3}) // f3=8
+ return record
+ },
+ true,
+ },
+ {
+ "<= MaxSize",
+ &core.FileField{Name: "test", MaxSize: 8, MaxSelect: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2, f3})
+ return record
+ },
+ false,
+ },
+ {
+ "non-matching MimeType",
+ &core.FileField{Name: "test", MaxSize: 999, MaxSelect: 3, MimeTypes: []string{"a", "b"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2})
+ return record
+ },
+ true,
+ },
+ {
+ "matching MimeType",
+ &core.FileField{Name: "test", MaxSize: 999, MaxSelect: 3, MimeTypes: []string{"text/plain", "b"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []any{f1, f2})
+ return record
+ },
+ false,
+ },
+ {
+ "existing files > MaxSelect",
+ &core.FileField{Name: "file_many", MaxSize: 999, MaxSelect: 2},
+ func() *core.Record {
+ record, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t") // 5 files
+ return record
+ },
+ true,
+ },
+ {
+ "existing files should ignore the MaxSize and Mimetypes checks",
+ &core.FileField{Name: "file_many", MaxSize: 1, MaxSelect: 5, MimeTypes: []string{"a", "b"}},
+ func() *core.Record {
+ record, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ return record
+ },
+ false,
+ },
+ {
+ "existing + new file > MaxSelect (5+2)",
+ &core.FileField{Name: "file_many", MaxSize: 999, MaxSelect: 6},
+ func() *core.Record {
+ record, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ record.Set("file_many+", []any{f1, f2})
+ return record
+ },
+ true,
+ },
+ {
+ "existing + new file <= MaxSelect (5+2)",
+ &core.FileField{Name: "file_many", MaxSize: 999, MaxSelect: 7},
+ func() *core.Record {
+ record, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ record.Set("file_many+", []any{f1, f2})
+ return record
+ },
+ false,
+ },
+ {
+ "existing + new filename",
+ &core.FileField{Name: "file_many", MaxSize: 999, MaxSelect: 99},
+ func() *core.Record {
+ record, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ record.Set("file_many+", "test123.png")
+ return record
+ },
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestFileFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeFile)
+ testDefaultFieldNameValidation(t, core.FieldTypeFile)
+ testDefaultFieldHelpValidation[core.FileField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field func() *core.FileField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "0x0 thumb",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "0x0"},
+ }
+ },
+ []string{"thumbs"},
+ },
+ {
+ "0x0t thumb",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "0x0t"},
+ }
+ },
+ []string{"thumbs"},
+ },
+ {
+ "0x0b thumb",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "0x0b"},
+ }
+ },
+ []string{"thumbs"},
+ },
+ {
+ "0x0f thumb",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "0x0f"},
+ }
+ },
+ []string{"thumbs"},
+ },
+ {
+ "invalid format",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "100x"},
+ }
+ },
+ []string{"thumbs"},
+ },
+ {
+ "valid thumbs",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ MaxSelect: 1,
+ Thumbs: []string{"100x200", "100x40", "100x200"},
+ }
+ },
+ []string{},
+ },
+ {
+ "MaxSize > safe json int",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1 << 53,
+ }
+ },
+ []string{"maxSize"},
+ },
+ {
+ "MaxSize < 0",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSize: -1,
+ }
+ },
+ []string{"maxSize"},
+ },
+ {
+ "MaxSelect > safe json int",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSelect: 1 << 53,
+ }
+ },
+ []string{"maxSelect"},
+ },
+ {
+ "MaxSelect < 0",
+ func() *core.FileField {
+ return &core.FileField{
+ Id: "test",
+ Name: "test",
+ MaxSelect: -1,
+ }
+ },
+ []string{"maxSelect"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ field := s.field()
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(field)
+
+ errs := field.ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestFileFieldCalculateMaxBodySize(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ scenarios := []struct {
+ field *core.FileField
+ expected int64
+ }{
+ {&core.FileField{}, core.DefaultFileFieldMaxSize},
+ {&core.FileField{MaxSelect: 2}, 2 * core.DefaultFileFieldMaxSize},
+ {&core.FileField{MaxSize: 10}, 10},
+ {&core.FileField{MaxSize: 10, MaxSelect: 1}, 10},
+ {&core.FileField{MaxSize: 10, MaxSelect: 2}, 20},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d_%d", i, s.field.MaxSelect, s.field.MaxSize), func(t *testing.T) {
+ result := s.field.CalculateMaxBodySize()
+
+ if result != s.expected {
+ t.Fatalf("Expected %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestFileFieldFindGetter(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "f1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1.Name = "f1"
+
+ f2, err := filesystem.NewFileFromBytes([]byte("test"), "f2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f2.Name = "f2"
+
+ record, err := app.FindRecordById("demo3", "lcl9d87w22ml6jy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ record.Set("files+", []any{f1, f2})
+ record.Set("files-", "test_FLurQTgrY8.txt")
+
+ field, ok := record.Collection().Fields.GetByName("files").(*core.FileField)
+ if !ok {
+ t.Fatalf("Expected *core.FileField, got %T", record.Collection().Fields.GetByName("files"))
+ }
+
+ scenarios := []struct {
+ name string
+ key string
+ hasGetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ false,
+ "",
+ },
+ {
+ "exact match",
+ field.GetName(),
+ true,
+ `["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
+ },
+ {
+ "unsaved",
+ field.GetName() + ":unsaved",
+ true,
+ `[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ getter := field.FindGetter(s.key)
+
+ hasGetter := getter != nil
+ if hasGetter != s.hasGetter {
+ t.Fatalf("Expected hasGetter %v, got %v", s.hasGetter, hasGetter)
+ }
+
+ if !hasGetter {
+ return
+ }
+
+ v := getter(record)
+
+ raw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestFileFieldFindSetter(t *testing.T) {
+ scenarios := []struct {
+ name string
+ key string
+ value any
+ field *core.FileField
+ hasSetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ "b",
+ &core.FileField{Name: "test", MaxSelect: 1},
+ false,
+ "",
+ },
+ {
+ "exact match (single)",
+ "test",
+ "b",
+ &core.FileField{Name: "test", MaxSelect: 1},
+ true,
+ `"b"`,
+ },
+ {
+ "exact match (multiple)",
+ "test",
+ []string{"a", "b", "b"},
+ &core.FileField{Name: "test", MaxSelect: 2},
+ true,
+ `["a","b"]`,
+ },
+ {
+ "append (single)",
+ "test+",
+ "b",
+ &core.FileField{Name: "test", MaxSelect: 1},
+ true,
+ `"b"`,
+ },
+ {
+ "append (multiple)",
+ "test+",
+ []string{"a"},
+ &core.FileField{Name: "test", MaxSelect: 2},
+ true,
+ `["c","d","a"]`,
+ },
+ {
+ "prepend (single)",
+ "+test",
+ "b",
+ &core.FileField{Name: "test", MaxSelect: 1},
+ true,
+ `"d"`, // the last of the existing values
+ },
+ {
+ "prepend (multiple)",
+ "+test",
+ []string{"a"},
+ &core.FileField{Name: "test", MaxSelect: 2},
+ true,
+ `["a","c","d"]`,
+ },
+ {
+ "subtract (single)",
+ "test-",
+ "d",
+ &core.FileField{Name: "test", MaxSelect: 1},
+ true,
+ `"c"`,
+ },
+ {
+ "subtract (multiple)",
+ "test-",
+ []string{"unknown", "c"},
+ &core.FileField{Name: "test", MaxSelect: 2},
+ true,
+ `["d"]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ setter := s.field.FindSetter(s.key)
+
+ hasSetter := setter != nil
+ if hasSetter != s.hasSetter {
+ t.Fatalf("Expected hasSetter %v, got %v", s.hasSetter, hasSetter)
+ }
+
+ if !hasSetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw(s.field.GetName(), []string{"c", "d"})
+
+ setter(record, s.value)
+
+ raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestFileFieldIntercept(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ demo1.Fields.GetByName("text").(*core.TextField).Required = true // trigger validation error
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "new1.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f2, err := filesystem.NewFileFromBytes([]byte("test"), "new2.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f3, err := filesystem.NewFileFromBytes([]byte("test"), "new3.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f4, err := filesystem.NewFileFromBytes([]byte("test"), "new4.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record := core.NewRecord(demo1)
+
+ ok := t.Run("1. create - with validation error", func(t *testing.T) {
+ record.Set("file_many", []any{f1, f2})
+
+ err := testApp.Save(record)
+
+ tests.TestValidationErrors(t, err, []string{"text"})
+
+ value, _ := record.GetRaw("file_many").([]any)
+ if len(value) != 2 {
+ t.Fatalf("Expected the file field value to be unchanged, got %v", value)
+ }
+ })
+ if !ok {
+ return
+ }
+
+ ok = t.Run("2. create - fixing the validation error", func(t *testing.T) {
+ record.Set("text", "abc")
+
+ err := testApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ expectedKeys := []string{f1.Name, f2.Name}
+
+ raw := record.GetRaw("file_many")
+
+ // ensure that the value was replaced with the file names
+ value := list.ToUniqueStringSlice(raw)
+ if len(value) != len(expectedKeys) {
+ t.Fatalf("Expected the file field to be updated with the %d file names, got\n%v", len(expectedKeys), raw)
+ }
+ for _, name := range expectedKeys {
+ if !slices.Contains(value, name) {
+ t.Fatalf("Missing file %q in %v", name, value)
+ }
+ }
+
+ checkRecordFiles(t, testApp, record, expectedKeys)
+ })
+ if !ok {
+ return
+ }
+
+ ok = t.Run("3. update - validation error", func(t *testing.T) {
+ record.Set("text", "")
+ record.Set("file_many+", f3)
+ record.Set("file_many-", f2.Name)
+
+ err := testApp.Save(record)
+
+ tests.TestValidationErrors(t, err, []string{"text"})
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
+ expectedRaw, _ := json.Marshal([]any{f1.Name, f3}, json.Deterministic(true))
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, testApp, record, []string{f1.Name, f2.Name})
+ })
+ if !ok {
+ return
+ }
+
+ ok = t.Run("4. update - fixing the validation error", func(t *testing.T) {
+ record.Set("text", "abc2")
+
+ err := testApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
+ expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, testApp, record, []string{f1.Name, f3.Name})
+ })
+ if !ok {
+ return
+ }
+
+ t.Run("5. update - second time update", func(t *testing.T) {
+ record.Set("file_many-", f1.Name)
+ record.Set("file_many+", f4)
+
+ err := testApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
+ expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, testApp, record, []string{f3.Name, f4.Name})
+ })
+}
+
+func TestFileFieldInterceptTx(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ demo1.Fields.GetByName("text").(*core.TextField).Required = true // trigger validation error
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "new1.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f2, err := filesystem.NewFileFromBytes([]byte("test"), "new2.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f3, err := filesystem.NewFileFromBytes([]byte("test"), "new3.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f4, err := filesystem.NewFileFromBytes([]byte("test"), "new4.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var record *core.Record
+
+ tx := func(succeed bool) func(txApp core.App) error {
+ var txErr error
+ if !succeed {
+ txErr = errors.New("tx error")
+ }
+
+ return func(txApp core.App) error {
+ record = core.NewRecord(demo1)
+ ok := t.Run(fmt.Sprintf("[tx_%v] create with validation error", succeed), func(t *testing.T) {
+ record.Set("text", "")
+ record.Set("file_many", []any{f1, f2})
+
+ err := txApp.Save(record)
+ tests.TestValidationErrors(t, err, []string{"text"})
+
+ checkRecordFiles(t, txApp, record, []string{}) // no uploaded files
+ })
+ if !ok {
+ return txErr
+ }
+
+ // ---
+
+ ok = t.Run(fmt.Sprintf("[tx_%v] create with fixed validation error", succeed), func(t *testing.T) {
+ record.Set("text", "abc")
+
+ err = txApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ checkRecordFiles(t, txApp, record, []string{f1.Name, f2.Name})
+ })
+ if !ok {
+ return txErr
+ }
+
+ // ---
+
+ ok = t.Run(fmt.Sprintf("[tx_%v] update with validation error", succeed), func(t *testing.T) {
+ record.Set("text", "")
+ record.Set("file_many+", f3)
+ record.Set("file_many-", f2.Name)
+
+ err = txApp.Save(record)
+ tests.TestValidationErrors(t, err, []string{"text"})
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"))
+ expectedRaw, _ := json.Marshal([]any{f1.Name, f3})
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, txApp, record, []string{f1.Name, f2.Name}) // no file changes
+ })
+ if !ok {
+ return txErr
+ }
+
+ // ---
+
+ ok = t.Run(fmt.Sprintf("[tx_%v] update with fixed validation error", succeed), func(t *testing.T) {
+ record.Set("text", "abc2")
+
+ err = txApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
+ expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, txApp, record, []string{f1.Name, f3.Name, f2.Name}) // f2 shouldn't be deleted yet
+ })
+ if !ok {
+ return txErr
+ }
+
+ // ---
+
+ ok = t.Run(fmt.Sprintf("[tx_%v] second time update", succeed), func(t *testing.T) {
+ record.Set("file_many-", f1.Name)
+ record.Set("file_many+", f4)
+
+ err := txApp.Save(record)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got %v", err)
+ }
+
+ raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
+ expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
+ if !bytes.Equal(expectedRaw, raw) {
+ t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
+ }
+
+ checkRecordFiles(t, txApp, record, []string{f3.Name, f4.Name, f1.Name, f2.Name}) // f1 and f2 shouldn't be deleted yet
+ })
+ if !ok {
+ return txErr
+ }
+
+ // ---
+
+ return txErr
+ }
+ }
+
+ // failed transaction
+ txErr := testApp.RunInTransaction(tx(false))
+ if txErr == nil {
+ t.Fatal("Expected transaction error")
+ }
+ // there shouldn't be any fails associated with the record id
+ checkRecordFiles(t, testApp, record, []string{})
+
+ txErr = testApp.RunInTransaction(tx(true))
+ if txErr != nil {
+ t.Fatalf("Expected transaction to succeed, got %v", txErr)
+ }
+ // only the last updated files should remain
+ checkRecordFiles(t, testApp, record, []string{f3.Name, f4.Name})
+}
+
+// -------------------------------------------------------------------
+
+func checkRecordFiles(t *testing.T, testApp core.App, record *core.Record, expectedKeys []string) {
+ fsys, err := testApp.NewFilesystem()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer fsys.Close()
+
+ objects, err := fsys.List(record.BaseFilesPath() + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ objectKeys := make([]string, 0, len(objects))
+ for _, obj := range objects {
+ // exclude thumbs
+ if !strings.Contains(obj.Key, "/thumbs_") {
+ objectKeys = append(objectKeys, obj.Key)
+ }
+ }
+
+ if len(objectKeys) != len(expectedKeys) {
+ t.Fatalf("Expected files:\n%v\ngot\n%v", expectedKeys, objectKeys)
+ }
+ for _, key := range expectedKeys {
+ fullKey := record.BaseFilesPath() + "/" + key
+ if !slices.Contains(objectKeys, fullKey) {
+ t.Fatalf("Missing expected file key\n%q\nin\n%v", fullKey, objectKeys)
+ }
+ }
+}
diff --git a/core/field_geo_point.go b/core/field_geo_point.go
new file mode 100644
index 0000000000..e7c5a18375
--- /dev/null
+++ b/core/field_geo_point.go
@@ -0,0 +1,153 @@
+package core
+
+import (
+ "context"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeGeoPoint] = func() Field {
+ return &GeoPointField{}
+ }
+}
+
+const FieldTypeGeoPoint = "geoPoint"
+
+var (
+ _ Field = (*GeoPointField)(nil)
+)
+
+// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
+//
+// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
+// The stored value is always converted to [types.GeoPoint].
+// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
+//
+// Examples of updating a record's GeoPointField value programmatically:
+//
+// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
+// record.Set("location", map[string]any{"lat":123, "lon":456})
+// record.Set("location", []byte(`{"lat":123, "lon":456}`)
+type GeoPointField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Required will require the field coordinates to be non-zero (aka. not "Null Island").
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *GeoPointField) Type() string {
+ return FieldTypeGeoPoint
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *GeoPointField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *GeoPointField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *GeoPointField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *GeoPointField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *GeoPointField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *GeoPointField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *GeoPointField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *GeoPointField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *GeoPointField) ColumnType(app App) string {
+ return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
+ point := types.GeoPoint{}
+ err := point.Scan(raw)
+ return point, err
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(types.GeoPoint)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ // zero value
+ if val.Lat == 0 && val.Lon == 0 {
+ if f.Required {
+ return validation.ErrRequired
+ }
+ return nil
+ }
+
+ if val.Lat < -90 || val.Lat > 90 {
+ return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
+ }
+
+ if val.Lon < -180 || val.Lon > 180 {
+ return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ )
+}
diff --git a/core/field_geo_point_test.go b/core/field_geo_point_test.go
new file mode 100644
index 0000000000..7597f8e1d5
--- /dev/null
+++ b/core/field_geo_point_test.go
@@ -0,0 +1,203 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestGeoPointFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeGeoPoint)
+}
+
+func TestGeoPointFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.GeoPointField{}
+
+ expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestGeoPointFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.GeoPointField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {nil, `{"lon":0,"lat":0}`},
+ {"", `{"lon":0,"lat":0}`},
+ {[]byte{}, `{"lon":0,"lat":0}`},
+ {map[string]any{}, `{"lon":0,"lat":0}`},
+ {types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
+ {&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
+ {[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
+ {map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
+ {map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestGeoPointFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.GeoPointField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (non-required)",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{})
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.GeoPointField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{})
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero Lat field value (required)",
+ &core.GeoPointField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lat: 1})
+ return record
+ },
+ false,
+ },
+ {
+ "non-zero Lon field value (required)",
+ &core.GeoPointField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lon: 1})
+ return record
+ },
+ false,
+ },
+ {
+ "non-zero Lat-Lon field value (required)",
+ &core.GeoPointField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
+ return record
+ },
+ false,
+ },
+ {
+ "lat < -90",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lat: -90.1})
+ return record
+ },
+ true,
+ },
+ {
+ "lat > 90",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lat: 90.1})
+ return record
+ },
+ true,
+ },
+ {
+ "lon < -180",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lon: -180.1})
+ return record
+ },
+ true,
+ },
+ {
+ "lon > 180",
+ &core.GeoPointField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.GeoPoint{Lon: 180.1})
+ return record
+ },
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestGeoPointFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
+ testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
+ testDefaultFieldHelpValidation[core.GeoPointField](t)
+}
diff --git a/core/field_json.go b/core/field_json.go
new file mode 100644
index 0000000000..2d3588d512
--- /dev/null
+++ b/core/field_json.go
@@ -0,0 +1,214 @@
+package core
+
+import (
+ "context"
+ "encoding/json/jsontext"
+ "encoding/json/v2"
+ "slices"
+ "strconv"
+ "strings"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeJSON] = func() Field {
+ return &JSONField{}
+ }
+}
+
+const FieldTypeJSON = "json"
+
+const DefaultJSONFieldMaxSize int64 = 1 << 20
+
+var (
+ _ Field = (*JSONField)(nil)
+ _ MaxBodySizeCalculator = (*JSONField)(nil)
+)
+
+// JSONField defines "json" type field for storing any serialized JSON value.
+//
+// The respective zero record field value is the zero [types.JSONRaw].
+type JSONField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
+ //
+ // If zero, a default limit of 1MB is applied.
+ MaxSize int64 `form:"maxSize" json:"maxSize"`
+
+ // Required will require the field value to be non-empty JSON value
+ // (aka. not "null", `""`, "[]", "{}").
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *JSONField) Type() string {
+ return FieldTypeJSON
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *JSONField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *JSONField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *JSONField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *JSONField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *JSONField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *JSONField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *JSONField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *JSONField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *JSONField) ColumnType(app App) string {
+ return "JSON DEFAULT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *JSONField) PrepareValue(record *Record, raw any) (any, error) {
+ if str, ok := raw.(string); ok {
+ // in order to support seamlessly both json and multipart/form-data requests,
+ // the following normalization rules are applied for plain string values:
+ // - "true" is converted to the json `true`
+ // - "false" is converted to the json `false`
+ // - "null" is converted to the json `null`
+ // - "[1,2,3]" is converted to the json `[1,2,3]`
+ // - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
+ // - numeric strings are converted to json number
+ // - double quoted strings are left as they are (aka. without normalizations)
+ // - any other string (empty string too) is double quoted
+ if str == "" {
+ raw = strconv.Quote(str)
+ } else if str == "null" || str == "true" || str == "false" {
+ raw = str
+ } else if ((str[0] >= '0' && str[0] <= '9') ||
+ str[0] == '-' ||
+ str[0] == '"' ||
+ str[0] == '[' ||
+ str[0] == '{') &&
+ is.JSON.Validate(str) == nil {
+ raw = str
+ } else {
+ raw = strconv.Quote(str)
+ }
+ }
+
+ return types.ParseJSONRaw(raw)
+}
+
+var emptyJSONValues = []string{
+ "null", `""`, "[]", "{}", "",
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ raw, ok := record.GetRaw(f.Name).(types.JSONRaw)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ maxSize := f.CalculateMaxBodySize()
+
+ if int64(len(raw)) > maxSize {
+ return validation.NewError(
+ "validation_json_size_limit",
+ "The maximum allowed JSON size is {{.maxSize}} bytes",
+ ).SetParams(map[string]any{"maxSize": maxSize})
+ }
+
+ if is.JSON.Validate(raw) != nil {
+ return validation.NewError("validation_invalid_json", "Must be a valid json value")
+ }
+
+ // temp extra encoding/json/v2 check since the above validator is
+ // still using the v1 semantics
+ //
+ // @todo remove after updating the string validator
+ if len(raw) > 0 {
+ var dummy any
+ err := json.Unmarshal(raw, &dummy, jsontext.AllowInvalidUTF8(true))
+ if err != nil {
+ return validation.NewError("validation_invalid_json", "Must be a valid json value")
+ }
+ }
+
+ rawStr := strings.TrimSpace(raw.String())
+
+ if f.Required && slices.Contains(emptyJSONValues, rawStr) {
+ return validation.ErrRequired
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ )
+}
+
+// CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface.
+func (f *JSONField) CalculateMaxBodySize() int64 {
+ if f.MaxSize <= 0 {
+ return DefaultJSONFieldMaxSize
+ }
+
+ return f.MaxSize
+}
diff --git a/core/field_json_test.go b/core/field_json_test.go
new file mode 100644
index 0000000000..52fa4be408
--- /dev/null
+++ b/core/field_json_test.go
@@ -0,0 +1,288 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestJSONFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeJSON)
+}
+
+func TestJSONFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.JSONField{}
+
+ expected := "JSON DEFAULT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestJSONFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.JSONField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"null", `null`},
+ {"", `""`},
+ {"true", `true`},
+ {"false", `false`},
+ {"test", `"test"`},
+ {"123", `123`},
+ {"-456", `-456`},
+ {"[1,2,3]", `[1,2,3]`},
+ {"[1,2,3", `"[1,2,3"`},
+ {`{"a":1,"b":2}`, `{"a":1,"b":2}`},
+ {`{"a":1,"b":2`, `"{\"a\":1,\"b\":2"`},
+ {[]int{1, 2, 3}, `[1,2,3]`},
+ {map[string]int{"a": 1, "b": 2}, `{"a":1,"b":2}`},
+ {nil, `null`},
+ {false, `false`},
+ {true, `true`},
+ {-78, `-78`},
+ {123.456, `123.456`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, ok := v.(types.JSONRaw)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+ rawStr := raw.String()
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected\n%#v\ngot\n%#v", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestJSONFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.JSONField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.JSONField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "invalid raw value (v2 semantics)",
+ &core.JSONField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw(`{"a": 1, "a": 2}`))
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.JSONField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw{})
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.JSONField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw{})
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.JSONField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw("[1,2,3]"))
+ return record
+ },
+ false,
+ },
+ {
+ "non-zero field value (required)",
+ &core.JSONField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw(`"aaa"`))
+ return record
+ },
+ false,
+ },
+ {
+ "> default MaxSize",
+ &core.JSONField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
+ return record
+ },
+ true,
+ },
+ {
+ "> MaxSize",
+ &core.JSONField{Name: "test", MaxSize: 5},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw(`"aaaa"`))
+ return record
+ },
+ true,
+ },
+ {
+ "<= MaxSize",
+ &core.JSONField{Name: "test", MaxSize: 5},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", types.JSONRaw(`"aaa"`))
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestJSONFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeJSON)
+ testDefaultFieldNameValidation(t, core.FieldTypeJSON)
+ testDefaultFieldHelpValidation[core.JSONField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.JSONField
+ expectErrors []string
+ }{
+ {
+ "MaxSize < 0",
+ func() *core.JSONField {
+ return &core.JSONField{
+ Id: "test",
+ Name: "test",
+ MaxSize: -1,
+ }
+ },
+ []string{"maxSize"},
+ },
+ {
+ "MaxSize = 0",
+ func() *core.JSONField {
+ return &core.JSONField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "MaxSize > 0",
+ func() *core.JSONField {
+ return &core.JSONField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1,
+ }
+ },
+ []string{},
+ },
+ {
+ "MaxSize > safe json int",
+ func() *core.JSONField {
+ return &core.JSONField{
+ Id: "test",
+ Name: "test",
+ MaxSize: 1 << 53,
+ }
+ },
+ []string{"maxSize"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestJSONFieldCalculateMaxBodySize(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ scenarios := []struct {
+ field *core.JSONField
+ expected int64
+ }{
+ {&core.JSONField{}, core.DefaultJSONFieldMaxSize},
+ {&core.JSONField{MaxSize: 10}, 10},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.field.MaxSize), func(t *testing.T) {
+ result := s.field.CalculateMaxBodySize()
+
+ if result != s.expected {
+ t.Fatalf("Expected %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/field_number.go b/core/field_number.go
new file mode 100644
index 0000000000..6c3da2a352
--- /dev/null
+++ b/core/field_number.go
@@ -0,0 +1,239 @@
+package core
+
+import (
+ "context"
+ "math"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeNumber] = func() Field {
+ return &NumberField{}
+ }
+}
+
+const FieldTypeNumber = "number"
+
+var (
+ _ Field = (*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.
+//
+// The respective zero record field value is 0.
+//
+// The following additional setter keys are available:
+//
+// - "fieldName+" - appends to the existing record value. For example:
+// record.Set("total+", 5)
+// - "fieldName-" - subtracts from the existing record value. For example:
+// record.Set("total-", 5)
+type NumberField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Min specifies the min allowed field value.
+ //
+ // Leave it nil to skip the validator.
+ Min *float64 `form:"min" json:"min"`
+
+ // Max specifies the max allowed field value.
+ //
+ // Leave it nil to skip the validator.
+ Max *float64 `form:"max" json:"max"`
+
+ // OnlyInt will require the field value to be integer.
+ OnlyInt bool `form:"onlyInt" json:"onlyInt"`
+
+ // Required will require the field value to be non-zero.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *NumberField) Type() string {
+ return FieldTypeNumber
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *NumberField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *NumberField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *NumberField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *NumberField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *NumberField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *NumberField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *NumberField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *NumberField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *NumberField) ColumnType(app App) string {
+ return "NUMERIC DEFAULT 0 NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *NumberField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToFloat64(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *NumberField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(float64)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if math.IsInf(val, 0) || math.IsNaN(val) {
+ return validation.NewError("validation_not_a_number", "The submitted number is not properly formatted")
+ }
+
+ if val == 0 {
+ if f.Required {
+ if err := validation.Required.Validate(val); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+
+ if f.OnlyInt && val != float64(int64(val)) {
+ return onlyIntValidationError
+ }
+
+ if f.Min != nil && val < *f.Min {
+ return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
+ }
+
+ if f.Max != nil && val > *f.Max {
+ return maxNumberValidationError.SetParams(map[string]any{"max": *f.Max})
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ maxRules := []validation.Rule{
+ validation.By(f.checkOnlyInt),
+ }
+ if f.Min != nil && f.Max != nil {
+ maxRules = append(maxRules, validation.By(func(value interface{}) error {
+ // similar to validation.Min but doesn't ignore zero values
+ v, _ := value.(*float64)
+ if v == nil || f.Min == nil || *v >= *f.Min {
+ return nil
+ }
+ return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
+ }))
+ }
+
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.Min, validation.By(f.checkOnlyInt)),
+ validation.Field(&f.Max, maxRules...),
+ )
+}
+
+func (f *NumberField) checkOnlyInt(value any) error {
+ v, _ := value.(*float64)
+ if v == nil || !f.OnlyInt {
+ return nil // nothing to check
+ }
+
+ if *v != float64(int64(*v)) {
+ return onlyIntValidationError
+ }
+
+ return nil
+}
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *NumberField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return f.setValue
+ case f.Name + "+":
+ return f.addValue
+ case f.Name + "-":
+ return f.subtractValue
+ default:
+ return nil
+ }
+}
+
+func (f *NumberField) setValue(record *Record, raw any) {
+ record.SetRaw(f.Name, cast.ToFloat64(raw))
+}
+
+func (f *NumberField) addValue(record *Record, raw any) {
+ val := cast.ToFloat64(record.GetRaw(f.Name))
+
+ record.SetRaw(f.Name, val+cast.ToFloat64(raw))
+}
+
+func (f *NumberField) subtractValue(record *Record, raw any) {
+ val := cast.ToFloat64(record.GetRaw(f.Name))
+
+ record.SetRaw(f.Name, val-cast.ToFloat64(raw))
+}
diff --git a/core/field_number_test.go b/core/field_number_test.go
new file mode 100644
index 0000000000..79e2f122f0
--- /dev/null
+++ b/core/field_number_test.go
@@ -0,0 +1,428 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNumberFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeNumber)
+}
+
+func TestNumberFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.NumberField{}
+
+ expected := "NUMERIC DEFAULT 0 NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestNumberFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.NumberField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected float64
+ }{
+ {"", 0},
+ {"test", 0},
+ {false, 0},
+ {true, 1},
+ {-2, -2},
+ {123.456, 123.456},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ vRaw, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ v, ok := vRaw.(float64)
+ if !ok {
+ t.Fatalf("Expected float64 instance, got %T", v)
+ }
+
+ if v != s.expected {
+ t.Fatalf("Expected %f, got %f", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestNumberFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.NumberField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.NumberField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "123")
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.NumberField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 0.0)
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.NumberField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 0.0)
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.NumberField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123.0)
+ return record
+ },
+ false,
+ },
+ {
+ "decimal with onlyInt",
+ &core.NumberField{Name: "test", OnlyInt: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123.456)
+ return record
+ },
+ true,
+ },
+ {
+ "int with onlyInt",
+ &core.NumberField{Name: "test", OnlyInt: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123.0)
+ return record
+ },
+ false,
+ },
+ {
+ "< min",
+ &core.NumberField{Name: "test", Min: types.Pointer(2.0)},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 1.0)
+ return record
+ },
+ true,
+ },
+ {
+ ">= min",
+ &core.NumberField{Name: "test", Min: types.Pointer(2.0)},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 2.0)
+ return record
+ },
+ false,
+ },
+ {
+ "> max",
+ &core.NumberField{Name: "test", Max: types.Pointer(2.0)},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 3.0)
+ return record
+ },
+ true,
+ },
+ {
+ "<= max",
+ &core.NumberField{Name: "test", Max: types.Pointer(2.0)},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 2.0)
+ return record
+ },
+ false,
+ },
+ {
+ "infinity",
+ &core.NumberField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.Set("test", "Inf")
+ return record
+ },
+ true,
+ },
+ {
+ "NaN",
+ &core.NumberField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.Set("test", "NaN")
+ return record
+ },
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestNumberFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeNumber)
+ testDefaultFieldNameValidation(t, core.FieldTypeNumber)
+ testDefaultFieldHelpValidation[core.NumberField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.NumberField
+ expectErrors []string
+ }{
+ {
+ "zero",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "decimal min",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ Min: types.Pointer(1.2),
+ }
+ },
+ []string{},
+ },
+ {
+ "decimal min (onlyInt)",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ OnlyInt: true,
+ Min: types.Pointer(1.2),
+ }
+ },
+ []string{"min"},
+ },
+ {
+ "int min (onlyInt)",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ OnlyInt: true,
+ Min: types.Pointer(1.0),
+ }
+ },
+ []string{},
+ },
+ {
+ "decimal max",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ Max: types.Pointer(1.2),
+ }
+ },
+ []string{},
+ },
+ {
+ "decimal max (onlyInt)",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ OnlyInt: true,
+ Max: types.Pointer(1.2),
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "int max (onlyInt)",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ OnlyInt: true,
+ Max: types.Pointer(1.0),
+ }
+ },
+ []string{},
+ },
+ {
+ "min > max (0)",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ Min: types.Pointer(2.0),
+ Max: types.Pointer(0.0),
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "min (0) > max",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ Min: types.Pointer(0.0),
+ Max: types.Pointer(-1.0),
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "min == max",
+ func() *core.NumberField {
+ return &core.NumberField{
+ Id: "test",
+ Name: "test",
+ Min: types.Pointer(2.0),
+ Max: types.Pointer(2.0),
+ }
+ },
+ []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 {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestNumberFieldFindSetter(t *testing.T) {
+ field := &core.NumberField{Name: "test"}
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(field)
+
+ t.Run("no match", func(t *testing.T) {
+ f := field.FindSetter("abc")
+ if f != nil {
+ t.Fatal("Expected nil setter")
+ }
+ })
+
+ t.Run("direct name match", func(t *testing.T) {
+ f := field.FindSetter("test")
+ if f == nil {
+ t.Fatal("Expected non-nil setter")
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 2.0)
+
+ f(record, "123.456") // should be casted
+
+ if v := record.Get("test"); v != 123.456 {
+ t.Fatalf("Expected %f, got %f", 123.456, v)
+ }
+ })
+
+ t.Run("name+ match", func(t *testing.T) {
+ f := field.FindSetter("test+")
+ if f == nil {
+ t.Fatal("Expected non-nil setter")
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 2.0)
+
+ f(record, "1.5") // should be casted and appended to the existing value
+
+ if v := record.Get("test"); v != 3.5 {
+ t.Fatalf("Expected %f, got %f", 3.5, v)
+ }
+ })
+
+ t.Run("name- match", func(t *testing.T) {
+ f := field.FindSetter("test-")
+ if f == nil {
+ t.Fatal("Expected non-nil setter")
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 2.0)
+
+ f(record, "1.5") // should be casted and subtracted from the existing value
+
+ if v := record.Get("test"); v != 0.5 {
+ t.Fatalf("Expected %f, got %f", 0.5, v)
+ }
+ })
+}
diff --git a/core/field_password.go b/core/field_password.go
new file mode 100644
index 0000000000..e552f6f716
--- /dev/null
+++ b/core/field_password.go
@@ -0,0 +1,325 @@
+package core
+
+import (
+ "context"
+ "database/sql/driver"
+ "fmt"
+ "regexp"
+ "strings"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func init() {
+ Fields[FieldTypePassword] = func() Field {
+ return &PasswordField{}
+ }
+}
+
+const FieldTypePassword = "password"
+
+var (
+ _ Field = (*PasswordField)(nil)
+ _ GetterFinder = (*PasswordField)(nil)
+ _ SetterFinder = (*PasswordField)(nil)
+ _ DriverValuer = (*PasswordField)(nil)
+ _ RecordInterceptor = (*PasswordField)(nil)
+)
+
+// PasswordField defines "password" type field for storing bcrypt hashed strings
+// (usually used only internally for the "password" auth collection system field).
+//
+// If you want to set a direct bcrypt hash as record field value you can use the SetRaw method, for example:
+//
+// // generates a bcrypt hash of "123456" and set it as field value
+// // (record.GetString("password") returns the plain password until persisted, otherwise empty string)
+// record.Set("password", "123456")
+//
+// // set directly a bcrypt hash of "123456" as field value
+// // (record.GetString("password") returns empty string)
+// record.SetRaw("password", "$2a$10$.5Elh8fgxypNUWhpUUr/xOa2sZm0VIaE0qWuGGl9otUfobb46T1Pq")
+//
+// The following additional getter keys are available:
+//
+// - "fieldName:hash" - returns the bcrypt hash string of the record field value (if any). For example:
+// record.GetString("password:hash")
+type PasswordField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // @todo remove
+ //
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Pattern specifies an optional regex pattern to match against the field value.
+ //
+ // Leave it empty to skip the pattern check.
+ Pattern string `form:"pattern" json:"pattern"`
+
+ // Min specifies an optional required field string length.
+ Min int `form:"min" json:"min"`
+
+ // Max specifies an optional required field string length.
+ //
+ // If zero, fallback to max 71 bytes.
+ Max int `form:"max" json:"max"`
+
+ // Cost specifies the cost/weight/iteration/etc. bcrypt factor.
+ //
+ // If zero, fallback to [bcrypt.DefaultCost].
+ //
+ // If explicitly set, must be between [bcrypt.MinCost] and [bcrypt.MaxCost].
+ Cost int `form:"cost" json:"cost"`
+
+ // Required will require the field value to be non-empty string.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *PasswordField) Type() string {
+ return FieldTypePassword
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *PasswordField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *PasswordField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *PasswordField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *PasswordField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *PasswordField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *PasswordField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *PasswordField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *PasswordField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *PasswordField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// DriverValue implements the [DriverValuer] interface.
+func (f *PasswordField) DriverValue(record *Record) (driver.Value, error) {
+ fp := f.getPasswordValue(record)
+ return fp.Hash, fp.LastError
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *PasswordField) PrepareValue(record *Record, raw any) (any, error) {
+ return &PasswordFieldValue{
+ Hash: cast.ToString(raw),
+ }, nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *PasswordField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ fp, ok := record.GetRaw(f.Name).(*PasswordFieldValue)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if fp.LastError != nil {
+ return fp.LastError
+ }
+
+ if f.Required {
+ if err := validation.Required.Validate(fp.Hash); err != nil {
+ return err
+ }
+ }
+
+ if fp.Plain == "" {
+ return nil // nothing to check
+ }
+
+ // note: casted to []rune to count multi-byte chars as one for the
+ // sake of more intuitive UX and clearer user error messages
+ //
+ // note2: technically multi-byte strings could produce bigger length than the bcrypt limit
+ // but it should be fine as it will be just truncated (even if it cuts a byte sequence in the middle)
+ length := len([]rune(fp.Plain))
+
+ if length < f.Min {
+ return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", f.Min))
+ }
+
+ maxLength := f.Max
+ if maxLength <= 0 {
+ maxLength = 71
+ }
+ if length > maxLength {
+ return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", maxLength))
+ }
+
+ if f.Pattern != "" {
+ match, _ := regexp.MatchString(f.Pattern, fp.Plain)
+ if !match {
+ return validation.NewError("validation_invalid_format", "Invalid value format")
+ }
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *PasswordField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.Min, validation.Min(1), validation.Max(71)),
+ validation.Field(&f.Max, validation.Min(f.Min), validation.Max(71)),
+ validation.Field(&f.Cost, validation.Min(bcrypt.MinCost), validation.Max(bcrypt.MaxCost)),
+ validation.Field(&f.Pattern, validation.By(validators.IsRegex)),
+ )
+}
+
+func (f *PasswordField) getPasswordValue(record *Record) *PasswordFieldValue {
+ raw := record.GetRaw(f.Name)
+
+ switch v := raw.(type) {
+ case *PasswordFieldValue:
+ return v
+ case string:
+ // we assume that any raw string starting with $2 is bcrypt hash
+ if strings.HasPrefix(v, "$2") {
+ return &PasswordFieldValue{Hash: v}
+ }
+ }
+
+ return &PasswordFieldValue{}
+}
+
+// Intercept implements the [RecordInterceptor] interface.
+func (f *PasswordField) Intercept(
+ ctx context.Context,
+ app App,
+ record *Record,
+ actionName string,
+ actionFunc func() error,
+) error {
+ switch actionName {
+ case InterceptorActionAfterCreate, InterceptorActionAfterUpdate:
+ // unset the plain field value after successful create/update
+ fp := f.getPasswordValue(record)
+ fp.Plain = ""
+ }
+
+ return actionFunc()
+}
+
+// FindGetter implements the [GetterFinder] interface.
+func (f *PasswordField) FindGetter(key string) GetterFunc {
+ switch key {
+ case f.Name:
+ return func(record *Record) any {
+ return f.getPasswordValue(record).Plain
+ }
+ case f.Name + ":hash":
+ return func(record *Record) any {
+ return f.getPasswordValue(record).Hash
+ }
+ default:
+ return nil
+ }
+}
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *PasswordField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return f.setValue
+ default:
+ return nil
+ }
+}
+
+func (f *PasswordField) setValue(record *Record, raw any) {
+ fv := &PasswordFieldValue{
+ Plain: cast.ToString(raw),
+ }
+
+ // hash the password
+ if fv.Plain != "" {
+ cost := f.Cost
+ if cost <= 0 {
+ cost = bcrypt.DefaultCost
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(fv.Plain), cost)
+ if err != nil {
+ fv.LastError = err
+ }
+
+ fv.Hash = string(hash)
+ }
+
+ record.SetRaw(f.Name, fv)
+}
+
+// -------------------------------------------------------------------
+
+type PasswordFieldValue struct {
+ LastError error
+ Hash string
+ Plain string
+}
+
+func (pv PasswordFieldValue) Validate(pass string) bool {
+ if pv.Hash == "" || pv.LastError != nil {
+ return false
+ }
+
+ err := bcrypt.CompareHashAndPassword([]byte(pv.Hash), []byte(pass))
+
+ return err == nil
+}
diff --git a/core/field_password_test.go b/core/field_password_test.go
new file mode 100644
index 0000000000..b7c9e019e3
--- /dev/null
+++ b/core/field_password_test.go
@@ -0,0 +1,569 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func TestPasswordFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypePassword)
+}
+
+func TestPasswordFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.PasswordField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestPasswordFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.PasswordField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ {123.456, "123.456"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ pv, ok := v.(*core.PasswordFieldValue)
+ if !ok {
+ t.Fatalf("Expected PasswordFieldValue instance, got %T", v)
+ }
+
+ if pv.Hash != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestPasswordFieldDriverValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.PasswordField{Name: "test"}
+
+ err := errors.New("example_err")
+
+ scenarios := []struct {
+ raw any
+ expected *core.PasswordFieldValue
+ }{
+ {123, &core.PasswordFieldValue{}},
+ {"abc", &core.PasswordFieldValue{}},
+ {"$2abc", &core.PasswordFieldValue{Hash: "$2abc"}},
+ {&core.PasswordFieldValue{Hash: "test", LastError: err}, &core.PasswordFieldValue{Hash: "test", LastError: err}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%v", i, s.raw), func(t *testing.T) {
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.SetRaw(f.GetName(), s.raw)
+
+ v, err := f.DriverValue(record)
+
+ vStr, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+
+ var errStr string
+ if err != nil {
+ errStr = err.Error()
+ }
+
+ var expectedErrStr string
+ if s.expected.LastError != nil {
+ expectedErrStr = s.expected.LastError.Error()
+ }
+
+ if errStr != expectedErrStr {
+ t.Fatalf("Expected error %q, got %q", expectedErrStr, errStr)
+ }
+
+ if vStr != s.expected.Hash {
+ t.Fatalf("Expected hash %q, got %q", s.expected.Hash, vStr)
+ }
+ })
+ }
+}
+
+func TestPasswordFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.PasswordField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.PasswordField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "123")
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.PasswordField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{})
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.PasswordField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{})
+ return record
+ },
+ true,
+ },
+ {
+ "empty hash but non-empty plain password (required)",
+ &core.PasswordField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "test"})
+ return record
+ },
+ true,
+ },
+ {
+ "non-empty hash (required)",
+ &core.PasswordField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Hash: "test"})
+ return record
+ },
+ false,
+ },
+ {
+ "with LastError",
+ &core.PasswordField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{LastError: errors.New("test")})
+ return record
+ },
+ true,
+ },
+ {
+ "< Min",
+ &core.PasswordField{Name: "test", Min: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "аб"}) // multi-byte chars test
+ return record
+ },
+ true,
+ },
+ {
+ ">= Min",
+ &core.PasswordField{Name: "test", Min: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "абв"}) // multi-byte chars test
+ return record
+ },
+ false,
+ },
+ {
+ "> default Max",
+ &core.PasswordField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: strings.Repeat("a", 72)})
+ return record
+ },
+ true,
+ },
+ {
+ "<= default Max",
+ &core.PasswordField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: strings.Repeat("a", 71)})
+ return record
+ },
+ false,
+ },
+ {
+ "> Max",
+ &core.PasswordField{Name: "test", Max: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "абв"}) // multi-byte chars test
+ return record
+ },
+ true,
+ },
+ {
+ "<= Max",
+ &core.PasswordField{Name: "test", Max: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "аб"}) // multi-byte chars test
+ return record
+ },
+ false,
+ },
+ {
+ "non-matching pattern",
+ &core.PasswordField{Name: "test", Pattern: `\d+`},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "abc"})
+ return record
+ },
+ true,
+ },
+ {
+ "matching pattern",
+ &core.PasswordField{Name: "test", Pattern: `\d+`},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", &core.PasswordFieldValue{Plain: "123"})
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestPasswordFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypePassword)
+ testDefaultFieldNameValidation(t, core.FieldTypePassword)
+ testDefaultFieldHelpValidation[core.PasswordField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field func(col *core.Collection) *core.PasswordField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "invalid pattern",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Pattern: "(invalid",
+ }
+ },
+ []string{"pattern"},
+ },
+ {
+ "valid pattern",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Pattern: `\d+`,
+ }
+ },
+ []string{},
+ },
+ {
+ "Min < 0",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Min: -1,
+ }
+ },
+ []string{"min"},
+ },
+ {
+ "Min > 71",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Min: 72,
+ }
+ },
+ []string{"min"},
+ },
+ {
+ "valid Min",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Min: 5,
+ }
+ },
+ []string{},
+ },
+ {
+ "Max < Min",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Min: 2,
+ Max: 1,
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "Min > Min",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Min: 2,
+ Max: 3,
+ }
+ },
+ []string{},
+ },
+ {
+ "Max > 71",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Max: 72,
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "cost < bcrypt.MinCost",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Cost: bcrypt.MinCost - 1,
+ }
+ },
+ []string{"cost"},
+ },
+ {
+ "cost > bcrypt.MaxCost",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Cost: bcrypt.MaxCost + 1,
+ }
+ },
+ []string{"cost"},
+ },
+ {
+ "valid cost",
+ func(col *core.Collection) *core.PasswordField {
+ return &core.PasswordField{
+ Id: "test",
+ Name: "test",
+ Cost: 12,
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.GetByName("id").SetId("test") // set a dummy known id so that it can be replaced
+
+ field := s.field(collection)
+
+ collection.Fields.Add(field)
+
+ errs := field.ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestPasswordFieldFindSetter(t *testing.T) {
+ scenarios := []struct {
+ name string
+ key string
+ value any
+ field *core.PasswordField
+ hasSetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ "abc",
+ &core.PasswordField{Name: "test"},
+ false,
+ "",
+ },
+ {
+ "exact match",
+ "test",
+ "abc",
+ &core.PasswordField{Name: "test"},
+ true,
+ `"abc"`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ setter := s.field.FindSetter(s.key)
+
+ hasSetter := setter != nil
+ if hasSetter != s.hasSetter {
+ t.Fatalf("Expected hasSetter %v, got %v", s.hasSetter, hasSetter)
+ }
+
+ if !hasSetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw(s.field.GetName(), []string{"c", "d"})
+
+ setter(record, s.value)
+
+ raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestPasswordFieldFindGetter(t *testing.T) {
+ scenarios := []struct {
+ name string
+ key string
+ field *core.PasswordField
+ hasGetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ &core.PasswordField{Name: "test"},
+ false,
+ "",
+ },
+ {
+ "field name match",
+ "test",
+ &core.PasswordField{Name: "test"},
+ true,
+ "test_plain",
+ },
+ {
+ "field name hash modifier",
+ "test:hash",
+ &core.PasswordField{Name: "test"},
+ true,
+ "test_hash",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ getter := s.field.FindGetter(s.key)
+
+ hasGetter := getter != nil
+ if hasGetter != s.hasGetter {
+ t.Fatalf("Expected hasGetter %v, got %v", s.hasGetter, hasGetter)
+ }
+
+ if !hasGetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw(s.field.GetName(), &core.PasswordFieldValue{Hash: "test_hash", Plain: "test_plain"})
+
+ result := getter(record)
+
+ if result != s.expected {
+ t.Fatalf("Expected %q, got %#v", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/field_relation.go b/core/field_relation.go
new file mode 100644
index 0000000000..51cbe31321
--- /dev/null
+++ b/core/field_relation.go
@@ -0,0 +1,355 @@
+package core
+
+import (
+ "context"
+ "database/sql/driver"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeRelation] = func() Field {
+ return &RelationField{}
+ }
+}
+
+const FieldTypeRelation = "relation"
+
+var (
+ _ Field = (*RelationField)(nil)
+ _ MultiValuer = (*RelationField)(nil)
+ _ DriverValuer = (*RelationField)(nil)
+ _ SetterFinder = (*RelationField)(nil)
+)
+
+// RelationField defines "relation" type field for storing single or
+// multiple collection record references.
+//
+// Requires the CollectionId option to be set.
+//
+// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
+//
+// If MaxSelect is > 1, then the field value is expected to be a slice of record ids.
+//
+// The respective zero record field value is either empty string (single) or empty string slice (multiple).
+//
+// ---
+//
+// The following additional setter keys are available:
+//
+// - "fieldName+" - append one or more values to the existing record one. For example:
+//
+// record.Set("categories+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"}
+//
+// - "+fieldName" - prepend one or more values to the existing record one. For example:
+//
+// record.Set("+categories", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"}
+//
+// - "fieldName-" - subtract one or more values from the existing record one. For example:
+//
+// record.Set("categories-", "old1") // []string{"old2"}
+type RelationField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // CollectionId is the id of the related collection.
+ CollectionId string `form:"collectionId" json:"collectionId"`
+
+ // CascadeDelete indicates whether the root model should be deleted
+ // in case of delete of all linked relations.
+ CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
+
+ // MinSelect indicates the min number of allowed relation records
+ // that could be linked to the main model.
+ //
+ // No min limit is applied if it is zero or negative value.
+ MinSelect int `form:"minSelect" json:"minSelect"`
+
+ // MaxSelect indicates the max number of allowed relation records
+ // that could be linked to the main model.
+ //
+ // For multiple select the value must be > 1, otherwise fallbacks to single (default).
+ //
+ // If MinSelect is set, MaxSelect must be at least >= MinSelect.
+ MaxSelect int `form:"maxSelect" json:"maxSelect"`
+
+ // Required will require the field value to be non-empty.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *RelationField) Type() string {
+ return FieldTypeRelation
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *RelationField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *RelationField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *RelationField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *RelationField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *RelationField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *RelationField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *RelationField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *RelationField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// IsMultiple implements [MultiValuer] interface and checks whether the
+// current field options support multiple values.
+func (f *RelationField) IsMultiple() bool {
+ return f.MaxSelect > 1
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *RelationField) ColumnType(app App) string {
+ if f.IsMultiple() {
+ return "JSON DEFAULT '[]' NOT NULL"
+ }
+
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *RelationField) PrepareValue(record *Record, raw any) (any, error) {
+ return f.normalizeValue(raw), nil
+}
+
+func (f *RelationField) normalizeValue(raw any) any {
+ val := list.ToUniqueStringSlice(raw)
+
+ if !f.IsMultiple() {
+ if len(val) > 0 {
+ return val[len(val)-1] // the last selected
+ }
+ return ""
+ }
+
+ return val
+}
+
+// DriverValue implements the [DriverValuer] interface.
+func (f *RelationField) DriverValue(record *Record) (driver.Value, error) {
+ val := list.ToUniqueStringSlice(record.GetRaw(f.Name))
+
+ if !f.IsMultiple() {
+ if len(val) > 0 {
+ return val[len(val)-1], nil // the last selected
+ }
+ return "", nil
+ }
+
+ // serialize as json string array
+ return append(types.JSONArray[string]{}, val...), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ ids := list.ToUniqueStringSlice(record.GetRaw(f.Name))
+ if len(ids) == 0 {
+ if f.Required {
+ return validation.ErrRequired
+ }
+ return nil // nothing to check
+ }
+
+ if f.MinSelect > 0 && len(ids) < f.MinSelect {
+ return validation.NewError("validation_not_enough_values", "Select at least {{.minSelect}}").
+ SetParams(map[string]any{"minSelect": f.MinSelect})
+ }
+
+ maxSelect := max(f.MaxSelect, 1)
+ if len(ids) > maxSelect {
+ return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
+ SetParams(map[string]any{"maxSelect": maxSelect})
+ }
+
+ // check if the related records exist
+ // ---
+ relCollection, err := app.FindCachedCollectionByNameOrId(f.CollectionId)
+ if err != nil {
+ return validation.NewError("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed")
+ }
+
+ var total int
+ _ = app.ConcurrentDB().
+ Select("count(*)").
+ From(relCollection.Name).
+ AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
+ Row(&total)
+ if total != len(ids) {
+ return validation.NewError("validation_missing_rel_records", "Failed to find all relation records with the provided ids")
+ }
+ // ---
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *RelationField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.CollectionId, validation.Required, validation.By(f.checkCollectionId(app, collection))),
+ validation.Field(&f.MinSelect, validation.Min(0)),
+ validation.Field(&f.MaxSelect, validation.When(f.MinSelect > 0, validation.Required), validation.Min(f.MinSelect)),
+ )
+}
+
+func (f *RelationField) checkCollectionId(app App, collection *Collection) validation.RuleFunc {
+ return func(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ var oldCollection *Collection
+
+ if !collection.IsNew() {
+ var err error
+ oldCollection, err = app.FindCachedCollectionByNameOrId(collection.Id)
+ if err != nil {
+ return err
+ }
+ }
+
+ // prevent collectionId change
+ if oldCollection != nil {
+ oldField, _ := oldCollection.Fields.GetById(f.Id).(*RelationField)
+ if oldField != nil && oldField.CollectionId != v {
+ return validation.NewError(
+ "validation_field_relation_change",
+ "The relation collection cannot be changed.",
+ )
+ }
+ }
+
+ relCollection, _ := app.FindCachedCollectionByNameOrId(v)
+
+ // validate collectionId
+ if relCollection == nil || relCollection.Id != v {
+ return validation.NewError(
+ "validation_field_relation_missing_collection",
+ "The relation collection doesn't exist.",
+ )
+ }
+
+ // allow only views to have relations to other views
+ // (see https://github.com/pocketbase/pocketbase/issues/3000)
+ if !collection.IsView() && relCollection.IsView() {
+ return validation.NewError(
+ "validation_relation_field_non_view_base_collection",
+ "Only view collections are allowed to have relations to other views.",
+ )
+ }
+
+ return nil
+ }
+}
+
+// ---
+
+// FindSetter implements [SetterFinder] interface method.
+func (f *RelationField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return f.setValue
+ case "+" + f.Name:
+ return f.prependValue
+ case f.Name + "+":
+ return f.appendValue
+ case f.Name + "-":
+ return f.subtractValue
+ default:
+ return nil
+ }
+}
+
+func (f *RelationField) setValue(record *Record, raw any) {
+ record.SetRaw(f.Name, f.normalizeValue(raw))
+}
+
+func (f *RelationField) appendValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = append(
+ list.ToUniqueStringSlice(val),
+ list.ToUniqueStringSlice(modifierValue)...,
+ )
+
+ f.setValue(record, val)
+}
+
+func (f *RelationField) prependValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = append(
+ list.ToUniqueStringSlice(modifierValue),
+ list.ToUniqueStringSlice(val)...,
+ )
+
+ f.setValue(record, val)
+}
+
+func (f *RelationField) subtractValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = list.SubtractSlice(
+ list.ToUniqueStringSlice(val),
+ list.ToUniqueStringSlice(modifierValue),
+ )
+
+ f.setValue(record, val)
+}
diff --git a/core/field_relation_test.go b/core/field_relation_test.go
new file mode 100644
index 0000000000..7a05817dd0
--- /dev/null
+++ b/core/field_relation_test.go
@@ -0,0 +1,604 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRelationFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeRelation)
+}
+
+func TestRelationFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field *core.RelationField
+ expected string
+ }{
+ {
+ "single (zero)",
+ &core.RelationField{},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "single",
+ &core.RelationField{MaxSelect: 1},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "multiple",
+ &core.RelationField{MaxSelect: 2},
+ "JSON DEFAULT '[]' NOT NULL",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.ColumnType(app); v != s.expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestRelationFieldIsMultiple(t *testing.T) {
+ scenarios := []struct {
+ name string
+ field *core.RelationField
+ expected bool
+ }{
+ {
+ "zero",
+ &core.RelationField{},
+ false,
+ },
+ {
+ "single",
+ &core.RelationField{MaxSelect: 1},
+ false,
+ },
+ {
+ "multiple",
+ &core.RelationField{MaxSelect: 2},
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.IsMultiple(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestRelationFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ field *core.RelationField
+ expected string
+ }{
+ // single
+ {nil, &core.RelationField{MaxSelect: 1}, `""`},
+ {"", &core.RelationField{MaxSelect: 1}, `""`},
+ {123, &core.RelationField{MaxSelect: 1}, `"123"`},
+ {"a", &core.RelationField{MaxSelect: 1}, `"a"`},
+ {`["a"]`, &core.RelationField{MaxSelect: 1}, `"a"`},
+ {[]string{}, &core.RelationField{MaxSelect: 1}, `""`},
+ {[]string{"a", "b"}, &core.RelationField{MaxSelect: 1}, `"b"`},
+
+ // multiple
+ {nil, &core.RelationField{MaxSelect: 2}, `[]`},
+ {"", &core.RelationField{MaxSelect: 2}, `[]`},
+ {123, &core.RelationField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.RelationField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.RelationField{MaxSelect: 2}, `["a"]`},
+ {[]string{}, &core.RelationField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.RelationField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ v, err := s.field.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestRelationFieldDriverValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ raw any
+ field *core.RelationField
+ expected string
+ }{
+ // single
+ {nil, &core.RelationField{MaxSelect: 1}, `""`},
+ {"", &core.RelationField{MaxSelect: 1}, `""`},
+ {123, &core.RelationField{MaxSelect: 1}, `"123"`},
+ {"a", &core.RelationField{MaxSelect: 1}, `"a"`},
+ {`["a"]`, &core.RelationField{MaxSelect: 1}, `"a"`},
+ {[]string{}, &core.RelationField{MaxSelect: 1}, `""`},
+ {[]string{"a", "b"}, &core.RelationField{MaxSelect: 1}, `"b"`},
+
+ // multiple
+ {nil, &core.RelationField{MaxSelect: 2}, `[]`},
+ {"", &core.RelationField{MaxSelect: 2}, `[]`},
+ {123, &core.RelationField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.RelationField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.RelationField{MaxSelect: 2}, `["a"]`},
+ {[]string{}, &core.RelationField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.RelationField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.SetRaw(s.field.GetName(), s.raw)
+
+ v, err := s.field.DriverValue(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if s.field.IsMultiple() {
+ _, ok := v.(types.JSONArray[string])
+ if !ok {
+ t.Fatalf("Expected types.JSONArray value, got %T", v)
+ }
+ } else {
+ _, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string value, got %T", v)
+ }
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestRelationFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ field *core.RelationField
+ record func() *core.Record
+ expectError bool
+ }{
+ // single
+ {
+ "[single] zero field value (not required)",
+ &core.RelationField{Name: "test", MaxSelect: 1, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "[single] zero field value (required)",
+ &core.RelationField{Name: "test", MaxSelect: 1, CollectionId: demo1.Id, Required: true},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "[single] id from other collection",
+ &core.RelationField{Name: "test", MaxSelect: 1, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", "achvryl401bhse3")
+ return record
+ },
+ true,
+ },
+ {
+ "[single] valid id",
+ &core.RelationField{Name: "test", MaxSelect: 1, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", "84nmscqy84lsi1t")
+ return record
+ },
+ false,
+ },
+ {
+ "[single] > MaxSelect",
+ &core.RelationField{Name: "test", MaxSelect: 1, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"})
+ return record
+ },
+ true,
+ },
+
+ // multiple
+ {
+ "[multiple] zero field value (not required)",
+ &core.RelationField{Name: "test", MaxSelect: 2, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{})
+ return record
+ },
+ false,
+ },
+ {
+ "[multiple] zero field value (required)",
+ &core.RelationField{Name: "test", MaxSelect: 2, CollectionId: demo1.Id, Required: true},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] id from other collection",
+ &core.RelationField{Name: "test", MaxSelect: 2, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t", "achvryl401bhse3"})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] valid id",
+ &core.RelationField{Name: "test", MaxSelect: 2, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"})
+ return record
+ },
+ false,
+ },
+ {
+ "[multiple] > MaxSelect",
+ &core.RelationField{Name: "test", MaxSelect: 2, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy", "imy661ixudk5izi"})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] < MinSelect",
+ &core.RelationField{Name: "test", MinSelect: 2, MaxSelect: 99, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t"})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] >= MinSelect",
+ &core.RelationField{Name: "test", MinSelect: 2, MaxSelect: 99, CollectionId: demo1.Id},
+ func() *core.Record {
+ record := core.NewRecord(core.NewBaseCollection("test_collection"))
+ record.SetRaw("test", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy", "imy661ixudk5izi"})
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestRelationFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeRelation)
+ testDefaultFieldNameValidation(t, core.FieldTypeRelation)
+ testDefaultFieldHelpValidation[core.RelationField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ field func(col *core.Collection) *core.RelationField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{"collectionId"},
+ },
+ {
+ "invalid collectionId",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Name,
+ }
+ },
+ []string{"collectionId"},
+ },
+ {
+ "valid collectionId",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Id,
+ }
+ },
+ []string{},
+ },
+ {
+ "base->view",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: "v9gwnfh02gjq1q0",
+ }
+ },
+ []string{"collectionId"},
+ },
+ {
+ "view->view",
+ func(col *core.Collection) *core.RelationField {
+ col.Type = core.CollectionTypeView
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: "v9gwnfh02gjq1q0",
+ }
+ },
+ []string{},
+ },
+ {
+ "MinSelect < 0",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Id,
+ MinSelect: -1,
+ }
+ },
+ []string{"minSelect"},
+ },
+ {
+ "MinSelect > 0",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Id,
+ MinSelect: 1,
+ }
+ },
+ []string{"maxSelect"},
+ },
+ {
+ "MaxSelect < MinSelect",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Id,
+ MinSelect: 2,
+ MaxSelect: 1,
+ }
+ },
+ []string{"maxSelect"},
+ },
+ {
+ "MaxSelect >= MinSelect",
+ func(col *core.Collection) *core.RelationField {
+ return &core.RelationField{
+ Id: "test",
+ Name: "test",
+ CollectionId: demo1.Id,
+ MinSelect: 2,
+ MaxSelect: 2,
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.GetByName("id").SetId("test") // set a dummy known id so that it can be replaced
+
+ field := s.field(collection)
+
+ collection.Fields.Add(field)
+
+ errs := field.ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestRelationFieldFindSetter(t *testing.T) {
+ scenarios := []struct {
+ name string
+ key string
+ value any
+ field *core.RelationField
+ hasSetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ "b",
+ &core.RelationField{Name: "test", MaxSelect: 1},
+ false,
+ "",
+ },
+ {
+ "exact match (single)",
+ "test",
+ "b",
+ &core.RelationField{Name: "test", MaxSelect: 1},
+ true,
+ `"b"`,
+ },
+ {
+ "exact match (multiple)",
+ "test",
+ []string{"a", "b"},
+ &core.RelationField{Name: "test", MaxSelect: 2},
+ true,
+ `["a","b"]`,
+ },
+ {
+ "append (single)",
+ "test+",
+ "b",
+ &core.RelationField{Name: "test", MaxSelect: 1},
+ true,
+ `"b"`,
+ },
+ {
+ "append (multiple)",
+ "test+",
+ []string{"a"},
+ &core.RelationField{Name: "test", MaxSelect: 2},
+ true,
+ `["c","d","a"]`,
+ },
+ {
+ "prepend (single)",
+ "+test",
+ "b",
+ &core.RelationField{Name: "test", MaxSelect: 1},
+ true,
+ `"d"`, // the last of the existing values
+ },
+ {
+ "prepend (multiple)",
+ "+test",
+ []string{"a"},
+ &core.RelationField{Name: "test", MaxSelect: 2},
+ true,
+ `["a","c","d"]`,
+ },
+ {
+ "subtract (single)",
+ "test-",
+ "d",
+ &core.RelationField{Name: "test", MaxSelect: 1},
+ true,
+ `"c"`,
+ },
+ {
+ "subtract (multiple)",
+ "test-",
+ []string{"unknown", "c"},
+ &core.RelationField{Name: "test", MaxSelect: 2},
+ true,
+ `["d"]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ setter := s.field.FindSetter(s.key)
+
+ hasSetter := setter != nil
+ if hasSetter != s.hasSetter {
+ t.Fatalf("Expected hasSetter %v, got %v", s.hasSetter, hasSetter)
+ }
+
+ if !hasSetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw(s.field.GetName(), []string{"c", "d"})
+
+ setter(record, s.value)
+
+ raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, rawStr)
+ }
+ })
+ }
+}
diff --git a/core/field_select.go b/core/field_select.go
new file mode 100644
index 0000000000..fefc73acef
--- /dev/null
+++ b/core/field_select.go
@@ -0,0 +1,280 @@
+package core
+
+import (
+ "context"
+ "database/sql/driver"
+ "slices"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func init() {
+ Fields[FieldTypeSelect] = func() Field {
+ return &SelectField{}
+ }
+}
+
+const FieldTypeSelect = "select"
+
+var (
+ _ Field = (*SelectField)(nil)
+ _ MultiValuer = (*SelectField)(nil)
+ _ DriverValuer = (*SelectField)(nil)
+ _ SetterFinder = (*SelectField)(nil)
+)
+
+// SelectField defines "select" type field for storing single or
+// multiple string values from a predefined list.
+//
+// Requires the Values option to be set.
+//
+// If MaxSelect is not set or <= 1, then the field value is expected to be a single Values element.
+//
+// If MaxSelect is > 1, then the field value is expected to be a subset of Values slice.
+//
+// The respective zero record field value is either empty string (single) or empty string slice (multiple).
+//
+// ---
+//
+// The following additional setter keys are available:
+//
+// - "fieldName+" - append one or more values to the existing record one. For example:
+//
+// record.Set("roles+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"}
+//
+// - "+fieldName" - prepend one or more values to the existing record one. For example:
+//
+// record.Set("+roles", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"}
+//
+// - "fieldName-" - subtract one or more values from the existing record one. For example:
+//
+// record.Set("roles-", "old1") // []string{"old2"}
+type SelectField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Values specifies the list of accepted values.
+ Values []string `form:"values" json:"values"`
+
+ // MaxSelect specifies the max allowed selected values.
+ //
+ // For multiple select the value must be > 1, otherwise fallbacks to single (default).
+ MaxSelect int `form:"maxSelect" json:"maxSelect"`
+
+ // Required will require the field value to be non-empty.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *SelectField) Type() string {
+ return FieldTypeSelect
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *SelectField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *SelectField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *SelectField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *SelectField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *SelectField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *SelectField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *SelectField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *SelectField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// IsMultiple implements [MultiValuer] interface and checks whether the
+// current field options support multiple values.
+func (f *SelectField) IsMultiple() bool {
+ return f.MaxSelect > 1
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *SelectField) ColumnType(app App) string {
+ if f.IsMultiple() {
+ return "JSON DEFAULT '[]' NOT NULL"
+ }
+
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *SelectField) PrepareValue(record *Record, raw any) (any, error) {
+ return f.normalizeValue(raw), nil
+}
+
+func (f *SelectField) normalizeValue(raw any) any {
+ val := list.ToUniqueStringSlice(raw)
+
+ if !f.IsMultiple() {
+ if len(val) > 0 {
+ return val[len(val)-1] // the last selected
+ }
+ return ""
+ }
+
+ return val
+}
+
+// DriverValue implements the [DriverValuer] interface.
+func (f *SelectField) DriverValue(record *Record) (driver.Value, error) {
+ val := list.ToUniqueStringSlice(record.GetRaw(f.Name))
+
+ if !f.IsMultiple() {
+ if len(val) > 0 {
+ return val[len(val)-1], nil // the last selected
+ }
+ return "", nil
+ }
+
+ // serialize as json string array
+ return append(types.JSONArray[string]{}, val...), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *SelectField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ normalizedVal := list.ToUniqueStringSlice(record.GetRaw(f.Name))
+ if len(normalizedVal) == 0 {
+ if f.Required {
+ return validation.ErrRequired
+ }
+ return nil // nothing to check
+ }
+
+ maxSelect := max(f.MaxSelect, 1)
+
+ // check max selected items
+ if len(normalizedVal) > maxSelect {
+ return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
+ SetParams(map[string]any{"maxSelect": maxSelect})
+ }
+
+ // check against the allowed values
+ for _, val := range normalizedVal {
+ if !slices.Contains(f.Values, val) {
+ return validation.NewError("validation_invalid_value", "Invalid value {{.value}}").
+ SetParams(map[string]any{"value": val})
+ }
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *SelectField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ max := len(f.Values)
+ if max == 0 {
+ max = 1
+ }
+
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.Values, validation.Required),
+ validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(max)),
+ )
+}
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *SelectField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return f.setValue
+ case "+" + f.Name:
+ return f.prependValue
+ case f.Name + "+":
+ return f.appendValue
+ case f.Name + "-":
+ return f.subtractValue
+ default:
+ return nil
+ }
+}
+
+func (f *SelectField) setValue(record *Record, raw any) {
+ record.SetRaw(f.Name, f.normalizeValue(raw))
+}
+
+func (f *SelectField) appendValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = append(
+ list.ToUniqueStringSlice(val),
+ list.ToUniqueStringSlice(modifierValue)...,
+ )
+
+ f.setValue(record, val)
+}
+
+func (f *SelectField) prependValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = append(
+ list.ToUniqueStringSlice(modifierValue),
+ list.ToUniqueStringSlice(val)...,
+ )
+
+ f.setValue(record, val)
+}
+
+func (f *SelectField) subtractValue(record *Record, modifierValue any) {
+ val := record.GetRaw(f.Name)
+
+ val = list.SubtractSlice(
+ list.ToUniqueStringSlice(val),
+ list.ToUniqueStringSlice(modifierValue),
+ )
+
+ f.setValue(record, val)
+}
diff --git a/core/field_select_test.go b/core/field_select_test.go
new file mode 100644
index 0000000000..6e716a840e
--- /dev/null
+++ b/core/field_select_test.go
@@ -0,0 +1,517 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestSelectFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeSelect)
+}
+
+func TestSelectFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field *core.SelectField
+ expected string
+ }{
+ {
+ "single (zero)",
+ &core.SelectField{},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "single",
+ &core.SelectField{MaxSelect: 1},
+ "TEXT DEFAULT '' NOT NULL",
+ },
+ {
+ "multiple",
+ &core.SelectField{MaxSelect: 2},
+ "JSON DEFAULT '[]' NOT NULL",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.ColumnType(app); v != s.expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestSelectFieldIsMultiple(t *testing.T) {
+ scenarios := []struct {
+ name string
+ field *core.SelectField
+ expected bool
+ }{
+ {
+ "single (zero)",
+ &core.SelectField{},
+ false,
+ },
+ {
+ "single",
+ &core.SelectField{MaxSelect: 1},
+ false,
+ },
+ {
+ "multiple (>1)",
+ &core.SelectField{MaxSelect: 2},
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ if v := s.field.IsMultiple(); v != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestSelectFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ field *core.SelectField
+ expected string
+ }{
+ // single
+ {nil, &core.SelectField{}, `""`},
+ {"", &core.SelectField{}, `""`},
+ {123, &core.SelectField{}, `"123"`},
+ {"a", &core.SelectField{}, `"a"`},
+ {`["a"]`, &core.SelectField{}, `"a"`},
+ {[]string{}, &core.SelectField{}, `""`},
+ {[]string{"a", "b"}, &core.SelectField{}, `"b"`},
+
+ // multiple
+ {nil, &core.SelectField{MaxSelect: 2}, `[]`},
+ {"", &core.SelectField{MaxSelect: 2}, `[]`},
+ {123, &core.SelectField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.SelectField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.SelectField{MaxSelect: 2}, `["a"]`},
+ {[]string{}, &core.SelectField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.SelectField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ v, err := s.field.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestSelectFieldDriverValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ raw any
+ field *core.SelectField
+ expected string
+ }{
+ // single
+ {nil, &core.SelectField{}, `""`},
+ {"", &core.SelectField{}, `""`},
+ {123, &core.SelectField{}, `"123"`},
+ {"a", &core.SelectField{}, `"a"`},
+ {`["a"]`, &core.SelectField{}, `"a"`},
+ {[]string{}, &core.SelectField{}, `""`},
+ {[]string{"a", "b"}, &core.SelectField{}, `"b"`},
+
+ // multiple
+ {nil, &core.SelectField{MaxSelect: 2}, `[]`},
+ {"", &core.SelectField{MaxSelect: 2}, `[]`},
+ {123, &core.SelectField{MaxSelect: 2}, `["123"]`},
+ {"a", &core.SelectField{MaxSelect: 2}, `["a"]`},
+ {`["a"]`, &core.SelectField{MaxSelect: 2}, `["a"]`},
+ {[]string{}, &core.SelectField{MaxSelect: 2}, `[]`},
+ {[]string{"a", "b", "c"}, &core.SelectField{MaxSelect: 2}, `["a","b","c"]`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v_%v", i, s.raw, s.field.IsMultiple()), func(t *testing.T) {
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.SetRaw(s.field.GetName(), s.raw)
+
+ v, err := s.field.DriverValue(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if s.field.IsMultiple() {
+ _, ok := v.(types.JSONArray[string])
+ if !ok {
+ t.Fatalf("Expected types.JSONArray value, got %T", v)
+ }
+ } else {
+ _, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string value, got %T", v)
+ }
+ }
+
+ vRaw, err := json.Marshal(v, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(vRaw) != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, vRaw)
+ }
+ })
+ }
+}
+
+func TestSelectFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ values := []string{"a", "b", "c"}
+
+ scenarios := []struct {
+ name string
+ field *core.SelectField
+ record func() *core.Record
+ expectError bool
+ }{
+ // single
+ {
+ "[single] zero field value (not required)",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "[single] zero field value (required)",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 1, Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "[single] unknown value",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "unknown")
+ return record
+ },
+ true,
+ },
+ {
+ "[single] known value",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "a")
+ return record
+ },
+ false,
+ },
+ {
+ "[single] > MaxSelect",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 1},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{"a", "b"})
+ return record
+ },
+ true,
+ },
+
+ // multiple
+ {
+ "[multiple] zero field value (not required)",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{})
+ return record
+ },
+ false,
+ },
+ {
+ "[multiple] zero field value (required)",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2, Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] unknown value",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{"a", "unknown"})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] known value",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{"a", "b"})
+ return record
+ },
+ false,
+ },
+ {
+ "[multiple] > MaxSelect",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{"a", "b", "c"})
+ return record
+ },
+ true,
+ },
+ {
+ "[multiple] > MaxSelect (duplicated values)",
+ &core.SelectField{Name: "test", Values: values, MaxSelect: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", []string{"a", "b", "b", "a"})
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestSelectFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeSelect)
+ testDefaultFieldNameValidation(t, core.FieldTypeSelect)
+ testDefaultFieldHelpValidation[core.SelectField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field func() *core.SelectField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func() *core.SelectField {
+ return &core.SelectField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{"values"},
+ },
+ {
+ "MaxSelect > Values length",
+ func() *core.SelectField {
+ return &core.SelectField{
+ Id: "test",
+ Name: "test",
+ Values: []string{"a", "b"},
+ MaxSelect: 3,
+ }
+ },
+ []string{"maxSelect"},
+ },
+ {
+ "MaxSelect <= Values length",
+ func() *core.SelectField {
+ return &core.SelectField{
+ Id: "test",
+ Name: "test",
+ Values: []string{"a", "b"},
+ MaxSelect: 2,
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ field := s.field()
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(field)
+
+ errs := field.ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestSelectFieldFindSetter(t *testing.T) {
+ values := []string{"a", "b", "c", "d"}
+
+ scenarios := []struct {
+ name string
+ key string
+ value any
+ field *core.SelectField
+ hasSetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ "b",
+ &core.SelectField{Name: "test", MaxSelect: 1, Values: values},
+ false,
+ "",
+ },
+ {
+ "exact match (single)",
+ "test",
+ "b",
+ &core.SelectField{Name: "test", MaxSelect: 1, Values: values},
+ true,
+ `"b"`,
+ },
+ {
+ "exact match (multiple)",
+ "test",
+ []string{"a", "b"},
+ &core.SelectField{Name: "test", MaxSelect: 2, Values: values},
+ true,
+ `["a","b"]`,
+ },
+ {
+ "append (single)",
+ "test+",
+ "b",
+ &core.SelectField{Name: "test", MaxSelect: 1, Values: values},
+ true,
+ `"b"`,
+ },
+ {
+ "append (multiple)",
+ "test+",
+ []string{"a"},
+ &core.SelectField{Name: "test", MaxSelect: 2, Values: values},
+ true,
+ `["c","d","a"]`,
+ },
+ {
+ "prepend (single)",
+ "+test",
+ "b",
+ &core.SelectField{Name: "test", MaxSelect: 1, Values: values},
+ true,
+ `"d"`, // the last of the existing values
+ },
+ {
+ "prepend (multiple)",
+ "+test",
+ []string{"a"},
+ &core.SelectField{Name: "test", MaxSelect: 2, Values: values},
+ true,
+ `["a","c","d"]`,
+ },
+ {
+ "subtract (single)",
+ "test-",
+ "d",
+ &core.SelectField{Name: "test", MaxSelect: 1, Values: values},
+ true,
+ `"c"`,
+ },
+ {
+ "subtract (multiple)",
+ "test-",
+ []string{"unknown", "c"},
+ &core.SelectField{Name: "test", MaxSelect: 2, Values: values},
+ true,
+ `["d"]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ setter := s.field.FindSetter(s.key)
+
+ hasSetter := setter != nil
+ if hasSetter != s.hasSetter {
+ t.Fatalf("Expected hasSetter %v, got %v", s.hasSetter, hasSetter)
+ }
+
+ if !hasSetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+ record.SetRaw(s.field.GetName(), []string{"c", "d"})
+
+ setter(record, s.value)
+
+ raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, rawStr)
+ }
+ })
+ }
+}
diff --git a/core/field_test.go b/core/field_test.go
new file mode 100644
index 0000000000..d28f207153
--- /dev/null
+++ b/core/field_test.go
@@ -0,0 +1,320 @@
+package core_test
+
+import (
+ "context"
+ "encoding/json/v2"
+ "reflect"
+ "strings"
+ "testing"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func testFieldBaseMethods(t *testing.T, fieldType string) {
+ factory, ok := core.Fields[fieldType]
+ if !ok {
+ t.Fatalf("Missing %q field factory", fieldType)
+ }
+
+ f := factory()
+ if f == nil {
+ t.Fatal("Expected non-nil Field instance")
+ }
+
+ t.Run("type", func(t *testing.T) {
+ if v := f.Type(); v != fieldType {
+ t.Fatalf("Expected type %q, got %q", fieldType, v)
+ }
+ })
+
+ t.Run("id", func(t *testing.T) {
+ testValues := []string{"new_id", ""}
+ for _, expected := range testValues {
+ f.SetId(expected)
+ if v := f.GetId(); v != expected {
+ t.Fatalf("Expected id %q, got %q", expected, v)
+ }
+ }
+ })
+
+ t.Run("name", func(t *testing.T) {
+ testValues := []string{"new_name", ""}
+ for _, expected := range testValues {
+ f.SetName(expected)
+ if v := f.GetName(); v != expected {
+ t.Fatalf("Expected name %q, got %q", expected, v)
+ }
+ }
+ })
+
+ t.Run("system", func(t *testing.T) {
+ testValues := []bool{false, true}
+ for _, expected := range testValues {
+ f.SetSystem(expected)
+ if v := f.GetSystem(); v != expected {
+ t.Fatalf("Expected system %v, got %v", expected, v)
+ }
+ }
+ })
+
+ t.Run("hidden", func(t *testing.T) {
+ testValues := []bool{false, true}
+ for _, expected := range testValues {
+ f.SetHidden(expected)
+ if v := f.GetHidden(); v != expected {
+ t.Fatalf("Expected hidden %v, got %v", expected, v)
+ }
+ }
+ })
+}
+
+func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() core.Field
+ expectError bool
+ }{
+ {
+ "empty value",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ return f
+ },
+ true,
+ },
+ {
+ "invalid length",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetId(strings.Repeat("a", 101))
+ return f
+ },
+ true,
+ },
+ {
+ "valid length",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetId(strings.Repeat("a", 100))
+ return f
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run("[id] "+s.name, func(t *testing.T) {
+ errs, _ := s.field().ValidateSettings(context.Background(), app, collection).(validation.Errors)
+
+ hasErr := errs["id"] != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
+ }
+ })
+ }
+}
+
+func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() core.Field
+ expectError bool
+ }{
+ {
+ "empty value",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ return f
+ },
+ true,
+ },
+ {
+ "invalid length",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName(strings.Repeat("a", 101))
+ return f
+ },
+ true,
+ },
+ {
+ "valid length",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName(strings.Repeat("a", 100))
+ return f
+ },
+ false,
+ },
+ {
+ "invalid regex",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("test(")
+ return f
+ },
+ true,
+ },
+ {
+ "valid regex",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("test_123")
+ return f
+ },
+ false,
+ },
+ {
+ "_via_",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("a_via_b")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - null",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("null")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - false",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("false")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - true",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("true")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - _rowid_",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("_rowid_")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - expand",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("expand")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - collectionId",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("collectionId")
+ return f
+ },
+ true,
+ },
+ {
+ "system reserved - collectionName",
+ func() core.Field {
+ f := core.Fields[fieldType]()
+ f.SetName("collectionName")
+ return f
+ },
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run("[name] "+s.name, func(t *testing.T) {
+ errs, _ := s.field().ValidateSettings(context.Background(), app, collection).(validation.Errors)
+
+ hasErr := errs["name"] != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
+ }
+ })
+ }
+}
+
+func testDefaultFieldHelpValidation[T any](t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ json string
+ expectError bool
+ }{
+ {
+ "empty value",
+ `{}`,
+ false,
+ },
+ {
+ "< max limit",
+ `{"help":"abc"}`,
+ false,
+ },
+ {
+ "= max limit",
+ `{"help":"` + strings.Repeat("a", 300) + `"}`,
+ false,
+ },
+ {
+ "> max limit",
+ `{"help":"` + strings.Repeat("a", 301) + `"}`,
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run("[help] "+s.name, func(t *testing.T) {
+ var zeroField T
+
+ field, ok := reflect.New(reflect.TypeOf(zeroField)).Interface().(core.Field)
+ if !ok {
+ t.Fatalf("Expected core.Field instance, got %T", zeroField)
+ }
+
+ err := json.Unmarshal([]byte(s.json), &field)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ errs, _ := field.ValidateSettings(context.Background(), app, collection).(validation.Errors)
+
+ hasErr := errs["help"] != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
+ }
+ })
+ }
+}
diff --git a/core/field_text.go b/core/field_text.go
new file mode 100644
index 0000000000..b2e35224bb
--- /dev/null
+++ b/core/field_text.go
@@ -0,0 +1,397 @@
+package core
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeText] = func() Field {
+ return &TextField{}
+ }
+}
+
+const FieldTypeText = "text"
+
+const autogenerateModifier = ":autogenerate"
+
+var (
+ _ Field = (*TextField)(nil)
+ _ SetterFinder = (*TextField)(nil)
+ _ RecordInterceptor = (*TextField)(nil)
+)
+
+var forbiddenPKCharacters = []string{
+ ".", "/", `\`, "|", `"`, "'", "`",
+ "<", ">", ":", "?", "*", "%", "$",
+ "\000", "\t", "\n", "\r", " ",
+}
+
+// (see largestReservedPKLength)
+var caseInsensitiveReservedPKs = []string{
+ // reserved Windows files names
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+}
+
+const largestReservedPKLength = 4
+
+// TextField defines "text" type field for storing any string value.
+//
+// The respective zero record field value is empty string.
+//
+// The following additional setter keys are available:
+//
+// - "fieldName:autogenerate" - autogenerate field value if AutogeneratePattern is set. For example:
+//
+// record.Set("slug:autogenerate", "") // [random value]
+// record.Set("slug:autogenerate", "abc-") // abc-[random value]
+type TextField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // Min specifies the minimum required string characters.
+ //
+ // if zero value, no min limit is applied.
+ Min int `form:"min" json:"min"`
+
+ // Max specifies the maximum allowed string characters.
+ //
+ // If zero, a default limit of 5000 is applied.
+ Max int `form:"max" json:"max"`
+
+ // Pattern specifies an optional regex pattern to match against the field value.
+ //
+ // Leave it empty to skip the pattern check.
+ Pattern string `form:"pattern" json:"pattern"`
+
+ // AutogeneratePattern specifies an optional regex pattern that could
+ // be used to generate random string from it and set it automatically
+ // on record create if no explicit value is set or when the `:autogenerate` modifier is used.
+ //
+ // Note: the generated value still needs to satisfy min, max, pattern (if set)
+ AutogeneratePattern string `form:"autogeneratePattern" json:"autogeneratePattern"`
+
+ // Required will require the field value to be non-empty string.
+ Required bool `form:"required" json:"required"`
+
+ // PrimaryKey will mark the field as primary key.
+ //
+ // A single collection can have only 1 field marked as primary key.
+ PrimaryKey bool `form:"primaryKey" json:"primaryKey"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *TextField) Type() string {
+ return FieldTypeText
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *TextField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *TextField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *TextField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *TextField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *TextField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *TextField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *TextField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *TextField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *TextField) ColumnType(app App) string {
+ if f.PrimaryKey {
+ // note: the default is just a last resort fallback to avoid empty
+ // string values in case the record was inserted with raw sql and
+ // it is not actually used when operating with the db abstraction
+ return "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL"
+ }
+
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToString(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ newVal, ok := record.GetRaw(f.Name).(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if f.PrimaryKey {
+ // disallow PK change
+ if !record.IsNew() {
+ oldVal := record.LastSavedPK()
+ if oldVal != newVal {
+ return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
+ }
+ if oldVal != "" {
+ // no need to further validate because the id can't be updated
+ // and because the id could have been inserted manually by migration from another system
+ // that may not comply with the user defined PocketBase validations
+ return nil
+ }
+ } else {
+ // this technically shouldn't be necessarily but again to
+ // minimize misuse of the Pattern validator that could cause
+ // side-effects on some platforms check for duplicates in a case-insensitive manner
+ //
+ // (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
+ if f.Pattern != defaultLowercaseRecordIdPattern {
+ var exists int
+ err := app.ConcurrentDB().
+ Select("(1)").
+ From(record.TableName()).
+ Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
+ Limit(1).
+ Row(&exists)
+ if exists > 0 || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
+ return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
+ }
+ }
+ }
+ }
+
+ return f.ValidatePlainValue(newVal)
+}
+
+// ValidatePlainValue validates the provided string against the field options.
+func (f *TextField) ValidatePlainValue(value string) error {
+ if f.Required || f.PrimaryKey {
+ if err := validation.Required.Validate(value); err != nil {
+ return err
+ }
+ }
+
+ if value == "" {
+ return nil // nothing to check
+ }
+
+ // note: casted to []rune to count multi-byte chars as one
+ length := len([]rune(value))
+
+ if f.Min > 0 && length < f.Min {
+ return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s).").
+ SetParams(map[string]any{"min": f.Min})
+ }
+
+ max := f.Max
+ if max == 0 {
+ max = 5000
+ }
+
+ if max > 0 && length > max {
+ return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s).").
+ SetParams(map[string]any{"max": max})
+ }
+
+ if f.Pattern != "" {
+ match, _ := regexp.MatchString(f.Pattern, value)
+ if !match {
+ return validation.NewError("validation_invalid_format", "Invalid value format.")
+ }
+ }
+
+ // additional primary key checks to minimize eventual filesystem compatibility issues
+ // because the primary key is often used as a file/directory name
+ if f.PrimaryKey && f.Pattern != defaultLowercaseRecordIdPattern {
+ for _, ch := range forbiddenPKCharacters {
+ if strings.Contains(value, ch) {
+ return validation.NewError("validation_forbidden_pk_character", "'{{.ch}}' is not a valid primary key character.").
+ SetParams(map[string]any{"ch": ch})
+ }
+ }
+
+ if largestReservedPKLength >= length {
+ for _, reserved := range caseInsensitiveReservedPKs {
+ if strings.EqualFold(value, reserved) {
+ return validation.NewError("validation_reserved_pk", "The primary key '{{.reserved}}' is reserved and cannot be used.").
+ SetParams(map[string]any{"reserved": reserved})
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name,
+ validation.By(DefaultFieldNameValidationRule),
+ validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
+ ),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
+ validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
+ validation.Field(&f.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
+ validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
+ validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
+ validation.Field(&f.AutogeneratePattern, validation.By(validators.IsRegex), validation.By(f.checkAutogeneratePattern)),
+ )
+}
+
+func (f *TextField) checkOtherFieldsForPK(collection *Collection) validation.RuleFunc {
+ return func(value any) error {
+ v, _ := value.(bool)
+ if !v {
+ return nil // not a pk
+ }
+
+ totalPrimaryKeys := 0
+ for _, field := range collection.Fields {
+ if text, ok := field.(*TextField); ok && text.PrimaryKey {
+ totalPrimaryKeys++
+ }
+
+ if totalPrimaryKeys > 1 {
+ return validation.NewError("validation_unsupported_composite_pk", "Composite PKs are not supported and the collection must have only 1 PK.")
+ }
+ }
+
+ return nil
+ }
+}
+
+func (f *TextField) checkAutogeneratePattern(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ // run 10 tests to check for conflicts with the other field validators
+ for i := 0; i < 10; i++ {
+ generated, err := security.RandomStringByRegex(v)
+ if err != nil {
+ return validation.NewError("validation_invalid_autogenerate_pattern", err.Error())
+ }
+
+ // (loosely) check whether the generated pattern satisfies the current field settings
+ if err := f.ValidatePlainValue(generated); err != nil {
+ return validation.NewError(
+ "validation_invalid_autogenerate_pattern_value",
+ fmt.Sprintf("The provided autogenerate pattern could produce invalid field values, ex.: %q", generated),
+ )
+ }
+ }
+
+ return nil
+}
+
+// Intercept implements the [RecordInterceptor] interface.
+func (f *TextField) Intercept(
+ ctx context.Context,
+ app App,
+ record *Record,
+ actionName string,
+ actionFunc func() error,
+) error {
+ // set autogenerated value if missing for new records
+ switch actionName {
+ case InterceptorActionValidate, InterceptorActionCreate:
+ if f.AutogeneratePattern != "" && f.hasZeroValue(record) && record.IsNew() {
+ v, err := security.RandomStringByRegex(f.AutogeneratePattern)
+ if err != nil {
+ return fmt.Errorf("failed to autogenerate %q value: %w", f.Name, err)
+ }
+ record.SetRaw(f.Name, v)
+ }
+ }
+
+ return actionFunc()
+}
+
+func (f *TextField) hasZeroValue(record *Record) bool {
+ v, _ := record.GetRaw(f.Name).(string)
+ return v == ""
+}
+
+// FindSetter implements the [SetterFinder] interface.
+func (f *TextField) FindSetter(key string) SetterFunc {
+ switch key {
+ case f.Name:
+ return func(record *Record, raw any) {
+ record.SetRaw(f.Name, cast.ToString(raw))
+ }
+ case f.Name + autogenerateModifier:
+ return func(record *Record, raw any) {
+ v := cast.ToString(raw)
+
+ if f.AutogeneratePattern != "" {
+ generated, _ := security.RandomStringByRegex(f.AutogeneratePattern)
+ v += generated
+ }
+
+ record.SetRaw(f.Name, v)
+ }
+ default:
+ return nil
+ }
+}
diff --git a/core/field_text_test.go b/core/field_text_test.go
new file mode 100644
index 0000000000..671a657582
--- /dev/null
+++ b/core/field_text_test.go
@@ -0,0 +1,745 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestTextFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeText)
+}
+
+func TestTextFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.TextField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestTextFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.TextField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ {123.456, "123.456"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vStr, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+
+ if vStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestTextFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ existingRecord, err := app.FindFirstRecordByFilter(collection, "id != ''")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ field *core.TextField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.TextField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.TextField{Name: "test", Pattern: `\d+`, Min: 10, Max: 100}, // other fields validators should be ignored
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.TextField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.TextField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character / (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc/")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character \\ (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc\\")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character . (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc.")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character ' ' (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "ab c")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character * (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc*")
+ return record
+ },
+ false,
+ },
+ {
+ "special forbidden character / (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc/")
+ return record
+ },
+ true,
+ },
+ {
+ "special forbidden character \\ (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc\\")
+ return record
+ },
+ true,
+ },
+ {
+ "special forbidden character . (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc.")
+ return record
+ },
+ true,
+ },
+ {
+ "special forbidden character ' ' (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "ab c")
+ return record
+ },
+ true,
+ },
+ {
+ "special forbidden character * (primaryKey; used in the realtime events too)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc*")
+ return record
+ },
+ true,
+ },
+ {
+ "reserved pk literal (non-primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: false},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "aUx")
+ return record
+ },
+ false,
+ },
+ {
+ "reserved pk literal (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "aUx")
+ return record
+ },
+ true,
+ },
+ {
+ "reserved pk literal (non-exact match, primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "aUx-")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (primaryKey)",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abcd")
+ return record
+ },
+ false,
+ },
+ {
+ "case-insensitive duplicated primary key check",
+ &core.TextField{Name: "test", PrimaryKey: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", strings.ToUpper(existingRecord.Id))
+ return record
+ },
+ true,
+ },
+ {
+ "< min",
+ &core.TextField{Name: "test", Min: 4},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "абв") // multi-byte
+ return record
+ },
+ true,
+ },
+ {
+ ">= min",
+ &core.TextField{Name: "test", Min: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "абв") // multi-byte
+ return record
+ },
+ false,
+ },
+ {
+ "> default max",
+ &core.TextField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", strings.Repeat("a", 5001))
+ return record
+ },
+ true,
+ },
+ {
+ "<= default max",
+ &core.TextField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", strings.Repeat("a", 500))
+ return record
+ },
+ false,
+ },
+ {
+ "> max",
+ &core.TextField{Name: "test", Max: 2},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "абв") // multi-byte
+ return record
+ },
+ true,
+ },
+ {
+ "<= max",
+ &core.TextField{Name: "test", Min: 3},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "абв") // multi-byte
+ return record
+ },
+ false,
+ },
+ {
+ "mismatched pattern",
+ &core.TextField{Name: "test", Pattern: `\d+`},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "abc")
+ return record
+ },
+ true,
+ },
+ {
+ "matched pattern",
+ &core.TextField{Name: "test", Pattern: `\d+`},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "123")
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestTextFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeText)
+ testDefaultFieldNameValidation(t, core.FieldTypeText)
+ testDefaultFieldHelpValidation[core.TextField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ field func() *core.TextField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "primaryKey without required",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "id",
+ PrimaryKey: true,
+ Pattern: `\d+`,
+ }
+ },
+ []string{"required"},
+ },
+ {
+ "primaryKey without pattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "id",
+ PrimaryKey: true,
+ Required: true,
+ }
+ },
+ []string{"pattern"},
+ },
+ {
+ "primaryKey with hidden",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "id",
+ Required: true,
+ PrimaryKey: true,
+ Hidden: true,
+ Pattern: `\d+`,
+ }
+ },
+ []string{"hidden"},
+ },
+ {
+ "primaryKey with name != id",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ PrimaryKey: true,
+ Required: true,
+ Pattern: `\d+`,
+ }
+ },
+ []string{"name"},
+ },
+ {
+ "multiple primaryKey fields",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ PrimaryKey: true,
+ Pattern: `\d+`,
+ Required: true,
+ }
+ },
+ []string{"primaryKey"},
+ },
+ {
+ "invalid pattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ Pattern: `(invalid`,
+ }
+ },
+ []string{"pattern"},
+ },
+ {
+ "valid pattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ Pattern: `\d+`,
+ }
+ },
+ []string{},
+ },
+ {
+ "invalid autogeneratePattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ AutogeneratePattern: `(invalid`,
+ }
+ },
+ []string{"autogeneratePattern"},
+ },
+ {
+ "valid autogeneratePattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ AutogeneratePattern: `[a-z]+`,
+ }
+ },
+ []string{},
+ },
+ {
+ "conflicting pattern and autogeneratePattern",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test2",
+ Name: "id",
+ Pattern: `\d+`,
+ AutogeneratePattern: `[a-z]+`,
+ }
+ },
+ []string{"autogeneratePattern"},
+ },
+ {
+ "Max > safe json int",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ Max: 1 << 53,
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "Max < 0",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ Max: -1,
+ }
+ },
+ []string{"max"},
+ },
+ {
+ "Min > safe json int",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ Min: 1 << 53,
+ }
+ },
+ []string{"min"},
+ },
+ {
+ "Min < 0",
+ func() *core.TextField {
+ return &core.TextField{
+ Id: "test",
+ Name: "test",
+ Min: -1,
+ }
+ },
+ []string{"min"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ field := s.field()
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.GetByName("id").SetId("test") // set a dummy known id so that it can be replaced
+ collection.Fields.Add(field)
+
+ errs := field.ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestTextFieldAutogenerate(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ actionName string
+ field *core.TextField
+ record func() *core.Record
+ expected string
+ }{
+ {
+ "non-matching action",
+ core.InterceptorActionUpdate,
+ &core.TextField{Name: "test", AutogeneratePattern: "abc"},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "",
+ },
+ {
+ "matching action (create)",
+ core.InterceptorActionCreate,
+ &core.TextField{Name: "test", AutogeneratePattern: "abc"},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "abc",
+ },
+ {
+ "matching action (validate)",
+ core.InterceptorActionValidate,
+ &core.TextField{Name: "test", AutogeneratePattern: "abc"},
+ func() *core.Record {
+ return core.NewRecord(collection)
+ },
+ "abc",
+ },
+ {
+ "existing non-zero value",
+ core.InterceptorActionCreate,
+ &core.TextField{Name: "test", AutogeneratePattern: "abc"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "123")
+ return record
+ },
+ "123",
+ },
+ {
+ "non-new record",
+ core.InterceptorActionValidate,
+ &core.TextField{Name: "test", AutogeneratePattern: "abc"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.Id = "test"
+ record.PostScan()
+ return record
+ },
+ "",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ actionCalls := 0
+ record := s.record()
+
+ err := s.field.Intercept(context.Background(), app, record, s.actionName, func() error {
+ actionCalls++
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if actionCalls != 1 {
+ t.Fatalf("Expected actionCalls %d, got %d", 1, actionCalls)
+ }
+
+ v := record.GetString(s.field.GetName())
+ if v != s.expected {
+ t.Fatalf("Expected value %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestTextFieldFindSetter(t *testing.T) {
+ scenarios := []struct {
+ name string
+ key string
+ value any
+ field *core.TextField
+ hasSetter bool
+ expected string
+ }{
+ {
+ "no match",
+ "example",
+ "abc",
+ &core.TextField{Name: "test", AutogeneratePattern: "test"},
+ false,
+ "",
+ },
+ {
+ "exact match",
+ "test",
+ "abc",
+ &core.TextField{Name: "test", AutogeneratePattern: "test"},
+ true,
+ "abc",
+ },
+ {
+ "autogenerate modifier",
+ "test:autogenerate",
+ "abc",
+ &core.TextField{Name: "test", AutogeneratePattern: "test"},
+ true,
+ "abctest",
+ },
+ {
+ "autogenerate modifier without AutogeneratePattern option",
+ "test:autogenerate",
+ "abc",
+ &core.TextField{Name: "test"},
+ true,
+ "abc",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(s.field)
+
+ setter := s.field.FindSetter(s.key)
+
+ hasSetter := setter != nil
+ if hasSetter != s.hasSetter {
+ t.Fatalf("Expected hasSetter %v, got %v", s.hasSetter, hasSetter)
+ }
+
+ if !hasSetter {
+ return
+ }
+
+ record := core.NewRecord(collection)
+
+ setter(record, s.value)
+
+ result := record.GetString(s.field.Name)
+
+ if result != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/field_url.go b/core/field_url.go
new file mode 100644
index 0000000000..8416a79a7a
--- /dev/null
+++ b/core/field_url.go
@@ -0,0 +1,173 @@
+package core
+
+import (
+ "context"
+ "net/url"
+ "slices"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/spf13/cast"
+)
+
+func init() {
+ Fields[FieldTypeURL] = func() Field {
+ return &URLField{}
+ }
+}
+
+const FieldTypeURL = "url"
+
+var _ Field = (*URLField)(nil)
+
+// URLField defines "url" type field for storing a single URL string value.
+//
+// The respective zero record field value is empty string.
+type URLField struct {
+ // Name (required) is the unique name of the field.
+ Name string `form:"name" json:"name"`
+
+ // Id is the unique stable field identifier.
+ //
+ // It is automatically generated from the name when adding to a collection FieldsList.
+ Id string `form:"id" json:"id"`
+
+ // System prevents the renaming and removal of the field.
+ System bool `form:"system" json:"system"`
+
+ // Hidden hides the field from the API response.
+ Hidden bool `form:"hidden" json:"hidden"`
+
+ // ---
+
+ // Presentable hints the Dashboard UI to use the underlying
+ // field record value in the relation preview label.
+ Presentable bool `form:"presentable" json:"presentable"`
+
+ // Help is an extra text explaining what the field is about.
+ // It is usually shown in Dashboard UI under the field input.
+ Help string `form:"help" json:"help"`
+
+ // ExceptDomains will require the URL domain to NOT be included in the listed ones.
+ //
+ // This validator can be set only if OnlyDomains is empty.
+ ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
+
+ // OnlyDomains will require the URL domain to be included in the listed ones.
+ //
+ // This validator can be set only if ExceptDomains is empty.
+ OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
+
+ // Required will require the field value to be non-empty URL string.
+ Required bool `form:"required" json:"required"`
+}
+
+// Type implements [Field.Type] interface method.
+func (f *URLField) Type() string {
+ return FieldTypeURL
+}
+
+// GetId implements [Field.GetId] interface method.
+func (f *URLField) GetId() string {
+ return f.Id
+}
+
+// SetId implements [Field.SetId] interface method.
+func (f *URLField) SetId(id string) {
+ f.Id = id
+}
+
+// GetName implements [Field.GetName] interface method.
+func (f *URLField) GetName() string {
+ return f.Name
+}
+
+// SetName implements [Field.SetName] interface method.
+func (f *URLField) SetName(name string) {
+ f.Name = name
+}
+
+// GetSystem implements [Field.GetSystem] interface method.
+func (f *URLField) GetSystem() bool {
+ return f.System
+}
+
+// SetSystem implements [Field.SetSystem] interface method.
+func (f *URLField) SetSystem(system bool) {
+ f.System = system
+}
+
+// GetHidden implements [Field.GetHidden] interface method.
+func (f *URLField) GetHidden() bool {
+ return f.Hidden
+}
+
+// SetHidden implements [Field.SetHidden] interface method.
+func (f *URLField) SetHidden(hidden bool) {
+ f.Hidden = hidden
+}
+
+// ColumnType implements [Field.ColumnType] interface method.
+func (f *URLField) ColumnType(app App) string {
+ return "TEXT DEFAULT '' NOT NULL"
+}
+
+// PrepareValue implements [Field.PrepareValue] interface method.
+func (f *URLField) PrepareValue(record *Record, raw any) (any, error) {
+ return cast.ToString(raw), nil
+}
+
+// ValidateValue implements [Field.ValidateValue] interface method.
+func (f *URLField) ValidateValue(ctx context.Context, app App, record *Record) error {
+ val, ok := record.GetRaw(f.Name).(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ if f.Required {
+ if err := validation.Required.Validate(val); err != nil {
+ return err
+ }
+ }
+
+ if val == "" {
+ return nil // nothing to check
+ }
+
+ if is.URL.Validate(val) != nil {
+ return validation.NewError("validation_invalid_url", "Must be a valid url")
+ }
+
+ // extract host/domain
+ u, _ := url.Parse(val)
+
+ // only domains check
+ if len(f.OnlyDomains) > 0 && !slices.Contains(f.OnlyDomains, u.Host) {
+ return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
+ }
+
+ // except domains check
+ if len(f.ExceptDomains) > 0 && slices.Contains(f.ExceptDomains, u.Host) {
+ return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
+ }
+
+ return nil
+}
+
+// ValidateSettings implements [Field.ValidateSettings] interface method.
+func (f *URLField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
+ return validation.ValidateStruct(f,
+ validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
+ validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
+ validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
+ validation.Field(
+ &f.ExceptDomains,
+ validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ validation.Field(
+ &f.OnlyDomains,
+ validation.When(len(f.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
+ ),
+ )
+}
diff --git a/core/field_url_test.go b/core/field_url_test.go
new file mode 100644
index 0000000000..7f59e06956
--- /dev/null
+++ b/core/field_url_test.go
@@ -0,0 +1,272 @@
+package core_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestURLFieldBaseMethods(t *testing.T) {
+ testFieldBaseMethods(t, core.FieldTypeURL)
+}
+
+func TestURLFieldColumnType(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.URLField{}
+
+ expected := "TEXT DEFAULT '' NOT NULL"
+
+ if v := f.ColumnType(app); v != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
+ }
+}
+
+func TestURLFieldPrepareValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f := &core.URLField{}
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ scenarios := []struct {
+ raw any
+ expected string
+ }{
+ {"", ""},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ {123.456, "123.456"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
+ v, err := f.PrepareValue(record, s.raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ vStr, ok := v.(string)
+ if !ok {
+ t.Fatalf("Expected string instance, got %T", v)
+ }
+
+ if vStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, v)
+ }
+ })
+ }
+}
+
+func TestURLFieldValidateValue(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field *core.URLField
+ record func() *core.Record
+ expectError bool
+ }{
+ {
+ "invalid raw value",
+ &core.URLField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", 123)
+ return record
+ },
+ true,
+ },
+ {
+ "zero field value (not required)",
+ &core.URLField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ false,
+ },
+ {
+ "zero field value (required)",
+ &core.URLField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "")
+ return record
+ },
+ true,
+ },
+ {
+ "non-zero field value (required)",
+ &core.URLField{Name: "test", Required: true},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "https://example.com")
+ return record
+ },
+ false,
+ },
+ {
+ "invalid url",
+ &core.URLField{Name: "test"},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "invalid")
+ return record
+ },
+ true,
+ },
+ {
+ "failed onlyDomains",
+ &core.URLField{Name: "test", OnlyDomains: []string{"example.org", "example.net"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "https://example.com")
+ return record
+ },
+ true,
+ },
+ {
+ "success onlyDomains",
+ &core.URLField{Name: "test", OnlyDomains: []string{"example.org", "example.com"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "https://example.com")
+ return record
+ },
+ false,
+ },
+ {
+ "failed exceptDomains",
+ &core.URLField{Name: "test", ExceptDomains: []string{"example.org", "example.com"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "https://example.com")
+ return record
+ },
+ true,
+ },
+ {
+ "success exceptDomains",
+ &core.URLField{Name: "test", ExceptDomains: []string{"example.org", "example.net"}},
+ func() *core.Record {
+ record := core.NewRecord(collection)
+ record.SetRaw("test", "https://example.com")
+ return record
+ },
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ err := s.field.ValidateValue(context.Background(), app, s.record())
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestURLFieldValidateSettings(t *testing.T) {
+ testDefaultFieldIdValidation(t, core.FieldTypeURL)
+ testDefaultFieldNameValidation(t, core.FieldTypeURL)
+ testDefaultFieldHelpValidation[core.URLField](t)
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection := core.NewBaseCollection("test_collection")
+
+ scenarios := []struct {
+ name string
+ field func() *core.URLField
+ expectErrors []string
+ }{
+ {
+ "zero minimal",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ }
+ },
+ []string{},
+ },
+ {
+ "both onlyDomains and exceptDomains",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com"},
+ ExceptDomains: []string{"example.org"},
+ }
+ },
+ []string{"onlyDomains", "exceptDomains"},
+ },
+ {
+ "invalid onlyDomains",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com", "invalid"},
+ }
+ },
+ []string{"onlyDomains"},
+ },
+ {
+ "valid onlyDomains",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ OnlyDomains: []string{"example.com", "example.org"},
+ }
+ },
+ []string{},
+ },
+ {
+ "invalid exceptDomains",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ ExceptDomains: []string{"example.com", "invalid"},
+ }
+ },
+ []string{"exceptDomains"},
+ },
+ {
+ "valid exceptDomains",
+ func() *core.URLField {
+ return &core.URLField{
+ Id: "test",
+ Name: "test",
+ ExceptDomains: []string{"example.com", "example.org"},
+ }
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := s.field().ValidateSettings(context.Background(), app, collection)
+
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
diff --git a/core/fields_list.go b/core/fields_list.go
new file mode 100644
index 0000000000..cc6262bf15
--- /dev/null
+++ b/core/fields_list.go
@@ -0,0 +1,388 @@
+package core
+
+import (
+ "database/sql/driver"
+ "encoding/json/v2"
+ "fmt"
+ "slices"
+ "strconv"
+)
+
+// NewFieldsList creates a new FieldsList instance with the provided fields.
+func NewFieldsList(fields ...Field) FieldsList {
+ l := make(FieldsList, 0, len(fields))
+
+ for _, f := range fields {
+ l.add(-1, f)
+ }
+
+ return l
+}
+
+// FieldsList defines a Collection slice of fields.
+type FieldsList []Field
+
+// Clone creates a deep clone of the current list.
+func (l FieldsList) Clone() (FieldsList, error) {
+ copyRaw, err := json.Marshal(l)
+ if err != nil {
+ return nil, err
+ }
+
+ result := FieldsList{}
+ if err := json.Unmarshal(copyRaw, &result); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FieldNames returns a slice with the name of all list fields.
+func (l FieldsList) FieldNames() []string {
+ result := make([]string, len(l))
+
+ for i, field := range l {
+ result[i] = field.GetName()
+ }
+
+ return result
+}
+
+// AsMap returns a map with all registered list field.
+// The returned map is indexed with each field name.
+func (l FieldsList) AsMap() map[string]Field {
+ result := make(map[string]Field, len(l))
+
+ for _, field := range l {
+ result[field.GetName()] = field
+ }
+
+ return result
+}
+
+// GetById returns a single field by its id.
+func (l FieldsList) GetById(fieldId string) Field {
+ for _, field := range l {
+ if field.GetId() == fieldId {
+ return field
+ }
+ }
+ return nil
+}
+
+// GetByName returns a single field by its name.
+func (l FieldsList) GetByName(fieldName string) Field {
+ for _, field := range l {
+ if field.GetName() == fieldName {
+ return field
+ }
+ }
+ return nil
+}
+
+// RemoveById removes a single field by its id.
+//
+// This method does nothing if field with the specified id doesn't exist.
+func (l *FieldsList) RemoveById(fieldId string) {
+ fields := *l
+ for i, field := range fields {
+ if field.GetId() == fieldId {
+ *l = append(fields[:i], fields[i+1:]...)
+ return
+ }
+ }
+}
+
+// RemoveByName removes a single field by its name.
+//
+// This method does nothing if field with the specified name doesn't exist.
+func (l *FieldsList) RemoveByName(fieldName string) {
+ fields := *l
+ for i, field := range fields {
+ if field.GetName() == fieldName {
+ *l = append(fields[:i], fields[i+1:]...)
+ return
+ }
+ }
+}
+
+// Add adds one or more fields to the current list.
+//
+// By default this method will try to REPLACE existing fields with
+// the new ones by their id or by their name if the new field doesn't have an explicit id.
+//
+// If no matching existing field is found, it will APPEND the field to the end of the list.
+//
+// In all cases, if any of the new fields don't have an explicit id it will auto generate a default one for them
+// (the id value doesn't really matter and it is mostly used as a stable identifier in case of a field rename).
+func (l *FieldsList) Add(fields ...Field) {
+ for _, f := range fields {
+ l.add(-1, f)
+ }
+}
+
+// AddAt is the same as Add but insert/move the fields at the specific position.
+//
+// If pos < 0, then this method acts the same as calling Add.
+//
+// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
+func (l *FieldsList) AddAt(pos int, fields ...Field) {
+ total := len(*l)
+
+ for i, f := range fields {
+ if pos < 0 {
+ l.add(-1, f)
+ } else if pos > total {
+ l.add(total+i, f)
+ } else {
+ l.add(pos+i, f)
+ }
+ }
+}
+
+// AddMarshaledJSON parses the provided raw json data and adds the
+// found fields into the current list (following the same rule as the Add method).
+//
+// The rawJSON argument could be one of:
+// - serialized array of field objects
+// - single field object.
+//
+// Example:
+//
+// l.AddMarshaledJSON([]byte{`{"type":"text", name: "test"}`})
+// l.AddMarshaledJSON([]byte{`[{"type":"text", name: "test1"}, {"type":"text", name: "test2"}]`})
+func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
+ extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
+ if err != nil {
+ return err
+ }
+
+ l.Add(extractedFields...)
+
+ return nil
+}
+
+// AddMarshaledJSONAt is the same as AddMarshaledJSON but insert/move the fields at the specific position.
+//
+// If pos < 0, then this method acts the same as calling AddMarshaledJSON.
+//
+// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
+func (l *FieldsList) AddMarshaledJSONAt(pos int, rawJSON []byte) error {
+ extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
+ if err != nil {
+ return err
+ }
+
+ l.AddAt(pos, extractedFields...)
+
+ return nil
+}
+
+func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
+ extractedFields := FieldsList{}
+
+ // nothing to add
+ if len(rawJSON) == 0 {
+ return extractedFields, nil
+ }
+
+ // try to unmarshal first into a new fields list
+ // (assuming that rawJSON is array of objects)
+ err := json.Unmarshal(rawJSON, &extractedFields)
+ if err != nil {
+ // try again but wrap the rawJSON in []
+ // (assuming that rawJSON is a single object)
+ wrapped := make([]byte, 0, len(rawJSON)+2)
+ wrapped = append(wrapped, '[')
+ wrapped = append(wrapped, rawJSON...)
+ wrapped = append(wrapped, ']')
+ err = json.Unmarshal(wrapped, &extractedFields)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal the provided JSON - expects array of objects or just single object: %w", err)
+ }
+ }
+
+ return extractedFields, nil
+}
+
+func (l *FieldsList) add(pos int, newField Field) {
+ fields := *l
+
+ var replaceByName bool
+ var replaceInPlace bool
+
+ if pos < 0 {
+ replaceInPlace = true
+ pos = len(fields)
+ } else if pos > len(fields) {
+ pos = len(fields)
+ }
+
+ newFieldId := newField.GetId()
+
+ // set default id
+ if newFieldId == "" {
+ replaceByName = true
+
+ baseId := newField.Type() + crc32Checksum(newField.GetName())
+ newFieldId = baseId
+ for i := 2; i < 1000; i++ {
+ if l.GetById(newFieldId) == nil {
+ break // already unique
+ }
+ newFieldId = baseId + strconv.Itoa(i)
+ }
+ newField.SetId(newFieldId)
+ }
+
+ // try to replace existing
+ for i, field := range fields {
+ if replaceByName {
+ if name := newField.GetName(); name != "" && field.GetName() == name {
+ // reuse the original id
+ newField.SetId(field.GetId())
+
+ if replaceInPlace {
+ (*l)[i] = newField
+ return
+ } else {
+ // remove the current field and insert it later at the specific position
+ *l = slices.Delete(*l, i, i+1)
+ if total := len(*l); pos > total {
+ pos = total
+ }
+ break
+ }
+ }
+ } else {
+ if field.GetId() == newFieldId {
+ if replaceInPlace {
+ (*l)[i] = newField
+ return
+ } else {
+ // remove the current field and insert it later at the specific position
+ *l = slices.Delete(*l, i, i+1)
+ if total := len(*l); pos > total {
+ pos = total
+ }
+ break
+ }
+ }
+ }
+ }
+
+ // insert the new field
+ *l = slices.Insert(*l, pos, newField)
+}
+
+// String returns the string representation of the current list.
+func (l FieldsList) String() string {
+ v, _ := l.MarshalJSON()
+ return string(v)
+}
+
+type onlyFieldType struct {
+ Type string `json:"type"`
+}
+
+type fieldWithType struct {
+ Field
+ Type string `json:"type"`
+}
+
+func (fwt *fieldWithType) UnmarshalJSON(data []byte) error {
+ // extract the field type to init a blank factory
+ t := &onlyFieldType{}
+ if err := json.Unmarshal(data, t); err != nil {
+ return fmt.Errorf("failed to unmarshal field type: %w", err)
+ }
+
+ factory, ok := Fields[t.Type]
+ if !ok {
+ return fmt.Errorf("missing or unknown field type in %s", data)
+ }
+
+ fwt.Type = t.Type
+ fwt.Field = factory()
+
+ // unmarshal the rest of the data into the created field
+ if err := json.Unmarshal(data, fwt.Field); err != nil {
+ return fmt.Errorf("failed to unmarshal field: %w", err)
+ }
+
+ return nil
+}
+
+// UnmarshalJSON implements [json.Unmarshaler] and
+// loads the provided json data into the current FieldsList.
+func (l *FieldsList) UnmarshalJSON(data []byte) error {
+ fwts := []fieldWithType{}
+
+ if err := json.Unmarshal(data, &fwts); err != nil {
+ return err
+ }
+
+ *l = []Field{} // reset
+
+ for _, fwt := range fwts {
+ l.add(-1, fwt.Field)
+ }
+
+ return nil
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (l FieldsList) MarshalJSON() ([]byte, error) {
+ if l == nil {
+ l = []Field{} // always init to ensure that it is serialized as empty array
+ }
+
+ wrapper := make([]map[string]any, 0, len(l))
+
+ for _, f := range l {
+ // precompute the json into a map so that we can append the type to a flatten object
+ raw, err := json.Marshal(f)
+ if err != nil {
+ return nil, err
+ }
+
+ data := map[string]any{}
+ if err := json.Unmarshal(raw, &data); err != nil {
+ return nil, err
+ }
+ data["type"] = f.Type()
+
+ wrapper = append(wrapper, data)
+ }
+
+ return json.Marshal(wrapper, json.Deterministic(true))
+}
+
+// Value implements the [driver.Valuer] interface.
+func (l FieldsList) Value() (driver.Value, error) {
+ data, err := l.MarshalJSON()
+
+ return string(data), err
+}
+
+// Scan implements [sql.Scanner] interface to scan the provided value
+// into the current FieldsList instance.
+func (l *FieldsList) Scan(value any) error {
+ var data []byte
+ switch v := value.(type) {
+ case nil:
+ // no cast needed
+ case []byte:
+ data = v
+ case string:
+ data = []byte(v)
+ default:
+ return fmt.Errorf("failed to unmarshal FieldsList value %q", value)
+ }
+
+ if len(data) == 0 {
+ data = []byte("[]")
+ }
+
+ return l.UnmarshalJSON(data)
+}
diff --git a/core/fields_list_test.go b/core/fields_list_test.go
new file mode 100644
index 0000000000..2b30952db8
--- /dev/null
+++ b/core/fields_list_test.go
@@ -0,0 +1,558 @@
+package core_test
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func TestNewFieldsList(t *testing.T) {
+ fields := core.NewFieldsList(
+ &core.TextField{Id: "id1", Name: "test1"},
+ &core.TextField{Name: "test2"},
+ &core.TextField{Id: "id1", Name: "test1_new"}, // should replace the original id1 field
+ )
+
+ if len(fields) != 2 {
+ t.Fatalf("Expected 2 fields, got %d (%v)", len(fields), fields)
+ }
+
+ for _, f := range fields {
+ if f.GetId() == "" {
+ t.Fatalf("Expected field id to be set, found empty id for field %v", f)
+ }
+ }
+
+ if fields[0].GetName() != "test1_new" {
+ t.Fatalf("Expected field with name test1_new, got %s", fields[0].GetName())
+ }
+
+ if fields[1].GetName() != "test2" {
+ t.Fatalf("Expected field with name test2, got %s", fields[1].GetName())
+ }
+}
+
+func TestFieldsListClone(t *testing.T) {
+ f1 := &core.TextField{Name: "test1"}
+ f2 := &core.EmailField{Name: "test2"}
+ s1 := core.NewFieldsList(f1, f2)
+
+ s2, err := s1.Clone()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s1Str := s1.String()
+ s2Str := s2.String()
+
+ if s1Str != s2Str {
+ t.Fatalf("Expected the cloned list to be equal, got \n%v\nVS\n%v", s1, s2)
+ }
+
+ // change in one list shouldn't result to change in the other
+ // (aka. check if it is a deep clone)
+ s1[0].SetName("test1_update")
+ if s2[0].GetName() != "test1" {
+ t.Fatalf("Expected s2 field name to not change, got %q", s2[0].GetName())
+ }
+}
+
+func TestFieldsListFieldNames(t *testing.T) {
+ f1 := &core.TextField{Name: "test1"}
+ f2 := &core.EmailField{Name: "test2"}
+ testFieldsList := core.NewFieldsList(f1, f2)
+
+ result := testFieldsList.FieldNames()
+
+ expected := []string{f1.Name, f2.Name}
+
+ if len(result) != len(expected) {
+ t.Fatalf("Expected %d slice elements, got %d\n%v", len(expected), len(result), result)
+ }
+
+ for _, name := range expected {
+ if !slices.Contains(result, name) {
+ t.Fatalf("Missing name %q in %v", name, result)
+ }
+ }
+}
+
+func TestFieldsListAsMap(t *testing.T) {
+ f1 := &core.TextField{Name: "test1"}
+ f2 := &core.EmailField{Name: "test2"}
+ testFieldsList := core.NewFieldsList(f1, f2)
+
+ result := testFieldsList.AsMap()
+
+ expectedIndexes := []string{f1.Name, f2.Name}
+
+ if len(result) != len(expectedIndexes) {
+ t.Fatalf("Expected %d map elements, got %d\n%v", len(expectedIndexes), len(result), result)
+ }
+
+ for _, index := range expectedIndexes {
+ if _, ok := result[index]; !ok {
+ t.Fatalf("Missing index %q", index)
+ }
+ }
+}
+
+func TestFieldsListGetById(t *testing.T) {
+ f1 := &core.TextField{Id: "id1", Name: "test1"}
+ f2 := &core.EmailField{Id: "id2", Name: "test2"}
+ testFieldsList := core.NewFieldsList(f1, f2)
+
+ // missing field id
+ result1 := testFieldsList.GetById("test1")
+ if result1 != nil {
+ t.Fatalf("Found unexpected field %v", result1)
+ }
+
+ // existing field id
+ result2 := testFieldsList.GetById("id2")
+ if result2 == nil || result2.GetId() != "id2" {
+ t.Fatalf("Cannot find field with id %q, got %v ", "id2", result2)
+ }
+}
+
+func TestFieldsListGetByName(t *testing.T) {
+ f1 := &core.TextField{Id: "id1", Name: "test1"}
+ f2 := &core.EmailField{Id: "id2", Name: "test2"}
+ testFieldsList := core.NewFieldsList(f1, f2)
+
+ // missing field name
+ result1 := testFieldsList.GetByName("id1")
+ if result1 != nil {
+ t.Fatalf("Found unexpected field %v", result1)
+ }
+
+ // existing field name
+ result2 := testFieldsList.GetByName("test2")
+ if result2 == nil || result2.GetName() != "test2" {
+ t.Fatalf("Cannot find field with name %q, got %v ", "test2", result2)
+ }
+}
+
+func TestFieldsListRemove(t *testing.T) {
+ testFieldsList := core.NewFieldsList(
+ &core.TextField{Id: "id1", Name: "test1"},
+ &core.TextField{Id: "id2", Name: "test2"},
+ &core.TextField{Id: "id3", Name: "test3"},
+ &core.TextField{Id: "id4", Name: "test4"},
+ &core.TextField{Id: "id5", Name: "test5"},
+ &core.TextField{Id: "id6", Name: "test6"},
+ )
+
+ // remove by id
+ testFieldsList.RemoveById("id2")
+ testFieldsList.RemoveById("test3") // should do nothing
+
+ // remove by name
+ testFieldsList.RemoveByName("test5")
+ testFieldsList.RemoveByName("id6") // should do nothing
+
+ expected := []string{"test1", "test3", "test4", "test6"}
+
+ if len(testFieldsList) != len(expected) {
+ t.Fatalf("Expected %d, got %d\n%v", len(expected), len(testFieldsList), testFieldsList)
+ }
+
+ for _, name := range expected {
+ if f := testFieldsList.GetByName(name); f == nil {
+ t.Fatalf("Missing field %q", name)
+ }
+ }
+}
+
+func TestFieldsListAdd(t *testing.T) {
+ f0 := &core.TextField{}
+ f1 := &core.TextField{Name: "test1"}
+ f2 := &core.TextField{Id: "f2Id", Name: "test2"}
+ f3 := &core.TextField{Id: "f3Id", Name: "test3"}
+ testFieldsList := core.NewFieldsList(f0, f1, f2, f3)
+
+ f2New := &core.EmailField{Id: "f2Id", Name: "test2_new"}
+ f4 := &core.URLField{Name: "test4"}
+
+ testFieldsList.Add(f2New)
+ testFieldsList.Add(f4)
+
+ if len(testFieldsList) != 5 {
+ t.Fatalf("Expected %d, got %d\n%v", 5, len(testFieldsList), testFieldsList)
+ }
+
+ // check if each field has id
+ for _, f := range testFieldsList {
+ if f.GetId() == "" {
+ t.Fatalf("Expected field id to be set, found empty id for field %v", f)
+ }
+ }
+
+ // check if f2 field was replaced
+ if f := testFieldsList.GetById("f2Id"); f == nil || f.Type() != core.FieldTypeEmail {
+ t.Fatalf("Expected f2 field to be replaced, found %v", f)
+ }
+
+ // check if f4 was added
+ if f := testFieldsList.GetByName("test4"); f == nil || f.GetName() != "test4" {
+ t.Fatalf("Expected f4 field to be added, found %v", f)
+ }
+}
+
+func TestFieldsListAddMarshaledJSON(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ raw []byte
+ expectError bool
+ expectedFields map[string]string
+ }{
+ {
+ "nil",
+ nil,
+ false,
+ map[string]string{"abc": core.FieldTypeNumber},
+ },
+ {
+ "empty array",
+ []byte(`[]`),
+ false,
+ map[string]string{"abc": core.FieldTypeNumber},
+ },
+ {
+ "empty object",
+ []byte(`{}`),
+ true,
+ map[string]string{"abc": core.FieldTypeNumber},
+ },
+ {
+ "array with empty object",
+ []byte(`[{}]`),
+ true,
+ map[string]string{"abc": core.FieldTypeNumber},
+ },
+ {
+ "single object with invalid type",
+ []byte(`{"type":"missing","name":"test"}`),
+ true,
+ map[string]string{"abc": core.FieldTypeNumber},
+ },
+ {
+ "single object with valid type",
+ []byte(`{"type":"text","name":"test"}`),
+ false,
+ map[string]string{
+ "abc": core.FieldTypeNumber,
+ "test": core.FieldTypeText,
+ },
+ },
+ {
+ "array of object with valid types",
+ []byte(`[{"type":"text","name":"test1"},{"type":"url","name":"test2"}]`),
+ false,
+ map[string]string{
+ "abc": core.FieldTypeNumber,
+ "test1": core.FieldTypeText,
+ "test2": core.FieldTypeURL,
+ },
+ },
+ {
+ "fields with duplicated ids should replace existing fields",
+ []byte(`[{"type":"text","name":"test1"},{"type":"url","name":"test2"},{"type":"text","name":"abc2", "id":"abc_id"}]`),
+ false,
+ map[string]string{
+ "abc2": core.FieldTypeText,
+ "test1": core.FieldTypeText,
+ "test2": core.FieldTypeURL,
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testList := core.NewFieldsList(&core.NumberField{Name: "abc", Id: "abc_id"})
+ err := testList.AddMarshaledJSON(s.raw)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
+ }
+
+ if len(s.expectedFields) != len(testList) {
+ t.Fatalf("Expected %d fields, got %d", len(s.expectedFields), len(testList))
+ }
+
+ for fieldName, typ := range s.expectedFields {
+ f := testList.GetByName(fieldName)
+
+ if f == nil {
+ t.Errorf("Missing expected field %q", fieldName)
+ continue
+ }
+
+ if f.Type() != typ {
+ t.Errorf("Expect field %q to has type %q, got %q", fieldName, typ, f.Type())
+ }
+ }
+ })
+ }
+}
+
+func TestFieldsListAddAt(t *testing.T) {
+ scenarios := []struct {
+ position int
+ expected []string
+ }{
+ {-2, []string{"test1", "test2_new", "test3", "test4"}},
+ {-1, []string{"test1", "test2_new", "test3", "test4"}},
+ {0, []string{"test2_new", "test4", "test1", "test3"}},
+ {1, []string{"test1", "test2_new", "test4", "test3"}},
+ {2, []string{"test1", "test3", "test2_new", "test4"}},
+ {3, []string{"test1", "test3", "test2_new", "test4"}},
+ {4, []string{"test1", "test3", "test2_new", "test4"}},
+ {5, []string{"test1", "test3", "test2_new", "test4"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(strconv.Itoa(s.position), func(t *testing.T) {
+ f1 := &core.TextField{Id: "f1Id", Name: "test1"}
+ f2 := &core.TextField{Id: "f2Id", Name: "test2"}
+ f3 := &core.TextField{Id: "f3Id", Name: "test3"}
+ testFieldsList := core.NewFieldsList(f1, f2, f3)
+
+ f2New := &core.EmailField{Id: "f2Id", Name: "test2_new"}
+ f4 := &core.URLField{Name: "test4"}
+ testFieldsList.AddAt(s.position, f2New, f4)
+
+ rawNames, err := json.Marshal(testFieldsList.FieldNames())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rawExpected, err := json.Marshal(s.expected)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !bytes.Equal(rawNames, rawExpected) {
+ t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
+ }
+ })
+ }
+}
+
+func TestFieldsListAddMarshaledJSONAt(t *testing.T) {
+ scenarios := []struct {
+ position int
+ expected []string
+ }{
+ {-2, []string{"test1", "test2_new", "test3", "test4"}},
+ {-1, []string{"test1", "test2_new", "test3", "test4"}},
+ {0, []string{"test2_new", "test4", "test1", "test3"}},
+ {1, []string{"test1", "test2_new", "test4", "test3"}},
+ {2, []string{"test1", "test3", "test2_new", "test4"}},
+ {3, []string{"test1", "test3", "test2_new", "test4"}},
+ {4, []string{"test1", "test3", "test2_new", "test4"}},
+ {5, []string{"test1", "test3", "test2_new", "test4"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(strconv.Itoa(s.position), func(t *testing.T) {
+ f1 := &core.TextField{Id: "f1Id", Name: "test1"}
+ f2 := &core.TextField{Id: "f2Id", Name: "test2"}
+ f3 := &core.TextField{Id: "f3Id", Name: "test3"}
+ testFieldsList := core.NewFieldsList(f1, f2, f3)
+
+ err := testFieldsList.AddMarshaledJSONAt(s.position, []byte(`[
+ {"id":"f2Id", "name":"test2_new", "type": "text"},
+ {"name": "test4", "type": "text"}
+ ]`))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rawNames, err := json.Marshal(testFieldsList.FieldNames())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rawExpected, err := json.Marshal(s.expected)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !bytes.Equal(rawNames, rawExpected) {
+ t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
+ }
+ })
+ }
+}
+
+func TestFieldsListStringAndValue(t *testing.T) {
+ t.Run("empty list", func(t *testing.T) {
+ testFieldsList := core.NewFieldsList()
+
+ str := testFieldsList.String()
+ if str != "[]" {
+ t.Fatalf("Expected empty slice, got\n%q", str)
+ }
+
+ v, err := testFieldsList.Value()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != str {
+ t.Fatalf("Expected String and Value to match")
+ }
+ })
+
+ t.Run("list with fields", func(t *testing.T) {
+ testFieldsList := core.NewFieldsList(
+ &core.TextField{Id: "f1id", Name: "test1"},
+ &core.BoolField{Id: "f2id", Name: "test2"},
+ &core.URLField{Id: "f3id", Name: "test3"},
+ )
+
+ str := testFieldsList.String()
+
+ v, err := testFieldsList.Value()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v != str {
+ t.Fatalf("Expected String and Value to match")
+ }
+
+ expectedParts := []string{
+ `"type":"bool"`,
+ `"type":"url"`,
+ `"type":"text"`,
+ `"id":"f1id"`,
+ `"id":"f2id"`,
+ `"id":"f3id"`,
+ `"name":"test1"`,
+ `"name":"test2"`,
+ `"name":"test3"`,
+ }
+
+ for _, part := range expectedParts {
+ if !strings.Contains(str, part) {
+ t.Fatalf("Missing %q in\nn%v", part, str)
+ }
+ }
+ })
+}
+
+func TestFieldsListScan(t *testing.T) {
+ scenarios := []struct {
+ name string
+ data any
+ expectError bool
+ expectJSON string
+ }{
+ {"nil", nil, false, "[]"},
+ {"empty string", "", false, "[]"},
+ {"empty byte", []byte{}, false, "[]"},
+ {"empty string array", "[]", false, "[]"},
+ {"invalid string", "invalid", true, "[]"},
+ {"non-string", 123, true, "[]"},
+ {"item with no field type", `[{}]`, true, "[]"},
+ {
+ "unknown field type",
+ `[{"id":"123","name":"test1","type":"unknown"},{"id":"456","name":"test2","type":"bool"}]`,
+ true,
+ `[]`,
+ },
+ {
+ "only the minimum field options",
+ `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
+ false,
+ `[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
+ },
+ {
+ "all field options",
+ `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
+ false,
+ `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testFieldsList := core.FieldsList{}
+
+ err := testFieldsList.Scan(s.data)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ str := testFieldsList.String()
+ if str != s.expectJSON {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expectJSON, str)
+ }
+ })
+ }
+}
+
+func TestFieldsListJSON(t *testing.T) {
+ scenarios := []struct {
+ name string
+ data string
+ expectError bool
+ expectJSON string
+ }{
+ {"empty string", "", true, "[]"},
+ {"invalid string", "invalid", true, "[]"},
+ {"empty string array", "[]", false, "[]"},
+ {"item with no field type", `[{}]`, true, "[]"},
+ {
+ "unknown field type",
+ `[{"id":"123","name":"test1","type":"unknown"},{"id":"456","name":"test2","type":"bool"}]`,
+ true,
+ `[]`,
+ },
+ {
+ "only the minimum field options",
+ `[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
+ false,
+ `[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
+ },
+ {
+ "all field options",
+ `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
+ false,
+ `[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testFieldsList := core.FieldsList{}
+
+ err := testFieldsList.UnmarshalJSON([]byte(s.data))
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ raw, err := testFieldsList.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ str := string(raw)
+ if str != s.expectJSON {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expectJSON, str)
+ }
+ })
+ }
+}
diff --git a/core/log_model.go b/core/log_model.go
new file mode 100644
index 0000000000..ade9232a47
--- /dev/null
+++ b/core/log_model.go
@@ -0,0 +1,89 @@
+package core
+
+import (
+ "encoding/json/v2"
+
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+var (
+ _ Model = (*Log)(nil)
+ _ DBExporter = (*Log)(nil)
+)
+
+const LogsTableName = "_logs"
+
+const (
+ defaultMaxLogDataSize = 16 << 10 // ~16kb
+ defaultMaxLogMessageSize = 8000
+)
+
+type Log struct {
+ BaseModel
+
+ Created types.DateTime `db:"created" json:"created"`
+ Data types.JSONMap[any] `db:"data" json:"data"`
+ Message string `db:"message" json:"message"`
+ Level int `db:"level" json:"level"`
+}
+
+func (l *Log) TableName() string {
+ return LogsTableName
+}
+
+// DBExport prepares and exports the current log model for db persistence.
+//
+// It also truncates the log's message and data to ensure that it is
+// under app.Settings().Logs.MaxDataSize.
+func (l *Log) DBExport(app App) (map[string]any, error) {
+ result := map[string]any{
+ "id": l.Id,
+ "created": l.Created,
+ "level": l.Level,
+ }
+
+ // truncate the raw message bytes
+ // (this is expected to be very rare so it is ok even if multi-byte chars)
+ if int64(len(l.Message)) > defaultMaxLogMessageSize {
+ result["message"] = l.Message[:defaultMaxLogMessageSize]
+ } else {
+ result["message"] = l.Message
+ }
+
+ // @todo once added in the standard library consider replacing with
+ // WithByteLimit and WithDepthLimit as suggested in https://github.com/golang/go/issues/56733
+ if len(l.Data) == 0 {
+ result["data"] = l.Data
+ } else {
+ maxDataSize := app.Settings().Logs.MaxDataSize
+ if maxDataSize == 0 {
+ maxDataSize = defaultMaxLogDataSize
+ }
+
+ rawData, err := l.Data.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+
+ if int64(len(rawData)) > maxDataSize {
+ truncatedData := types.JSONMap[any]{}
+
+ // ignore syntax errors in case of truncated incomplete json
+ //
+ // jsonv2 stream decodes and all "valid" attrs read up to the
+ // invalid part will be populated in truncatedData
+ _ = json.Unmarshal(rawData[:maxDataSize], &truncatedData)
+
+ truncatedData["__pb_truncated__"] = true
+
+ rawData, err = truncatedData.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ result["data"] = types.JSONRaw(rawData)
+ }
+
+ return result, nil
+}
diff --git a/core/log_model_test.go b/core/log_model_test.go
new file mode 100644
index 0000000000..2e3c170f7b
--- /dev/null
+++ b/core/log_model_test.go
@@ -0,0 +1,119 @@
+package core_test
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestLogTableName(t *testing.T) {
+ t.Parallel()
+
+ var log core.Log
+
+ if name := log.TableName(); name != core.LogsTableName {
+ t.Fatalf("Expected Log table name %q, got %q", core.LogsTableName, name)
+ }
+}
+
+func TestLogDBExport(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ date, err := types.ParseDateTime("2026-08-18 10:20:30.456Z")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ messageLimit := 8000
+ dataLimit := 16 << 10
+
+ scenarios := []struct {
+ name string
+ log core.Log
+ limit int64 // 0 -> use default
+ expectJSON string
+ }{
+ {
+ "empty log",
+ core.Log{},
+ 0,
+ `{"created":"","data":{},"id":"","level":0,"message":""}`,
+ },
+ {
+ "with message and data below the default limits",
+ core.Log{
+ BaseModel: core.BaseModel{Id: "test_id"},
+ Created: date,
+ Level: 123,
+ Message: "test_message",
+ Data: types.JSONMap[any]{"a": "test1", "b": "test2"},
+ },
+ 0,
+ `{"created":"2026-08-18 10:20:30.456Z","data":{"a":"test1","b":"test2"},"id":"test_id","level":123,"message":"test_message"}`,
+ },
+ {
+ "with message and data exactly the default limits",
+ core.Log{
+ BaseModel: core.BaseModel{Id: "test_id"},
+ Created: date,
+ Level: 123,
+ Message: strings.Repeat("a", messageLimit),
+ Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", dataLimit-32)},
+ },
+ 0,
+ `{"created":"2026-08-18 10:20:30.456Z","data":{"a":"test1","b":"test2","c":"` + strings.Repeat("a", dataLimit-32) + `"},"id":"test_id","level":123,"message":"` + strings.Repeat("a", messageLimit) + `"}`,
+ },
+ {
+ "with message and data above the default limits",
+ core.Log{
+ BaseModel: core.BaseModel{Id: "test_id"},
+ Created: date,
+ Level: 123,
+ Message: strings.Repeat("a", messageLimit) + "x", // "x" should be omitted
+ Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", dataLimit-32) + "x"}, // the end will be incomplete and something like `"c":"...aaaaaax`
+ },
+ 0,
+ `{"created":"2026-08-18 10:20:30.456Z","data":{"__pb_truncated__":true,"a":"test1","b":"test2","c":"` + strings.Repeat("a", dataLimit-32) + `x"},"id":"test_id","level":123,"message":"` + strings.Repeat("a", messageLimit) + `"}`,
+ },
+ {
+ "with data above custom limit",
+ core.Log{
+ BaseModel: core.BaseModel{Id: "test_id"},
+ Created: date,
+ Level: 123,
+ Message: "test_message",
+ Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", (2<<10)-32) + "x"}, // the end will be incomplete and something like `"c":"...aaaaaax`
+ },
+ 2 << 10,
+ `{"created":"2026-08-18 10:20:30.456Z","data":{"__pb_truncated__":true,"a":"test1","b":"test2","c":"` + strings.Repeat("a", (2<<10)-32) + `x"},"id":"test_id","level":123,"message":"test_message"}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ testApp.Settings().Logs.MaxDataSize = s.limit
+
+ result, err := s.log.DBExport(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(result, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !bytes.Equal(raw, []byte(s.expectJSON)) {
+ t.Fatalf("Expected export data\n%s\ngot\n%s", s.expectJSON, raw)
+ }
+ })
+ }
+}
diff --git a/core/log_printer.go b/core/log_printer.go
new file mode 100644
index 0000000000..0f8eb3b8f4
--- /dev/null
+++ b/core/log_printer.go
@@ -0,0 +1,67 @@
+package core
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/pocketbase/tools/logger"
+ "github.com/pocketbase/pocketbase/tools/store"
+ "github.com/spf13/cast"
+)
+
+var cachedColors = store.New[string, *color.Color](nil)
+
+// getColor returns [color.Color] object and cache it (if not already).
+func getColor(attrs ...color.Attribute) (c *color.Color) {
+ cacheKey := fmt.Sprint(attrs)
+ if c = cachedColors.Get(cacheKey); c == nil {
+ c = color.New(attrs...)
+ cachedColors.Set(cacheKey, c)
+ }
+ return
+}
+
+// printLog prints the provided log to the stderr.
+// (note: defined as variable to overwriting in the tests)
+var printLog = func(log *logger.Log) {
+ var str strings.Builder
+
+ switch log.Level {
+ case slog.LevelDebug:
+ str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
+ str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
+ case slog.LevelInfo:
+ str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
+ str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
+ case slog.LevelWarn:
+ str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
+ str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
+ case slog.LevelError:
+ str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
+ str.WriteString(getColor(color.FgRed).Sprint(log.Message))
+ default:
+ str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
+ str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
+ }
+
+ str.WriteString("\n")
+
+ if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
+ padding := 0
+ keys := []string{"error", "details"}
+ for _, k := range keys {
+ if v := log.Data[k]; v != nil {
+ str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
+ str.WriteString("\n")
+ padding += 3
+ }
+ }
+ } else if len(log.Data) > 0 {
+ str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
+ str.WriteString("\n")
+ }
+
+ fmt.Print(str.String())
+}
diff --git a/core/log_printer_test.go b/core/log_printer_test.go
new file mode 100644
index 0000000000..37f59baee7
--- /dev/null
+++ b/core/log_printer_test.go
@@ -0,0 +1,131 @@
+package core
+
+import (
+ "context"
+ "database/sql"
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/logger"
+)
+
+func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
+ // temp unset to avoid littering the stdout if the test fails when in dev mode
+ colorOutput := color.Output
+ color.Output = io.Discard
+ defer func() { color.Output = colorOutput }()
+
+ testLogLevel := 4
+
+ scenarios := []struct {
+ name string
+ isDev bool
+ levels []int
+ printedLevels []int
+ persistedLevels []int
+ }{
+ {
+ "dev mode",
+ true,
+ []int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
+ []int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
+ []int{testLogLevel, testLogLevel + 1},
+ },
+ {
+ "nondev mode",
+ false,
+ []int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
+ []int{},
+ []int{testLogLevel, testLogLevel + 1},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ const testDataDir = "./pb_base_app_test_data_dir/"
+ defer os.RemoveAll(testDataDir)
+
+ app := NewBaseApp(BaseAppConfig{
+ DataDir: testDataDir,
+ IsDev: s.isDev,
+ })
+ defer app.ClearBootstrap()
+
+ if err := app.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ // silence query logs
+ app.concurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
+ app.concurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
+ app.nonconcurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
+ app.nonconcurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
+
+ app.Settings().Logs.MinLevel = testLogLevel
+ if err := app.Save(app.Settings()); err != nil {
+ t.Fatal(err)
+ }
+
+ var printedLevels []int
+ var persistedLevels []int
+
+ ctx := context.WithValue(context.Background(), logger.BlockKey, true)
+
+ // track printed logs
+ originalPrintLog := printLog
+ defer func() {
+ printLog = originalPrintLog
+ }()
+ printLog = func(log *logger.Log) {
+ printedLevels = append(printedLevels, int(log.Level))
+ }
+
+ // track persisted logs
+ app.OnModelAfterCreateSuccess("_logs").BindFunc(func(e *ModelEvent) error {
+ l, ok := e.Model.(*Log)
+ if ok {
+ persistedLevels = append(persistedLevels, l.Level)
+ }
+ return e.Next()
+ })
+
+ // write and persist logs
+ for _, l := range s.levels {
+ app.Logger().Log(ctx, slog.Level(l), "test")
+ }
+ handler, ok := app.Logger().Handler().(*logger.BatchHandler)
+ if !ok {
+ t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
+ }
+ if err := handler.WriteAll(ctx); err != nil {
+ t.Fatalf("Failed to write all logs: %v", err)
+ }
+
+ // check persisted log levels
+ if len(s.persistedLevels) != len(persistedLevels) {
+ t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
+ }
+ for _, l := range persistedLevels {
+ if !list.ExistInSlice(l, s.persistedLevels) {
+ t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
+ }
+ }
+
+ // check printed log levels
+ if len(s.printedLevels) != len(printedLevels) {
+ t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
+ }
+ for _, l := range printedLevels {
+ if !list.ExistInSlice(l, s.printedLevels) {
+ t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
+ }
+ }
+ })
+ }
+}
diff --git a/core/log_query.go b/core/log_query.go
new file mode 100644
index 0000000000..a1ebc77a3e
--- /dev/null
+++ b/core/log_query.go
@@ -0,0 +1,65 @@
+package core
+
+import (
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+// LogQuery returns a new Log select query.
+func (app *BaseApp) LogQuery() *dbx.SelectQuery {
+ return app.AuxModelQuery(&Log{})
+}
+
+// FindLogById finds a single Log entry by its id.
+func (app *BaseApp) FindLogById(id string) (*Log, error) {
+ model := &Log{}
+
+ err := app.LogQuery().
+ AndWhere(dbx.HashExp{"id": id}).
+ Limit(1).
+ One(model)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return model, nil
+}
+
+// LogsStatsItem defines the total number of logs for a specific time period.
+type LogsStatsItem struct {
+ Date types.DateTime `db:"date" json:"date"`
+ Total int `db:"total" json:"total"`
+}
+
+// LogsStats returns hourly grouped logs statistics.
+func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
+ result := []*LogsStatsItem{}
+
+ query := app.LogQuery().
+ Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
+ GroupBy("date")
+
+ if expr != nil {
+ query.AndWhere(expr)
+ }
+
+ err := query.All(&result)
+
+ return result, err
+}
+
+// DeleteOldLogs delete all logs that are created before createdBefore.
+//
+// For better performance the logs delete is executed as plain SQL statement,
+// aka. no delete model hook events will be fired.
+func (app *BaseApp) DeleteOldLogs(createdBefore time.Time) error {
+ formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
+ expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
+
+ _, err := app.auxNonconcurrentDB.Delete((&Log{}).TableName(), expr).Execute()
+
+ return err
+}
diff --git a/core/log_query_test.go b/core/log_query_test.go
new file mode 100644
index 0000000000..03694cb3cc
--- /dev/null
+++ b/core/log_query_test.go
@@ -0,0 +1,114 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestFindLogById(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ tests.StubLogsData(app)
+
+ scenarios := []struct {
+ id string
+ expectError bool
+ }{
+ {"", true},
+ {"invalid", true},
+ {"00000000-9f38-44fb-bf82-c8f53b310d91", true},
+ {"873f2133-9f38-44fb-bf82-c8f53b310d91", false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.id), func(t *testing.T) {
+ log, err := app.FindLogById(s.id)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if log != nil && log.Id != s.id {
+ t.Fatalf("Expected log with id %q, got %q", s.id, log.Id)
+ }
+ })
+ }
+}
+
+func TestLogsStats(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ tests.StubLogsData(app)
+
+ expected := `[{"date":"2022-05-01 10:00:00.000Z","total":1},{"date":"2022-05-02 10:00:00.000Z","total":1}]`
+
+ now := time.Now().UTC().Format(types.DefaultDateLayout)
+ exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
+ result, err := app.LogsStats(exp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ encoded, _ := json.Marshal(result, json.Deterministic(true))
+ if string(encoded) != expected {
+ t.Fatalf("Expected\n%q\ngot\n%q", expected, string(encoded))
+ }
+}
+
+func TestDeleteOldLogs(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ tests.StubLogsData(app)
+
+ scenarios := []struct {
+ date string
+ expectedTotal int
+ }{
+ {"2022-01-01 10:00:00.000Z", 2}, // no logs to delete before that time
+ {"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
+ {"2022-05-03 11:00:00.000Z", 0}, // no more logs should have left
+ {"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.date, func(t *testing.T) {
+ date, dateErr := time.Parse(types.DefaultDateLayout, s.date)
+ if dateErr != nil {
+ t.Fatalf("Date error %v", dateErr)
+ }
+
+ deleteErr := app.DeleteOldLogs(date)
+ if deleteErr != nil {
+ t.Fatalf("Delete error %v", deleteErr)
+ }
+
+ // check total remaining logs
+ var total int
+ countErr := app.AuxModelQuery(&core.Log{}).Select("count(*)").Row(&total)
+ if countErr != nil {
+ t.Errorf("Count error %v", countErr)
+ }
+
+ if total != s.expectedTotal {
+ t.Errorf("Expected %d remaining logs, got %d", s.expectedTotal, total)
+ }
+ })
+ }
+}
diff --git a/core/mfa_model.go b/core/mfa_model.go
new file mode 100644
index 0000000000..4cac5e943d
--- /dev/null
+++ b/core/mfa_model.go
@@ -0,0 +1,158 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+const (
+ MFAMethodPassword = "password"
+ MFAMethodOAuth2 = "oauth2"
+ MFAMethodOTP = "otp"
+)
+
+const CollectionNameMFAs = "_mfas"
+
+var (
+ _ Model = (*MFA)(nil)
+ _ PreValidator = (*MFA)(nil)
+ _ RecordProxy = (*MFA)(nil)
+)
+
+// MFA defines a Record proxy for working with the mfas collection.
+type MFA struct {
+ *Record
+}
+
+// NewMFA instantiates and returns a new blank *MFA model.
+//
+// Example usage:
+//
+// mfa := core.NewMFA(app)
+// mfa.SetRecordRef(user.Id)
+// mfa.SetCollectionRef(user.Collection().Id)
+// mfa.SetMethod(core.MFAMethodPassword)
+// app.Save(mfa)
+func NewMFA(app App) *MFA {
+ m := &MFA{}
+
+ c, err := app.FindCachedCollectionByNameOrId(CollectionNameMFAs)
+ if err != nil {
+ // this is just to make tests easier since mfa is a system collection and it is expected to be always accessible
+ // (note: the loaded record is further checked on MFA.PreValidate())
+ c = NewBaseCollection("@__invalid__")
+ }
+
+ m.Record = NewRecord(c)
+
+ return m
+}
+
+// PreValidate implements the [PreValidator] interface and checks
+// whether the proxy is properly loaded.
+func (m *MFA) PreValidate(ctx context.Context, app App) error {
+ if m.Record == nil || m.Record.Collection().Name != CollectionNameMFAs {
+ return errors.New("missing or invalid mfa ProxyRecord")
+ }
+
+ return nil
+}
+
+// ProxyRecord returns the proxied Record model.
+func (m *MFA) ProxyRecord() *Record {
+ return m.Record
+}
+
+// SetProxyRecord loads the specified record model into the current proxy.
+func (m *MFA) SetProxyRecord(record *Record) {
+ m.Record = record
+}
+
+// CollectionRef returns the "collectionRef" field value.
+func (m *MFA) CollectionRef() string {
+ return m.GetString("collectionRef")
+}
+
+// SetCollectionRef updates the "collectionRef" record field value.
+func (m *MFA) SetCollectionRef(collectionId string) {
+ m.Set("collectionRef", collectionId)
+}
+
+// RecordRef returns the "recordRef" record field value.
+func (m *MFA) RecordRef() string {
+ return m.GetString("recordRef")
+}
+
+// SetRecordRef updates the "recordRef" record field value.
+func (m *MFA) SetRecordRef(recordId string) {
+ m.Set("recordRef", recordId)
+}
+
+// Method returns the "method" record field value.
+func (m *MFA) Method() string {
+ return m.GetString("method")
+}
+
+// SetMethod updates the "method" record field value.
+func (m *MFA) SetMethod(method string) {
+ m.Set("method", method)
+}
+
+// Created returns the "created" record field value.
+func (m *MFA) Created() types.DateTime {
+ return m.GetDateTime("created")
+}
+
+// Updated returns the "updated" record field value.
+func (m *MFA) Updated() types.DateTime {
+ return m.GetDateTime("updated")
+}
+
+// HasExpired checks if the mfa is expired, aka. whether it has been
+// more than maxElapsed time since its creation.
+func (m *MFA) HasExpired(maxElapsed time.Duration) bool {
+ return time.Since(m.Created().Time()) > maxElapsed
+}
+
+func (app *BaseApp) registerMFAHooks() {
+ recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
+
+ // run on every hour to cleanup expired mfa sessions
+ app.Cron().Add("__pbMFACleanup__", "0 * * * *", func() {
+ if err := app.DeleteExpiredMFAs(); err != nil {
+ app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
+ }
+ })
+
+ // delete existing mfas on password change
+ app.OnRecordUpdate().Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ err := e.Next()
+ if err != nil || !e.Record.Collection().IsAuth() {
+ return err
+ }
+
+ old := e.Record.Original().GetString(FieldNamePassword + ":hash")
+ new := e.Record.GetString(FieldNamePassword + ":hash")
+ if old != new {
+ err = e.App.DeleteAllMFAsByRecord(e.Record)
+ if err != nil {
+ return fmt.Errorf(
+ "[%s] failed to delete all previous MFAs for record %q: %w",
+ e.Record.Collection().Name,
+ e.Record.Id,
+ err,
+ )
+ }
+ }
+
+ return nil
+ },
+ Priority: 99,
+ })
+}
diff --git a/core/mfa_model_test.go b/core/mfa_model_test.go
new file mode 100644
index 0000000000..694b7bef74
--- /dev/null
+++ b/core/mfa_model_test.go
@@ -0,0 +1,363 @@
+package core_test
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNewMFA(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ if mfa.Collection().Name != core.CollectionNameMFAs {
+ t.Fatalf("Expected record with %q collection, got %q", core.CollectionNameMFAs, mfa.Collection().Name)
+ }
+}
+
+func TestMFAProxyRecord(t *testing.T) {
+ t.Parallel()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.Id = "test_id"
+
+ mfa := core.MFA{}
+ mfa.SetProxyRecord(record)
+
+ if mfa.ProxyRecord() == nil || mfa.ProxyRecord().Id != record.Id {
+ t.Fatalf("Expected proxy record with id %q, got %v", record.Id, mfa.ProxyRecord())
+ }
+}
+
+func TestMFARecordRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ mfa.SetRecordRef(testValue)
+
+ if v := mfa.RecordRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := mfa.GetString("recordRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestMFACollectionRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ mfa.SetCollectionRef(testValue)
+
+ if v := mfa.CollectionRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := mfa.GetString("collectionRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestMFAMethod(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ mfa.SetMethod(testValue)
+
+ if v := mfa.Method(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := mfa.GetString("method"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestMFACreated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ if v := mfa.Created().String(); v != "" {
+ t.Fatalf("Expected empty created, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ mfa.SetRaw("created", now)
+
+ if v := mfa.Created().String(); v != now.String() {
+ t.Fatalf("Expected %q created, got %q", now.String(), v)
+ }
+}
+
+func TestMFAUpdated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfa := core.NewMFA(app)
+
+ if v := mfa.Updated().String(); v != "" {
+ t.Fatalf("Expected empty updated, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ mfa.SetRaw("updated", now)
+
+ if v := mfa.Updated().String(); v != now.String() {
+ t.Fatalf("Expected %q updated, got %q", now.String(), v)
+ }
+}
+
+func TestMFAHasExpired(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ now := types.NowDateTime()
+
+ mfa := core.NewMFA(app)
+ mfa.SetRaw("created", now.Add(-5*time.Minute))
+
+ scenarios := []struct {
+ maxElapsed time.Duration
+ expected bool
+ }{
+ {0 * time.Minute, true},
+ {3 * time.Minute, true},
+ {5 * time.Minute, true},
+ {6 * time.Minute, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.maxElapsed.String()), func(t *testing.T) {
+ result := mfa.HasExpired(s.maxElapsed)
+
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestMFAPreValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ mfasCol, err := app.FindCollectionByNameOrId(core.CollectionNameMFAs)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("no proxy record", func(t *testing.T) {
+ mfa := &core.MFA{}
+
+ if err := app.Validate(mfa); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("non-MFA collection", func(t *testing.T) {
+ mfa := &core.MFA{}
+ mfa.SetProxyRecord(core.NewRecord(core.NewBaseCollection("invalid")))
+ mfa.SetRecordRef(user.Id)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetMethod("test123")
+
+ if err := app.Validate(mfa); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("MFA collection", func(t *testing.T) {
+ mfa := &core.MFA{}
+ mfa.SetProxyRecord(core.NewRecord(mfasCol))
+ mfa.SetRecordRef(user.Id)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetMethod("test123")
+
+ if err := app.Validate(mfa); err != nil {
+ t.Fatalf("Expected nil validation error, got %v", err)
+ }
+ })
+}
+
+func TestMFAValidateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ mfa func() *core.MFA
+ expectErrors []string
+ }{
+ {
+ "empty",
+ func() *core.MFA {
+ return core.NewMFA(app)
+ },
+ []string{"collectionRef", "recordRef", "method"},
+ },
+ {
+ "non-auth collection",
+ func() *core.MFA {
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(demo1.Collection().Id)
+ mfa.SetRecordRef(demo1.Id)
+ mfa.SetMethod("test123")
+ return mfa
+ },
+ []string{"collectionRef"},
+ },
+ {
+ "missing record id",
+ func() *core.MFA {
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef("missing")
+ mfa.SetMethod("test123")
+ return mfa
+ },
+ []string{"recordRef"},
+ },
+ {
+ "valid ref",
+ func() *core.MFA {
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod("test123")
+ return mfa
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := app.Validate(s.mfa())
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestMFAClearOnPasswordChange(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ mfasToCreate := map[*core.Record]int{
+ user1: 3,
+ user2: 2,
+ }
+ for user, total := range mfasToCreate {
+ for range total {
+ mfa := core.NewMFA(app)
+ mfa.SetCollectionRef(user.Collection().Id)
+ mfa.SetRecordRef(user.Id)
+ mfa.SetMethod(core.MFAMethodPassword)
+ if err := app.Save(mfa); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ // update both users
+ err = app.Save(user1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2.SetRandomPassword()
+ err = app.Save(user2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectedMFAs := map[*core.Record]int{
+ user1: 3,
+ user2: 0,
+ }
+
+ for user, expected := range expectedMFAs {
+ mfas, err := app.FindAllMFAsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(mfas) != expected {
+ t.Fatalf("Expected %d MFAs, got %d", expected, len(mfas))
+ }
+ }
+}
diff --git a/core/mfa_query.go b/core/mfa_query.go
new file mode 100644
index 0000000000..1ccfd457c1
--- /dev/null
+++ b/core/mfa_query.go
@@ -0,0 +1,117 @@
+package core
+
+import (
+ "errors"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+// FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
+func (app *BaseApp) FindAllMFAsByRecord(authRecord *Record) ([]*MFA, error) {
+ result := []*MFA{}
+
+ err := app.RecordQuery(CollectionNameMFAs).
+ AndWhere(dbx.HashExp{
+ "collectionRef": authRecord.Collection().Id,
+ "recordRef": authRecord.Id,
+ }).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindAllMFAsByCollection returns all MFA models linked to the provided collection.
+func (app *BaseApp) FindAllMFAsByCollection(collection *Collection) ([]*MFA, error) {
+ result := []*MFA{}
+
+ err := app.RecordQuery(CollectionNameMFAs).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindMFAById returns a single MFA model by its id.
+func (app *BaseApp) FindMFAById(id string) (*MFA, error) {
+ result := &MFA{}
+
+ err := app.RecordQuery(CollectionNameMFAs).
+ AndWhere(dbx.HashExp{"id": id}).
+ Limit(1).
+ One(result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteAllMFAsByRecord deletes all MFA models associated with the provided record.
+//
+// Returns a combined error with the failed deletes.
+func (app *BaseApp) DeleteAllMFAsByRecord(authRecord *Record) error {
+ models, err := app.FindAllMFAsByRecord(authRecord)
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ for _, m := range models {
+ if err := app.Delete(m); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+// DeleteExpiredMFAs deletes the expired MFAs for all auth collections.
+func (app *BaseApp) DeleteExpiredMFAs() error {
+ authCollections, err := app.FindAllCollections(CollectionTypeAuth)
+ if err != nil {
+ return err
+ }
+
+ // note: perform even if MFA is disabled to ensure that there are no dangling old records
+ for _, collection := range authCollections {
+ minValidDate, err := types.ParseDateTime(time.Now().Add(-1 * collection.MFA.DurationTime()))
+ if err != nil {
+ return err
+ }
+
+ items := []*Record{}
+
+ err = app.RecordQuery(CollectionNameMFAs).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ AndWhere(dbx.NewExp("[[created]] < {:date}", dbx.Params{"date": minValidDate})).
+ All(&items)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range items {
+ err = app.Delete(item)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/core/mfa_query_test.go b/core/mfa_query_test.go
new file mode 100644
index 0000000000..e26e2b3c27
--- /dev/null
+++ b/core/mfa_query_test.go
@@ -0,0 +1,311 @@
+package core_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestFindAllMFAsByRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ expected []string
+ }{
+ {demo1, nil},
+ {superuser2, []string{"superuser2_0", "superuser2_3", "superuser2_2", "superuser2_1", "superuser2_4"}},
+ {superuser4, nil},
+ {user1, []string{"user1_0"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.record.Collection().Name+"_"+s.record.Id, func(t *testing.T) {
+ result, err := app.FindAllMFAsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total mfas %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAllMFAsByCollection(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ clients, err := app.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ collection *core.Collection
+ expected []string
+ }{
+ {demo1, nil},
+ {superusers, []string{
+ "superuser2_0",
+ "superuser2_3",
+ "superuser3_0",
+ "superuser2_2",
+ "superuser3_1",
+ "superuser2_1",
+ "superuser2_4",
+ }},
+ {clients, nil},
+ {users, []string{"user1_0"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.collection.Name, func(t *testing.T) {
+ result, err := app.FindAllMFAsByCollection(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total mfas %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindMFAById(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ id string
+ expectError bool
+ }{
+ {"", true},
+ {"84nmscqy84lsi1t", true}, // non-mfa id
+ {"superuser2_0", false},
+ {"superuser2_4", false}, // expired
+ {"user1_0", false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.id, func(t *testing.T) {
+ result, err := app.FindMFAById(s.id)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if result.Id != s.id {
+ t.Fatalf("Expected record with id %q, got %q", s.id, result.Id)
+ }
+ })
+ }
+}
+
+func TestDeleteAllMFAsByRecord(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ deletedIds []string
+ }{
+ {demo1, nil}, // non-auth record
+ {superuser2, []string{"superuser2_0", "superuser2_1", "superuser2_3", "superuser2_2", "superuser2_4"}},
+ {superuser4, nil},
+ {user1, []string{"user1_0"}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ deletedIds := []string{}
+ app.OnRecordAfterDeleteSuccess().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ err := app.DeleteAllMFAsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(s.deletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
+ }
+
+ for _, id := range s.deletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ })
+ }
+}
+
+func TestDeleteExpiredMFAs(t *testing.T) {
+ t.Parallel()
+
+ checkDeletedIds := func(app core.App, t *testing.T, expectedDeletedIds []string) {
+ if err := tests.StubMFARecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ deletedIds := []string{}
+ app.OnRecordDelete().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ if err := app.DeleteExpiredMFAs(); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(expectedDeletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", expectedDeletedIds, deletedIds)
+ }
+
+ for _, id := range expectedDeletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ }
+
+ t.Run("default test collections", func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ checkDeletedIds(app, t, []string{
+ "user1_0",
+ "superuser2_1",
+ "superuser2_4",
+ })
+ })
+
+ t.Run("mfa collection duration mock", func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ superusers.MFA.Duration = 60
+ if err := app.Save(superusers); err != nil {
+ t.Fatalf("Failed to mock superusers mfa duration: %v", err)
+ }
+
+ checkDeletedIds(app, t, []string{
+ "user1_0",
+ "superuser2_1",
+ "superuser2_2",
+ "superuser2_4",
+ "superuser3_1",
+ })
+ })
+}
diff --git a/core/migrations_list.go b/core/migrations_list.go
new file mode 100644
index 0000000000..dc5418147e
--- /dev/null
+++ b/core/migrations_list.go
@@ -0,0 +1,83 @@
+package core
+
+import (
+ "path/filepath"
+ "runtime"
+ "sort"
+)
+
+type Migration struct {
+ Up func(txApp App) error
+ Down func(txApp App) error
+ File string
+ ReapplyCondition func(txApp App, runner *MigrationsRunner, fileName string) (bool, error)
+}
+
+// MigrationsList defines a list with migration definitions
+type MigrationsList struct {
+ list []*Migration
+}
+
+// Item returns a single migration from the list by its index.
+func (l *MigrationsList) Item(index int) *Migration {
+ return l.list[index]
+}
+
+// Items returns the internal migrations list slice.
+func (l *MigrationsList) Items() []*Migration {
+ return l.list
+}
+
+// Copy copies all provided list migrations into the current one.
+func (l *MigrationsList) Copy(list MigrationsList) {
+ for _, item := range list.Items() {
+ l.Register(item.Up, item.Down, item.File)
+ }
+}
+
+// Add adds adds an existing migration definition to the list.
+//
+// If m.File is not provided, it will try to get the name from its .go file.
+//
+// The list will be sorted automatically based on the migrations file name.
+func (l *MigrationsList) Add(m *Migration) {
+ if m.File == "" {
+ _, path, _, _ := runtime.Caller(1)
+ m.File = filepath.Base(path)
+ }
+
+ l.list = append(l.list, m)
+
+ sort.SliceStable(l.list, func(i int, j int) bool {
+ return l.list[i].File < l.list[j].File
+ })
+}
+
+// Register adds new migration definition to the list.
+//
+// If optFilename is not provided, it will try to get the name from its .go file.
+//
+// The list will be sorted automatically based on the migrations file name.
+func (l *MigrationsList) Register(
+ up func(txApp App) error,
+ down func(txApp App) error,
+ optFilename ...string,
+) {
+ var file string
+ if len(optFilename) > 0 {
+ file = optFilename[0]
+ } else {
+ _, path, _, _ := runtime.Caller(1)
+ file = filepath.Base(path)
+ }
+
+ l.list = append(l.list, &Migration{
+ File: file,
+ Up: up,
+ Down: down,
+ })
+
+ sort.SliceStable(l.list, func(i int, j int) bool {
+ return l.list[i].File < l.list[j].File
+ })
+}
diff --git a/core/migrations_list_test.go b/core/migrations_list_test.go
new file mode 100644
index 0000000000..36acd407d6
--- /dev/null
+++ b/core/migrations_list_test.go
@@ -0,0 +1,48 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func TestMigrationsList(t *testing.T) {
+ l1 := core.MigrationsList{}
+ l1.Add(&core.Migration{File: "5_test.go"})
+ l1.Add(&core.Migration{ /* auto detect file name */ })
+ l1.Register(nil, nil, "3_test.go")
+ l1.Register(nil, nil, "1_test.go")
+ l1.Register(nil, nil, "2_test.go")
+ l1.Register(nil, nil /* auto detect file name */)
+
+ l2 := core.MigrationsList{}
+ l2.Register(nil, nil, "4_test.go")
+ l2.Copy(l1)
+
+ expected := []string{
+ "1_test.go",
+ "2_test.go",
+ "3_test.go",
+ "4_test.go",
+ "5_test.go",
+ // twice because there 2 test migrations with auto filename
+ "migrations_list_test.go",
+ "migrations_list_test.go",
+ }
+
+ items := l2.Items()
+ if len(items) != len(expected) {
+ names := make([]string, len(items))
+ for i, item := range items {
+ names[i] = item.File
+ }
+ t.Fatalf("Expected %d items, got %d:\n%v", len(expected), len(names), names)
+ }
+
+ for i, name := range expected {
+ item := l2.Item(i)
+ if item.File != name {
+ t.Fatalf("Expected name %s for index %d, got %s", name, i, item.File)
+ }
+ }
+}
diff --git a/core/migrations_runner.go b/core/migrations_runner.go
new file mode 100644
index 0000000000..9b0ff76a82
--- /dev/null
+++ b/core/migrations_runner.go
@@ -0,0 +1,317 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/osutils"
+ "github.com/spf13/cast"
+)
+
+var AppMigrations MigrationsList
+var SystemMigrations MigrationsList
+
+const DefaultMigrationsTable = "_migrations"
+
+// MigrationsRunner defines a simple struct for managing the execution of db migrations.
+type MigrationsRunner struct {
+ app App
+ tableName string
+ migrationsList MigrationsList
+ inited bool
+}
+
+// NewMigrationsRunner creates and initializes a new db migrations MigrationsRunner instance.
+func NewMigrationsRunner(app App, migrationsList MigrationsList) *MigrationsRunner {
+ return &MigrationsRunner{
+ app: app,
+ migrationsList: migrationsList,
+ tableName: DefaultMigrationsTable,
+ }
+}
+
+// Run interactively executes the current runner with the provided args.
+//
+// The following commands are supported:
+// - up - applies all migrations
+// - down [n] - reverts the last n (default 1) applied migrations
+// - history-sync - syncs the migrations table with the runner's migrations list
+func (r *MigrationsRunner) Run(args ...string) error {
+ if err := r.initMigrationsTable(); err != nil {
+ return err
+ }
+
+ cmd := "up"
+ if len(args) > 0 {
+ cmd = args[0]
+ }
+
+ switch cmd {
+ case "up":
+ applied, err := r.Up()
+ if err != nil {
+ return err
+ }
+
+ if len(applied) == 0 {
+ color.Green("No new migrations to apply.")
+ } else {
+ for _, file := range applied {
+ color.Green("Applied %s", file)
+ }
+ }
+
+ return nil
+ case "down":
+ toRevertCount := 1
+ if len(args) > 1 {
+ toRevertCount = cast.ToInt(args[1])
+ if toRevertCount < 0 {
+ // revert all applied migrations
+ toRevertCount = len(r.migrationsList.Items())
+ }
+ }
+
+ names, err := r.lastAppliedMigrations(toRevertCount)
+ if err != nil {
+ return err
+ }
+
+ confirm := osutils.YesNoPrompt(fmt.Sprintf(
+ "\n%v\nDo you really want to revert the last %d applied migration(s)?",
+ strings.Join(names, "\n"),
+ toRevertCount,
+ ), false)
+ if !confirm {
+ fmt.Println("The command has been cancelled")
+ return nil
+ }
+
+ reverted, err := r.Down(toRevertCount)
+ if err != nil {
+ return err
+ }
+
+ if len(reverted) == 0 {
+ color.Green("No migrations to revert.")
+ } else {
+ for _, file := range reverted {
+ color.Green("Reverted %s", file)
+ }
+ }
+
+ return nil
+ case "history-sync":
+ if err := r.RemoveMissingAppliedMigrations(); err != nil {
+ return err
+ }
+
+ color.Green("The %s table was synced with the available migrations.", r.tableName)
+ return nil
+ default:
+ return fmt.Errorf("unsupported command: %q", cmd)
+ }
+}
+
+// Up executes all unapplied migrations for the provided runner.
+//
+// On success returns list with the applied migrations file names.
+func (r *MigrationsRunner) Up() ([]string, error) {
+ if err := r.initMigrationsTable(); err != nil {
+ return nil, err
+ }
+
+ applied := []string{}
+
+ err := r.app.AuxRunInTransaction(func(txApp App) error {
+ return txApp.RunInTransaction(func(txApp App) error {
+ for _, m := range r.migrationsList.Items() {
+ // applied migrations check
+ if r.isMigrationApplied(txApp, m.File) {
+ if m.ReapplyCondition == nil {
+ continue // no need to reapply
+ }
+
+ shouldReapply, err := m.ReapplyCondition(txApp, r, m.File)
+ if err != nil {
+ return err
+ }
+ if !shouldReapply {
+ continue
+ }
+
+ // clear previous history stored entry
+ // (it will be recreated after successful execution)
+ r.saveRevertedMigration(txApp, m.File)
+ }
+
+ // ignore empty Up action
+ if m.Up != nil {
+ if err := m.Up(txApp); err != nil {
+ return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
+ }
+ }
+
+ if err := r.saveAppliedMigration(txApp, m.File); err != nil {
+ return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
+ }
+
+ applied = append(applied, m.File)
+ }
+
+ return nil
+ })
+ })
+
+ if err != nil {
+ return nil, err
+ }
+ return applied, nil
+}
+
+// Down reverts the last `toRevertCount` applied migrations
+// (in the order they were applied).
+//
+// On success returns list with the reverted migrations file names.
+func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
+ if err := r.initMigrationsTable(); err != nil {
+ return nil, err
+ }
+
+ reverted := make([]string, 0, toRevertCount)
+
+ names, appliedErr := r.lastAppliedMigrations(toRevertCount)
+ if appliedErr != nil {
+ return nil, appliedErr
+ }
+
+ err := r.app.AuxRunInTransaction(func(txApp App) error {
+ return txApp.RunInTransaction(func(txApp App) error {
+ for _, name := range names {
+ for _, m := range r.migrationsList.Items() {
+ if m.File != name {
+ continue
+ }
+
+ // revert limit reached
+ if toRevertCount-len(reverted) <= 0 {
+ return nil
+ }
+
+ // ignore empty Down action
+ if m.Down != nil {
+ if err := m.Down(txApp); err != nil {
+ return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
+ }
+ }
+
+ if err := r.saveRevertedMigration(txApp, m.File); err != nil {
+ return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
+ }
+
+ reverted = append(reverted, m.File)
+ }
+ }
+ return nil
+ })
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return reverted, nil
+}
+
+// RemoveMissingAppliedMigrations removes the db entries of all applied migrations
+// that are not listed in the runner's migrations list.
+func (r *MigrationsRunner) RemoveMissingAppliedMigrations() error {
+ loadedMigrations := r.migrationsList.Items()
+
+ names := make([]any, len(loadedMigrations))
+ for i, migration := range loadedMigrations {
+ names[i] = migration.File
+ }
+
+ _, err := r.app.DB().Delete(r.tableName, dbx.Not(dbx.HashExp{
+ "file": names,
+ })).Execute()
+
+ return err
+}
+
+func (r *MigrationsRunner) initMigrationsTable() error {
+ if r.inited {
+ return nil // already inited
+ }
+
+ rawQuery := fmt.Sprintf(
+ "CREATE TABLE IF NOT EXISTS {{%s}} (file VARCHAR(255) PRIMARY KEY NOT NULL, applied INTEGER NOT NULL)",
+ r.tableName,
+ )
+
+ _, err := r.app.DB().NewQuery(rawQuery).Execute()
+
+ if err == nil {
+ r.inited = true
+ }
+
+ return err
+}
+
+func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
+ var exists int
+
+ err := txApp.DB().Select("(1)").
+ From(r.tableName).
+ Where(dbx.HashExp{"file": file}).
+ Limit(1).
+ Row(&exists)
+
+ return err == nil && exists > 0
+}
+
+func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
+ _, err := txApp.DB().Insert(r.tableName, dbx.Params{
+ "file": file,
+ "applied": time.Now().UnixMicro(),
+ }).Execute()
+
+ return err
+}
+
+func (r *MigrationsRunner) saveRevertedMigration(txApp App, file string) error {
+ _, err := txApp.DB().Delete(r.tableName, dbx.HashExp{"file": file}).Execute()
+
+ return err
+}
+
+func (r *MigrationsRunner) lastAppliedMigrations(limit int) ([]string, error) {
+ var files = make([]string, 0, limit)
+
+ loadedMigrations := r.migrationsList.Items()
+
+ names := make([]any, len(loadedMigrations))
+ for i, migration := range loadedMigrations {
+ names[i] = migration.File
+ }
+
+ err := r.app.DB().Select("file").
+ From(r.tableName).
+ Where(dbx.Not(dbx.HashExp{"applied": nil})).
+ AndWhere(dbx.HashExp{"file": names}).
+ // unify microseconds and seconds applied time for backward compatibility
+ OrderBy("substr(applied||'0000000000000000', 0, 17) DESC").
+ AndOrderBy("file DESC").
+ Limit(int64(limit)).
+ Column(&files)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return files, nil
+}
diff --git a/core/migrations_runner_test.go b/core/migrations_runner_test.go
new file mode 100644
index 0000000000..d16e4e2f09
--- /dev/null
+++ b/core/migrations_runner_test.go
@@ -0,0 +1,212 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestMigrationsRunnerUpAndDown(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ callsOrder := []string{}
+
+ l := core.MigrationsList{}
+ l.Register(func(app core.App) error {
+ callsOrder = append(callsOrder, "up2")
+ return nil
+ }, func(app core.App) error {
+ callsOrder = append(callsOrder, "down2")
+ return nil
+ }, "2_test")
+ l.Register(func(app core.App) error {
+ callsOrder = append(callsOrder, "up3")
+ return nil
+ }, func(app core.App) error {
+ callsOrder = append(callsOrder, "down3")
+ return nil
+ }, "3_test")
+ l.Register(func(app core.App) error {
+ callsOrder = append(callsOrder, "up1")
+ return nil
+ }, func(app core.App) error {
+ callsOrder = append(callsOrder, "down1")
+ return nil
+ }, "1_test")
+ l.Register(func(app core.App) error {
+ callsOrder = append(callsOrder, "up4")
+ return nil
+ }, func(app core.App) error {
+ callsOrder = append(callsOrder, "down4")
+ return nil
+ }, "4_test")
+ l.Add(&core.Migration{
+ Up: func(app core.App) error {
+ callsOrder = append(callsOrder, "up5")
+ return nil
+ },
+ Down: func(app core.App) error {
+ callsOrder = append(callsOrder, "down5")
+ return nil
+ },
+ File: "5_test",
+ ReapplyCondition: func(txApp core.App, runner *core.MigrationsRunner, fileName string) (bool, error) {
+ return true, nil
+ },
+ })
+
+ runner := core.NewMigrationsRunner(app, l)
+
+ // ---------------------------------------------------------------
+ // simulate partially out-of-order applied migration
+ // ---------------------------------------------------------------
+
+ _, err := app.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
+ "file": "4_test",
+ "applied": time.Now().UnixMicro() - 2,
+ }).Execute()
+ if err != nil {
+ t.Fatalf("Failed to insert 5_test migration: %v", err)
+ }
+
+ _, err = app.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
+ "file": "5_test",
+ "applied": time.Now().UnixMicro() - 1,
+ }).Execute()
+ if err != nil {
+ t.Fatalf("Failed to insert 5_test migration: %v", err)
+ }
+
+ _, err = app.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
+ "file": "2_test",
+ "applied": time.Now().UnixMicro(),
+ }).Execute()
+ if err != nil {
+ t.Fatalf("Failed to insert 2_test migration: %v", err)
+ }
+
+ // ---------------------------------------------------------------
+ // Up()
+ // ---------------------------------------------------------------
+
+ if _, err := runner.Up(); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedUpCallsOrder := `["up1","up3","up5"]` // skip up2 and up4 since they were applied already (up5 has extra reapply condition)
+
+ upCallsOrder, err := json.Marshal(callsOrder)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v := string(upCallsOrder); v != expectedUpCallsOrder {
+ t.Fatalf("Expected Up() calls order %s, got %s", expectedUpCallsOrder, upCallsOrder)
+ }
+
+ // ---------------------------------------------------------------
+
+ // reset callsOrder
+ callsOrder = []string{}
+
+ // simulate unrun migration
+ l.Register(nil, func(app core.App) error {
+ callsOrder = append(callsOrder, "down6")
+ return nil
+ }, "6_test")
+
+ // simulate applied migrations from different migrations list
+ _, err = app.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
+ "file": "from_different_list",
+ "applied": time.Now().UnixMicro(),
+ }).Execute()
+ if err != nil {
+ t.Fatalf("Failed to insert from_different_list migration: %v", err)
+ }
+
+ // ---------------------------------------------------------------
+
+ // ---------------------------------------------------------------
+ // Down()
+ // ---------------------------------------------------------------
+
+ if _, err := runner.Down(2); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedDownCallsOrder := `["down5","down3"]` // revert in the applied order
+
+ downCallsOrder, err := json.Marshal(callsOrder)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if v := string(downCallsOrder); v != expectedDownCallsOrder {
+ t.Fatalf("Expected Down() calls order %s, got %s", expectedDownCallsOrder, downCallsOrder)
+ }
+}
+
+func TestMigrationsRunnerRemoveMissingAppliedMigrations(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // mock migrations history
+ for i := 1; i <= 3; i++ {
+ _, err := app.DB().Insert(core.DefaultMigrationsTable, dbx.Params{
+ "file": fmt.Sprintf("%d_test", i),
+ "applied": time.Now().UnixMicro(),
+ }).Execute()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if !isMigrationApplied(app, "2_test") {
+ t.Fatalf("Expected 2_test migration to be applied")
+ }
+
+ // create a runner without 2_test to mock deleted migration
+ l := core.MigrationsList{}
+ l.Register(func(app core.App) error {
+ return nil
+ }, func(app core.App) error {
+ return nil
+ }, "1_test")
+ l.Register(func(app core.App) error {
+ return nil
+ }, func(app core.App) error {
+ return nil
+ }, "3_test")
+
+ r := core.NewMigrationsRunner(app, l)
+
+ if err := r.RemoveMissingAppliedMigrations(); err != nil {
+ t.Fatalf("Failed to remove missing applied migrations: %v", err)
+ }
+
+ if isMigrationApplied(app, "2_test") {
+ t.Fatalf("Expected 2_test migration to NOT be applied")
+ }
+}
+
+func isMigrationApplied(app core.App, file string) bool {
+ var exists int
+
+ err := app.DB().Select("(1)").
+ From(core.DefaultMigrationsTable).
+ Where(dbx.HashExp{"file": file}).
+ Limit(1).
+ Row(&exists)
+
+ return err == nil && exists > 0
+}
diff --git a/core/notify_watcher.go b/core/notify_watcher.go
new file mode 100644
index 0000000000..40d0a16654
--- /dev/null
+++ b/core/notify_watcher.go
@@ -0,0 +1,211 @@
+package core
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/fatih/color"
+ "github.com/fsnotify/fsnotify"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/routine"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+const systemHookIdNotifyWatcher = "__pbNotifyWatcherSystemHook__"
+
+func (app *BaseApp) registerNotifyWatcherHooks() {
+ var notifyWatcher *fsnotify.Watcher
+
+ instanceId := "@" + security.PseudorandomString(10)
+
+ localNotifyDirPath := filepath.Join(app.DataDir(), LocalNotifyDirName)
+ settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
+ collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
+
+ // init
+ app.OnBootstrap().Bind(&hook.Handler[*BootstrapEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: func(e *BootstrapEvent) error {
+ err := e.Next()
+ if err != nil {
+ return err
+ }
+
+ if notifyWatcher != nil {
+ _ = notifyWatcher.Close()
+ }
+
+ notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
+ if err != nil {
+ e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
+ }
+
+ return nil
+ },
+ Priority: -998,
+ })
+
+ // cleanup
+ app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: func(e *TerminateEvent) error {
+ if notifyWatcher != nil {
+ _ = notifyWatcher.Close()
+ }
+
+ _ = os.Remove(settingsFile)
+ _ = os.Remove(collectionsFile)
+
+ return e.Next()
+ },
+ Priority: -998,
+ })
+
+ // ---------------------------------------------------------------
+
+ settingsNotify := func(e *ModelEvent) error {
+ err := e.Next()
+ if err != nil || e.Model.PK() != paramsKeySettings {
+ return err
+ }
+
+ if notifyWatcher != nil {
+ if err := os.WriteFile(settingsFile, nil, 0644); err != nil {
+ e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
+ }
+ _ = os.Remove(settingsFile)
+ }
+
+ return nil
+ }
+ app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: settingsNotify,
+ Priority: 999,
+ })
+ app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: settingsNotify,
+ Priority: 999,
+ })
+
+ // ---------------------------------------------------------------
+
+ collectionsNotify := func(e *CollectionEvent) error {
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ if notifyWatcher != nil {
+ if err := os.WriteFile(collectionsFile, nil, 0644); err != nil {
+ e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
+ }
+ _ = os.Remove(collectionsFile)
+ }
+
+ return nil
+ }
+ app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: collectionsNotify,
+ Priority: 999,
+ })
+ app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: collectionsNotify,
+ Priority: 999,
+ })
+ app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdNotifyWatcher,
+ Func: collectionsNotify,
+ Priority: 999,
+ })
+}
+
+func createNotifyDirWatcher(app App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
+ // create the notify dir (if not already)
+ err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create a notify dir: %w", err)
+ }
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
+ }
+
+ err = watcher.Add(localNotifyDirPath)
+ if err != nil {
+ _ = watcher.Close()
+ return nil, fmt.Errorf("unable to watch notify dir: %w", err)
+ }
+
+ var debounceTimer *time.Timer
+
+ stopDebounceTimer := func() {
+ if debounceTimer != nil {
+ debounceTimer.Stop()
+ debounceTimer = nil
+ }
+ }
+
+ // watch
+ 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
+}
diff --git a/core/notify_watcher_test.go b/core/notify_watcher_test.go
new file mode 100644
index 0000000000..a081c5e506
--- /dev/null
+++ b/core/notify_watcher_test.go
@@ -0,0 +1,190 @@
+package core_test
+
+import (
+ "context"
+ "database/sql"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/store"
+ "golang.org/x/sync/semaphore"
+)
+
+func TestNotifyWatcher_SettingsUpdate(t *testing.T) {
+ t.Parallel()
+
+ testEvents := store.New[core.App, int](nil)
+
+ tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ app1 := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: tmpDir,
+ })
+ if err := app1.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ app2 := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: tmpDir,
+ })
+ if err := app2.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ timeout := time.After(3 * time.Second)
+ done := make(chan struct{})
+
+ app1.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
+ testEvents.SetFunc(app1, func(old int) int {
+ return old + 1
+ })
+ return e.Next()
+ })
+
+ app2.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
+ testEvents.SetFunc(app2, func(old int) int {
+ defer func() {
+ done <- struct{}{}
+ }()
+
+ return old + 1
+ })
+ return e.Next()
+ })
+
+ // updating app1 settings should trigger a reload in app2
+ app1.Settings().SuperuserIPs = []string{"127.0.0.1"}
+ if err := app1.Save(app1.Settings()); err != nil {
+ t.Fatal(err)
+ }
+
+ // wait for the event
+ select {
+ case <-timeout:
+ t.Fatal("app2 reload event timeout")
+ case <-done:
+ // ready
+ }
+
+ if app1Total := testEvents.Get(app1); app1Total != 1 {
+ t.Fatalf("Expected 1 app1 event, got %d", app1Total)
+ }
+
+ if app2Total := testEvents.Get(app2); app2Total != 1 {
+ t.Fatalf("Expected 1 app2 event, got %d", app2Total)
+ }
+
+ app2SuperuserIPs := app2.Settings().SuperuserIPs
+ if len(app2SuperuserIPs) != 1 || app2SuperuserIPs[0] != "127.0.0.1" {
+ t.Fatalf("Expected exactly 127.0.0.1 superuser IP in app2 settings event, got %v", app2SuperuserIPs)
+ }
+}
+
+func TestNotifyWatcher_CollectionsUpdate(t *testing.T) {
+ t.Parallel()
+
+ tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ app1 := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: tmpDir,
+ })
+ if err := app1.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ app2 := core.NewBaseApp(core.BaseAppConfig{
+ DataDir: tmpDir,
+ })
+ if err := app2.Bootstrap(); err != nil {
+ t.Fatal(err)
+ }
+
+ testQueries := store.New[string, []string](nil)
+ app2.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ testQueries.SetFunc("concurrent", func(old []string) []string {
+ return append(old, sql)
+ })
+ }
+ app2.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ testQueries.SetFunc("concurrent", func(old []string) []string {
+ return append(old, sql)
+ })
+ }
+ app2.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ testQueries.SetFunc("nonconcurrent", func(old []string) []string {
+ return append(old, sql)
+ })
+ }
+ app2.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ testQueries.SetFunc("nonconcurrent", func(old []string) []string {
+ return append(old, sql)
+ })
+ }
+
+ ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancelCtx()
+
+ sem := semaphore.NewWeighted(1)
+ sem.Acquire(ctx, 1)
+
+ // currently there is no hook for the collections cache reload so we pool instead
+ done := make(chan bool, 1)
+ ticker := time.NewTicker(100 * time.Millisecond)
+ go func() {
+ for {
+ select {
+ case <-ticker.C:
+ if len(testQueries.Get("concurrent")) == 1 {
+ sem.Release(1)
+ return
+ }
+ case <-done:
+ return
+ }
+ }
+ }()
+
+ // create/update/delete app1 collections should trigger a reload in app2
+ dummyCollection := core.NewBaseCollection("test")
+ if err := app1.Save(dummyCollection); err != nil {
+ t.Fatal(err)
+ }
+ dummyCollection.Fields.Add(&core.TextField{Name: "test"})
+ if err := app1.Save(dummyCollection); err != nil {
+ t.Fatal(err)
+ }
+ if err := app1.Delete(dummyCollection); err != nil {
+ t.Fatal(err)
+ }
+
+ // block until released or timeouted
+ sem.Acquire(ctx, 1)
+ ticker.Stop()
+ done <- true
+
+ nonconcurrentQueries := testQueries.Get("nonconcurrent")
+ concurrentQueries := testQueries.Get("concurrent")
+
+ if len(nonconcurrentQueries) != 0 {
+ t.Fatalf("Expected 0 concurrent queries, got %d (%v)", len(nonconcurrentQueries), nonconcurrentQueries)
+ }
+ if len(concurrentQueries) != 1 {
+ t.Fatalf("Expected 1 concurrent query, got %d (%v)", len(concurrentQueries), concurrentQueries)
+ }
+
+ expectedQuery := "SELECT {{_collections}}.* FROM `_collections` ORDER BY `rowid` ASC"
+ if concurrentQueries[0] != expectedQuery {
+ t.Fatalf("Expected query\n%s\ngot\n%s", expectedQuery, concurrentQueries[0])
+ }
+}
diff --git a/core/otp_model.go b/core/otp_model.go
new file mode 100644
index 0000000000..81882958e0
--- /dev/null
+++ b/core/otp_model.go
@@ -0,0 +1,154 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+const CollectionNameOTPs = "_otps"
+
+var (
+ _ Model = (*OTP)(nil)
+ _ PreValidator = (*OTP)(nil)
+ _ RecordProxy = (*OTP)(nil)
+)
+
+// OTP defines a Record proxy for working with the otps collection.
+type OTP struct {
+ *Record
+}
+
+// NewOTP instantiates and returns a new blank *OTP model.
+//
+// Example usage:
+//
+// otp := core.NewOTP(app)
+// otp.SetRecordRef(user.Id)
+// otp.SetCollectionRef(user.Collection().Id)
+// otp.SetPassword(security.RandomStringWithAlphabet(6, "1234567890"))
+// app.Save(otp)
+func NewOTP(app App) *OTP {
+ m := &OTP{}
+
+ c, err := app.FindCachedCollectionByNameOrId(CollectionNameOTPs)
+ if err != nil {
+ // this is just to make tests easier since otp is a system collection and it is expected to be always accessible
+ // (note: the loaded record is further checked on OTP.PreValidate())
+ c = NewBaseCollection("__invalid__")
+ }
+
+ m.Record = NewRecord(c)
+
+ return m
+}
+
+// PreValidate implements the [PreValidator] interface and checks
+// whether the proxy is properly loaded.
+func (m *OTP) PreValidate(ctx context.Context, app App) error {
+ if m.Record == nil || m.Record.Collection().Name != CollectionNameOTPs {
+ return errors.New("missing or invalid otp ProxyRecord")
+ }
+
+ return nil
+}
+
+// ProxyRecord returns the proxied Record model.
+func (m *OTP) ProxyRecord() *Record {
+ return m.Record
+}
+
+// SetProxyRecord loads the specified record model into the current proxy.
+func (m *OTP) SetProxyRecord(record *Record) {
+ m.Record = record
+}
+
+// CollectionRef returns the "collectionRef" field value.
+func (m *OTP) CollectionRef() string {
+ return m.GetString("collectionRef")
+}
+
+// SetCollectionRef updates the "collectionRef" record field value.
+func (m *OTP) SetCollectionRef(collectionId string) {
+ m.Set("collectionRef", collectionId)
+}
+
+// RecordRef returns the "recordRef" record field value.
+func (m *OTP) RecordRef() string {
+ return m.GetString("recordRef")
+}
+
+// SetRecordRef updates the "recordRef" record field value.
+func (m *OTP) SetRecordRef(recordId string) {
+ m.Set("recordRef", recordId)
+}
+
+// SentTo returns the "sentTo" record field value.
+//
+// It could be any string value (email, phone, message app id, etc.)
+// and usually is used as part of the auth flow to update the verified
+// user state in case for example the sentTo value matches with the user record email.
+func (m *OTP) SentTo() string {
+ return m.GetString("sentTo")
+}
+
+// SetSentTo updates the "sentTo" record field value.
+func (m *OTP) SetSentTo(val string) {
+ m.Set("sentTo", val)
+}
+
+// Created returns the "created" record field value.
+func (m *OTP) Created() types.DateTime {
+ return m.GetDateTime("created")
+}
+
+// Updated returns the "updated" record field value.
+func (m *OTP) Updated() types.DateTime {
+ return m.GetDateTime("updated")
+}
+
+// HasExpired checks if the otp is expired, aka. whether it has been
+// more than maxElapsed time since its creation.
+func (m *OTP) HasExpired(maxElapsed time.Duration) bool {
+ return time.Since(m.Created().Time()) > maxElapsed
+}
+
+func (app *BaseApp) registerOTPHooks() {
+ recordRefHooks[*OTP](app, CollectionNameOTPs, CollectionTypeAuth)
+
+ // run on every hour to cleanup expired otp sessions
+ app.Cron().Add("__pbOTPCleanup__", "0 * * * *", func() {
+ if err := app.DeleteExpiredOTPs(); err != nil {
+ app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
+ }
+ })
+
+ // delete all record OTPs on tokenKey change to minimize the risk of hijacking attacks
+ app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
+ Func: func(e *RecordEvent) error {
+ err := e.Next()
+ if err != nil || !e.Record.Collection().IsAuth() {
+ return err
+ }
+
+ if !e.Record.Original().IsNew() && e.Record.Original().TokenKey() != e.Record.TokenKey() {
+ err := e.App.DeleteAllOTPsByRecord(e.Record)
+ if err != nil {
+ return fmt.Errorf(
+ "[%s] failed to delete all previous OTPs for record %q: %w",
+ e.Record.Collection().Name,
+ e.Record.Id,
+ err,
+ )
+ }
+ }
+
+ return nil
+ },
+ Priority: 99,
+ })
+}
diff --git a/core/otp_model_test.go b/core/otp_model_test.go
new file mode 100644
index 0000000000..a30aca1336
--- /dev/null
+++ b/core/otp_model_test.go
@@ -0,0 +1,363 @@
+package core_test
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestNewOTP(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ if otp.Collection().Name != core.CollectionNameOTPs {
+ t.Fatalf("Expected record with %q collection, got %q", core.CollectionNameOTPs, otp.Collection().Name)
+ }
+}
+
+func TestOTPProxyRecord(t *testing.T) {
+ t.Parallel()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.Id = "test_id"
+
+ otp := core.OTP{}
+ otp.SetProxyRecord(record)
+
+ if otp.ProxyRecord() == nil || otp.ProxyRecord().Id != record.Id {
+ t.Fatalf("Expected proxy record with id %q, got %v", record.Id, otp.ProxyRecord())
+ }
+}
+
+func TestOTPRecordRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ otp.SetRecordRef(testValue)
+
+ if v := otp.RecordRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := otp.GetString("recordRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestOTPCollectionRef(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ otp.SetCollectionRef(testValue)
+
+ if v := otp.CollectionRef(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := otp.GetString("collectionRef"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestOTPSentTo(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ testValues := []string{"test_1", "test2", ""}
+ for i, testValue := range testValues {
+ t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
+ otp.SetSentTo(testValue)
+
+ if v := otp.SentTo(); v != testValue {
+ t.Fatalf("Expected getter %q, got %q", testValue, v)
+ }
+
+ if v := otp.GetString("sentTo"); v != testValue {
+ t.Fatalf("Expected field value %q, got %q", testValue, v)
+ }
+ })
+ }
+}
+
+func TestOTPCreated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ if v := otp.Created().String(); v != "" {
+ t.Fatalf("Expected empty created, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ otp.SetRaw("created", now)
+
+ if v := otp.Created().String(); v != now.String() {
+ t.Fatalf("Expected %q created, got %q", now.String(), v)
+ }
+}
+
+func TestOTPUpdated(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otp := core.NewOTP(app)
+
+ if v := otp.Updated().String(); v != "" {
+ t.Fatalf("Expected empty updated, got %q", v)
+ }
+
+ now := types.NowDateTime()
+ otp.SetRaw("updated", now)
+
+ if v := otp.Updated().String(); v != now.String() {
+ t.Fatalf("Expected %q updated, got %q", now.String(), v)
+ }
+}
+
+func TestOTPHasExpired(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ now := types.NowDateTime()
+
+ otp := core.NewOTP(app)
+ otp.SetRaw("created", now.Add(-5*time.Minute))
+
+ scenarios := []struct {
+ maxElapsed time.Duration
+ expected bool
+ }{
+ {0 * time.Minute, true},
+ {3 * time.Minute, true},
+ {5 * time.Minute, true},
+ {6 * time.Minute, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.maxElapsed.String()), func(t *testing.T) {
+ result := otp.HasExpired(s.maxElapsed)
+
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestOTPPreValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ otpsCol, err := app.FindCollectionByNameOrId(core.CollectionNameOTPs)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("no proxy record", func(t *testing.T) {
+ otp := &core.OTP{}
+
+ if err := app.Validate(otp); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("non-OTP collection", func(t *testing.T) {
+ otp := &core.OTP{}
+ otp.SetProxyRecord(core.NewRecord(core.NewBaseCollection("invalid")))
+ otp.SetRecordRef(user.Id)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetPassword("test123")
+
+ if err := app.Validate(otp); err == nil {
+ t.Fatal("Expected collection validation error")
+ }
+ })
+
+ t.Run("OTP collection", func(t *testing.T) {
+ otp := &core.OTP{}
+ otp.SetProxyRecord(core.NewRecord(otpsCol))
+ otp.SetRecordRef(user.Id)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetPassword("test123")
+
+ if err := app.Validate(otp); err != nil {
+ t.Fatalf("Expected nil validation error, got %v", err)
+ }
+ })
+}
+
+func TestOTPValidateHook(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ otp func() *core.OTP
+ expectErrors []string
+ }{
+ {
+ "empty",
+ func() *core.OTP {
+ return core.NewOTP(app)
+ },
+ []string{"collectionRef", "recordRef", "password"},
+ },
+ {
+ "non-auth collection",
+ func() *core.OTP {
+ otp := core.NewOTP(app)
+ otp.SetCollectionRef(demo1.Collection().Id)
+ otp.SetRecordRef(demo1.Id)
+ otp.SetPassword("test123")
+ return otp
+ },
+ []string{"collectionRef"},
+ },
+ {
+ "missing record id",
+ func() *core.OTP {
+ otp := core.NewOTP(app)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef("missing")
+ otp.SetPassword("test123")
+ return otp
+ },
+ []string{"recordRef"},
+ },
+ {
+ "valid ref",
+ func() *core.OTP {
+ otp := core.NewOTP(app)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("test123")
+ return otp
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ errs := app.Validate(s.otp())
+ tests.TestValidationErrors(t, errs, s.expectErrors)
+ })
+ }
+}
+
+func TestOTPClearOnTokenKeyChange(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2, err := app.FindAuthRecordByEmail("users", "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ otpsToCreate := map[*core.Record]int{
+ user1: 3,
+ user2: 2,
+ }
+ for user, total := range otpsToCreate {
+ for range total {
+ otp := core.NewOTP(app)
+ otp.SetCollectionRef(user.Collection().Id)
+ otp.SetRecordRef(user.Id)
+ otp.SetPassword("123456")
+ if err := app.Save(otp); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ // update both users
+ err = app.Save(user1)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user2.RefreshTokenKey()
+ err = app.Save(user2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectedOTPs := map[*core.Record]int{
+ user1: 3,
+ user2: 0,
+ }
+
+ for user, expected := range expectedOTPs {
+ otps, err := app.FindAllOTPsByRecord(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(otps) != expected {
+ t.Fatalf("Expected %d OTPs, got %d", expected, len(otps))
+ }
+ }
+}
diff --git a/core/otp_query.go b/core/otp_query.go
new file mode 100644
index 0000000000..8b2b4a9404
--- /dev/null
+++ b/core/otp_query.go
@@ -0,0 +1,117 @@
+package core
+
+import (
+ "errors"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+// FindAllOTPsByRecord returns all OTP models linked to the provided auth record.
+func (app *BaseApp) FindAllOTPsByRecord(authRecord *Record) ([]*OTP, error) {
+ result := []*OTP{}
+
+ err := app.RecordQuery(CollectionNameOTPs).
+ AndWhere(dbx.HashExp{
+ "collectionRef": authRecord.Collection().Id,
+ "recordRef": authRecord.Id,
+ }).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindAllOTPsByCollection returns all OTP models linked to the provided collection.
+func (app *BaseApp) FindAllOTPsByCollection(collection *Collection) ([]*OTP, error) {
+ result := []*OTP{}
+
+ err := app.RecordQuery(CollectionNameOTPs).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ OrderBy("created DESC").
+ All(&result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// FindOTPById returns a single OTP model by its id.
+func (app *BaseApp) FindOTPById(id string) (*OTP, error) {
+ result := &OTP{}
+
+ err := app.RecordQuery(CollectionNameOTPs).
+ AndWhere(dbx.HashExp{"id": id}).
+ Limit(1).
+ One(result)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// DeleteAllOTPsByRecord deletes all OTP models associated with the provided record.
+//
+// Returns a combined error with the failed deletes.
+func (app *BaseApp) DeleteAllOTPsByRecord(authRecord *Record) error {
+ models, err := app.FindAllOTPsByRecord(authRecord)
+ if err != nil {
+ return err
+ }
+
+ var errs []error
+ for _, m := range models {
+ if err := app.Delete(m); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+// DeleteExpiredOTPs deletes the expired OTPs for all auth collections.
+func (app *BaseApp) DeleteExpiredOTPs() error {
+ authCollections, err := app.FindAllCollections(CollectionTypeAuth)
+ if err != nil {
+ return err
+ }
+
+ // note: perform even if OTP is disabled to ensure that there are no dangling old records
+ for _, collection := range authCollections {
+ minValidDate, err := types.ParseDateTime(time.Now().Add(-1 * collection.OTP.DurationTime()))
+ if err != nil {
+ return err
+ }
+
+ items := []*Record{}
+
+ err = app.RecordQuery(CollectionNameOTPs).
+ AndWhere(dbx.HashExp{"collectionRef": collection.Id}).
+ AndWhere(dbx.NewExp("[[created]] < {:date}", dbx.Params{"date": minValidDate})).
+ All(&items)
+ if err != nil {
+ return err
+ }
+
+ for _, item := range items {
+ err = app.Delete(item)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/core/otp_query_test.go b/core/otp_query_test.go
new file mode 100644
index 0000000000..440ffb96a0
--- /dev/null
+++ b/core/otp_query_test.go
@@ -0,0 +1,310 @@
+package core_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestFindAllOTPsByRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ expected []string
+ }{
+ {demo1, nil},
+ {superuser2, []string{"superuser2_0", "superuser2_1", "superuser2_3", "superuser2_2", "superuser2_4"}},
+ {superuser4, nil},
+ {user1, []string{"user1_0"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.record.Collection().Name+"_"+s.record.Id, func(t *testing.T) {
+ result, err := app.FindAllOTPsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total otps %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAllOTPsByCollection(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ demo1, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ clients, err := app.FindCollectionByNameOrId("clients")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ users, err := app.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ collection *core.Collection
+ expected []string
+ }{
+ {demo1, nil},
+ {superusers, []string{
+ "superuser2_0",
+ "superuser2_1",
+ "superuser2_3",
+ "superuser3_0",
+ "superuser3_1",
+ "superuser2_2",
+ "superuser2_4",
+ }},
+ {clients, nil},
+ {users, []string{"user1_0"}},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.collection.Name, func(t *testing.T) {
+ result, err := app.FindAllOTPsByCollection(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected total otps %d, got %d", len(s.expected), len(result))
+ }
+
+ for i, id := range s.expected {
+ if result[i].Id != id {
+ t.Errorf("[%d] Expected id %q, got %q", i, id, result[i].Id)
+ }
+ }
+ })
+ }
+}
+
+func TestFindOTPById(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ id string
+ expectError bool
+ }{
+ {"", true},
+ {"84nmscqy84lsi1t", true}, // non-otp id
+ {"superuser2_0", false},
+ {"superuser2_4", false}, // expired
+ {"user1_0", false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.id, func(t *testing.T) {
+ result, err := app.FindOTPById(s.id)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if result.Id != s.id {
+ t.Fatalf("Expected record with id %q, got %q", s.id, result.Id)
+ }
+ })
+ }
+}
+
+func TestDeleteAllOTPsByRecord(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ demo1, err := testApp.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser2, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser4, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test4@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ deletedIds []string
+ }{
+ {demo1, nil}, // non-auth record
+ {superuser2, []string{"superuser2_0", "superuser2_1", "superuser2_3", "superuser2_2", "superuser2_4"}},
+ {superuser4, nil},
+ {user1, []string{"user1_0"}},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.record.Collection().Name, s.record.Id), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ deletedIds := []string{}
+ app.OnRecordAfterDeleteSuccess().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ err := app.DeleteAllOTPsByRecord(s.record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(s.deletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", s.deletedIds, deletedIds)
+ }
+
+ for _, id := range s.deletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ })
+ }
+}
+
+func TestDeleteExpiredOTPs(t *testing.T) {
+ t.Parallel()
+
+ checkDeletedIds := func(app core.App, t *testing.T, expectedDeletedIds []string) {
+ if err := tests.StubOTPRecords(app); err != nil {
+ t.Fatal(err)
+ }
+
+ deletedIds := []string{}
+ app.OnRecordAfterDeleteSuccess().BindFunc(func(e *core.RecordEvent) error {
+ deletedIds = append(deletedIds, e.Record.Id)
+ return e.Next()
+ })
+
+ if err := app.DeleteExpiredOTPs(); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(deletedIds) != len(expectedDeletedIds) {
+ t.Fatalf("Expected deleted ids\n%v\ngot\n%v", expectedDeletedIds, deletedIds)
+ }
+
+ for _, id := range expectedDeletedIds {
+ if !slices.Contains(deletedIds, id) {
+ t.Errorf("Expected to find deleted id %q in %v", id, deletedIds)
+ }
+ }
+ }
+
+ t.Run("default test collections", func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ checkDeletedIds(app, t, []string{
+ "user1_0",
+ "superuser2_2",
+ "superuser2_4",
+ })
+ })
+
+ t.Run("otp collection duration mock", func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ superusers.OTP.Duration = 60
+ if err := app.Save(superusers); err != nil {
+ t.Fatalf("Failed to mock superusers otp duration: %v", err)
+ }
+
+ checkDeletedIds(app, t, []string{
+ "user1_0",
+ "superuser2_2",
+ "superuser2_4",
+ "superuser3_1",
+ })
+ })
+}
diff --git a/core/record_field_resolver.go b/core/record_field_resolver.go
new file mode 100644
index 0000000000..425cc5efce
--- /dev/null
+++ b/core/record_field_resolver.go
@@ -0,0 +1,592 @@
+package core
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+// filter modifiers
+const (
+ eachModifier string = "each"
+ issetModifier string = "isset"
+ lengthModifier string = "length"
+ lowerModifier string = "lower"
+ changedModifier string = "changed"
+)
+
+type ruleJoin struct {
+ collection *Collection
+ tableAlias string
+}
+
+// ensure that `search.FieldResolver` interface is implemented
+var _ search.FieldResolver = (*RecordFieldResolver)(nil)
+
+// RecordFieldResolver defines a custom search resolver struct for
+// managing Record model search fields.
+//
+// Usually used together with `search.Provider`.
+// Example:
+//
+// resolver := resolvers.NewRecordFieldResolver(
+// app,
+// myCollection,
+// &models.RequestInfo{...},
+// true,
+// )
+// provider := search.NewProvider(resolver)
+// ...
+type RecordFieldResolver struct {
+ app App
+ baseCollection *Collection
+ requestInfo *RequestInfo
+ staticRequestInfo map[string]any
+ allowedFields []string
+ joins []*search.Join
+ allowHiddenFields bool
+ // ---
+ listRuleJoins []ruleJoin
+ joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
+ baseCollectionAlias string
+}
+
+// AllowedFields returns a copy of the resolver's allowed fields.
+func (r *RecordFieldResolver) AllowedFields() []string {
+ return slices.Clone(r.allowedFields)
+}
+
+// SetAllowedFields replaces the resolver's allowed fields with the new ones.
+func (r *RecordFieldResolver) SetAllowedFields(newAllowedFields []string) {
+ r.allowedFields = slices.Clone(newAllowedFields)
+}
+
+// AllowHiddenFields returns whether the current resolver allows filtering hidden fields.
+func (r *RecordFieldResolver) AllowHiddenFields() bool {
+ return r.allowHiddenFields
+}
+
+// SetAllowHiddenFields enables or disables hidden fields filtering.
+func (r *RecordFieldResolver) SetAllowHiddenFields(allowHiddenFields bool) {
+ r.allowHiddenFields = allowHiddenFields
+}
+
+// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
+func NewRecordFieldResolver(
+ app App,
+ baseCollection *Collection,
+ requestInfo *RequestInfo,
+ allowHiddenFields bool,
+) *RecordFieldResolver {
+ r := &RecordFieldResolver{
+ app: app,
+ baseCollection: baseCollection,
+ requestInfo: requestInfo,
+ allowHiddenFields: allowHiddenFields, // note: it is not based only on the requestInfo.auth since it could be used by a non-request internal method
+ joins: []*search.Join{},
+ allowedFields: []string{
+ `^\w+[\w\.\:]*$`,
+ `^\@request\.context$`,
+ `^\@request\.method$`,
+ `^\@request\.auth\.[\w\.\:]*\w+$`,
+ `^\@request\.body\.[\w\.\:]*\w+$`,
+ `^\@request\.query\.[\w\.\:]*\w+$`,
+ `^\@request\.headers\.[\w\.\:]*\w+$`,
+ `^\@collection\.\w+(\:\w+)?\.[\w\.\:]*\w+$`,
+ },
+ }
+
+ r.staticRequestInfo = map[string]any{}
+ if r.requestInfo != nil {
+ r.staticRequestInfo["context"] = r.requestInfo.Context
+ r.staticRequestInfo["method"] = r.requestInfo.Method
+ r.staticRequestInfo["query"] = r.requestInfo.Query
+ r.staticRequestInfo["headers"] = r.requestInfo.Headers
+ r.staticRequestInfo["body"] = r.requestInfo.Body
+ r.staticRequestInfo["auth"] = nil
+ if r.requestInfo.Auth != nil {
+ authClone := r.requestInfo.Auth.Clone()
+ r.staticRequestInfo["auth"] = authClone.
+ Unhide(authClone.Collection().Fields.FieldNames()...).
+ IgnoreEmailVisibility(true).
+ PublicExport()
+ }
+ }
+
+ return r
+}
+
+// @todo think of a better a way how to call it automatically after BuildExpr
+//
+// UpdateQuery implements `search.FieldResolver` interface.
+//
+// Conditionally updates the provided search query based on the
+// resolved fields (eg. dynamically joining relations).
+func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
+ if len(r.joins) > 0 {
+ r.updateQueryWithDeduplicateConstraint(query)
+
+ for _, join := range r.joins {
+ query.LeftJoin(
+ (join.TableName + " " + join.TableAlias),
+ join.On,
+ )
+ }
+ }
+
+ // note: for now the joins are not applied for multi-match conditions to avoid excessive checks
+ if len(r.listRuleJoins) > 0 {
+ for _, join := range r.listRuleJoins {
+ err := r.updateQueryWithCollectionListRule(join.collection, join.tableAlias, query)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, tableAlias string, query *dbx.SelectQuery) error {
+ if r.allowHiddenFields || c == nil || c.ListRule == nil || *c.ListRule == "" {
+ return nil
+ }
+
+ cloneR := *r
+ cloneR.joins = []*search.Join{}
+ cloneR.baseCollection = c
+ cloneR.baseCollectionAlias = tableAlias
+ cloneR.allowHiddenFields = true
+ cloneR.joinAliasSuffix = security.PseudorandomString(8)
+
+ // The extra "id='' || (\nRULE\n)" concatenated part on its own
+ // doesn't make much sense because all records are required to have an id,
+ // but it is necessary to properly resolve client-side filters when
+ // referencing missing relations (the "\n" is for leading and trailing comments).
+ //
+ // Consider the client-side filter: "a.name != '' || b.name != ''",
+ // where both "a" and "b" ref collections have non-empty ListRule.
+ // Without the empty check the query will always evaluate to FALSE
+ // when one of the "a" or "b" relation fields are empty,
+ // even if for example "a.name != ''" is true.
+ expr, err := search.FilterData("id='' || (\n" + *c.ListRule + "\n)").BuildExpr(&cloneR)
+ if err != nil {
+ return fmt.Errorf("failed to build %q ListRule join subquery filter expression: %w", c.Name, err)
+ }
+
+ // Bind the extra rule expression at the top query level for performance and security reasons
+ // (it is more strict and minimizes the risk of data disclosure from a side-channel attack).
+ //
+ // @todo Investigate with the refactoring if there is a way to group it
+ // together with the client-side constraint that invoked it and benchmark
+ // it with many (tag.name="1"||...) like statements to evaluate the impact of applying the check many times.
+ // If not feasible - document it as caveat and maybe add --dev log.
+ query.AndWhere(expr)
+
+ if len(cloneR.joins) > 0 {
+ r.updateQueryWithDeduplicateConstraint(query)
+
+ for _, j := range cloneR.joins {
+ query.LeftJoin(
+ (j.TableName + " " + j.TableAlias),
+ j.On,
+ )
+ }
+ }
+
+ return nil
+}
+
+func (r *RecordFieldResolver) updateQueryWithDeduplicateConstraint(query *dbx.SelectQuery) {
+ query.Distinct(true)
+
+ // @todo Research better options for generic rows deduplication.
+ //
+ // Disable the GROUP BY conditional checks for now since it prevents
+ // proper utilization of ORDER BY indexes (and maybe others)
+ // (https://github.com/pocketbase/pocketbase/discussions/7461)
+
+ // info := query.Info()
+ // if info.Distinct {
+ // return
+ // }
+
+ // // already has the group by registered
+ // var groupByCol = r.baseCollection.Name
+ // if r.baseCollectionAlias != "" {
+ // groupByCol = r.baseCollectionAlias
+ // }
+ // groupByCol += ".id"
+ // if len(info.GroupBy) > 0 && info.GroupBy[0] == groupByCol {
+ // return
+ // }
+
+ // // when deemed safe (GROUP BY could have different execution order compared to DISTINCT),
+ // // prefer GROUP BY to deduplicate only on the id field instead of all columns
+ // // so that the size of a single row wouldn't matter that much
+ // if preferGroupBy(info, groupByCol) {
+ // query.GroupBy(groupByCol)
+ // } else {
+ // query.Distinct(true)
+ // }
+}
+
+//nolint:unused
+func preferGroupBy(info *dbx.QueryInfo, fullUnquotedGroupByCol string) bool {
+ if len(info.GroupBy) != 0 {
+ return false
+ }
+
+ if info.Having != nil {
+ return false
+ }
+
+ // dbx fallbacks to * if not set
+ if len(info.Selects) == 0 {
+ return true
+ }
+
+ if len(info.Selects) != 1 {
+ return false
+ }
+
+ identifier := info.Selects[0]
+
+ if identifier == "*" || identifier == fullUnquotedGroupByCol {
+ return true
+ }
+
+ // try again as direct col match in an unquoted column format
+ identifier = inflector.Columnify(identifier)
+ if identifier == fullUnquotedGroupByCol {
+ return true
+ }
+
+ // remains table.* to check
+ // (aliased columns for now are ignored as they could be represented by expressions)
+ if !strings.HasSuffix(identifier, ".*") {
+ return false
+ }
+
+ return strings.HasPrefix(fullUnquotedGroupByCol, strings.TrimSuffix(identifier, "*"))
+}
+
+// Resolve implements `search.FieldResolver` interface.
+//
+// Example of some resolvable fieldName formats:
+//
+// id
+// someSelect.each
+// project.screen.status
+// screen.project_via_prototype.name
+// @request.context
+// @request.method
+// @request.query.filter
+// @request.headers.x_token
+// @request.auth.someRelation.name
+// @request.body.someRelation.name
+// @request.body.someField
+// @request.body.someSelect:each
+// @request.body.someField:isset
+// @collection.product.name
+func (r *RecordFieldResolver) Resolve(fieldName string) (*search.ResolverResult, error) {
+ return parseAndRun(fieldName, r)
+}
+
+func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search.ResolverResult, error) {
+ if len(path) == 0 {
+ return nil, errors.New("at least one path key should be provided")
+ }
+
+ lastProp, modifier, err := splitModifier(path[len(path)-1])
+ if err != nil {
+ return nil, err
+ }
+
+ path[len(path)-1] = lastProp
+
+ // extract value
+ resultVal, err := extractNestedVal(r.staticRequestInfo, path...)
+ if err != nil {
+ r.app.Logger().Debug("resolveStaticRequestField graceful fallback", "error", err.Error())
+ }
+
+ if modifier == issetModifier {
+ if err != nil {
+ return &search.ResolverResult{Identifier: "FALSE"}, nil
+ }
+ return &search.ResolverResult{Identifier: "TRUE"}, nil
+ }
+
+ // note: we are ignoring the error because requestInfo is dynamic
+ // and some of the lookup keys may not be defined for the request
+
+ switch v := resultVal.(type) {
+ case nil:
+ // no further processing is needed...
+ case string:
+ // check if it is a number field and explicitly try to cast to
+ // float in case of a numeric string value was used
+ // (this usually the case when the data is from a multipart/form-data request)
+ field := r.baseCollection.Fields.GetByName(path[len(path)-1])
+ if field != nil && field.Type() == FieldTypeNumber {
+ if nv, err := strconv.ParseFloat(v, 64); err == nil {
+ resultVal = nv
+ }
+ }
+ // otherwise - no further processing is needed...
+ case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
+ // no further processing is needed...
+ default:
+ // non-plain value
+ // try casting to string (in case for example fmt.Stringer is implemented)
+ val, castErr := cast.ToStringE(v)
+
+ // if that doesn't work, try encoding it
+ if castErr != nil {
+ encoded, jsonErr := json.Marshal(v, json.Deterministic(true))
+ if jsonErr == nil {
+ val = string(encoded)
+ }
+ }
+
+ resultVal = val
+ }
+
+ // unsupported modifier
+ // @todo consider deprecating with the introduction of filter functions
+ if modifier != "" && modifier != lowerModifier {
+ return nil, fmt.Errorf("invalid modifier sequence %s:%s", lastProp, modifier)
+ }
+
+ // no need to wrap as placeholder if we already know that it is null
+ if resultVal == nil {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+
+ placeholder := "f" + security.PseudorandomString(10)
+
+ // @todo consider deprecating with the introduction of filter functions
+ if modifier == lowerModifier {
+ return &search.ResolverResult{
+ Identifier: "LOWER({:" + placeholder + "})",
+ Params: dbx.Params{placeholder: resultVal},
+ }, nil
+ }
+
+ return &search.ResolverResult{
+ Identifier: "{:" + placeholder + "}",
+ Params: dbx.Params{placeholder: resultVal},
+ }, nil
+}
+
+func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*Collection, error) {
+ if collectionNameOrId == r.baseCollection.Name || collectionNameOrId == r.baseCollection.Id {
+ return r.baseCollection, nil
+ }
+
+ return getCollectionByModelOrIdentifier(r.app, collectionNameOrId)
+}
+
+func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) error {
+ newJoin := &search.Join{
+ TableName: tableName,
+ TableAlias: tableAlias,
+ On: on,
+ }
+
+ // (see updateQueryWithCollectionListRule)
+ if !r.allowHiddenFields {
+ c, _ := r.loadCollection(tableName)
+
+ // ignore non-collections since the table name could be an expression (e.g. json) or some other subquery
+ if c != nil {
+ // treat all fields as if they are hidden
+ if c.ListRule == nil {
+ return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name)
+ }
+
+ r.registerRuleJoin(c, newJoin.TableAlias)
+ }
+ }
+
+ // replace existing join
+ for i, j := range r.joins {
+ if j.TableAlias == newJoin.TableAlias {
+ r.joins[i] = newJoin
+ return nil
+ }
+ }
+
+ // register new join
+ r.joins = append(r.joins, newJoin)
+ return nil
+}
+
+func (r *RecordFieldResolver) registerRuleJoin(collection *Collection, tableAlias string) {
+ // replace existing
+ for i, j := range r.listRuleJoins {
+ if j.tableAlias == tableAlias {
+ r.listRuleJoins[i].collection = collection
+ return
+ }
+ }
+
+ // register new
+ r.listRuleJoins = append(r.listRuleJoins, ruleJoin{collection, tableAlias})
+}
+
+type mapExtractor interface {
+ AsMap() map[string]any
+}
+
+func extractNestedVal(rawData any, keys ...string) (any, error) {
+ if len(keys) == 0 {
+ return nil, errors.New("at least one key should be provided")
+ }
+
+ switch m := rawData.(type) {
+ // maps
+ case map[string]any:
+ return mapVal(m, keys...)
+ case map[string]string:
+ return mapVal(m, keys...)
+ case map[string]bool:
+ return mapVal(m, keys...)
+ case map[string]float32:
+ return mapVal(m, keys...)
+ case map[string]float64:
+ return mapVal(m, keys...)
+ case map[string]int:
+ return mapVal(m, keys...)
+ case map[string]int8:
+ return mapVal(m, keys...)
+ case map[string]int16:
+ return mapVal(m, keys...)
+ case map[string]int32:
+ return mapVal(m, keys...)
+ case map[string]int64:
+ return mapVal(m, keys...)
+ case map[string]uint:
+ return mapVal(m, keys...)
+ case map[string]uint8:
+ return mapVal(m, keys...)
+ case map[string]uint16:
+ return mapVal(m, keys...)
+ case map[string]uint32:
+ return mapVal(m, keys...)
+ case map[string]uint64:
+ return mapVal(m, keys...)
+ case mapExtractor:
+ return mapVal(m.AsMap(), keys...)
+ case types.JSONRaw:
+ var raw any
+ err := json.Unmarshal(m, &raw)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
+ }
+ return extractNestedVal(raw, keys...)
+
+ // slices
+ case []string:
+ return arrVal(m, keys...)
+ case []bool:
+ return arrVal(m, keys...)
+ case []float32:
+ return arrVal(m, keys...)
+ case []float64:
+ return arrVal(m, keys...)
+ case []int:
+ return arrVal(m, keys...)
+ case []int8:
+ return arrVal(m, keys...)
+ case []int16:
+ return arrVal(m, keys...)
+ case []int32:
+ return arrVal(m, keys...)
+ case []int64:
+ return arrVal(m, keys...)
+ case []uint:
+ return arrVal(m, keys...)
+ case []uint8:
+ return arrVal(m, keys...)
+ case []uint16:
+ return arrVal(m, keys...)
+ case []uint32:
+ return arrVal(m, keys...)
+ case []uint64:
+ return arrVal(m, keys...)
+ case []mapExtractor:
+ extracted := make([]any, len(m))
+ for i, v := range m {
+ extracted[i] = v.AsMap()
+ }
+ return arrVal(extracted, keys...)
+ case []any:
+ return arrVal(m, keys...)
+ case []types.JSONRaw:
+ return arrVal(m, keys...)
+ default:
+ return nil, fmt.Errorf("expected map or array, got %#v", rawData)
+ }
+}
+
+func mapVal[T any](m map[string]T, keys ...string) (any, error) {
+ result, ok := m[keys[0]]
+ if !ok {
+ return nil, fmt.Errorf("invalid key path - missing key %q", keys[0])
+ }
+
+ // end key reached
+ if len(keys) == 1 {
+ return result, nil
+ }
+
+ return extractNestedVal(result, keys[1:]...)
+}
+
+func arrVal[T any](m []T, keys ...string) (any, error) {
+ idx, err := strconv.Atoi(keys[0])
+ if err != nil || idx < 0 || idx >= len(m) {
+ return nil, fmt.Errorf("invalid key path - invalid or missing array index %q", keys[0])
+ }
+
+ result := m[idx]
+
+ // end key reached
+ if len(keys) == 1 {
+ return result, nil
+ }
+
+ return extractNestedVal(result, keys[1:]...)
+}
+
+func splitModifier(combined string) (string, string, error) {
+ parts := strings.Split(combined, ":")
+
+ if len(parts) != 2 {
+ return combined, "", nil
+ }
+
+ // validate modifier
+ switch parts[1] {
+ case issetModifier,
+ eachModifier,
+ lengthModifier,
+ lowerModifier,
+ changedModifier:
+ return parts[0], parts[1], nil
+ }
+
+ return "", "", fmt.Errorf("unknown modifier in %q", combined)
+}
diff --git a/core/record_field_resolver_replace_expr.go b/core/record_field_resolver_replace_expr.go
new file mode 100644
index 0000000000..9f4821c565
--- /dev/null
+++ b/core/record_field_resolver_replace_expr.go
@@ -0,0 +1,31 @@
+package core
+
+import (
+ "strings"
+
+ "github.com/pocketbase/dbx"
+)
+
+var _ dbx.Expression = (*replaceWithExpression)(nil)
+
+// replaceWithExpression defines a custom expression that will replace
+// a placeholder identifier found in "old" with the result of "new".
+type replaceWithExpression struct {
+ placeholder string
+ old dbx.Expression
+ new dbx.Expression
+}
+
+// Build converts the expression into a SQL fragment.
+//
+// Implements [dbx.Expression] interface.
+func (e *replaceWithExpression) Build(db *dbx.DB, params dbx.Params) string {
+ if e.placeholder == "" || e.old == nil || e.new == nil {
+ return "0=1"
+ }
+
+ oldResult := e.old.Build(db, params)
+ newResult := e.new.Build(db, params)
+
+ return strings.ReplaceAll(oldResult, e.placeholder, newResult)
+}
diff --git a/core/record_field_resolver_runner.go b/core/record_field_resolver_runner.go
new file mode 100644
index 0000000000..fa65893ec9
--- /dev/null
+++ b/core/record_field_resolver_runner.go
@@ -0,0 +1,853 @@
+package core
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+// maxNestedRels defines the max allowed nested relations depth.
+const maxNestedRels = 6
+
+// list of auth filter fields that don't require join with the auth
+// collection or any other extra checks to be resolved.
+var plainRequestAuthFields = map[string]struct{}{
+ "@request.auth." + FieldNameId: {},
+ "@request.auth." + FieldNameCollectionId: {},
+ "@request.auth." + FieldNameCollectionName: {},
+ "@request.auth." + FieldNameEmail: {},
+ "@request.auth." + FieldNameEmailVisibility: {},
+ "@request.auth." + FieldNameVerified: {},
+}
+
+// parseAndRun starts a new one-off RecordFieldResolver.Resolve execution.
+func parseAndRun(fieldName string, resolver *RecordFieldResolver) (*search.ResolverResult, error) {
+ r := &runner{
+ fieldName: fieldName,
+ resolver: resolver,
+ }
+
+ return r.run()
+}
+
+type runner struct {
+ used bool // indicates whether the runner was already executed
+ resolver *RecordFieldResolver // resolver is the shared expression fields resolver
+ fieldName string // the name of the single field expression the runner is responsible for
+
+ // shared processing state
+ // ---------------------------------------------------------------
+ activeProps []string // holds the active props that remains to be processed
+ activeCollectionName string // the last used collection name
+ activeTableAlias string // the last used table alias
+ nullifyMisingField bool // indicating whether to return null on missing field or return an error
+ withMultiMatch bool // indicates whether to attach a MultiMatchSubquery condition to the ResolverResult
+ multiMatchActiveTableAlias string // the last used multi-match table alias
+ multiMatch *search.MultiMatchSubquery // the multi-match subquery expression generated from the fieldName
+}
+
+func (r *runner) run() (*search.ResolverResult, error) {
+ if r.used {
+ return nil, errors.New("the runner was already used")
+ }
+
+ if len(r.resolver.allowedFields) > 0 && !list.ExistInSliceWithRegex(r.fieldName, r.resolver.allowedFields) {
+ return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
+ }
+
+ defer func() {
+ r.used = true
+ }()
+
+ r.prepare()
+
+ // check for @collection field (aka. non-relational join)
+ // must be in the format "@collection.COLLECTION_NAME.FIELD[.FIELD2....]"
+ if r.activeProps[0] == "@collection" {
+ return r.processCollectionField()
+ }
+
+ if r.activeProps[0] == "@request" {
+ // @todo consider returning an error instead?
+ if r.resolver.requestInfo == nil {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+
+ if strings.HasPrefix(r.fieldName, "@request.auth.") {
+ return r.processRequestAuthField()
+ }
+
+ totalProps := len(r.activeProps)
+
+ if strings.HasPrefix(r.fieldName, "@request.body.") && totalProps > 2 {
+ name, modifier, err := splitModifier(r.activeProps[2])
+ if err != nil {
+ return nil, err
+ }
+
+ bodyField := r.resolver.baseCollection.Fields.GetByName(name)
+ if bodyField == nil {
+ return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
+ }
+
+ // check for body relation field
+ if bodyField.Type() == FieldTypeRelation && totalProps > 3 {
+ return r.processRequestBodyRelationField(bodyField)
+ }
+
+ if totalProps == 3 { // aka. last prop
+ switch modifier {
+ case eachModifier:
+ return r.processRequestBodyEachModifier(bodyField)
+ case lengthModifier:
+ return r.processRequestBodyLengthModifier(bodyField)
+ case lowerModifier:
+ return r.processRequestBodyLowerModifier(bodyField)
+ case changedModifier:
+ return r.processRequestBodyChangedModifier(bodyField)
+ }
+ }
+ }
+
+ // some other @request.* static field
+ return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
+ }
+
+ // regular field
+ return r.processActiveProps()
+}
+
+func (r *runner) prepare() {
+ r.activeProps = strings.Split(r.fieldName, ".")
+
+ r.activeCollectionName = r.resolver.baseCollection.Name
+ if r.resolver.baseCollectionAlias == "" {
+ r.activeTableAlias = inflector.Columnify(r.activeCollectionName)
+ } else {
+ r.activeTableAlias = r.resolver.baseCollectionAlias
+ }
+
+ // enable the ignore flag for missing @request.* fields for backward
+ // compatibility and consistency with all @request.* filter fields and types
+ r.nullifyMisingField = r.activeProps[0] == "@request"
+
+ // prepare a multi-match subquery
+ r.multiMatch = &search.MultiMatchSubquery{
+ TargetTableAlias: r.activeTableAlias,
+ Params: dbx.Params{},
+ }
+ r.multiMatch.FromTableName = inflector.Columnify(r.activeCollectionName)
+ r.multiMatch.FromTableAlias = "__mm_" + r.activeTableAlias
+ r.multiMatchActiveTableAlias = r.multiMatch.FromTableAlias
+ r.withMultiMatch = false
+}
+
+func (r *runner) processCollectionField() (*search.ResolverResult, error) {
+ if len(r.activeProps) < 3 {
+ return nil, fmt.Errorf("invalid @collection field path in %q", r.fieldName)
+ }
+
+ // nameOrId or nameOrId:alias
+ collectionParts := strings.SplitN(r.activeProps[1], ":", 2)
+
+ collection, err := r.resolver.loadCollection(collectionParts[0])
+ if err != nil {
+ return nil, fmt.Errorf("failed to load collection %q from field path %q", r.activeProps[1], r.fieldName)
+ }
+
+ r.activeCollectionName = collection.Name
+
+ if len(collectionParts) == 2 && collectionParts[1] != "" {
+ r.activeTableAlias = inflector.Columnify("__collection_alias_"+collectionParts[1]) + r.resolver.joinAliasSuffix
+ } else {
+ r.activeTableAlias = inflector.Columnify("__collection_"+r.activeCollectionName) + r.resolver.joinAliasSuffix
+ }
+
+ r.withMultiMatch = true
+
+ // join the collection to the main query
+ err = r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // join the collection to the multi-match subquery
+ r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
+ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
+ TableName: inflector.Columnify(collection.Name),
+ TableAlias: r.multiMatchActiveTableAlias,
+ })
+
+ // leave only the collection fields
+ // aka. @collection.someCollection.fieldA.fieldB -> fieldA.fieldB
+ r.activeProps = r.activeProps[2:]
+
+ return r.processActiveProps()
+}
+
+func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
+ if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+
+ // plain auth field
+ // ---
+ if _, ok := plainRequestAuthFields[r.fieldName]; ok {
+ return r.resolver.resolveStaticRequestField(r.activeProps[1:]...)
+ }
+
+ // resolve the auth collection field
+ // ---
+ collection := r.resolver.requestInfo.Auth.Collection()
+
+ r.activeCollectionName = collection.Name
+ r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName) + r.resolver.joinAliasSuffix
+
+ // join the auth collection to the main query
+ err := r.resolver.registerJoin(
+ inflector.Columnify(r.activeCollectionName),
+ r.activeTableAlias,
+ dbx.HashExp{
+ // aka. __auth_users.id = :userId
+ (r.activeTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // join the auth collection to the multi-match subquery
+ r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: inflector.Columnify(r.activeCollectionName),
+ TableAlias: r.multiMatchActiveTableAlias,
+ On: dbx.HashExp{
+ (r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.Auth.Id,
+ },
+ },
+ )
+
+ // leave only the auth relation fields
+ // aka. @request.auth.fieldA.fieldB -> fieldA.fieldB
+ r.activeProps = r.activeProps[2:]
+
+ return r.processActiveProps()
+}
+
+// note: nil value is returned as empty slice
+func toSlice(value any) []any {
+ if value == nil {
+ return []any{}
+ }
+
+ rv := reflect.ValueOf(value)
+
+ kind := rv.Kind()
+ if kind != reflect.Slice && kind != reflect.Array {
+ return []any{value}
+ }
+
+ rvLen := rv.Len()
+
+ result := make([]interface{}, rvLen)
+
+ for i := 0; i < rvLen; i++ {
+ result[i] = rv.Index(i).Interface()
+ }
+
+ return result
+}
+
+func (r *runner) processRequestBodyChangedModifier(bodyField Field) (*search.ResolverResult, error) {
+ name := bodyField.GetName()
+
+ alias := search.FilterData(fmt.Sprintf("@request.body.%s:isset = true && @request.body.%s != %s", name, name, name))
+
+ aliasExpr, err := alias.BuildExpr(r.resolver)
+ if err != nil {
+ return nil, err
+ }
+
+ placeholder := "@changed@" + name + security.PseudorandomString(8)
+
+ result := &search.ResolverResult{
+ Identifier: placeholder,
+ NullFallback: search.NullFallbackDisabled,
+ AfterBuild: func(expr dbx.Expression) dbx.Expression {
+ return &replaceWithExpression{
+ placeholder: placeholder,
+ old: expr,
+ new: aliasExpr,
+ }
+ },
+ }
+
+ return result, nil
+}
+
+func (r *runner) processRequestBodyLowerModifier(bodyField Field) (*search.ResolverResult, error) {
+ rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
+
+ placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(8)
+
+ result := &search.ResolverResult{
+ Identifier: "LOWER({:" + placeholder + "})",
+ Params: dbx.Params{placeholder: rawValue},
+ }
+
+ return result, nil
+}
+
+func (r *runner) processRequestBodyLengthModifier(bodyField Field) (*search.ResolverResult, error) {
+ if _, ok := bodyField.(MultiValuer); !ok {
+ return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
+ }
+
+ bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()])
+
+ result := &search.ResolverResult{
+ Identifier: strconv.Itoa(len(bodyItems)),
+ }
+
+ return result, nil
+}
+
+func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.ResolverResult, error) {
+ multiValuer, ok := bodyField.(MultiValuer)
+ if !ok {
+ return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
+ }
+
+ bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()])
+ bodyItemsRaw, err := json.Marshal(bodyItems, json.Deterministic(true))
+ if err != nil {
+ return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
+ }
+
+ placeholder := "dataEach" + security.PseudorandomString(8)
+ cleanFieldName := inflector.Columnify(bodyField.GetName())
+ jeTable := fmt.Sprintf("json_each({:%s})", placeholder)
+ jeAlias := "__dataEach_je_" + cleanFieldName + r.resolver.joinAliasSuffix
+
+ err = r.resolver.registerJoin(jeTable, jeAlias, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &search.ResolverResult{
+ Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
+ Params: dbx.Params{placeholder: bodyItemsRaw},
+ }
+
+ if multiValuer.IsMultiple() {
+ r.withMultiMatch = true
+ }
+
+ if r.withMultiMatch {
+ placeholder2 := "mm" + placeholder
+ jeTable2 := fmt.Sprintf("json_each({:%s})", placeholder2)
+ jeAlias2 := "__mm_" + jeAlias
+
+ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
+ TableName: jeTable2,
+ TableAlias: jeAlias2,
+ })
+ r.multiMatch.Params[placeholder2] = bodyItemsRaw
+ r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
+
+ result.MultiMatchSubQuery = r.multiMatch
+ }
+
+ return result, nil
+}
+
+func (r *runner) processRequestBodyRelationField(bodyField Field) (*search.ResolverResult, error) {
+ relField, ok := bodyField.(*RelationField)
+ if !ok {
+ return nil, fmt.Errorf("failed to initialize data relation field %q", bodyField.GetName())
+ }
+
+ dataRelCollection, err := r.resolver.loadCollection(relField.CollectionId)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load collection %q from data field %q", relField.CollectionId, relField.Name)
+ }
+
+ var dataRelIds []string
+ if r.resolver.requestInfo != nil && len(r.resolver.requestInfo.Body) != 0 {
+ dataRelIds = list.ToUniqueStringSlice(r.resolver.requestInfo.Body[relField.Name])
+ }
+ if len(dataRelIds) == 0 {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+
+ r.activeCollectionName = dataRelCollection.Name
+ r.activeTableAlias = inflector.Columnify("__data_"+dataRelCollection.Name+"_"+relField.Name) + r.resolver.joinAliasSuffix
+
+ // join the data rel collection to the main collection
+ err = r.resolver.registerJoin(
+ r.activeCollectionName,
+ r.activeTableAlias,
+ dbx.In(
+ fmt.Sprintf("[[%s.id]]", r.activeTableAlias),
+ list.ToInterfaceSlice(dataRelIds)...,
+ ),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ if relField.IsMultiple() {
+ r.withMultiMatch = true
+ }
+
+ // join the data rel collection to the multi-match subquery
+ r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: r.activeCollectionName,
+ TableAlias: r.multiMatchActiveTableAlias,
+ On: dbx.In(
+ fmt.Sprintf("[[%s.id]]", r.multiMatchActiveTableAlias),
+ list.ToInterfaceSlice(dataRelIds)...,
+ ),
+ },
+ )
+
+ // leave only the data relation fields
+ // aka. @request.body.someRel.fieldA.fieldB -> fieldA.fieldB
+ r.activeProps = r.activeProps[3:]
+
+ return r.processActiveProps()
+}
+
+var viaRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
+
+// @todo refactor and abstract lastProp processing with the support of field plugins
+func (r *runner) processActiveProps() (*search.ResolverResult, error) {
+ totalProps := len(r.activeProps)
+
+ for i, prop := range r.activeProps {
+ collection, err := r.resolver.loadCollection(r.activeCollectionName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve field %q", prop)
+ }
+
+ // last prop
+ if i == totalProps-1 {
+ return r.finalizeActivePropsProcessing(collection, prop, i)
+ }
+
+ field := collection.Fields.GetByName(prop)
+
+ if field != nil && field.GetHidden() && !r.resolver.allowHiddenFields {
+ return nil, fmt.Errorf("non-filterable field %q", prop)
+ }
+
+ // @todo consider moving to the finalizer and converting to "JSONExtractable" interface with optional extra validation for the remaining props?
+ // json or geoPoint field -> treat the rest of the props as json path
+ if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
+ var jsonPath strings.Builder
+ for j, p := range r.activeProps[i+1:] {
+ if _, err := strconv.Atoi(p); err == nil {
+ jsonPath.WriteString("[")
+ jsonPath.WriteString(inflector.Columnify(p))
+ jsonPath.WriteString("]")
+ } else {
+ if j > 0 {
+ jsonPath.WriteString(".")
+ }
+ jsonPath.WriteString(inflector.Columnify(p))
+ }
+ }
+ jsonPathStr := jsonPath.String()
+
+ result := &search.ResolverResult{
+ NullFallback: search.NullFallbackDisabled,
+ Identifier: dbutils.JSONExtract(r.activeTableAlias+"."+inflector.Columnify(prop), jsonPathStr),
+ }
+
+ if r.withMultiMatch {
+ r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+inflector.Columnify(prop), jsonPathStr)
+ result.MultiMatchSubQuery = r.multiMatch
+ }
+
+ return result, nil
+ }
+
+ if i >= maxNestedRels {
+ return nil, fmt.Errorf("max nested relations reached for field %q", prop)
+ }
+
+ // check for back relation (eg. yourCollection_via_yourRelField)
+ // -----------------------------------------------------------
+ if field == nil {
+ parts := viaRegex.FindStringSubmatch(prop)
+ if len(parts) != 3 {
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("failed to resolve field %q", prop)
+ }
+
+ backCollection, err := r.resolver.loadCollection(parts[1])
+ if err != nil {
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("failed to load back relation field %q collection", prop)
+ }
+
+ backField := backCollection.Fields.GetByName(parts[2])
+ if backField == nil {
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("missing back relation field %q", parts[2])
+ }
+
+ if backField.Type() != FieldTypeRelation {
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("invalid back relation field %q", parts[2])
+ }
+
+ if backField.GetHidden() && !r.resolver.allowHiddenFields {
+ return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
+ }
+
+ backRelField, ok := backField.(*RelationField)
+ if !ok {
+ return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
+ }
+ if backRelField.CollectionId != collection.Id {
+ // https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("invalid collection reference of a back relation field %q", backField.GetName())
+ }
+
+ // join the back relation to the main query
+ // ---
+ cleanProp := inflector.Columnify(prop)
+ cleanBackFieldName := inflector.Columnify(backRelField.Name)
+
+ newTableAlias := r.activeTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
+ newCollectionName := inflector.Columnify(backCollection.Name)
+
+ isBackRelMultiple := backRelField.IsMultiple()
+
+ if !isBackRelMultiple {
+ err := r.resolver.registerJoin(
+ newCollectionName,
+ newTableAlias,
+ dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias, cleanBackFieldName, r.activeTableAlias)),
+ )
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ jeAlias := "__je_" + newTableAlias
+ err := r.resolver.registerJoin(
+ newCollectionName,
+ newTableAlias,
+ dbx.NewExp(fmt.Sprintf(
+ "[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
+ r.activeTableAlias,
+ jeAlias,
+ dbutils.JSONEach(newTableAlias+"."+cleanBackFieldName),
+ jeAlias,
+ )),
+ )
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ r.activeCollectionName = newCollectionName
+ r.activeTableAlias = newTableAlias
+ // ---
+
+ // join the back relation to the multi-match subquery
+ // ---
+ if isBackRelMultiple {
+ r.withMultiMatch = true // enable multimatch if not already
+ } else if !r.withMultiMatch {
+ // additionally check if the rel field has a single column unique index;
+ // if not - apply a multi-match check
+ _, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
+ r.withMultiMatch = !hasUniqueIndex
+ }
+
+ newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanProp + r.resolver.joinAliasSuffix
+
+ if !isBackRelMultiple {
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: newCollectionName,
+ TableAlias: newTableAlias2,
+ On: dbx.NewExp(fmt.Sprintf("[[%s.%s]] = [[%s.id]]", newTableAlias2, cleanBackFieldName, r.multiMatchActiveTableAlias)),
+ },
+ )
+ } else {
+ jeAlias2 := "__je_" + newTableAlias2
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: newCollectionName,
+ TableAlias: newTableAlias2,
+ On: dbx.NewExp(fmt.Sprintf(
+ "[[%s.id]] IN (SELECT [[%s.value]] FROM %s {{%s}})",
+ r.multiMatchActiveTableAlias,
+ jeAlias2,
+ dbutils.JSONEach(newTableAlias2+"."+cleanBackFieldName),
+ jeAlias2,
+ )),
+ },
+ )
+ }
+
+ r.multiMatchActiveTableAlias = newTableAlias2
+ // ---
+
+ continue
+ }
+ // -----------------------------------------------------------
+
+ // check for direct relation
+ if field.Type() != FieldTypeRelation {
+ return nil, fmt.Errorf("field %q is not a valid relation", prop)
+ }
+
+ // join the relation to the main query
+ // ---
+ relField, ok := field.(*RelationField)
+ if !ok {
+ return nil, fmt.Errorf("failed to initialize relation field %q", prop)
+ }
+
+ relCollection, relErr := r.resolver.loadCollection(relField.CollectionId)
+ if relErr != nil {
+ return nil, fmt.Errorf("failed to load field %q collection", prop)
+ }
+
+ // "id" lookups optimization for single relations to avoid unnecessary joins,
+ // aka. "user.id" and "user" should produce the same query identifier
+ if !relField.IsMultiple() &&
+ // the penultimate prop is "id"
+ i == totalProps-2 && r.activeProps[i+1] == FieldNameId {
+ return r.finalizeActivePropsProcessing(collection, relField.Name, i)
+ }
+
+ cleanFieldName := inflector.Columnify(relField.Name)
+ prefixedFieldName := r.activeTableAlias + "." + cleanFieldName
+ newTableAlias := r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
+ newCollectionName := relCollection.Name
+
+ if !relField.IsMultiple() {
+ err := r.resolver.registerJoin(
+ inflector.Columnify(newCollectionName),
+ newTableAlias,
+ dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)),
+ )
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ jeAlias := "__je_" + newTableAlias
+
+ err := r.resolver.registerJoin(dbutils.JSONEach(prefixedFieldName), jeAlias, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ err = r.resolver.registerJoin(
+ inflector.Columnify(newCollectionName),
+ newTableAlias,
+ dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)),
+ )
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ r.activeCollectionName = newCollectionName
+ r.activeTableAlias = newTableAlias
+ // ---
+
+ // join the relation to the multi-match subquery
+ // ---
+ if relField.IsMultiple() {
+ r.withMultiMatch = true // enable multimatch if not already
+ }
+
+ newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName
+ prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
+
+ if !relField.IsMultiple() {
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: inflector.Columnify(newCollectionName),
+ TableAlias: newTableAlias2,
+ On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)),
+ },
+ )
+ } else {
+ jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je"
+ r.multiMatch.Joins = append(
+ r.multiMatch.Joins,
+ &search.Join{
+ TableName: dbutils.JSONEach(prefixedFieldName2),
+ TableAlias: jeAlias2,
+ },
+ &search.Join{
+ TableName: inflector.Columnify(newCollectionName),
+ TableAlias: newTableAlias2,
+ On: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias2, jeAlias2)),
+ },
+ )
+ }
+
+ r.multiMatchActiveTableAlias = newTableAlias2
+ // ---
+ }
+
+ return nil, fmt.Errorf("failed to resolve field %q", r.fieldName)
+}
+
+func (r *runner) finalizeActivePropsProcessing(collection *Collection, prop string, propDepth int) (*search.ResolverResult, error) {
+ name, modifier, err := splitModifier(prop)
+ if err != nil {
+ return nil, err
+ }
+
+ field := collection.Fields.GetByName(name)
+ if field == nil {
+ if r.nullifyMisingField {
+ return &search.ResolverResult{Identifier: "NULL"}, nil
+ }
+ return nil, fmt.Errorf("unknown field %q", name)
+ }
+
+ if field.GetHidden() && !r.resolver.allowHiddenFields {
+ return nil, fmt.Errorf("non-filterable field %q", name)
+ }
+
+ multvaluer, isMultivaluer := field.(MultiValuer)
+
+ cleanFieldName := inflector.Columnify(field.GetName())
+
+ // arrayable fields with ":length" modifier
+ // -------------------------------------------------------
+ if modifier == lengthModifier && isMultivaluer {
+ jePair := r.activeTableAlias + "." + cleanFieldName
+
+ result := &search.ResolverResult{
+ Identifier: dbutils.JSONArrayLength(jePair),
+ }
+
+ if r.withMultiMatch {
+ jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
+ r.multiMatch.ValueIdentifier = dbutils.JSONArrayLength(jePair2)
+ result.MultiMatchSubQuery = r.multiMatch
+ }
+
+ return result, nil
+ }
+
+ // arrayable fields with ":each" modifier
+ // -------------------------------------------------------
+ if modifier == eachModifier && isMultivaluer {
+ jePair := r.activeTableAlias + "." + cleanFieldName
+ jeAlias := "__je_" + r.activeTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
+
+ err := r.resolver.registerJoin(dbutils.JSONEach(jePair), jeAlias, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &search.ResolverResult{
+ Identifier: fmt.Sprintf("[[%s.value]]", jeAlias),
+ }
+
+ if multvaluer.IsMultiple() {
+ r.withMultiMatch = true
+ }
+
+ if r.withMultiMatch {
+ jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName
+ jeAlias2 := "__je_" + r.multiMatchActiveTableAlias + "_" + cleanFieldName + r.resolver.joinAliasSuffix
+
+ r.multiMatch.Joins = append(r.multiMatch.Joins, &search.Join{
+ TableName: dbutils.JSONEach(jePair2),
+ TableAlias: jeAlias2,
+ })
+ r.multiMatch.ValueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2)
+
+ result.MultiMatchSubQuery = r.multiMatch
+ }
+
+ return result, nil
+ }
+
+ // default
+ // -------------------------------------------------------
+ result := &search.ResolverResult{
+ Identifier: "[[" + r.activeTableAlias + "." + cleanFieldName + "]]",
+ }
+
+ if r.withMultiMatch {
+ r.multiMatch.ValueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
+ result.MultiMatchSubQuery = r.multiMatch
+ }
+
+ // allow querying only auth records with emails marked as public
+ if field.GetName() == FieldNameEmail && !r.resolver.allowHiddenFields && collection.IsAuth() {
+ result.AfterBuild = func(expr dbx.Expression) dbx.Expression {
+ return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf(
+ "[[%s.%s]] = TRUE",
+ r.activeTableAlias,
+ FieldNameEmailVisibility,
+ ))))
+ }
+ }
+
+ // wrap in json_extract to ensure that top-level primitives
+ // stored as json work correctly when compared to their SQL equivalent
+ // (https://github.com/pocketbase/pocketbase/issues/4068)
+ if field.Type() == FieldTypeJSON {
+ result.NullFallback = search.NullFallbackDisabled
+ result.Identifier = dbutils.JSONExtract(r.activeTableAlias+"."+cleanFieldName, "")
+ if r.withMultiMatch {
+ r.multiMatch.ValueIdentifier = dbutils.JSONExtract(r.multiMatchActiveTableAlias+"."+cleanFieldName, "")
+ }
+ }
+
+ // account for the ":lower" modifier
+ if modifier == lowerModifier {
+ result.Identifier = "LOWER(" + result.Identifier + ")"
+ if r.withMultiMatch {
+ r.multiMatch.ValueIdentifier = "LOWER(" + r.multiMatch.ValueIdentifier + ")"
+ }
+ }
+
+ return result, nil
+}
diff --git a/core/record_field_resolver_test.go b/core/record_field_resolver_test.go
new file mode 100644
index 0000000000..7b9917c482
--- /dev/null
+++ b/core/record_field_resolver_test.go
@@ -0,0 +1,957 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRecordFieldResolverAllowedFields(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := core.NewRecordFieldResolver(app, collection, nil, false)
+
+ fields := r.AllowedFields()
+ if len(fields) != 8 {
+ t.Fatalf("Expected %d original allowed fields, got %d", 8, len(fields))
+ }
+
+ // change the allowed fields
+ newFields := []string{"a", "b", "c"}
+ expected := slices.Clone(newFields)
+ r.SetAllowedFields(newFields)
+
+ // change the new fields to ensure that the slice was cloned
+ newFields[2] = "d"
+
+ fields = r.AllowedFields()
+ if len(fields) != len(expected) {
+ t.Fatalf("Expected %d changed allowed fields, got %d", len(expected), len(fields))
+ }
+
+ for i, v := range expected {
+ if fields[i] != v {
+ t.Errorf("[%d] Expected field %q", i, v)
+ }
+ }
+}
+
+func TestRecordFieldResolverAllowHiddenFields(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := core.NewRecordFieldResolver(app, collection, nil, false)
+
+ allowHiddenFields := r.AllowHiddenFields()
+ if allowHiddenFields {
+ t.Fatalf("Expected original allowHiddenFields %v, got %v", allowHiddenFields, !allowHiddenFields)
+ }
+
+ // change the flag
+ expected := !allowHiddenFields
+ r.SetAllowHiddenFields(expected)
+
+ allowHiddenFields = r.AllowHiddenFields()
+ if allowHiddenFields != expected {
+ t.Fatalf("Expected changed allowHiddenFields %v, got %v", expected, allowHiddenFields)
+ }
+}
+
+func TestRecordFieldResolverUpdateQuery(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ authRecord, err := app.FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requestInfo := &core.RequestInfo{
+ Context: "ctx",
+ Headers: map[string]string{
+ "a": "123",
+ "b": "456",
+ },
+ Query: map[string]string{
+ "a": "", // to ensure that :isset returns true because the key exists
+ "b": "123",
+ },
+ Body: map[string]any{
+ "a": nil, // to ensure that :isset returns true because the key exists
+ "b": 123,
+ "number": 10,
+ "select_many": []string{"optionA", "optionC"},
+ "rel_one": "test",
+ "rel_many": []string{"test1", "test2"},
+ "file_one": "test",
+ "file_many": []string{"test1", "test2", "test3"},
+ "self_rel_one": "test",
+ "self_rel_many": []string{"test1"},
+ "rel_many_cascade": []string{"test1", "test2"},
+ "rel_one_cascade": "test1",
+ "rel_one_no_cascade": "test1",
+ },
+ Auth: authRecord,
+ }
+
+ scenarios := []struct {
+ name string
+ collectionIdOrName string
+ rule string
+ allowHiddenFields bool
+ expectQuery string
+ }{
+ {
+ "none relation field (with all default operators)",
+ "demo4",
+ "title = true || title != 'test' || title ~ 'test1' || title !~ '%test2' || title > 1 || title >= 2 || title < 3 || title <= 4",
+ false,
+ "SELECT `demo4`.* FROM `demo4` WHERE ([[demo4.title]] = 1 OR [[demo4.title]] IS NOT {:TEST} OR [[demo4.title]] LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] NOT LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] > {:TEST} OR [[demo4.title]] >= {:TEST} OR [[demo4.title]] < {:TEST} OR [[demo4.title]] <= {:TEST})",
+ },
+ {
+ "none relation field (with all opt/any operators)",
+ "demo4",
+ "title ?= true || title ?!= 'test' || title ?~ 'test1' || title ?!~ '%test2' || title ?> 1 || title ?>= 2 || title ?< 3 || title ?<= 4",
+ false,
+ "SELECT `demo4`.* FROM `demo4` WHERE ([[demo4.title]] = 1 OR [[demo4.title]] IS NOT {:TEST} OR [[demo4.title]] LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] NOT LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] > {:TEST} OR [[demo4.title]] >= {:TEST} OR [[demo4.title]] < {:TEST} OR [[demo4.title]] <= {:TEST})",
+ },
+ {
+ "single direct rel",
+ "demo4",
+ "self_rel_one > true",
+ false,
+ "SELECT `demo4`.* FROM `demo4` WHERE [[demo4.self_rel_one]] > 1",
+ },
+ {
+ "single direct rel (with id)",
+ "demo4",
+ "self_rel_one.id > true", // should NOT have join
+ false,
+ "SELECT `demo4`.* FROM `demo4` WHERE [[demo4.self_rel_one]] > 1",
+ },
+ {
+ "multiple direct rel (with id)",
+ "demo4",
+ "self_rel_many.id ?> true", // should have join
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] WHERE [[demo4_self_rel_many.id]] > 1",
+ },
+ {
+ "rel to collection with empty list rule",
+ "demo4",
+ "self_rel_one.created > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] WHERE [[demo4_self_rel_one.created]] > 1",
+ },
+ {
+ "rel to collection with non-empty list rule",
+ "demo4",
+ "rel_one_cascade.created > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4.rel_one_cascade]] WHERE ((([[demo4_rel_one_cascade.id]] = '' OR [[demo4_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_rel_one_cascade.created]] > 1)",
+ },
+ {
+ "rel to collection with non-empty list rule (with allowHiddenFields)",
+ "demo4",
+ "rel_one_cascade.created > true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4.rel_one_cascade]] WHERE [[demo4_rel_one_cascade.created]] > 1",
+ },
+ {
+ "rel to collection with superusers only list rule",
+ "demo1",
+ "rel_many.created ?> true",
+ false,
+ "",
+ },
+ {
+ "rel to collection with superusers only list rule (with allowHiddenFields)",
+ "demo1",
+ "rel_many.created ?> true",
+ true,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `__je_demo1_rel_many` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[__je_demo1_rel_many.value]] WHERE [[demo1_rel_many.created]] > 1",
+ },
+ {
+ "nested rels with all empty list rules",
+ "demo4",
+ "self_rel_one.self_rel_one.title > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo4` `demo4_self_rel_one_self_rel_one` ON [[demo4_self_rel_one_self_rel_one.id]] = [[demo4_self_rel_one.self_rel_one]] WHERE [[demo4_self_rel_one_self_rel_one.title]] > 1",
+ },
+ {
+ "nested rels with non-empty list rule",
+ "demo4",
+ "self_rel_one.rel_one_cascade.created > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_self_rel_one_rel_one_cascade.created]] > 1)",
+ },
+ {
+ "nested rels with non-empty list rule (joins reuse test)",
+ "demo4",
+ "self_rel_one.rel_one_cascade.created > true && self_rel_one.rel_one_cascade.updated > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (([[demo4_self_rel_one_rel_one_cascade.created]] > 1 AND [[demo4_self_rel_one_rel_one_cascade.updated]] > 1))",
+ },
+ {
+ "nested rels with non-empty list rule (with allowHiddenFields)",
+ "demo4",
+ "self_rel_one.rel_one_cascade.created > true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE [[demo4_self_rel_one_rel_one_cascade.created]] > 1",
+ },
+ {
+ "non-relation field + single rel",
+ "demo4",
+ "title > true || self_rel_one.title > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] WHERE ([[demo4.title]] > 1 OR [[demo4_self_rel_one.title]] > 1)",
+ },
+ {
+ "nested incomplete relations (opt/any operator)",
+ "demo4",
+ "self_rel_many.self_rel_one ?> true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] WHERE [[demo4_self_rel_many.self_rel_one]] > 1",
+ },
+ {
+ "nested incomplete relations (multi-match operator)",
+ "demo4",
+ "self_rel_many.self_rel_one > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] WHERE ((([[demo4_self_rel_many.self_rel_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.self_rel_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
+ },
+ {
+ "nested complete relations (opt/any operator)",
+ "demo4",
+ "self_rel_many.self_rel_one.title ?> true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE [[demo4_self_rel_many_self_rel_one.title]] > 1",
+ },
+ {
+ "nested complete relations (multi-match operator)",
+ "demo4",
+ "self_rel_many.self_rel_one.title > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
+ },
+ {
+ "repeated nested relations (opt/any operator)",
+ "demo4",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.title ?> true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]), json_type([[demo4_self_rel_many_self_rel_one.self_rel_many]])='array', FALSE) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__je_demo4_self_rel_many_self_rel_one_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__je_demo4_self_rel_many_self_rel_one_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1",
+ },
+ {
+ "repeated nested relations (multi-match operator)",
+ "demo4",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.title > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]), json_type([[demo4_self_rel_many_self_rel_one.self_rel_many]])='array', FALSE) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__je_demo4_self_rel_many_self_rel_one_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__je_demo4_self_rel_many_self_rel_one_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]), json_type([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]])='array', FALSE) THEN [[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
+ },
+ {
+ "multiple relations (opt/any operators)",
+ "demo4",
+ "self_rel_many.title ?= 'test' || self_rel_one.json_object.a ?> true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] WHERE ([[demo4_self_rel_many.title]] = {:TEST} OR (CASE WHEN json_valid([[demo4_self_rel_one.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_one.json_object]], '$.a') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_one.json_object]]), '$.pb.a') END) > 1)",
+ },
+ {
+ "multiple relations (multi-match operators)",
+ "demo4",
+ "self_rel_many.title = 'test' || self_rel_one.json_object.a > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] WHERE ((([[demo4_self_rel_many.title]] = {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = {:TEST})))) OR (CASE WHEN json_valid([[demo4_self_rel_one.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_one.json_object]], '$.a') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_one.json_object]]), '$.pb.a') END) > 1)",
+ },
+ {
+ "back relations via single relation field (without unique index)",
+ "demo3",
+ "demo4_via_rel_one_cascade.id = true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_one_cascade` ON [[demo3_demo4_via_rel_one_cascade.rel_one_cascade]] = [[demo3.id]] WHERE ((([[demo3_demo4_via_rel_one_cascade.id]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo3_demo4_via_rel_one_cascade.id]] as [[multiMatchValue]] FROM `demo3` `__mm_demo3` LEFT JOIN `demo4` `__mm_demo3_demo4_via_rel_one_cascade` ON [[__mm_demo3_demo4_via_rel_one_cascade.rel_one_cascade]] = [[__mm_demo3.id]] WHERE `__mm_demo3`.`id` = `demo3`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
+ },
+ {
+ "back relations via single relation field (with unique index)",
+ "demo3",
+ "demo4_via_rel_one_unique.id = true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_one_unique` ON [[demo3_demo4_via_rel_one_unique.rel_one_unique]] = [[demo3.id]] WHERE [[demo3_demo4_via_rel_one_unique.id]] = 1",
+ },
+ {
+ "back relations via multiple relation field (opt/any operators)",
+ "demo3",
+ "demo4_via_rel_many_cascade.id ?= true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) WHERE [[demo3_demo4_via_rel_many_cascade.id]] = 1",
+ },
+ {
+ "back relations via multiple relation field (multi-match operators)",
+ "demo3",
+ "demo4_via_rel_many_cascade.id = true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) WHERE ((([[demo3_demo4_via_rel_many_cascade.id]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo3_demo4_via_rel_many_cascade.id]] as [[multiMatchValue]] FROM `demo3` `__mm_demo3` LEFT JOIN `demo4` `__mm_demo3_demo4_via_rel_many_cascade` ON [[__mm_demo3.id]] IN (SELECT [[__je___mm_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[__mm_demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[__mm_demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[__mm_demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[__mm_demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je___mm_demo3_demo4_via_rel_many_cascade}}) WHERE `__mm_demo3`.`id` = `demo3`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
+ },
+ {
+ "back relations via unique multiple relation field (should be the same as multi-match)",
+ "demo3",
+ "demo4_via_rel_many_unique.id = true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_unique` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_unique.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_unique.rel_many_unique]]), json_type([[demo3_demo4_via_rel_many_unique.rel_many_unique]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_unique.rel_many_unique]] ELSE json_array([[demo3_demo4_via_rel_many_unique.rel_many_unique]]) END) {{__je_demo3_demo4_via_rel_many_unique}}) WHERE ((([[demo3_demo4_via_rel_many_unique.id]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo3_demo4_via_rel_many_unique.id]] as [[multiMatchValue]] FROM `demo3` `__mm_demo3` LEFT JOIN `demo4` `__mm_demo3_demo4_via_rel_many_unique` ON [[__mm_demo3.id]] IN (SELECT [[__je___mm_demo3_demo4_via_rel_many_unique.value]] FROM json_each(CASE WHEN iif(json_valid([[__mm_demo3_demo4_via_rel_many_unique.rel_many_unique]]), json_type([[__mm_demo3_demo4_via_rel_many_unique.rel_many_unique]])='array', FALSE) THEN [[__mm_demo3_demo4_via_rel_many_unique.rel_many_unique]] ELSE json_array([[__mm_demo3_demo4_via_rel_many_unique.rel_many_unique]]) END) {{__je___mm_demo3_demo4_via_rel_many_unique}}) WHERE `__mm_demo3`.`id` = `demo3`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
+ },
+ {
+ "view back relation with non-empty and superusers list rules",
+ "demo1",
+ "view1_via_rel_one.rel_many.created ?> true",
+ false,
+ "",
+ },
+ {
+ "view back relation with non-empty and superusers list rules (with allowHiddenFields)",
+ "demo1",
+ "view1_via_rel_one.rel_many.created ?> true",
+ true,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `view1` `demo1_view1_via_rel_one` ON [[demo1_view1_via_rel_one.rel_one]] = [[demo1.id]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1_view1_via_rel_one.rel_many]]), json_type([[demo1_view1_via_rel_one.rel_many]])='array', FALSE) THEN [[demo1_view1_via_rel_one.rel_many]] ELSE json_array([[demo1_view1_via_rel_one.rel_many]]) END) `__je_demo1_view1_via_rel_one_rel_many` LEFT JOIN `users` `demo1_view1_via_rel_one_rel_many` ON [[demo1_view1_via_rel_one_rel_many.id]] = [[__je_demo1_view1_via_rel_one_rel_many.value]] WHERE [[demo1_view1_via_rel_one_rel_many.created]] > 1",
+ },
+ {
+ "recursive back relations with non-empty list rule",
+ "demo3",
+ "demo4_via_rel_many_cascade.rel_one_cascade.demo4_via_rel_many_cascade.id ?= true",
+ false,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) LEFT JOIN `demo3` `demo3_demo4_via_rel_many_cascade_rel_one_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = [[demo3_demo4_via_rel_many_cascade.rel_one_cascade]] LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade}}) WHERE ((([[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = '' OR [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1)",
+ },
+ {
+ "recursive back relations with non-empty list rule (with allowHiddenFields)",
+ "demo3",
+ "demo4_via_rel_many_cascade.rel_one_cascade.demo4_via_rel_many_cascade.id ?= true",
+ true,
+ "SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) LEFT JOIN `demo3` `demo3_demo4_via_rel_many_cascade_rel_one_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = [[demo3_demo4_via_rel_many_cascade.rel_one_cascade]] LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade}}) WHERE [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1",
+ },
+ {
+ "@collection join (opt/any operators)",
+ "demo4",
+ "@collection.demo1.text ?> true || @collection.demo2.active ?> true || @collection.demo1:demo1_alias.file_one ?> true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `demo1` `__collection_alias_demo1_alias` WHERE ([[__collection_demo1.text]] > 1 OR [[__collection_demo2.active]] > 1 OR [[__collection_alias_demo1_alias.file_one]] > 1)",
+ },
+ {
+ "@collection join (multi-match operators)",
+ "demo4",
+ "@collection.demo1.text > true || @collection.demo2.active > true || @collection.demo1.file_one > true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ((([[__collection_demo1.text]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo1.text]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm___collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo2.active]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo2.active]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo2` `__mm___collection_demo2` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo1.file_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo1.file_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm___collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
+ },
+ {
+ "@request.auth fields",
+ "demo4",
+ "@request.auth.id > true || @request.auth.username > true || @request.auth.rel.title > true || @request.body.demo < true || @request.auth.missingA.missingB > false",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__auth_users` ON `__auth_users`.`id`={:p0} LEFT JOIN `demo2` `__auth_users_rel` ON [[__auth_users_rel.id]] = [[__auth_users.rel]] WHERE ({:TEST} > 1 OR [[__auth_users.username]] > 1 OR [[__auth_users_rel.title]] > 1 OR NULL < 1 OR NULL > 0)",
+ },
+ {
+ "@request.* static fields",
+ "demo4",
+ "@request.context = true || @request.query.a = true || @request.query.b = true || @request.query.missing = true || @request.headers.a = true || @request.headers.missing = true",
+ true,
+ "SELECT `demo4`.* FROM `demo4` WHERE ({:TEST} = 1 OR '' = 1 OR {:TEST} = 1 OR '' = 1 OR {:TEST} = 1 OR '' = 1)",
+ },
+ {
+ "direct hidden field (add emailVisibility)",
+ "users",
+ "email > true",
+ false,
+ "SELECT `users`.* FROM `users` WHERE ((([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)))",
+ },
+ {
+ "direct hidden field (force ignore emailVisibility)",
+ "users",
+ "email > true",
+ true,
+ "SELECT `users`.* FROM `users` WHERE [[users.email]] > 1",
+ },
+ {
+ "mixed regular with hidden field and modifier (add emailVisibility)",
+ "nologin",
+ "id > true || email > true || email:lower > false",
+ false,
+ "SELECT `nologin`.* FROM `nologin` WHERE ([[nologin.id]] > 1 OR (([[nologin.email]] > 1) AND ([[nologin.emailVisibility]] = TRUE)) OR ((LOWER([[nologin.email]]) > 0) AND ([[nologin.emailVisibility]] = TRUE)))",
+ },
+ {
+ "system filters in a public auth collection with hidden field and no allowHiddenFields (multi-match and add emailVisibility)",
+ "demo4",
+ "@collection.nologin.email > true || @request.auth.email > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `nologin` `__collection_nologin` WHERE ((((([[__collection_nologin.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_nologin.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `nologin` `__mm___collection_nologin` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))) AND ([[__collection_nologin.emailVisibility]] = TRUE)) OR {:TEST} > 1)",
+ },
+ {
+ "system filters in a superuser auth collection with hidden field and NO allowHiddenFields (multi-match and add emailVisibility)",
+ "demo4",
+ "@collection.users.email > true || @request.auth.email > true",
+ false,
+ "",
+ },
+ {
+ "system filters in a superuser auth collection with hidden field and allowHiddenFields (multi-match and add emailVisibility)",
+ "demo4",
+ "@collection.users.email > true || @request.auth.email > true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users` WHERE ((([[__collection_users.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_users.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `users` `__mm___collection_users` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR {:TEST} > 1)",
+ },
+ {
+ "collection filter in a non-empty list rule collection",
+ "demo4",
+ "@collection.demo3.title > true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE ((([[__collection_demo3.id]] = '' OR [[__collection_demo3.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))))",
+ },
+ {
+ "collection filter in a non-empty list rule collection (with allowHiddenFields)",
+ "demo4",
+ "@collection.demo3.title > true",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE ((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
+ },
+ {
+ "collection fields with :lower modifier",
+ "demo1",
+ "@request.body.rel_one:lower > true ||" +
+ "@request.body.rel_many:lower > true ||" +
+ "@request.body.rel_many.email:lower > true ||" +
+ "text:lower > true ||" +
+ "bool:lower > true ||" +
+ "url:lower > true ||" +
+ "select_one:lower > true ||" +
+ "select_many:lower > true ||" +
+ "file_one:lower > true ||" +
+ "file_many:lower > true ||" +
+ "number:lower > true ||" +
+ "email:lower > true ||" +
+ "datetime:lower > true ||" +
+ "json:lower > true ||" +
+ "rel_one:lower > true ||" +
+ "rel_many:lower > true ||" +
+ "rel_many.name:lower > true ||" +
+ "created:lower > true",
+ true,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `__je_demo1_rel_many` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[__je_demo1_rel_many.value]] WHERE (LOWER({:infoLowerrel_oneTEST}) > 1 OR LOWER({:infoLowerrel_manyTEST}) > 1 OR ((LOWER([[__data_users_rel_many.email]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__mm___data_users_rel_many.email]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__mm___data_users_rel_many` ON [[__mm___data_users_rel_many.id]] IN ({:p4}, {:p5}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.text]]) > 1 OR LOWER([[demo1.bool]]) > 1 OR LOWER([[demo1.url]]) > 1 OR LOWER([[demo1.select_one]]) > 1 OR LOWER([[demo1.select_many]]) > 1 OR LOWER([[demo1.file_one]]) > 1 OR LOWER([[demo1.file_many]]) > 1 OR LOWER([[demo1.number]]) > 1 OR LOWER([[demo1.email]]) > 1 OR LOWER([[demo1.datetime]]) > 1 OR LOWER((CASE WHEN json_valid([[demo1.json]]) THEN JSON_EXTRACT([[demo1.json]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo1.json]]), '$.pb') END)) > 1 OR LOWER([[demo1.rel_one]]) > 1 OR LOWER([[demo1.rel_many]]) > 1 OR ((LOWER([[demo1_rel_many.name]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__mm_demo1_rel_many.name]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.created]]) > 1)",
+ },
+ {
+ "static @request fields with :lower modifier",
+ "demo1",
+ "@request.body.a:lower > true ||" +
+ "@request.body.b:lower > true ||" +
+ "@request.body.c:lower > true ||" +
+ "@request.query.a:lower > true ||" +
+ "@request.query.b:lower > true ||" +
+ "@request.query.c:lower > true ||" +
+ "@request.headers.a:lower > true ||" +
+ "@request.headers.c:lower > true",
+ false,
+ "SELECT `demo1`.* FROM `demo1` WHERE (NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1)",
+ },
+ {
+ "isset modifier",
+ "demo1",
+ "@request.body.a:isset > true ||" +
+ "@request.body.b:isset > true ||" +
+ "@request.body.c:isset > true ||" +
+ "@request.query.a:isset > true ||" +
+ "@request.query.b:isset > true ||" +
+ "@request.query.c:isset > true ||" +
+ "@request.headers.a:isset > true ||" +
+ "@request.headers.c:isset > true",
+ false,
+ "SELECT `demo1`.* FROM `demo1` WHERE (TRUE > 1 OR TRUE > 1 OR FALSE > 1 OR TRUE > 1 OR TRUE > 1 OR FALSE > 1 OR TRUE > 1 OR FALSE > 1)",
+ },
+ {
+ "@request.body.rel.* fields",
+ "demo4",
+ "@request.body.rel_one_cascade.title > true &&" +
+ // reference the same as rel_one_cascade collection but should use a different join alias
+ "@request.body.rel_one_no_cascade.title < true &&" +
+ // different collection
+ "@request.body.self_rel_many.title = true",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_one_no_cascade` ON [[__data_demo3_rel_one_no_cascade.id]]={:p1} LEFT JOIN `demo4` `__data_demo4_self_rel_many` ON [[__data_demo4_self_rel_many.id]]={:p2} WHERE (((([[__data_demo3_rel_one_cascade.id]] = '' OR [[__data_demo3_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ((([[__data_demo3_rel_one_no_cascade.id]] = '' OR [[__data_demo3_rel_one_no_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST})))) AND (([[__data_demo3_rel_one_cascade.title]] > 1 AND [[__data_demo3_rel_one_no_cascade.title]] < 1 AND (([[__data_demo4_self_rel_many.title]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___data_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm___data_demo4_self_rel_many` ON [[__mm___data_demo4_self_rel_many.id]]={:p13} WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))))",
+ },
+ {
+ "@request.body.arrayble:each fields",
+ "demo1",
+ "@request.body.select_one:each > true &&" +
+ "@request.body.select_one:each ?< true &&" +
+ "@request.body.select_many:each > true &&" +
+ "@request.body.select_many:each ?< true &&" +
+ "@request.body.file_one:each > true &&" +
+ "@request.body.file_one:each ?< true &&" +
+ "@request.body.file_many:each > true &&" +
+ "@request.body.file_many:each ?< true &&" +
+ "@request.body.rel_one:each > true &&" +
+ "@request.body.rel_one:each ?< true &&" +
+ "@request.body.rel_many:each > true &&" +
+ "@request.body.rel_many:each ?< true",
+ false,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_select_one` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_select_many` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_file_one` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_file_many` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_rel_one` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_rel_many` WHERE ([[__dataEach_je_select_one.value]] > 1 AND [[__dataEach_je_select_one.value]] < 1 AND (([[__dataEach_je_select_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___dataEach_je_select_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm___dataEach_je_select_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_je_select_many.value]] < 1 AND [[__dataEach_je_file_one.value]] > 1 AND [[__dataEach_je_file_one.value]] < 1 AND (([[__dataEach_je_file_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___dataEach_je_file_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm___dataEach_je_file_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_je_file_many.value]] < 1 AND [[__dataEach_je_rel_one.value]] > 1 AND [[__dataEach_je_rel_one.value]] < 1 AND (([[__dataEach_je_rel_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___dataEach_je_rel_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm___dataEach_je_rel_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_je_rel_many.value]] < 1)",
+ },
+ {
+ "regular arrayble:each fields",
+ "view1",
+ "select_one:each > true &&" +
+ "select_one:each ?< true &&" +
+ "select_many:each > true &&" +
+ "select_many:each ?< true &&" +
+ "file_one:each > true &&" +
+ "file_one:each ?< true &&" +
+ "file_many:each > true &&" +
+ "file_many:each ?< true &&" +
+ "rel_one:each > true &&" +
+ "rel_one:each ?< true &&" +
+ "rel_many:each > true &&" +
+ "rel_many:each ?< true",
+ false,
+ "SELECT DISTINCT `view1`.* FROM `view1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.select_one]]), json_type([[view1.select_one]])='array', FALSE) THEN [[view1.select_one]] ELSE json_array([[view1.select_one]]) END) `__je_view1_select_one` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.select_many]]), json_type([[view1.select_many]])='array', FALSE) THEN [[view1.select_many]] ELSE json_array([[view1.select_many]]) END) `__je_view1_select_many` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.file_one]]), json_type([[view1.file_one]])='array', FALSE) THEN [[view1.file_one]] ELSE json_array([[view1.file_one]]) END) `__je_view1_file_one` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.file_many]]), json_type([[view1.file_many]])='array', FALSE) THEN [[view1.file_many]] ELSE json_array([[view1.file_many]]) END) `__je_view1_file_many` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.rel_one]]), json_type([[view1.rel_one]])='array', FALSE) THEN [[view1.rel_one]] ELSE json_array([[view1.rel_one]]) END) `__je_view1_rel_one` LEFT JOIN json_each(CASE WHEN iif(json_valid([[view1.rel_many]]), json_type([[view1.rel_many]])='array', FALSE) THEN [[view1.rel_many]] ELSE json_array([[view1.rel_many]]) END) `__je_view1_rel_many` WHERE ([[__je_view1_select_one.value]] > 1 AND [[__je_view1_select_one.value]] < 1 AND (([[__je_view1_select_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_view1_select_many.value]] as [[multiMatchValue]] FROM `view1` `__mm_view1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_view1.select_many]]), json_type([[__mm_view1.select_many]])='array', FALSE) THEN [[__mm_view1.select_many]] ELSE json_array([[__mm_view1.select_many]]) END) `__je___mm_view1_select_many` WHERE `__mm_view1`.`id` = `view1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__je_view1_select_many.value]] < 1 AND [[__je_view1_file_one.value]] > 1 AND [[__je_view1_file_one.value]] < 1 AND (([[__je_view1_file_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_view1_file_many.value]] as [[multiMatchValue]] FROM `view1` `__mm_view1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_view1.file_many]]), json_type([[__mm_view1.file_many]])='array', FALSE) THEN [[__mm_view1.file_many]] ELSE json_array([[__mm_view1.file_many]]) END) `__je___mm_view1_file_many` WHERE `__mm_view1`.`id` = `view1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__je_view1_file_many.value]] < 1 AND [[__je_view1_rel_one.value]] > 1 AND [[__je_view1_rel_one.value]] < 1 AND (([[__je_view1_rel_many.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_view1_rel_many.value]] as [[multiMatchValue]] FROM `view1` `__mm_view1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_view1.rel_many]]), json_type([[__mm_view1.rel_many]])='array', FALSE) THEN [[__mm_view1.rel_many]] ELSE json_array([[__mm_view1.rel_many]]) END) `__je___mm_view1_rel_many` WHERE `__mm_view1`.`id` = `view1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__je_view1_rel_many.value]] < 1)",
+ },
+ {
+ "arrayble:each vs arrayble:each",
+ "demo1",
+ "select_one:each != select_many:each &&" +
+ "select_many:each > select_one:each &&" +
+ "select_many:each ?< select_one:each &&" +
+ "select_many:each = @request.body.select_many:each",
+ false,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1.select_one]]), json_type([[demo1.select_one]])='array', FALSE) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `__je_demo1_select_one` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1.select_many]]), json_type([[demo1.select_many]])='array', FALSE) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `__je_demo1_select_many` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_je_select_many` WHERE (((COALESCE([[__je_demo1_select_one.value]], '') IS NOT COALESCE([[__je_demo1_select_many.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_demo1_select_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.select_many]]), json_type([[__mm_demo1.select_many]])='array', FALSE) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__je___mm_demo1_select_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT (COALESCE([[__je_demo1_select_one.value]], '') IS NOT COALESCE([[__smTEST.multiMatchValue]], ''))))) AND (([[__je_demo1_select_many.value]] > [[__je_demo1_select_one.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_demo1_select_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.select_many]]), json_type([[__mm_demo1.select_many]])='array', FALSE) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__je___mm_demo1_select_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > [[__je_demo1_select_one.value]])))) AND [[__je_demo1_select_many.value]] < [[__je_demo1_select_one.value]] AND (([[__je_demo1_select_many.value]] = [[__dataEach_je_select_many.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__je___mm_demo1_select_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.select_many]]), json_type([[__mm_demo1.select_many]])='array', FALSE) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__je___mm_demo1_select_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm___dataEach_je_select_many.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm___dataEach_je_select_many` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))",
+ },
+ {
+ "mixed multi-match vs multi-match in superuser only collections",
+ "demo1",
+ "rel_many.rel.active != rel_many.name &&" +
+ "rel_many.rel.active ?= rel_many.name &&" +
+ "rel_many.rel.title ~ rel_one.email &&" +
+ "@collection.demo2.active = rel_many.rel.active &&" +
+ "@collection.demo2.active ?= rel_many.rel.active &&" +
+ "rel_many.verified > @request.body.rel_many.verified",
+ false,
+ "",
+ },
+ {
+ "mixed multi-match vs multi-match in superuser only collections (with allowHiddenFields)",
+ "demo1",
+ "rel_many.rel.active != rel_many.name &&" +
+ "rel_many.rel.active ?= rel_many.name &&" +
+ "rel_many.rel.title ~ rel_one.email &&" +
+ "@collection.demo2.active = rel_many.rel.active &&" +
+ "@collection.demo2.active ?= rel_many.rel.active &&" +
+ "rel_many.verified > @request.body.rel_many.verified",
+ true,
+ "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `__je_demo1_rel_many` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[__je_demo1_rel_many.value]] LEFT JOIN `demo2` `demo1_rel_many_rel` ON [[demo1_rel_many_rel.id]] = [[demo1_rel_many.rel]] LEFT JOIN `demo1` `demo1_rel_one` ON [[demo1_rel_one.id]] = [[demo1.rel_one]] LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') IS NOT COALESCE([[demo1_rel_many.name]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many.name]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') IS NOT COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[demo1_rel_many_rel.active]], '') = COALESCE([[demo1_rel_many.name]], '') AND (([[demo1_rel_many_rel.title]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.title]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\')))) AND ((COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo2.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `demo2` `__mm___collection_demo2` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '') AND (([[demo1_rel_many.verified]] > [[__data_users_rel_many.verified]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.verified]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo1.rel_many]]), json_type([[__mm_demo1.rel_many]])='array', FALSE) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm___data_users_rel_many.verified]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__mm___data_users_rel_many` ON [[__mm___data_users_rel_many.id]] IN ({:p2}, {:p3}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]])))))",
+ },
+ {
+ "@request.body.arrayable:length fields",
+ "demo1",
+ "@request.body.select_one:length > 1 &&" +
+ "@request.body.select_one:length ?> 2 &&" +
+ "@request.body.select_many:length < 3 &&" +
+ "@request.body.select_many:length ?> 4 &&" +
+ "@request.body.rel_one:length = 5 &&" +
+ "@request.body.rel_one:length ?= 6 &&" +
+ "@request.body.rel_many:length != 7 &&" +
+ "@request.body.rel_many:length ?!= 8 &&" +
+ "@request.body.file_one:length = 9 &&" +
+ "@request.body.file_one:length ?= 0 &&" +
+ "@request.body.file_many:length != 1 &&" +
+ "@request.body.file_many:length ?!= 2",
+ false,
+ "SELECT `demo1`.* FROM `demo1` WHERE (0 > {:TEST} AND 0 > {:TEST} AND 2 < {:TEST} AND 2 > {:TEST} AND 1 = {:TEST} AND 1 = {:TEST} AND 2 IS NOT {:TEST} AND 2 IS NOT {:TEST} AND 1 = {:TEST} AND 1 = {:TEST} AND 3 IS NOT {:TEST} AND 3 IS NOT {:TEST})",
+ },
+ {
+ "regular arrayable:length fields",
+ "demo4",
+ "@request.body.self_rel_one.self_rel_many:length > 1 &&" +
+ "@request.body.self_rel_one.self_rel_many:length ?> 2 &&" +
+ "@request.body.rel_many_cascade.files:length ?< 3 &&" +
+ "@request.body.rel_many_cascade.files:length < 4 &&" +
+ "@request.body.rel_one_cascade.files:length < 4.1 &&" + // to ensure that the join to the same as above table will be aliased
+ "self_rel_one.self_rel_many:length = 5 &&" +
+ "self_rel_one.self_rel_many:length ?= 6 &&" +
+ "self_rel_one.rel_many_cascade.files:length != 7 &&" +
+ "self_rel_one.rel_many_cascade.files:length ?!= 8",
+ true,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4_self_rel_one` ON [[__data_demo4_self_rel_one.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_many_cascade` ON [[__data_demo3_rel_many_cascade.id]] IN ({:p1}, {:p2}) LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p3} LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4_self_rel_one.rel_many_cascade]]), json_type([[demo4_self_rel_one.rel_many_cascade]])='array', FALSE) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `__je_demo4_self_rel_one_rel_many_cascade` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[__je_demo4_self_rel_one_rel_many_cascade.value]] WHERE (json_array_length(CASE WHEN iif(json_valid([[__data_demo4_self_rel_one.self_rel_many]]), json_type([[__data_demo4_self_rel_one.self_rel_many]])='array', FALSE) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[__data_demo4_self_rel_one.self_rel_many]]), json_type([[__data_demo4_self_rel_one.self_rel_many]])='array', FALSE) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[__data_demo3_rel_many_cascade.files]]), json_type([[__data_demo3_rel_many_cascade.files]])='array', FALSE) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN iif(json_valid([[__data_demo3_rel_many_cascade.files]]), json_type([[__data_demo3_rel_many_cascade.files]])='array', FALSE) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN iif(json_valid([[__mm___data_demo3_rel_many_cascade.files]]), json_type([[__mm___data_demo3_rel_many_cascade.files]])='array', FALSE) THEN [[__mm___data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm___data_demo3_rel_many_cascade.files]] = '' OR [[__mm___data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm___data_demo3_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___data_demo3_rel_many_cascade` ON [[__mm___data_demo3_rel_many_cascade.id]] IN ({:p8}, {:p9}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] < {:TEST})))) AND json_array_length(CASE WHEN iif(json_valid([[__data_demo3_rel_one_cascade.files]]), json_type([[__data_demo3_rel_one_cascade.files]])='array', FALSE) THEN [[__data_demo3_rel_one_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_one_cascade.files]] = '' OR [[__data_demo3_rel_one_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_one_cascade.files]]) END) END) < {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[demo4_self_rel_one.self_rel_many]]), json_type([[demo4_self_rel_one.self_rel_many]])='array', FALSE) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[demo4_self_rel_one.self_rel_many]]), json_type([[demo4_self_rel_one.self_rel_many]])='array', FALSE) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN iif(json_valid([[demo4_self_rel_one_rel_many_cascade.files]]), json_type([[demo4_self_rel_one_rel_many_cascade.files]])='array', FALSE) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN iif(json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]), json_type([[__mm_demo4_self_rel_one_rel_many_cascade.files]])='array', FALSE) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]), json_type([[__mm_demo4_self_rel_one.rel_many_cascade]])='array', FALSE) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_cascade]]) END) `__mm_demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `__mm_demo4_self_rel_one_rel_many_cascade` ON [[__mm_demo4_self_rel_one_rel_many_cascade.id]] = [[__mm_demo4_self_rel_one_rel_many_cascade_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] IS NOT {:TEST})))) AND json_array_length(CASE WHEN iif(json_valid([[demo4_self_rel_one_rel_many_cascade.files]]), json_type([[demo4_self_rel_one_rel_many_cascade.files]])='array', FALSE) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST})",
+ },
+ {
+ "request body :changed modifier with non-existing collection field",
+ "demo1",
+ "@request.body.a:changed > 1",
+ true,
+ "",
+ },
+ {
+ "regular body :changed modifier",
+ "demo1",
+ "@request.body.number:changed = false &&" +
+ "@request.body.email:changed = true &&" +
+ "@request.body.number:changed = @request.body.select_many:changed",
+ true,
+ "SELECT `demo1`.* FROM `demo1` WHERE ((TRUE = 1 AND {:TEST} IS NOT [[demo1.number]]) IS 0 AND (FALSE = 1 AND ('' IS NOT [[demo1.email]] AND [[demo1.email]] IS NOT NULL)) IS 1 AND (TRUE = 1 AND {:TEST} IS NOT [[demo1.number]]) IS (TRUE = 1 AND {:TEST} IS NOT [[demo1.select_many]]))",
+ },
+ {
+ "json_extract and json_array_length COALESCE equal normalizations",
+ "demo4",
+ "json_object.a.b = '' && self_rel_many:length != 2 && json_object.a.b > 3 && self_rel_many:length <= 4",
+ false,
+ "SELECT `demo4`.* FROM `demo4` WHERE ((CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$.a.b') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb.a.b') END) IS {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE (CASE WHEN [[demo4.self_rel_many]] = '' OR [[demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4.self_rel_many]]) END) END) IS NOT {:TEST} AND (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$.a.b') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb.a.b') END) > {:TEST} AND json_array_length(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE (CASE WHEN [[demo4.self_rel_many]] = '' OR [[demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4.self_rel_many]]) END) END) <= {:TEST})",
+ },
+ {
+ "json field equal normalization checks",
+ "demo4",
+ "json_object = '' || json_object != '' || '' = json_object || '' != json_object ||" +
+ "json_object = null || json_object != null || null = json_object || null != json_object ||" +
+ "json_object = true || json_object != true || true = json_object || true != json_object ||" +
+ "json_object = json_object || json_object != json_object ||" +
+ "json_object = title || title != json_object ||" +
+ // multimatch expressions
+ "self_rel_many.json_object = '' || null = self_rel_many.json_object ||" +
+ "self_rel_many.json_object = self_rel_many.json_object",
+ false,
+ "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo4.self_rel_many]]), json_type([[demo4.self_rel_many]])='array', FALSE) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `__je_demo4_self_rel_many` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[__je_demo4_self_rel_many.value]] WHERE ((CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS {:TEST} OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS NOT {:TEST} OR {:TEST} IS (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR {:TEST} IS NOT (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS NULL OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS NOT NULL OR NULL IS (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR NULL IS NOT (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS 1 OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS NOT 1 OR 1 IS (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR 1 IS NOT (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS NOT (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) IS [[demo4.title]] OR [[demo4.title]] IS NOT (CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb') END) OR (((CASE WHEN json_valid([[demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_many.json_object]]), '$.pb') END) IS {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT (CASE WHEN json_valid([[__mm_demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[__mm_demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[__mm_demo4_self_rel_many.json_object]]), '$.pb') END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] IS {:TEST})))) OR ((NULL IS (CASE WHEN json_valid([[demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_many.json_object]]), '$.pb') END)) AND (NOT EXISTS (SELECT 1 FROM (SELECT (CASE WHEN json_valid([[__mm_demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[__mm_demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[__mm_demo4_self_rel_many.json_object]]), '$.pb') END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT (NULL IS [[__smTEST.multiMatchValue]])))) OR (((CASE WHEN json_valid([[demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_many.json_object]]), '$.pb') END) IS (CASE WHEN json_valid([[demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo4_self_rel_many.json_object]]), '$.pb') END)) AND (NOT EXISTS (SELECT 1 FROM (SELECT (CASE WHEN json_valid([[__mm_demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[__mm_demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[__mm_demo4_self_rel_many.json_object]]), '$.pb') END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__mlTEST}} LEFT JOIN (SELECT (CASE WHEN json_valid([[__mm_demo4_self_rel_many.json_object]]) THEN JSON_EXTRACT([[__mm_demo4_self_rel_many.json_object]], '$') ELSE JSON_EXTRACT(json_object('pb', [[__mm_demo4_self_rel_many.json_object]]), '$.pb') END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo4.self_rel_many]]), json_type([[__mm_demo4.self_rel_many]])='array', FALSE) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__mrTEST}} WHERE NOT ([[__mlTEST.multiMatchValue]] IS [[__mrTEST.multiMatchValue]])))))",
+ },
+ {
+ "geoPoint props access",
+ "view1",
+ "point = '' || point.lat > 1 || point.lon < 2 || point.something > 3",
+ false,
+ "SELECT `view1`.* FROM `view1` WHERE (([[view1.point]] = '' OR [[view1.point]] IS NULL) OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lat') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lat') END) > {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.lon') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.lon') END) < {:TEST} OR (CASE WHEN json_valid([[view1.point]]) THEN JSON_EXTRACT([[view1.point]], '$.something') ELSE JSON_EXTRACT(json_object('pb', [[view1.point]]), '$.pb.something') END) > {:TEST})",
+ },
+ {
+ "strftime with fixed string as time-value against known empty value (null normalizations)",
+ "demo5",
+ "strftime('%Y-%m', '2026-01-01') = ''",
+ false,
+ "SELECT `demo5`.* FROM `demo5` WHERE ((strftime({:TEST},{:TEST}) = '' OR strftime({:TEST},{:TEST}) IS NULL))",
+ },
+ {
+ "strftime without multi-match",
+ "demo5",
+ "strftime('%Y-%m', rel_one.created) = true",
+ false,
+ "SELECT DISTINCT `demo5`.* FROM `demo5` LEFT JOIN `demo4` `demo5_rel_one` ON [[demo5_rel_one.id]] = [[demo5.rel_one]] WHERE strftime({:TEST},[[demo5_rel_one.created]]) = 1",
+ },
+ {
+ "strftime with multi-match",
+ "demo5",
+ "strftime('%Y-%m', rel_many.created) = true",
+ false,
+ "SELECT DISTINCT `demo5`.* FROM `demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[demo5.rel_many]]), json_type([[demo5.rel_many]])='array', FALSE) THEN [[demo5.rel_many]] ELSE json_array([[demo5.rel_many]]) END) `__je_demo5_rel_many` LEFT JOIN `demo4` `demo5_rel_many` ON [[demo5_rel_many.id]] = [[__je_demo5_rel_many.value]] WHERE (((strftime({:TEST},[[demo5_rel_many.created]]) = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT strftime({:TEST},[[__mm_demo5_rel_many.created]]) as [[multiMatchValue]] FROM `demo5` `__mm_demo5` LEFT JOIN json_each(CASE WHEN iif(json_valid([[__mm_demo5.rel_many]]), json_type([[__mm_demo5.rel_many]])='array', FALSE) THEN [[__mm_demo5.rel_many]] ELSE json_array([[__mm_demo5.rel_many]]) END) `__mm_demo5_rel_many_je` LEFT JOIN `demo4` `__mm_demo5_rel_many` ON [[__mm_demo5_rel_many.id]] = [[__mm_demo5_rel_many_je.value]] WHERE `__mm_demo5`.`id` = `demo5`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1)))))",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection, err := app.FindCollectionByNameOrId(s.collectionIdOrName)
+ if err != nil {
+ t.Fatalf("Failed to load collection %s: %v", s.collectionIdOrName, err)
+ }
+
+ expectError := s.expectQuery == ""
+
+ query := app.RecordQuery(collection)
+
+ r := core.NewRecordFieldResolver(app, collection, requestInfo, s.allowHiddenFields)
+
+ expr, err := search.FilterData(s.rule).BuildExpr(r)
+ hasErr := err != nil
+ if hasErr != expectError {
+ t.Fatalf("BuildExpr failed: expected hasErr %v, got %v (%v)", expectError, hasErr, err)
+ }
+
+ err = r.UpdateQuery(query)
+ hasErr = err != nil
+ if hasErr && expectError {
+ t.Fatalf("UpdateQuery failed: expected hasErr %v, got %v (%v)", expectError, hasErr, err)
+ }
+
+ if expectError {
+ return
+ }
+
+ rawQuery := query.AndWhere(expr).Build().SQL()
+
+ // replace TEST placeholder with .+ regex pattern
+ expectQuery := strings.ReplaceAll(
+ "^"+regexp.QuoteMeta(s.expectQuery)+"$",
+ "TEST",
+ `\w+`,
+ )
+
+ if !list.ExistInSliceWithRegex(rawQuery, []string{expectQuery}) {
+ t.Fatalf("Expected query\n %v \ngot:\n %v", expectQuery, rawQuery)
+ }
+ })
+ }
+}
+
+func TestRecordFieldResolverResolveCollectionFields(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo4")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ authRecord, err := app.FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requestInfo := &core.RequestInfo{
+ Auth: authRecord,
+ }
+
+ r := core.NewRecordFieldResolver(app, collection, requestInfo, true)
+
+ scenarios := []struct {
+ fieldName string
+ expectError bool
+ expectName string
+ }{
+ {"", true, ""},
+ {" ", true, ""},
+ {"unknown", true, ""},
+ {"invalid format", true, ""},
+ {"id", false, "[[demo4.id]]"},
+ {"created", false, "[[demo4.created]]"},
+ {"updated", false, "[[demo4.updated]]"},
+ {"title", false, "[[demo4.title]]"},
+ {"title.test", true, ""},
+ {"self_rel_many", false, "[[demo4.self_rel_many]]"},
+ {"self_rel_many.", true, ""},
+ {"self_rel_many.unknown", true, ""},
+ {"self_rel_many.title", false, "[[demo4_self_rel_many.title]]"},
+ {"self_rel_many.self_rel_one.self_rel_many.title", false, "[[demo4_self_rel_many_self_rel_one_self_rel_many.title]]"},
+
+ // max relations limit
+ {"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.id", false, "[[demo4_self_rel_many_self_rel_many_self_rel_many_self_rel_many_self_rel_many_self_rel_many.id]]"},
+ {"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.id", true, ""},
+
+ // back relations
+ {"rel_one_cascade.demo4_via_title.id", true, ""}, // not a relation field
+ {"rel_one_cascade.demo4_via_self_rel_one.id", true, ""}, // relation field but to a different collection
+ {"rel_one_cascade.demo4_via_rel_one_cascade.id", false, "[[demo4_rel_one_cascade_demo4_via_rel_one_cascade.id]]"},
+ {"rel_one_cascade.demo4_via_rel_one_cascade.rel_one_cascade.demo4_via_rel_one_cascade.id", false, "[[demo4_rel_one_cascade_demo4_via_rel_one_cascade_rel_one_cascade_demo4_via_rel_one_cascade.id]]"},
+
+ // json_extract
+ {"json_array.0", false, "(CASE WHEN json_valid([[demo4.json_array]]) THEN JSON_EXTRACT([[demo4.json_array]], '$[0]') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_array]]), '$.pb[0]') END)"},
+ {"json_object.a.b.c", false, "(CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$.a.b.c') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb.a.b.c') END)"},
+
+ // max relations limit shouldn't apply for json paths
+ {"json_object.a.b.c.e.f.g.h.i.j.k.l.m.n.o.p", false, "(CASE WHEN json_valid([[demo4.json_object]]) THEN JSON_EXTRACT([[demo4.json_object]], '$.a.b.c.e.f.g.h.i.j.k.l.m.n.o.p') ELSE JSON_EXTRACT(json_object('pb', [[demo4.json_object]]), '$.pb.a.b.c.e.f.g.h.i.j.k.l.m.n.o.p') END)"},
+
+ // @request.auth relation join
+ {"@request.auth.rel", false, "[[__auth_users.rel]]"},
+ {"@request.auth.rel.title", false, "[[__auth_users_rel.title]]"},
+ {"@request.auth.demo1_via_rel_many.id", false, "[[__auth_users_demo1_via_rel_many.id]]"},
+ {"@request.auth.rel.missing", false, "NULL"},
+ {"@request.auth.missing_via_rel", false, "NULL"},
+ {"@request.auth.demo1_via_file_one.id", false, "NULL"}, // not a relation field
+ {"@request.auth.demo1_via_rel_one.id", false, "NULL"}, // relation field but to a different collection
+
+ // @collection fields
+ {"@collect", true, ""},
+ {"collection.demo4.title", true, ""},
+ {"@collection", true, ""},
+ {"@collection.unknown", true, ""},
+ {"@collection.demo2", true, ""},
+ {"@collection.demo2.", true, ""},
+ {"@collection.demo2:someAlias", true, ""},
+ {"@collection.demo2:someAlias.", true, ""},
+ {"@collection.demo2.title", false, "[[__collection_demo2.title]]"},
+ {"@collection.demo2:someAlias.title", false, "[[__collection_alias_someAlias.title]]"},
+ {"@collection.demo4.id", false, "[[__collection_demo4.id]]"},
+ {"@collection.demo4.created", false, "[[__collection_demo4.created]]"},
+ {"@collection.demo4.updated", false, "[[__collection_demo4.updated]]"},
+ {"@collection.demo4.self_rel_many.missing", true, ""},
+ {"@collection.demo4.self_rel_many.self_rel_one.self_rel_many.self_rel_one.title", false, "[[__collection_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]]"},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.fieldName, func(t *testing.T) {
+ r, err := r.Resolve(s.fieldName)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if r.Identifier != s.expectName {
+ t.Fatalf("Expected r.Identifier\n%q\ngot\n%q", s.expectName, r.Identifier)
+ }
+
+ // params should be empty for non @request fields
+ if len(r.Params) != 0 {
+ t.Fatalf("Expected 0 r.Params, got\n%v", r.Params)
+ }
+ })
+ }
+}
+
+func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ authRecord, err := app.FindRecordById("users", "4q1xlclmfloku33")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ requestInfo := &core.RequestInfo{
+ Context: "ctx",
+ Method: "get",
+ Query: map[string]string{
+ "a": "123",
+ },
+ Body: map[string]any{
+ "number": "10",
+ "number_unknown": "20",
+ "raw_json_obj": types.JSONRaw(`{"a":123}`),
+ "raw_json_arr1": types.JSONRaw(`[123, 456]`),
+ "raw_json_arr2": types.JSONRaw(`[{"a":123},{"b":456}]`),
+ "raw_json_simple": types.JSONRaw(`123`),
+ "b": 456,
+ "c": map[string]int{"sub": 1},
+ },
+ Headers: map[string]string{
+ "d": "789",
+ },
+ Auth: authRecord,
+ }
+
+ r := core.NewRecordFieldResolver(app, collection, requestInfo, true)
+
+ scenarios := []struct {
+ fieldName string
+ expectError bool
+ expectParamValue string // encoded json
+ }{
+ {"@request", true, ""},
+ {"@request.invalid format", true, ""},
+ {"@request.invalid_format2!", true, ""},
+ {"@request.missing", true, ""},
+ {"@request.context", false, `"ctx"`},
+ {"@request.method", false, `"get"`},
+ {"@request.query", true, ``},
+ {"@request.query.a", false, `"123"`},
+ {"@request.query.a.missing", false, ``},
+ {"@request.headers", true, ``},
+ {"@request.headers.missing", false, ``},
+ {"@request.headers.d", false, `"789"`},
+ {"@request.headers.d.sub", false, ``},
+ {"@request.body", true, ``},
+ {"@request.body.b", false, `456`},
+ {"@request.body.number", false, `10`}, // number field normalization
+ {"@request.body.number_unknown", false, `"20"`}, // no numeric normalizations for unknown fields
+ {"@request.body.b.missing", false, ``},
+ {"@request.body.c", false, `"{\"sub\":1}"`},
+ {"@request.auth", true, ""},
+ {"@request.auth.id", false, `"4q1xlclmfloku33"`},
+ {"@request.auth.collectionId", false, `"` + authRecord.Collection().Id + `"`},
+ {"@request.auth.collectionName", false, `"` + authRecord.Collection().Name + `"`},
+ {"@request.auth.verified", false, `false`},
+ {"@request.auth.emailVisibility", false, `false`},
+ {"@request.auth.email", false, `"test@example.com"`}, // should always be returned no matter of the emailVisibility state
+ {"@request.auth.missing", false, `NULL`},
+ {"@request.body.raw_json_simple", false, `"123"`},
+ {"@request.body.raw_json_simple.a", false, `NULL`},
+ {"@request.body.raw_json_obj.a", false, `123`},
+ {"@request.body.raw_json_obj.b", false, `NULL`},
+ {"@request.body.raw_json_arr1.1", false, `456`},
+ {"@request.body.raw_json_arr1.3", false, `NULL`},
+ {"@request.body.raw_json_arr2.0.a", false, `123`},
+ {"@request.body.raw_json_arr2.0.b", false, `NULL`},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.fieldName, func(t *testing.T) {
+ r, err := r.Resolve(s.fieldName)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ // missing key
+ // ---
+ if len(r.Params) == 0 {
+ if r.Identifier != "NULL" {
+ t.Fatalf("Expected 0 placeholder parameters for %v, got %v", r.Identifier, r.Params)
+ }
+ return
+ }
+
+ // existing key
+ // ---
+ if len(r.Params) != 1 {
+ t.Fatalf("Expected 1 placeholder parameter for %v, got %v", r.Identifier, r.Params)
+ }
+
+ var paramName string
+ var paramValue any
+ for k, v := range r.Params {
+ paramName = k
+ paramValue = v
+ }
+
+ if r.Identifier != ("{:" + paramName + "}") {
+ t.Fatalf("Expected parameter r.Identifier %q, got %q", paramName, r.Identifier)
+ }
+
+ encodedParamValue, _ := json.Marshal(paramValue, json.Deterministic(true))
+ if string(encodedParamValue) != s.expectParamValue {
+ t.Fatalf("Expected r.Params %#v for %s, got %#v", s.expectParamValue, r.Identifier, string(encodedParamValue))
+ }
+ })
+ }
+
+ // ensure that the original email visibility was restored
+ if authRecord.EmailVisibility() {
+ t.Fatal("Expected the original authRecord emailVisibility to remain unchanged")
+ }
+ if v, ok := authRecord.PublicExport()[core.FieldNameEmail]; ok {
+ t.Fatalf("Expected the original authRecord email to not be exported, got %q", v)
+ }
+}
diff --git a/core/record_model.go b/core/record_model.go
new file mode 100644
index 0000000000..b654864d08
--- /dev/null
+++ b/core/record_model.go
@@ -0,0 +1,1646 @@
+package core
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json/jsontext"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "log"
+ "maps"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/store"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+// used as a workaround by some fields for persisting local state between various events
+// (for now is kept private and cannot be changed or cloned outside of the core package)
+const internalCustomFieldKeyPrefix = "@pbInternal"
+
+var (
+ _ Model = (*Record)(nil)
+ _ HookTagger = (*Record)(nil)
+ _ DBExporter = (*Record)(nil)
+ _ FilesManager = (*Record)(nil)
+)
+
+type Record struct {
+ collection *Collection
+ originalData map[string]any
+ customVisibility *store.Store[string, bool]
+ data *store.Store[string, any]
+ expand *store.Store[string, any]
+
+ BaseModel
+
+ exportCustomData bool
+ ignoreEmailVisibility bool
+ ignoreUnchangedFields bool
+}
+
+const systemHookIdRecord = "__pbRecordSystemHook__"
+
+func (app *BaseApp) registerRecordHooks() {
+ app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelCreate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelCreateExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterCreateSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterCreateError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelErrorEvent) error {
+ if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ defer syncRecordErrorEventWithModelErrorEvent(re, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelUpdate().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelUpdateExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterUpdateError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelErrorEvent) error {
+ if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ defer syncRecordErrorEventWithModelErrorEvent(re, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelDelete().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelDeleteExecute().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelEvent) error {
+ if re, ok := newRecordEventFromModelEvent(me); ok {
+ err := me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
+ syncModelEventWithRecordEvent(me, re)
+ defer syncRecordEventWithModelEvent(re, me)
+ return me.Next()
+ })
+ syncModelEventWithRecordEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ app.OnModelAfterDeleteError().Bind(&hook.Handler[*ModelErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(me *ModelErrorEvent) error {
+ if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
+ err := me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ defer syncRecordErrorEventWithModelErrorEvent(re, me)
+ return me.Next()
+ })
+ syncModelErrorEventWithRecordErrorEvent(me, re)
+ return err
+ }
+
+ return me.Next()
+ },
+ Priority: -99,
+ })
+
+ // ---------------------------------------------------------------
+
+ app.OnRecordValidate().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionValidate,
+ func() error {
+ return onRecordValidate(e)
+ },
+ )
+ },
+ Priority: 99,
+ })
+
+ app.OnRecordCreate().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionCreate,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordCreateExecute().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionCreateExecute,
+ func() error {
+ return onRecordSaveExecute(e)
+ },
+ )
+ },
+ Priority: 99,
+ })
+
+ app.OnRecordAfterCreateSuccess().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterCreate,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordAfterCreateError().Bind(&hook.Handler[*RecordErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordErrorEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterCreateError,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordUpdate().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionUpdate,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordUpdateExecute().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionUpdateExecute,
+ func() error {
+ return onRecordSaveExecute(e)
+ },
+ )
+ },
+ Priority: 99,
+ })
+
+ app.OnRecordAfterUpdateSuccess().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterUpdate,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordAfterUpdateError().Bind(&hook.Handler[*RecordErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordErrorEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterUpdateError,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordDelete().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionDelete,
+ func() error {
+ if e.Record.Collection().IsView() {
+ return errors.New("view records cannot be deleted")
+ }
+
+ return e.Next()
+ },
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordDeleteExecute().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionDeleteExecute,
+ func() error {
+ return onRecordDeleteExecute(e)
+ },
+ )
+ },
+ Priority: 99,
+ })
+
+ app.OnRecordAfterDeleteSuccess().Bind(&hook.Handler[*RecordEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterDelete,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+
+ app.OnRecordAfterDeleteError().Bind(&hook.Handler[*RecordErrorEvent]{
+ Id: systemHookIdRecord,
+ Func: func(e *RecordErrorEvent) error {
+ return e.Record.callFieldInterceptors(
+ e.Context,
+ e.App,
+ InterceptorActionAfterDeleteError,
+ e.Next,
+ )
+ },
+ Priority: -99,
+ })
+}
+
+// -------------------------------------------------------------------
+
+// newRecordFromNullStringMap initializes a single new Record model
+// with data loaded from the provided NullStringMap.
+//
+// Note that this method is intended to load and Scan data from a database row result.
+func newRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) (*Record, error) {
+ record := NewRecord(collection)
+
+ var fieldName string
+ for _, field := range collection.Fields {
+ fieldName = field.GetName()
+
+ nullString, ok := data[fieldName]
+
+ var value any
+ var err error
+
+ if ok && nullString.Valid {
+ value, err = field.PrepareValue(record, nullString.String)
+ } else {
+ value, err = field.PrepareValue(record, nil)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ // we load only the original data to avoid unnecessary copying the same data into the record.data store
+ // (it is also the reason why we don't invoke PostScan on the record itself)
+ record.originalData[fieldName] = value
+
+ if fieldName == FieldNameId {
+ record.Id = cast.ToString(value)
+ }
+ }
+
+ record.BaseModel.PostScan()
+
+ return record, nil
+}
+
+// newRecordsFromNullStringMaps initializes a new Record model for
+// each row in the provided NullStringMap slice.
+//
+// Note that this method is intended to load and Scan data from a database rows result.
+func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) ([]*Record, error) {
+ result := make([]*Record, len(rows))
+
+ var err error
+ for i, row := range rows {
+ result[i], err = newRecordFromNullStringMap(collection, row)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return result, nil
+}
+
+// -------------------------------------------------------------------
+
+// NewRecord initializes a new empty Record model.
+func NewRecord(collection *Collection) *Record {
+ record := &Record{
+ collection: collection,
+ data: store.New[string, any](nil),
+ customVisibility: store.New[string, bool](nil),
+ originalData: make(map[string]any, len(collection.Fields)),
+ }
+
+ // initialize default field values
+ var fieldName string
+ for _, field := range collection.Fields {
+ fieldName = field.GetName()
+
+ if fieldName == FieldNameId {
+ continue
+ }
+
+ value, _ := field.PrepareValue(record, nil)
+ record.originalData[fieldName] = value
+ }
+
+ return record
+}
+
+// Collection returns the Collection model associated with the current Record model.
+//
+// NB! The returned collection is only for read purposes and it shouldn't be modified
+// because it could have unintended side-effects on other Record models from the same collection.
+func (m *Record) Collection() *Collection {
+ return m.collection
+}
+
+// TableName returns the table name associated with the current Record model.
+func (m *Record) TableName() string {
+ return m.collection.Name
+}
+
+// PostScan implements the [dbx.PostScanner] interface.
+//
+// It essentially refreshes/updates the current Record original state
+// as if the model was fetched from the databases for the first time.
+//
+// Or in other words, it means that m.Original().FieldsData() will have
+// the same values as m.Record().FieldsData().
+func (m *Record) PostScan() error {
+ if m.Id == "" {
+ return errors.New("missing record primary key")
+ }
+
+ if err := m.BaseModel.PostScan(); err != nil {
+ return err
+ }
+
+ m.originalData = m.FieldsData()
+
+ return nil
+}
+
+// HookTags returns the hook tags associated with the current record.
+func (m *Record) HookTags() []string {
+ return []string{m.collection.Name, m.collection.Id}
+}
+
+// BaseFilesPath returns the storage dir path used by the record.
+func (m *Record) BaseFilesPath() string {
+ id := cast.ToString(m.LastSavedPK())
+ if id == "" {
+ id = m.Id
+ }
+
+ return m.collection.BaseFilesPath() + "/" + id
+}
+
+// Original returns a shallow copy of the current record model populated
+// with its ORIGINAL db data state (aka. right after PostScan())
+// and everything else reset to the defaults.
+//
+// If record was created using NewRecord() the original will be always
+// a blank record (until PostScan() is invoked).
+func (m *Record) Original() *Record {
+ newRecord := NewRecord(m.collection)
+
+ newRecord.originalData = maps.Clone(m.originalData)
+
+ if newRecord.originalData[FieldNameId] != nil {
+ newRecord.lastSavedPK = cast.ToString(newRecord.originalData[FieldNameId])
+ newRecord.Id = newRecord.lastSavedPK
+ }
+
+ return newRecord
+}
+
+// Fresh returns a shallow copy of the current record model populated
+// with its LATEST data state and everything else reset to the defaults
+// (aka. no expand, no unknown fields and with default visibility flags).
+func (m *Record) Fresh() *Record {
+ newRecord := m.Original()
+
+ // note: this will also load the Id field through m.GetRaw
+ var fieldName string
+ for _, field := range m.collection.Fields {
+ fieldName = field.GetName()
+ newRecord.SetRaw(fieldName, m.GetRaw(fieldName))
+ }
+
+ return newRecord
+}
+
+// Clone returns a shallow copy of the current record model with all of
+// its collection and unknown fields data, expand and flags copied.
+//
+// use [Record.Fresh()] instead if you want a copy with only the latest
+// collection fields data and everything else reset to the defaults.
+func (m *Record) Clone() *Record {
+ newRecord := m.Original()
+
+ newRecord.Id = m.Id
+ newRecord.exportCustomData = m.exportCustomData
+ newRecord.ignoreEmailVisibility = m.ignoreEmailVisibility
+ newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields
+ newRecord.customVisibility.Reset(m.customVisibility.GetAll())
+
+ data := m.data.GetAll()
+ for k, v := range data {
+ newRecord.SetRaw(k, v)
+ }
+
+ if m.expand != nil {
+ newRecord.SetExpand(m.expand.GetAll())
+ }
+
+ return newRecord
+}
+
+// Expand returns a shallow copy of the current Record model expand data (if any).
+func (m *Record) Expand() map[string]any {
+ if m.expand == nil {
+ // return a dummy initialized map to avoid assignment to nil map errors
+ return map[string]any{}
+ }
+
+ return m.expand.GetAll()
+}
+
+// SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied).
+func (m *Record) SetExpand(expand map[string]any) {
+ if m.expand == nil {
+ m.expand = store.New[string, any](nil)
+ }
+
+ m.expand.Reset(expand)
+}
+
+// MergeExpand merges recursively the provided expand data into
+// the current model's expand (if any).
+//
+// Note that if an expanded prop with the same key is a slice (old or new expand)
+// then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]).
+// Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew).
+func (m *Record) MergeExpand(expand map[string]any) {
+ // nothing to merge
+ if len(expand) == 0 {
+ return
+ }
+
+ // no old expand
+ if m.expand == nil {
+ m.expand = store.New(expand)
+ return
+ }
+
+ oldExpand := m.expand.GetAll()
+
+ for key, new := range expand {
+ old, ok := oldExpand[key]
+ if !ok {
+ oldExpand[key] = new
+ continue
+ }
+
+ var wasOldSlice bool
+ var oldSlice []*Record
+ switch v := old.(type) {
+ case *Record:
+ oldSlice = []*Record{v}
+ case []*Record:
+ wasOldSlice = true
+ oldSlice = v
+ default:
+ // invalid old expand data -> assign directly the new
+ // (no matter whether new is valid or not)
+ oldExpand[key] = new
+ continue
+ }
+
+ var wasNewSlice bool
+ var newSlice []*Record
+ switch v := new.(type) {
+ case *Record:
+ newSlice = []*Record{v}
+ case []*Record:
+ wasNewSlice = true
+ newSlice = v
+ default:
+ // invalid new expand data -> skip
+ continue
+ }
+
+ oldIndexed := make(map[string]*Record, len(oldSlice))
+ for _, oldRecord := range oldSlice {
+ oldIndexed[oldRecord.Id] = oldRecord
+ }
+
+ for _, newRecord := range newSlice {
+ oldRecord := oldIndexed[newRecord.Id]
+ if oldRecord != nil {
+ // note: there is no need to update oldSlice since oldRecord is a reference
+ oldRecord.MergeExpand(newRecord.Expand())
+ } else {
+ // missing new entry
+ oldSlice = append(oldSlice, newRecord)
+ }
+ }
+
+ if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
+ oldExpand[key] = oldSlice
+ } else {
+ oldExpand[key] = oldSlice[0]
+ }
+ }
+
+ m.expand.Reset(oldExpand)
+}
+
+// FieldsData returns a shallow copy ONLY of the collection's fields record's data.
+func (m *Record) FieldsData() map[string]any {
+ result := make(map[string]any, len(m.collection.Fields))
+
+ var fieldName string
+ for _, field := range m.collection.Fields {
+ fieldName = field.GetName()
+ result[fieldName] = m.Get(fieldName)
+ }
+
+ return result
+}
+
+// CustomData returns a shallow copy ONLY of the custom record fields data,
+// aka. fields that are neither defined by the collection, nor special system ones.
+//
+// Note that custom fields prefixed with "@pbInternal" are always skipped.
+func (m *Record) CustomData() map[string]any {
+ if m.data == nil {
+ return nil
+ }
+
+ fields := m.Collection().Fields
+
+ knownFields := make(map[string]struct{}, len(fields))
+
+ for _, f := range fields {
+ knownFields[f.GetName()] = struct{}{}
+ }
+
+ result := map[string]any{}
+
+ rawData := m.data.GetAll()
+ for k, v := range rawData {
+ if _, ok := knownFields[k]; !ok {
+ // skip internal custom fields
+ if strings.HasPrefix(k, internalCustomFieldKeyPrefix) {
+ continue
+ }
+
+ result[k] = v
+ }
+ }
+
+ return result
+}
+
+// WithCustomData toggles the export/serialization of custom data fields
+// (false by default).
+func (m *Record) WithCustomData(state bool) *Record {
+ m.exportCustomData = state
+ return m
+}
+
+// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
+func (m *Record) IgnoreEmailVisibility(state bool) *Record {
+ m.ignoreEmailVisibility = state
+ return m
+}
+
+// IgnoreUnchangedFields toggles the flag to ignore the unchanged fields
+// from the DB export for the UPDATE SQL query.
+//
+// This could be used if you want to save only the record fields that you've changed
+// without overwrite other untouched fields in case of concurrent update.
+//
+// Note that the fields change comparison is based on the current fields against m.Original()
+// (aka. if you have performed save on the same Record instance multiple times you may have to refetch it,
+// so that m.Original() could reflect the last saved change).
+func (m *Record) IgnoreUnchangedFields(state bool) *Record {
+ m.ignoreUnchangedFields = state
+ return m
+}
+
+// Set sets the provided key-value data pair into the current Record
+// model directly as it is WITHOUT NORMALIZATIONS.
+//
+// See also [Record.Set].
+func (m *Record) SetRaw(key string, value any) {
+ if key == FieldNameId {
+ m.Id = cast.ToString(value)
+ }
+
+ m.data.Set(key, value)
+}
+
+// SetIfFieldExists sets the provided key-value data pair into the current Record model
+// ONLY if key is existing Collection field name/modifier.
+//
+// This method does nothing if key is not a known Collection field name/modifier.
+//
+// On success returns the matched Field, otherwise - nil.
+//
+// To set any key-value, including custom/unknown fields, use the [Record.Set] method.
+func (m *Record) SetIfFieldExists(key string, value any) Field {
+ for _, field := range m.Collection().Fields {
+ ff, ok := field.(SetterFinder)
+ if ok {
+ setter := ff.FindSetter(key)
+ if setter != nil {
+ setter(m, value)
+ return field
+ }
+ }
+
+ // fallback to the default field PrepareValue method for direct match
+ if key == field.GetName() {
+ value, _ = field.PrepareValue(m, value)
+ m.SetRaw(key, value)
+ return field
+ }
+ }
+
+ return nil
+}
+
+// Set sets the provided key-value data pair into the current Record model.
+//
+// If the record collection has field with name matching the provided "key",
+// the value will be further normalized according to the field setter(s).
+func (m *Record) Set(key string, value any) {
+ switch key {
+ case FieldNameExpand: // for backward-compatibility with earlier versions
+ m.SetExpand(cast.ToStringMap(value))
+ default:
+ field := m.SetIfFieldExists(key, value)
+ if field == nil {
+ // custom key - set it without any transformations
+ m.SetRaw(key, value)
+ }
+ }
+}
+
+func (m *Record) GetRaw(key string) any {
+ if key == FieldNameId {
+ return m.Id
+ }
+
+ if v, ok := m.data.GetOk(key); ok {
+ return v
+ }
+
+ return m.originalData[key]
+}
+
+// Get returns a normalized single record model data value for "key".
+func (m *Record) Get(key string) any {
+ switch key {
+ case FieldNameExpand: // for backward-compatibility with earlier versions
+ return m.Expand()
+ default:
+ for _, field := range m.Collection().Fields {
+ gm, ok := field.(GetterFinder)
+ if !ok {
+ continue // no custom getters
+ }
+
+ getter := gm.FindGetter(key)
+ if getter != nil {
+ return getter(m)
+ }
+ }
+
+ return m.GetRaw(key)
+ }
+}
+
+// Load bulk loads the provided data into the current Record model.
+func (m *Record) Load(data map[string]any) {
+ for k, v := range data {
+ m.Set(k, v)
+ }
+}
+
+// GetBool returns the data value for "key" as a bool.
+func (m *Record) GetBool(key string) bool {
+ return cast.ToBool(m.Get(key))
+}
+
+// GetString returns the data value for "key" as a string.
+func (m *Record) GetString(key string) string {
+ return cast.ToString(m.Get(key))
+}
+
+// GetInt returns the data value for "key" as an int.
+func (m *Record) GetInt(key string) int {
+ return cast.ToInt(m.Get(key))
+}
+
+// GetInt64 returns the data value for "key" as an int64.
+func (m *Record) GetInt64(key string) int64 {
+ return cast.ToInt64(m.Get(key))
+}
+
+// GetFloat returns the data value for "key" as a float64.
+func (m *Record) GetFloat(key string) float64 {
+ return cast.ToFloat64(m.Get(key))
+}
+
+// GetDateTime returns the data value for "key" as a DateTime instance.
+func (m *Record) GetDateTime(key string) types.DateTime {
+ d, _ := types.ParseDateTime(m.Get(key))
+ return d
+}
+
+// GetGeoPoint returns the data value for "key" as a GeoPoint instance.
+func (m *Record) GetGeoPoint(key string) types.GeoPoint {
+ point := types.GeoPoint{}
+ _ = point.Scan(m.Get(key))
+ return point
+}
+
+// GetStringSlice returns the data value for "key" as a slice of non-zero unique strings.
+func (m *Record) GetStringSlice(key string) []string {
+ return list.ToUniqueStringSlice(m.Get(key))
+}
+
+// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
+// (aka. the current [*filesystem.File] values) so that you can apply further
+// validations or modifications (including changing the file name or content before persisting).
+//
+// Example:
+//
+// files := record.GetUnsavedFiles("documents")
+// for _, f := range files {
+// f.Name = "doc_" + f.Name // add a prefix to each file name
+// }
+// app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value
+func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
+ if !strings.HasSuffix(key, ":unsaved") {
+ key += ":unsaved"
+ }
+
+ values, _ := m.Get(key).([]*filesystem.File)
+
+ return values
+}
+
+// Deprecated: replaced with GetUnsavedFiles.
+func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
+ log.Println("Please replace GetUploadedFiles with GetUnsavedFiles")
+ return m.GetUnsavedFiles(key)
+}
+
+// Retrieves the "key" json field value and unmarshals it into "result".
+//
+// Example
+//
+// result := struct {
+// FirstName string `json:"first_name"`
+// }{}
+// err := m.UnmarshalJSONField("my_field_name", &result)
+func (m *Record) UnmarshalJSONField(key string, result any) error {
+ return json.Unmarshal([]byte(m.GetString(key)), &result)
+}
+
+// ExpandedOne retrieves a single relation Record from the already
+// loaded expand data of the current model.
+//
+// If the requested expand relation is multiple, this method returns
+// only first available Record from the expanded relation.
+//
+// Returns nil if there is no such expand relation loaded.
+func (m *Record) ExpandedOne(relField string) *Record {
+ if m.expand == nil {
+ return nil
+ }
+
+ rel := m.expand.Get(relField)
+
+ switch v := rel.(type) {
+ case *Record:
+ return v
+ case []*Record:
+ if len(v) > 0 {
+ return v[0]
+ }
+ }
+
+ return nil
+}
+
+// ExpandedAll retrieves a slice of relation Records from the already
+// loaded expand data of the current model.
+//
+// If the requested expand relation is single, this method normalizes
+// the return result and will wrap the single model as a slice.
+//
+// Returns nil slice if there is no such expand relation loaded.
+func (m *Record) ExpandedAll(relField string) []*Record {
+ if m.expand == nil {
+ return nil
+ }
+
+ rel := m.expand.Get(relField)
+
+ switch v := rel.(type) {
+ case *Record:
+ return []*Record{v}
+ case []*Record:
+ return v
+ }
+
+ return nil
+}
+
+// FindFileFieldByFile returns the first file type field for which
+// any of the record's data contains the provided filename.
+func (m *Record) FindFileFieldByFile(filename string) *FileField {
+ for _, field := range m.Collection().Fields {
+ if field.Type() != FieldTypeFile {
+ continue
+ }
+
+ f, ok := field.(*FileField)
+ if !ok {
+ continue
+ }
+
+ filenames := m.GetStringSlice(f.GetName())
+ if slices.Contains(filenames, filename) {
+ return f
+ }
+ }
+
+ return nil
+}
+
+// DBExport implements the [DBExporter] interface and returns a key-value
+// map with the data to be persisted when saving the Record in the database.
+func (m *Record) DBExport(app App) (map[string]any, error) {
+ result, err := m.dbExport()
+ if err != nil {
+ return nil, err
+ }
+
+ // remove exported fields that haven't changed
+ // (with exception of the id column)
+ if !m.IsNew() && m.ignoreUnchangedFields {
+ oldResult, err := m.Original().dbExport()
+ if err != nil {
+ return nil, err
+ }
+
+ for oldK, oldV := range oldResult {
+ if oldK == idColumn {
+ continue
+ }
+ newV, ok := result[oldK]
+ if ok && areValuesEqual(newV, oldV) {
+ delete(result, oldK)
+ }
+ }
+ }
+
+ return result, nil
+}
+
+func (m *Record) dbExport() (map[string]any, error) {
+ fields := m.Collection().Fields
+
+ result := make(map[string]any, len(fields))
+
+ var fieldName string
+ for _, field := range fields {
+ fieldName = field.GetName()
+
+ if f, ok := field.(DriverValuer); ok {
+ v, err := f.DriverValue(m)
+ if err != nil {
+ return nil, err
+ }
+ result[fieldName] = v
+ } else {
+ result[fieldName] = m.GetRaw(fieldName)
+ }
+ }
+
+ return result, nil
+}
+
+func areValuesEqual(a any, b any) bool {
+ switch av := a.(type) {
+ case string:
+ bv, ok := b.(string)
+ return ok && bv == av
+ case bool:
+ bv, ok := b.(bool)
+ return ok && bv == av
+ case float32:
+ bv, ok := b.(float32)
+ return ok && bv == av
+ case float64:
+ bv, ok := b.(float64)
+ return ok && bv == av
+ case uint:
+ bv, ok := b.(uint)
+ return ok && bv == av
+ case uint8:
+ bv, ok := b.(uint8)
+ return ok && bv == av
+ case uint16:
+ bv, ok := b.(uint16)
+ return ok && bv == av
+ case uint32:
+ bv, ok := b.(uint32)
+ return ok && bv == av
+ case uint64:
+ bv, ok := b.(uint64)
+ return ok && bv == av
+ case int:
+ bv, ok := b.(int)
+ return ok && bv == av
+ case int8:
+ bv, ok := b.(int8)
+ return ok && bv == av
+ case int16:
+ bv, ok := b.(int16)
+ return ok && bv == av
+ case int32:
+ bv, ok := b.(int32)
+ return ok && bv == av
+ case int64:
+ bv, ok := b.(int64)
+ return ok && bv == av
+ case []byte:
+ bv, ok := b.([]byte)
+ return ok && bytes.Equal(av, bv)
+ case []string:
+ bv, ok := b.([]string)
+ return ok && slices.Equal(av, bv)
+ case []int:
+ bv, ok := b.([]int)
+ return ok && slices.Equal(av, bv)
+ case []int32:
+ bv, ok := b.([]int32)
+ return ok && slices.Equal(av, bv)
+ case []int64:
+ bv, ok := b.([]int64)
+ return ok && slices.Equal(av, bv)
+ case []float32:
+ bv, ok := b.([]float32)
+ return ok && slices.Equal(av, bv)
+ case []float64:
+ bv, ok := b.([]float64)
+ return ok && slices.Equal(av, bv)
+ case types.JSONArray[string]:
+ bv, ok := b.(types.JSONArray[string])
+ return ok && slices.Equal(av, bv)
+ case types.JSONRaw:
+ bv, ok := b.(types.JSONRaw)
+ return ok && bytes.Equal(av, bv)
+ default:
+ aRaw, err := json.Marshal(a, json.Deterministic(true))
+ if err != nil {
+ return false
+ }
+
+ bRaw, err := json.Marshal(b, json.Deterministic(true))
+ if err != nil {
+ return false
+ }
+
+ return bytes.Equal(aRaw, bRaw)
+ }
+}
+
+// Hide hides the specified fields from the public safe serialization of the record.
+func (record *Record) Hide(fieldNames ...string) *Record {
+ for _, name := range fieldNames {
+ record.customVisibility.Set(name, false)
+ }
+
+ return record
+}
+
+// Unhide forces to unhide the specified fields from the public safe serialization
+// of the record (even when the collection field itself is marked as hidden).
+func (record *Record) Unhide(fieldNames ...string) *Record {
+ for _, name := range fieldNames {
+ record.customVisibility.Set(name, true)
+ }
+
+ return record
+}
+
+// PublicExport exports only the record fields that are safe to be public.
+//
+// To export unknown data fields you need to set record.WithCustomData(true).
+//
+// For auth records, to force the export of the email field you need to set
+// record.IgnoreEmailVisibility(true).
+func (record *Record) PublicExport() map[string]any {
+ export := make(map[string]any, len(record.collection.Fields)+3)
+
+ var isVisible, hasCustomVisibility bool
+
+ customVisibility := record.customVisibility.GetAll()
+
+ // export schema fields
+ var fieldName string
+ for _, f := range record.collection.Fields {
+ fieldName = f.GetName()
+
+ isVisible, hasCustomVisibility = customVisibility[fieldName]
+ if !hasCustomVisibility {
+ isVisible = !f.GetHidden()
+ }
+
+ if !isVisible {
+ continue
+ }
+
+ export[fieldName] = record.Get(fieldName)
+ }
+
+ // export custom fields
+ if record.exportCustomData {
+ for k, v := range record.CustomData() {
+ isVisible, hasCustomVisibility = customVisibility[k]
+ if !hasCustomVisibility || isVisible {
+ export[k] = v
+ }
+ }
+ }
+
+ if record.Collection().IsAuth() {
+ // always hide the password and tokenKey fields
+ delete(export, FieldNamePassword)
+ delete(export, FieldNameTokenKey)
+
+ if !record.ignoreEmailVisibility && !record.GetBool(FieldNameEmailVisibility) {
+ delete(export, FieldNameEmail)
+ }
+ }
+
+ // add helper collection reference fields
+ isVisible, hasCustomVisibility = customVisibility[FieldNameCollectionId]
+ if !hasCustomVisibility || isVisible {
+ export[FieldNameCollectionId] = record.collection.Id
+ }
+ isVisible, hasCustomVisibility = customVisibility[FieldNameCollectionName]
+ if !hasCustomVisibility || isVisible {
+ export[FieldNameCollectionName] = record.collection.Name
+ }
+
+ // add expand (if non-nil)
+ isVisible, hasCustomVisibility = customVisibility[FieldNameExpand]
+ if (!hasCustomVisibility || isVisible) && record.expand != nil {
+ export[FieldNameExpand] = record.expand.GetAll()
+ }
+
+ return export
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+//
+// Only the data exported by `PublicExport()` will be serialized.
+func (m Record) MarshalJSON() ([]byte, error) {
+ return json.Marshal(
+ m.PublicExport(),
+ json.Deterministic(true),
+ // for compliance with old data (and slightly better performance)
+ jsontext.AllowDuplicateNames(true),
+ // preserve the old jsonv1 behavior in case of invalid data
+ jsontext.AllowInvalidUTF8(true),
+ )
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (m *Record) UnmarshalJSON(data []byte) error {
+ result := map[string]any{}
+
+ if err := json.Unmarshal(data, &result); err != nil {
+ return err
+ }
+
+ m.Load(result)
+
+ return nil
+}
+
+// ReplaceModifiers returns a new map with applied modifier
+// values based on the current record and the specified data.
+//
+// The resolved modifier keys will be removed.
+//
+// Multiple modifiers will be applied one after another,
+// while reusing the previous base key value result (ex. 1; -5; +2 => -2).
+//
+// Note that because Go doesn't guaranteed the iteration order of maps,
+// we would explicitly apply shorter keys first for a more consistent and reproducible behavior.
+//
+// Example usage:
+//
+// newData := record.ReplaceModifiers(data)
+// // record: {"field": 10}
+// // data: {"field+": 5}
+// // result: {"field": 15}
+func (m *Record) ReplaceModifiers(data map[string]any) map[string]any {
+ if len(data) == 0 {
+ return data
+ }
+
+ dataCopy := maps.Clone(data)
+
+ recordCopy := m.Fresh()
+
+ // key orders is not guaranteed so
+ sortedDataKeys := make([]string, 0, len(data))
+ for k := range data {
+ sortedDataKeys = append(sortedDataKeys, k)
+ }
+ sort.SliceStable(sortedDataKeys, func(i int, j int) bool {
+ return len(sortedDataKeys[i]) < len(sortedDataKeys[j])
+ })
+
+ for _, k := range sortedDataKeys {
+ field := recordCopy.SetIfFieldExists(k, data[k])
+ if field != nil {
+ // delete the original key in case it is with a modifer (ex. "items+")
+ delete(dataCopy, k)
+
+ // store the transformed value under the field name
+ dataCopy[field.GetName()] = recordCopy.Get(field.GetName())
+ }
+ }
+
+ return dataCopy
+}
+
+// -------------------------------------------------------------------
+
+func (m *Record) callFieldInterceptors(
+ ctx context.Context,
+ app App,
+ actionName string,
+ actionFunc func() error,
+) error {
+ // the firing order of the fields doesn't matter
+ for _, field := range m.Collection().Fields {
+ if f, ok := field.(RecordInterceptor); ok {
+ oldfn := actionFunc
+ actionFunc = func() error {
+ return f.Intercept(ctx, app, m, actionName, oldfn)
+ }
+ }
+ }
+
+ return actionFunc()
+}
+
+func onRecordValidate(e *RecordEvent) error {
+ errs := validation.Errors{}
+
+ for _, f := range e.Record.Collection().Fields {
+ if err := f.ValidateValue(e.Context, e.App, e.Record); err != nil {
+ errs[f.GetName()] = err
+ }
+ }
+
+ if len(errs) > 0 {
+ return errs
+ }
+
+ return e.Next()
+}
+
+func onRecordSaveExecute(e *RecordEvent) error {
+ if e.Record.Collection().IsAuth() {
+ if !e.Record.IsNew() {
+ lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
+ if err != nil {
+ return err
+ }
+
+ // ensure that the token key is regenerated on password change or email change
+ if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
+ (lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
+ lastSavedRecord.Email() != e.Record.Email()) {
+ e.Record.RefreshTokenKey()
+ }
+ }
+
+ // loosely cross-check that the auth record id is unique across all auth collections
+ // to minimize impact of mistakes in API rules when multiple auth collections are used
+ authCollections, err := e.App.FindAllCollections(CollectionTypeAuth)
+ if err != nil {
+ return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
+ }
+ for _, collection := range authCollections {
+ if e.Record.Collection().Id == collection.Id {
+ continue // skip current collection (sqlite will do the check for us)
+ }
+ record, _ := e.App.FindRecordById(collection, e.Record.Id)
+ if record != nil {
+ return validation.Errors{
+ FieldNameId: validation.NewError("validation_invalid_auth_id", "Invalid or duplicated auth record id."),
+ }
+ }
+ }
+ }
+
+ err := e.Next()
+ if err == nil {
+ return nil
+ }
+
+ return validators.NormalizeUniqueIndexError(
+ err,
+ e.Record.Collection().Name,
+ e.Record.Collection().Fields.FieldNames(),
+ )
+}
+
+func onRecordDeleteExecute(e *RecordEvent) error {
+ // fetch rel references (if any)
+ //
+ // note: the select is outside of the transaction to minimize
+ // SQLITE_BUSY errors when mixing read&write in a single transaction
+ refs, err := e.App.FindCachedCollectionReferences(e.Record.Collection())
+ if err != nil {
+ return err
+ }
+
+ originalApp := e.App
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ // delete the record before the relation references to ensure that there
+ // will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ return cascadeRecordDelete(txApp, e.Record, refs)
+ })
+ e.App = originalApp
+
+ return txErr
+}
+
+// cascadeRecordDelete triggers cascade deletion for the provided references.
+//
+// NB! This method is expected to be called from inside of a transaction.
+func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Field) error {
+ // Sort the refs keys to ensure that the cascade events firing order is always the same.
+ // This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
+ sortedRefKeys := make([]*Collection, 0, len(refs))
+ for k := range refs {
+ sortedRefKeys = append(sortedRefKeys, k)
+ }
+ sort.Slice(sortedRefKeys, func(i, j int) bool {
+ return sortedRefKeys[i].Name < sortedRefKeys[j].Name
+ })
+
+ for _, refCollection := range sortedRefKeys {
+ fields, ok := refs[refCollection]
+
+ if !ok || refCollection.IsView() {
+ continue // skip missing or view collections
+ }
+
+ refTableName := inflector.Columnify(refCollection.Name)
+
+ for _, field := range fields {
+ prefixedFieldName := refTableName + "." + inflector.Columnify(field.GetName())
+
+ // fetch only the related ids because they will be queried anyway right
+ // before delete to ensure that we are working with fresh record data
+ query := app.DB().Select(refTableName + ".id").From(refTableName)
+
+ if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() {
+ query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
+ } else {
+ query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
+ `SELECT 1 FROM %s {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
+ dbutils.JSONEach(prefixedFieldName),
+ ), dbx.Params{
+ "jevalue": mainRecord.Id,
+ })))
+ }
+
+ if refCollection.Id == mainRecord.Collection().Id {
+ query.AndWhere(dbx.Not(dbx.HashExp{refTableName + ".id": mainRecord.Id}))
+ }
+
+ // trigger cascade for each batchSize rel items until there is none
+ batchSize := 8000
+ refIds := make([]string, 0, batchSize)
+ for {
+ err := query.Limit(int64(batchSize)).Column(&refIds)
+ if err != nil {
+ return err
+ }
+
+ total := len(refIds)
+ if total == 0 {
+ break
+ }
+
+ err = deleteRefRecords(app, mainRecord, refCollection, refIds, field)
+ if err != nil {
+ return err
+ }
+
+ if total < batchSize {
+ break // no more items
+ }
+
+ refIds = refIds[:0] // keep allocated memory
+ }
+ }
+ }
+
+ return nil
+}
+
+// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
+// OR
+// just unset the record id from any relation field values (if they are not required).
+//
+// NB! This method is expected to be called from inside of a transaction.
+func deleteRefRecords(app App, mainRecord *Record, refCollection *Collection, refIds []string, field Field) error {
+ relField, _ := field.(*RelationField)
+ if relField == nil {
+ return errors.New("only RelationField is supported at the moment, got " + field.Type())
+ }
+
+ for _, refId := range refIds {
+ refRecord, err := app.FindRecordById(refCollection, refId)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ continue // already deleted
+ }
+ return err
+ }
+
+ ids := refRecord.GetStringSlice(relField.Name)
+
+ // unset the record id
+ for i := len(ids) - 1; i >= 0; i-- {
+ if ids[i] == mainRecord.Id {
+ ids = append(ids[:i], ids[i+1:]...)
+ break
+ }
+ }
+
+ // cascade delete the reference
+ // (only if there are no other active references in case of multiple select)
+ if relField.CascadeDelete && len(ids) == 0 {
+ if err := app.Delete(refRecord); err != nil {
+ return err
+ }
+ // no further actions are needed (the reference is deleted)
+ continue
+ }
+
+ if relField.Required && len(ids) == 0 {
+ return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
+ }
+
+ // save the reference changes
+ // (without validation because it is possible that another relation field to have a reference to a previous deleted record)
+ refRecord.Set(relField.Name, ids)
+ if err := app.SaveNoValidate(refRecord); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/core/record_model_auth.go b/core/record_model_auth.go
new file mode 100644
index 0000000000..07858f43f6
--- /dev/null
+++ b/core/record_model_auth.go
@@ -0,0 +1,85 @@
+package core
+
+import "github.com/pocketbase/pocketbase/tools/security"
+
+// Email returns the "email" record field value (usually available with Auth collections).
+func (m *Record) Email() string {
+ return m.GetString(FieldNameEmail)
+}
+
+// SetEmail sets the "email" record field value (usually available with Auth collections).
+func (m *Record) SetEmail(email string) {
+ m.Set(FieldNameEmail, email)
+}
+
+// Verified returns the "emailVisibility" record field value (usually available with Auth collections).
+func (m *Record) EmailVisibility() bool {
+ return m.GetBool(FieldNameEmailVisibility)
+}
+
+// SetEmailVisibility sets the "emailVisibility" record field value (usually available with Auth collections).
+func (m *Record) SetEmailVisibility(visible bool) {
+ m.Set(FieldNameEmailVisibility, visible)
+}
+
+// Verified returns the "verified" record field value (usually available with Auth collections).
+func (m *Record) Verified() bool {
+ return m.GetBool(FieldNameVerified)
+}
+
+// SetVerified sets the "verified" record field value (usually available with Auth collections).
+func (m *Record) SetVerified(verified bool) {
+ m.Set(FieldNameVerified, verified)
+}
+
+// TokenKey returns the "tokenKey" record field value (usually available with Auth collections).
+func (m *Record) TokenKey() string {
+ return m.GetString(FieldNameTokenKey)
+}
+
+// SetTokenKey sets the "tokenKey" record field value (usually available with Auth collections).
+func (m *Record) SetTokenKey(key string) {
+ m.Set(FieldNameTokenKey, key)
+}
+
+// RefreshTokenKey generates and sets a new random auth record "tokenKey".
+func (m *Record) RefreshTokenKey() {
+ m.Set(FieldNameTokenKey+autogenerateModifier, "")
+}
+
+// SetPassword sets the "password" record field value (usually available with Auth collections).
+func (m *Record) SetPassword(password string) {
+ // note: the tokenKey will be auto changed if necessary before db write
+ m.Set(FieldNamePassword, password)
+}
+
+// SetRandomPassword sets the "password" auth record field to a random autogenerated value.
+//
+// The autogenerated password is ~30 characters and it is set directly as hash,
+// aka. the field plain password value validators (length, pattern, etc.) are ignored
+// (this is usually used as part of the auto created OTP or OAuth2 user flows).
+func (m *Record) SetRandomPassword() string {
+ pass := security.RandomString(30)
+
+ m.Set(FieldNamePassword, pass)
+ m.RefreshTokenKey() // manually refresh the token key because the plain password is resetted
+
+ // unset the plain value to skip the field validators
+ if raw, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue); ok {
+ raw.Plain = ""
+ }
+
+ return pass
+}
+
+// ValidatePassword validates a plain password against the "password" record field.
+//
+// Returns false if the password is incorrect.
+func (m *Record) ValidatePassword(password string) bool {
+ pv, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue)
+ if !ok {
+ return false
+ }
+
+ return pv.Validate(password)
+}
diff --git a/core/record_model_auth_test.go b/core/record_model_auth_test.go
new file mode 100644
index 0000000000..f71685008d
--- /dev/null
+++ b/core/record_model_auth_test.go
@@ -0,0 +1,160 @@
+package core_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func TestRecordEmail(t *testing.T) {
+ record := core.NewRecord(core.NewAuthCollection("test"))
+
+ if record.Email() != "" {
+ t.Fatalf("Expected email %q, got %q", "", record.Email())
+ }
+
+ email := "test@example.com"
+ record.SetEmail(email)
+
+ if record.Email() != email {
+ t.Fatalf("Expected email %q, got %q", email, record.Email())
+ }
+}
+
+func TestRecordEmailVisibility(t *testing.T) {
+ record := core.NewRecord(core.NewAuthCollection("test"))
+
+ if record.EmailVisibility() != false {
+ t.Fatalf("Expected emailVisibility %v, got %v", false, record.EmailVisibility())
+ }
+
+ record.SetEmailVisibility(true)
+
+ if record.EmailVisibility() != true {
+ t.Fatalf("Expected emailVisibility %v, got %v", true, record.EmailVisibility())
+ }
+}
+
+func TestRecordVerified(t *testing.T) {
+ record := core.NewRecord(core.NewAuthCollection("test"))
+
+ if record.Verified() != false {
+ t.Fatalf("Expected verified %v, got %v", false, record.Verified())
+ }
+
+ record.SetVerified(true)
+
+ if record.Verified() != true {
+ t.Fatalf("Expected verified %v, got %v", true, record.Verified())
+ }
+}
+
+func TestRecordTokenKey(t *testing.T) {
+ record := core.NewRecord(core.NewAuthCollection("test"))
+
+ if record.TokenKey() != "" {
+ t.Fatalf("Expected tokenKey %q, got %q", "", record.TokenKey())
+ }
+
+ tokenKey := "example"
+
+ record.SetTokenKey(tokenKey)
+
+ if record.TokenKey() != tokenKey {
+ t.Fatalf("Expected tokenKey %q, got %q", tokenKey, record.TokenKey())
+ }
+
+ record.RefreshTokenKey()
+
+ if record.TokenKey() == tokenKey {
+ t.Fatalf("Expected tokenKey to be random generated, got %q", tokenKey)
+ }
+
+ if len(record.TokenKey()) != 50 {
+ t.Fatalf("Expected %d characters, got %d", 50, len(record.TokenKey()))
+ }
+}
+
+func TestRecordPassword(t *testing.T) {
+ scenarios := []struct {
+ name string
+ password string
+ expected bool
+ }{
+ {
+ "empty password",
+ "",
+ false,
+ },
+ {
+ "non-empty password",
+ "123456",
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ record := core.NewRecord(core.NewAuthCollection("test"))
+
+ if record.ValidatePassword(s.password) {
+ t.Fatal("[before set] Expected password to be invalid")
+ }
+
+ record.SetPassword(s.password)
+
+ result := record.ValidatePassword(s.password)
+
+ if result != s.expected {
+ t.Fatalf("[after set] Expected ValidatePassword %v, got %v", result, s.expected)
+ }
+
+ // try with a random string to ensure that not any string validates
+ if record.ValidatePassword(security.PseudorandomString(5)) {
+ t.Fatal("[random] Expected password to be invalid")
+ }
+ })
+ }
+}
+
+func TestRecordSetRandomPassword(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ oldTokenKey := "old_tokenKey"
+ record := core.NewRecord(core.NewAuthCollection("test"))
+ record.SetTokenKey(oldTokenKey)
+
+ pass := record.SetRandomPassword()
+
+ if pass == "" {
+ t.Fatal("Expected non-empty generated random password")
+ }
+
+ if !record.ValidatePassword(pass) {
+ t.Fatal("Expected the generated random password to be valid")
+ }
+
+ if record.TokenKey() == oldTokenKey {
+ t.Fatal("Expected token key to change")
+ }
+
+ f, ok := record.Collection().Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
+ if !ok {
+ t.Fatal("Expected *core.PasswordField")
+ }
+
+ // ensure that the field validators will be ignored
+ f.Min = 1
+ f.Max = 2
+ f.Pattern = `\d+`
+
+ if err := f.ValidateValue(context.Background(), app, record); err != nil {
+ t.Fatalf("Expected password field plain value validators to be ignored, got %v", err)
+ }
+}
diff --git a/core/record_model_superusers.go b/core/record_model_superusers.go
new file mode 100644
index 0000000000..8b1da48cc0
--- /dev/null
+++ b/core/record_model_superusers.go
@@ -0,0 +1,118 @@
+package core
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/router"
+)
+
+const CollectionNameSuperusers = "_superusers"
+
+// DefaultInstallerEmail is the default superuser email address
+// for the initial autogenerated superuser account.
+const DefaultInstallerEmail = "__pbinstaller@example.com"
+
+func (app *BaseApp) registerSuperuserHooks() {
+ app.OnRecordDelete(CollectionNameSuperusers).Bind(&hook.Handler[*RecordEvent]{
+ Id: "pbSuperusersRecordDelete",
+ Func: func(e *RecordEvent) error {
+ originalApp := e.App
+ txErr := e.App.RunInTransaction(func(txApp App) error {
+ e.App = txApp
+
+ total, err := e.App.CountRecords(CollectionNameSuperusers)
+ if err != nil {
+ return fmt.Errorf("failed to fetch total superusers count: %w", err)
+ }
+
+ if total == 1 {
+ return router.NewBadRequestError("You can't delete the only existing superuser", nil)
+ }
+
+ return e.Next()
+ })
+ e.App = originalApp
+
+ return txErr
+ },
+ Priority: -99,
+ })
+
+ recordSaveHandler := &hook.Handler[*RecordEvent]{
+ Id: "pbSuperusersRecordSaveExec",
+ Func: func(e *RecordEvent) error {
+ e.Record.SetVerified(true) // always mark superusers as verified
+
+ if err := e.Next(); err != nil {
+ return err
+ }
+
+ // ensure that the installer superuser is deleted
+ if e.Type == ModelEventTypeCreate && e.Record.Email() != DefaultInstallerEmail {
+ record, err := app.FindAuthRecordByEmail(CollectionNameSuperusers, DefaultInstallerEmail)
+ if errors.Is(err, sql.ErrNoRows) {
+ // already deleted
+ } else if err != nil {
+ e.App.Logger().Warn("Failed to fetch installer superuser", "error", err)
+ } else {
+ err = e.App.Delete(record)
+ if err != nil {
+ e.App.Logger().Warn("Failed to delete installer superuser", "error", err)
+ }
+ }
+ }
+
+ return nil
+ },
+ Priority: -99,
+ }
+ app.OnRecordCreateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
+ app.OnRecordUpdateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
+
+ // prevent sending password reset emails to the installer address
+ app.OnMailerRecordPasswordResetSend(CollectionNameSuperusers).Bind(&hook.Handler[*MailerRecordEvent]{
+ Id: "pbSuperusersInstallerPasswordReset",
+ Func: func(e *MailerRecordEvent) error {
+ if e.Record.Email() == DefaultInstallerEmail {
+ return errors.New("cannot reset the password for the installer superuser")
+ }
+
+ return e.Next()
+ },
+ })
+
+ collectionSaveHandler := &hook.Handler[*CollectionEvent]{
+ Id: "pbSuperusersCollectionSaveExec",
+ Func: func(e *CollectionEvent) error {
+ // don't allow name change even if executed with SaveNoValidate
+ e.Collection.Name = CollectionNameSuperusers
+
+ // for now don't allow superusers OAuth2 since we don't want
+ // to accidentally create a new superuser by just OAuth2 signin
+ e.Collection.OAuth2.Enabled = false
+ e.Collection.OAuth2.Providers = nil
+
+ // force password auth
+ e.Collection.PasswordAuth.Enabled = true
+
+ // for superusers we don't allow for now standalone OTP auth and always require to be combined with MFA
+ if e.Collection.OTP.Enabled {
+ e.Collection.MFA.Enabled = true
+ }
+
+ return e.Next()
+ },
+ Priority: 99,
+ }
+ app.OnCollectionCreateExecute(CollectionNameSuperusers).Bind(collectionSaveHandler)
+ app.OnCollectionUpdateExecute(CollectionNameSuperusers).Bind(collectionSaveHandler)
+}
+
+// IsSuperuser returns whether the current record is a superuser, aka.
+// whether the record is from the _superusers collection.
+func (m *Record) IsSuperuser() bool {
+ return m.Collection().Name == CollectionNameSuperusers
+}
diff --git a/core/record_model_superusers_test.go b/core/record_model_superusers_test.go
new file mode 100644
index 0000000000..db8a70a418
--- /dev/null
+++ b/core/record_model_superusers_test.go
@@ -0,0 +1,48 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestRecordIsSuperUser(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ record *core.Record
+ expected bool
+ }{
+ {demo1, false},
+ {user, false},
+ {superuser, true},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.record.Collection().Name, func(t *testing.T) {
+ result := s.record.IsSuperuser()
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/record_model_test.go b/core/record_model_test.go
new file mode 100644
index 0000000000..bac46a8b24
--- /dev/null
+++ b/core/record_model_test.go
@@ -0,0 +1,2596 @@
+package core_test
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json/jsontext"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+)
+
+func TestNewRecord(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(&core.BoolField{Name: "status"})
+
+ m := core.NewRecord(collection)
+
+ rawData, err := json.Marshal(m.FieldsData(), json.Deterministic(true)) // should be initialized with the defaults
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := `{"id":"","status":false}`
+
+ if str := string(rawData); str != expected {
+ t.Fatalf("Expected schema data\n%v\ngot\n%v", expected, str)
+ }
+}
+
+func TestRecordCollection(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ m := core.NewRecord(collection)
+
+ if m.Collection().Name != collection.Name {
+ t.Fatalf("Expected collection with name %q, got %q", collection.Name, m.Collection().Name)
+ }
+}
+
+func TestRecordTableName(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ m := core.NewRecord(collection)
+
+ if m.TableName() != collection.Name {
+ t.Fatalf("Expected table %q, got %q", collection.Name, m.TableName())
+ }
+}
+
+func TestRecordPostScan(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test_collection")
+ collection.Fields.Add(&core.TextField{Name: "test"})
+
+ m := core.NewRecord(collection)
+
+ // calling PostScan without id
+ err := m.PostScan()
+ if err == nil {
+ t.Fatal("Expected PostScan id error, got nil")
+ }
+
+ m.Id = "test_id"
+ m.Set("test", "abc")
+
+ if v := m.IsNew(); v != true {
+ t.Fatalf("[before PostScan] Expected IsNew %v, got %v", true, v)
+ }
+ if v := m.Original().PK(); v != "" {
+ t.Fatalf("[before PostScan] Expected the original PK to be empty string, got %v", v)
+ }
+ if v := m.Original().Get("test"); v != "" {
+ t.Fatalf("[before PostScan] Expected the original 'test' field to be empty string, got %v", v)
+ }
+
+ err = m.PostScan()
+ if err != nil {
+ t.Fatalf("Expected PostScan nil error, got %v", err)
+ }
+
+ if v := m.IsNew(); v != false {
+ t.Fatalf("[after PostScan] Expected IsNew %v, got %v", false, v)
+ }
+ if v := m.Original().PK(); v != "test_id" {
+ t.Fatalf("[after PostScan] Expected the original PK to be %q, got %v", "test_id", v)
+ }
+ if v := m.Original().Get("test"); v != "abc" {
+ t.Fatalf("[after PostScan] Expected the original 'test' field to be %q, got %v", "abc", v)
+ }
+}
+
+func TestRecordHookTags(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ m := core.NewRecord(collection)
+
+ tags := m.HookTags()
+
+ expectedTags := []string{collection.Id, collection.Name}
+
+ if len(tags) != len(expectedTags) {
+ t.Fatalf("Expected tags\n%v\ngot\n%v", expectedTags, tags)
+ }
+
+ for _, tag := range tags {
+ if !slices.Contains(expectedTags, tag) {
+ t.Errorf("Missing expected tag %q", tag)
+ }
+ }
+}
+
+func TestRecordBaseFilesPath(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ m := core.NewRecord(collection)
+ m.Id = "abc"
+
+ result := m.BaseFilesPath()
+ expected := collection.BaseFilesPath() + "/" + m.Id
+ if result != expected {
+ t.Fatalf("Expected %q, got %q", expected, result)
+ }
+}
+
+func TestRecordOriginal(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalId := record.Id
+ originalName := record.GetString("name")
+
+ extraFieldsCheck := []string{`"email":`, `"custom":`}
+
+ // change the fields
+ record.Id = "changed"
+ record.Set("name", "name_new")
+ record.Set("custom", "test_custom")
+ record.SetExpand(map[string]any{"test": 123})
+ record.IgnoreEmailVisibility(true)
+ record.IgnoreUnchangedFields(true)
+ record.WithCustomData(true)
+ record.Unhide(record.Collection().Fields.FieldNames()...)
+
+ // ensure that the email visibility and the custom data toggles are active
+ raw, err := record.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+ for _, f := range extraFieldsCheck {
+ if !strings.Contains(rawStr, f) {
+ t.Fatalf("Expected %s in\n%s", f, rawStr)
+ }
+ }
+
+ // check changes
+ if v := record.GetString("name"); v != "name_new" {
+ t.Fatalf("Expected name to be %q, got %q", "name_new", v)
+ }
+ if v := record.GetString("custom"); v != "test_custom" {
+ t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
+ }
+
+ // check original
+ if v := record.Original().PK(); v != originalId {
+ t.Fatalf("Expected the original PK to be %q, got %q", originalId, v)
+ }
+ if v := record.Original().Id; v != originalId {
+ t.Fatalf("Expected the original id to be %q, got %q", originalId, v)
+ }
+ if v := record.Original().GetString("name"); v != originalName {
+ t.Fatalf("Expected the original name to be %q, got %q", originalName, v)
+ }
+ if v := record.Original().GetString("custom"); v != "" {
+ t.Fatalf("Expected the original custom to be %q, got %q", "", v)
+ }
+ if v := record.Original().Expand(); len(v) != 0 {
+ t.Fatalf("Expected empty original expand, got\n%v", v)
+ }
+
+ // ensure that the email visibility and the custom flag toggles weren't copied
+ originalRaw, err := record.Original().MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalRawStr := string(originalRaw)
+ for _, f := range extraFieldsCheck {
+ if strings.Contains(originalRawStr, f) {
+ t.Fatalf("Didn't expected %s in original\n%s", f, originalRawStr)
+ }
+ }
+
+ // loading new data shouldn't affect the original state
+ record.Load(map[string]any{"name": "name_new2"})
+
+ if v := record.GetString("name"); v != "name_new2" {
+ t.Fatalf("Expected name to be %q, got %q", "name_new2", v)
+ }
+
+ if v := record.Original().GetString("name"); v != originalName {
+ t.Fatalf("Expected the original name still to be %q, got %q", originalName, v)
+ }
+}
+
+func TestRecordFresh(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalId := record.Id
+
+ extraFieldsCheck := []string{`"email":`, `"custom":`}
+
+ autodateTest := types.NowDateTime()
+
+ // change the fields
+ record.Id = "changed"
+ record.Set("name", "name_new")
+ record.Set("custom", "test_custom")
+ record.SetRaw("created", autodateTest)
+ record.SetExpand(map[string]any{"test": 123})
+ record.IgnoreEmailVisibility(true)
+ record.IgnoreUnchangedFields(true)
+ record.WithCustomData(true)
+ record.Unhide(record.Collection().Fields.FieldNames()...)
+
+ // ensure that the email visibility and the custom data toggles are active
+ raw, err := record.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+ for _, f := range extraFieldsCheck {
+ if !strings.Contains(rawStr, f) {
+ t.Fatalf("Expected %s in\n%s", f, rawStr)
+ }
+ }
+
+ // check changes
+ if v := record.GetString("name"); v != "name_new" {
+ t.Fatalf("Expected name to be %q, got %q", "name_new", v)
+ }
+ if v := record.GetDateTime("created").String(); v != autodateTest.String() {
+ t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
+ }
+ if v := record.GetString("custom"); v != "test_custom" {
+ t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
+ }
+
+ // check fresh
+ if v := record.Fresh().LastSavedPK(); v != originalId {
+ t.Fatalf("Expected the fresh LastSavedPK to be %q, got %q", originalId, v)
+ }
+ if v := record.Fresh().PK(); v != record.Id {
+ t.Fatalf("Expected the fresh PK to be %q, got %q", record.Id, v)
+ }
+ if v := record.Fresh().Id; v != record.Id {
+ t.Fatalf("Expected the fresh id to be %q, got %q", record.Id, v)
+ }
+ if v := record.Fresh().GetString("name"); v != record.GetString("name") {
+ t.Fatalf("Expected the fresh name to be %q, got %q", record.GetString("name"), v)
+ }
+ if v := record.Fresh().GetDateTime("created").String(); v != autodateTest.String() {
+ t.Fatalf("Expected the fresh created to be %q, got %q", autodateTest.String(), v)
+ }
+ if v := record.Fresh().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
+ t.Fatalf("Expected the fresh updated to be %q, got %q", record.GetDateTime("updated").String(), v)
+ }
+ if v := record.Fresh().GetString("custom"); v != "" {
+ t.Fatalf("Expected the fresh custom to be %q, got %q", "", v)
+ }
+ if v := record.Fresh().Expand(); len(v) != 0 {
+ t.Fatalf("Expected empty fresh expand, got\n%v", v)
+ }
+
+ // ensure that the email visibility and the custom flag toggles weren't copied
+ freshRaw, err := record.Fresh().MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ freshRawStr := string(freshRaw)
+ for _, f := range extraFieldsCheck {
+ if strings.Contains(freshRawStr, f) {
+ t.Fatalf("Didn't expected %s in fresh\n%s", f, freshRawStr)
+ }
+ }
+}
+
+func TestRecordClone(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalId := record.Id
+
+ extraFieldsCheck := []string{`"email":`, `"custom":`}
+
+ autodateTest := types.NowDateTime()
+
+ // change the fields
+ record.Id = "changed"
+ record.Set("name", "name_new")
+ record.Set("custom", "test_custom")
+ record.SetRaw("created", autodateTest)
+ record.SetExpand(map[string]any{"test": 123})
+ record.IgnoreEmailVisibility(true)
+ record.WithCustomData(true)
+ record.Unhide(record.Collection().Fields.FieldNames()...)
+
+ // ensure that the email visibility and the custom data toggles are active
+ raw, err := record.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+ for _, f := range extraFieldsCheck {
+ if !strings.Contains(rawStr, f) {
+ t.Fatalf("Expected %s in\n%s", f, rawStr)
+ }
+ }
+
+ // check changes
+ if v := record.GetString("name"); v != "name_new" {
+ t.Fatalf("Expected name to be %q, got %q", "name_new", v)
+ }
+ if v := record.GetDateTime("created").String(); v != autodateTest.String() {
+ t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
+ }
+ if v := record.GetString("custom"); v != "test_custom" {
+ t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
+ }
+
+ // check clone
+ if v := record.Clone().LastSavedPK(); v != originalId {
+ t.Fatalf("Expected the clone LastSavedPK to be %q, got %q", originalId, v)
+ }
+ if v := record.Clone().PK(); v != record.Id {
+ t.Fatalf("Expected the clone PK to be %q, got %q", record.Id, v)
+ }
+ if v := record.Clone().Id; v != record.Id {
+ t.Fatalf("Expected the clone id to be %q, got %q", record.Id, v)
+ }
+ if v := record.Clone().GetString("name"); v != record.GetString("name") {
+ t.Fatalf("Expected the clone name to be %q, got %q", record.GetString("name"), v)
+ }
+ if v := record.Clone().GetDateTime("created").String(); v != autodateTest.String() {
+ t.Fatalf("Expected the clone created to be %q, got %q", autodateTest.String(), v)
+ }
+ if v := record.Clone().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
+ t.Fatalf("Expected the clone updated to be %q, got %q", record.GetDateTime("updated").String(), v)
+ }
+ if v := record.Clone().GetString("custom"); v != "test_custom" {
+ t.Fatalf("Expected the clone custom to be %q, got %q", "test_custom", v)
+ }
+ if _, ok := record.Clone().Expand()["test"]; !ok {
+ t.Fatalf("Expected non-empty clone expand")
+ }
+
+ // ensure that the email visibility and the custom data toggles state were copied
+ cloneRaw, err := record.Clone().MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cloneRawStr := string(cloneRaw)
+ for _, f := range extraFieldsCheck {
+ if !strings.Contains(cloneRawStr, f) {
+ t.Fatalf("Expected %s in clone\n%s", f, cloneRawStr)
+ }
+ }
+}
+
+func TestRecordExpand(t *testing.T) {
+ t.Parallel()
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+
+ expand := record.Expand()
+ if expand == nil || len(expand) != 0 {
+ t.Fatalf("Expected empty map expand, got %v", expand)
+ }
+
+ data1 := map[string]any{"a": 123, "b": 456}
+ data2 := map[string]any{"c": 123}
+ record.SetExpand(data1)
+ record.SetExpand(data2) // should overwrite the previous call
+
+ // modify the expand map to check for shallow copy
+ data2["d"] = 456
+
+ expand = record.Expand()
+ if len(expand) != 1 {
+ t.Fatalf("Expected empty map expand, got %v", expand)
+ }
+ if v := expand["c"]; v != 123 {
+ t.Fatalf("Expected to find expand.c %v, got %v", 123, v)
+ }
+}
+
+func TestRecordMergeExpand(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Id = "_pbc_123"
+
+ m := core.NewRecord(collection)
+ m.Id = "m"
+
+ // a
+ a := core.NewRecord(collection)
+ a.Id = "a"
+ a1 := core.NewRecord(collection)
+ a1.Id = "a1"
+ a2 := core.NewRecord(collection)
+ a2.Id = "a2"
+ a3 := core.NewRecord(collection)
+ a3.Id = "a3"
+ a31 := core.NewRecord(collection)
+ a31.Id = "a31"
+ a32 := core.NewRecord(collection)
+ a32.Id = "a32"
+ a.SetExpand(map[string]any{
+ "a1": a1,
+ "a23": []*core.Record{a2, a3},
+ })
+ a3.SetExpand(map[string]any{
+ "a31": a31,
+ "a32": []*core.Record{a32},
+ })
+
+ // b
+ b := core.NewRecord(collection)
+ b.Id = "b"
+ b1 := core.NewRecord(collection)
+ b1.Id = "b1"
+ b.SetExpand(map[string]any{
+ "b1": b1,
+ })
+
+ // c
+ c := core.NewRecord(collection)
+ c.Id = "c"
+
+ // load initial expand
+ m.SetExpand(map[string]any{
+ "a": a,
+ "b": b,
+ "c": []*core.Record{c},
+ })
+
+ // a (new)
+ aNew := core.NewRecord(collection)
+ aNew.Id = a.Id
+ a3New := core.NewRecord(collection)
+ a3New.Id = a3.Id
+ a32New := core.NewRecord(collection)
+ a32New.Id = "a32New"
+ a33New := core.NewRecord(collection)
+ a33New.Id = "a33New"
+ a3New.SetExpand(map[string]any{
+ "a32": []*core.Record{a32New},
+ "a33New": a33New,
+ })
+ aNew.SetExpand(map[string]any{
+ "a23": []*core.Record{a2, a3New},
+ })
+
+ // b (new)
+ bNew := core.NewRecord(collection)
+ bNew.Id = "bNew"
+ dNew := core.NewRecord(collection)
+ dNew.Id = "dNew"
+
+ // merge expands
+ m.MergeExpand(map[string]any{
+ "a": aNew,
+ "b": []*core.Record{bNew},
+ "dNew": dNew,
+ })
+
+ result := m.Expand()
+
+ raw, err := json.Marshal(result, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ expected := `{"a":{"collectionId":"_pbc_123","collectionName":"test","expand":{"a1":{"collectionId":"_pbc_123","collectionName":"test","id":"a1"},"a23":[{"collectionId":"_pbc_123","collectionName":"test","id":"a2"},{"collectionId":"_pbc_123","collectionName":"test","expand":{"a31":{"collectionId":"_pbc_123","collectionName":"test","id":"a31"},"a32":[{"collectionId":"_pbc_123","collectionName":"test","id":"a32"},{"collectionId":"_pbc_123","collectionName":"test","id":"a32New"}],"a33New":{"collectionId":"_pbc_123","collectionName":"test","id":"a33New"}},"id":"a3"}]},"id":"a"},"b":[{"collectionId":"_pbc_123","collectionName":"test","expand":{"b1":{"collectionId":"_pbc_123","collectionName":"test","id":"b1"}},"id":"b"},{"collectionId":"_pbc_123","collectionName":"test","id":"bNew"}],"c":[{"collectionId":"_pbc_123","collectionName":"test","id":"c"}],"dNew":{"collectionId":"_pbc_123","collectionName":"test","id":"dNew"}}`
+
+ if expected != rawStr {
+ t.Fatalf("Expected \n%v, \ngot \n%v", expected, rawStr)
+ }
+}
+
+func TestRecordMergeExpandNilCheck(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Id = "_pbc_123"
+
+ scenarios := []struct {
+ name string
+ expand map[string]any
+ expected string
+ }{
+ {
+ "nil expand",
+ nil,
+ `{"collectionId":"_pbc_123","collectionName":"test","id":""}`,
+ },
+ {
+ "empty expand",
+ map[string]any{},
+ `{"collectionId":"_pbc_123","collectionName":"test","id":""}`,
+ },
+ {
+ "non-empty expand",
+ map[string]any{"test": core.NewRecord(collection)},
+ `{"collectionId":"_pbc_123","collectionName":"test","expand":{"test":{"collectionId":"_pbc_123","collectionName":"test","id":""}},"id":""}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ m := core.NewRecord(collection)
+ m.MergeExpand(s.expand)
+
+ raw, err := json.Marshal(m, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if rawStr != s.expected {
+ t.Fatalf("Expected \n%v, \ngot \n%v", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestRecordExpandedOne(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ main := core.NewRecord(collection)
+
+ single := core.NewRecord(collection)
+ single.Id = "single"
+
+ multiple1 := core.NewRecord(collection)
+ multiple1.Id = "multiple1"
+
+ multiple2 := core.NewRecord(collection)
+ multiple2.Id = "multiple2"
+
+ main.SetExpand(map[string]any{
+ "single": single,
+ "multiple": []*core.Record{multiple1, multiple2},
+ })
+
+ if v := main.ExpandedOne("missing"); v != nil {
+ t.Fatalf("Expected nil, got %v", v)
+ }
+
+ if v := main.ExpandedOne("single"); v == nil || v.Id != "single" {
+ t.Fatalf("Expected record with id %q, got %v", "single", v)
+ }
+
+ if v := main.ExpandedOne("multiple"); v == nil || v.Id != "multiple1" {
+ t.Fatalf("Expected record with id %q, got %v", "multiple1", v)
+ }
+}
+
+func TestRecordExpandedAll(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+
+ main := core.NewRecord(collection)
+
+ single := core.NewRecord(collection)
+ single.Id = "single"
+
+ multiple1 := core.NewRecord(collection)
+ multiple1.Id = "multiple1"
+
+ multiple2 := core.NewRecord(collection)
+ multiple2.Id = "multiple2"
+
+ main.SetExpand(map[string]any{
+ "single": single,
+ "multiple": []*core.Record{multiple1, multiple2},
+ })
+
+ if v := main.ExpandedAll("missing"); v != nil {
+ t.Fatalf("Expected nil, got %v", v)
+ }
+
+ if v := main.ExpandedAll("single"); len(v) != 1 || v[0].Id != "single" {
+ t.Fatalf("Expected [single] slice, got %v", v)
+ }
+
+ if v := main.ExpandedAll("multiple"); len(v) != 2 || v[0].Id != "multiple1" || v[1].Id != "multiple2" {
+ t.Fatalf("Expected [multiple1, multiple2] slice, got %v", v)
+ }
+}
+
+func TestRecordFieldsData(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewAuthCollection("test")
+ collection.Fields.Add(&core.TextField{Name: "field1"})
+ collection.Fields.Add(&core.TextField{Name: "field2"})
+
+ m := core.NewRecord(collection)
+ m.Id = "test_id" // direct id assignment
+ m.Set("email", "test@example.com")
+ m.Set("password", "123") // hidden fields should be also returned
+ m.Set("tokenKey", "789")
+ m.Set("field1", 123)
+ m.Set("field2", 456)
+ m.Set("unknown", 789)
+
+ raw, err := json.Marshal(m.FieldsData(), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := `{"email":"test@example.com","emailVisibility":false,"field1":"123","field2":"456","id":"test_id","password":"123","tokenKey":"789","verified":false}`
+
+ if v := string(raw); v != expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, v)
+ }
+}
+
+func TestRecordCustomData(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewAuthCollection("test")
+ collection.Fields.Add(&core.TextField{Name: "field1"})
+ collection.Fields.Add(&core.TextField{Name: "field2"})
+
+ m := core.NewRecord(collection)
+ m.Id = "test_id" // direct id assignment
+ m.Set("email", "test@example.com")
+ m.Set("password", "123") // hidden fields should be also returned
+ m.Set("tokenKey", "789")
+ m.Set("field1", 123)
+ m.Set("field2", 456)
+ m.Set("unknown", 789)
+
+ raw, err := json.Marshal(m.CustomData(), json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expected := `{"unknown":789}`
+
+ if v := string(raw); v != expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, v)
+ }
+}
+
+func TestRecordSetGet(t *testing.T) {
+ t.Parallel()
+
+ f1 := &mockField{}
+ f1.Name = "mock1"
+
+ f2 := &mockField{}
+ f2.Name = "mock2"
+
+ f3 := &mockField{}
+ f3.Name = "mock3"
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(&core.TextField{Name: "text1"})
+ collection.Fields.Add(&core.TextField{Name: "text2"})
+ collection.Fields.Add(f1)
+ collection.Fields.Add(f2)
+ collection.Fields.Add(f3)
+
+ record := core.NewRecord(collection)
+ record.Set("text1", 123) // should be converted to string using the ScanValue fallback
+ record.SetRaw("text2", 456)
+ record.Set("mock1", 1) // should be converted to string using the setter
+ record.SetRaw("mock2", 1)
+ record.Set("mock3:test", "abc")
+ record.Set("unknown", 789)
+
+ t.Run("GetRaw", func(t *testing.T) {
+ expected := map[string]any{
+ "text1": "123",
+ "text2": 456,
+ "mock1": "1",
+ "mock2": 1,
+ "mock3": "modifier_set",
+ "mock3:test": nil,
+ "unknown": 789,
+ }
+
+ for k, v := range expected {
+ raw := record.GetRaw(k)
+ if raw != v {
+ t.Errorf("Expected %q to be %v, got %v", k, v, raw)
+ }
+ }
+ })
+
+ t.Run("Get", func(t *testing.T) {
+ expected := map[string]any{
+ "text1": "123",
+ "text2": 456,
+ "mock1": "1",
+ "mock2": 1,
+ "mock3": "modifier_set",
+ "mock3:test": "modifier_get",
+ "unknown": 789,
+ }
+
+ for k, v := range expected {
+ get := record.Get(k)
+ if get != v {
+ t.Errorf("Expected %q to be %v, got %v", k, v, get)
+ }
+ }
+ })
+}
+
+func TestRecordLoad(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(&core.TextField{Name: "text"})
+
+ record := core.NewRecord(collection)
+ record.Load(map[string]any{
+ "text": 123,
+ "custom": 456,
+ })
+
+ expected := map[string]any{
+ "text": "123",
+ "custom": 456,
+ }
+
+ for k, v := range expected {
+ get := record.Get(k)
+ if get != v {
+ t.Errorf("Expected %q to be %#v, got %#v", k, v, get)
+ }
+ }
+}
+
+func TestRecordGetBool(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected bool
+ }{
+ {nil, false},
+ {"", false},
+ {0, false},
+ {1, true},
+ {[]string{"true"}, false},
+ {time.Now(), false},
+ {"test", false},
+ {"false", false},
+ {"true", true},
+ {false, false},
+ {true, true},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetBool("test")
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetString(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected string
+ }{
+ {nil, ""},
+ {"", ""},
+ {0, "0"},
+ {1.4, "1.4"},
+ {[]string{"true"}, ""},
+ {map[string]int{"test": 1}, ""},
+ {[]byte("abc"), "abc"},
+ {"test", "test"},
+ {false, "false"},
+ {true, "true"},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetString("test")
+ if result != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetInt(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected int
+ }{
+ {nil, 0},
+ {"", 0},
+ {[]string{"true"}, 0},
+ {map[string]int{"test": 1}, 0},
+ {time.Now(), 0},
+ {"test", 0},
+ {123, 123},
+ {2.4, 2},
+ {"123", 123},
+ {"123.5", 123},
+ {false, 0},
+ {true, 1},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetInt("test")
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetInt64(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected int64
+ }{
+ {nil, 0},
+ {"", 0},
+ {[]string{"true"}, 0},
+ {map[string]int{"test": 1}, 0},
+ {time.Now(), 0},
+ {"test", 0},
+ {123, 123},
+ {2.4, 2},
+ {1<<63 - 1, 1<<63 - 1},
+ {"123", 123},
+ {"123.5", 123},
+ {false, 0},
+ {true, 1},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetInt64("test")
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetFloat(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected float64
+ }{
+ {nil, 0},
+ {"", 0},
+ {[]string{"true"}, 0},
+ {map[string]int{"test": 1}, 0},
+ {time.Now(), 0},
+ {"test", 0},
+ {123, 123},
+ {2.4, 2.4},
+ {"123", 123},
+ {"123.5", 123.5},
+ {false, 0},
+ {true, 1},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetFloat("test")
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetDateTime(t *testing.T) {
+ t.Parallel()
+
+ nowTime := time.Now()
+ testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000Z")
+
+ scenarios := []struct {
+ value any
+ expected time.Time
+ }{
+ {nil, time.Time{}},
+ {"", time.Time{}},
+ {false, time.Time{}},
+ {true, time.Time{}},
+ {"test", time.Time{}},
+ {[]string{"true"}, time.Time{}},
+ {map[string]int{"test": 1}, time.Time{}},
+ {1641024040, testTime},
+ {"2022-01-01 08:00:40.000", testTime},
+ {nowTime, nowTime},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetDateTime("test")
+ if !result.Time().Equal(s.expected) {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRecordGetStringSlice(t *testing.T) {
+ t.Parallel()
+
+ nowTime := time.Now()
+
+ scenarios := []struct {
+ value any
+ expected []string
+ }{
+ {nil, []string{}},
+ {"", []string{}},
+ {false, []string{"false"}},
+ {true, []string{"true"}},
+ {nowTime, []string{}},
+ {123, []string{"123"}},
+ {"test", []string{"test"}},
+ {map[string]int{"test": 1}, []string{}},
+ {`["test1", "test2"]`, []string{"test1", "test2"}},
+ {[]int{123, 123, 456}, []string{"123", "456"}},
+ {[]string{"test", "test", "123"}, []string{"test", "123"}},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ result := record.GetStringSlice("test")
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected %d elements, got %d: %v", len(s.expected), len(result), result)
+ }
+
+ for _, v := range result {
+ if !slices.Contains(s.expected, v) {
+ t.Fatalf("Cannot find %v in %v", v, s.expected)
+ }
+ }
+ })
+ }
+}
+
+func TestRecordGetGeoPoint(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ value any
+ expected string
+ }{
+ {nil, `{"lon":0,"lat":0}`},
+ {"", `{"lon":0,"lat":0}`},
+ {0, `{"lon":0,"lat":0}`},
+ {false, `{"lon":0,"lat":0}`},
+ {"{}", `{"lon":0,"lat":0}`},
+ {"[]", `{"lon":0,"lat":0}`},
+ {[]int{1, 2}, `{"lon":0,"lat":0}`},
+ {map[string]any{"lon": 1, "lat": 2}, `{"lon":1,"lat":2}`},
+ {[]byte(`{"lon":1,"lat":2}`), `{"lon":1,"lat":2}`},
+ {`{"lon":1,"lat":2}`, `{"lon":1,"lat":2}`},
+ {types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
+ {&types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
+ }
+
+ collection := core.NewBaseCollection("test")
+ record := core.NewRecord(collection)
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("test", s.value)
+
+ pointStr := record.GetGeoPoint("test").String()
+
+ if pointStr != s.expected {
+ t.Fatalf("Expected %q, got %q", s.expected, pointStr)
+ }
+ })
+ }
+}
+
+func TestRecordGetUnsavedFiles(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f1, err := filesystem.NewFileFromBytes([]byte("test"), "f1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1.Name = "f1"
+
+ f2, err := filesystem.NewFileFromBytes([]byte("test"), "f2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f2.Name = "f2"
+
+ record, err := app.FindRecordById("demo3", "lcl9d87w22ml6jy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ record.Set("files+", []any{f1, f2})
+
+ scenarios := []struct {
+ key string
+ expected string
+ }{
+ {
+ "",
+ "null",
+ },
+ {
+ "title",
+ "null",
+ },
+ {
+ "files",
+ `[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
+ },
+ {
+ "files:unsaved",
+ `[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
+ v := record.GetUnsavedFiles(s.key)
+
+ raw, err := json.Marshal(v,
+ json.Deterministic(true),
+ json.FormatNilSliceAsNull(true),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if str := string(raw); str != s.expected {
+ t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str)
+ }
+ })
+ }
+}
+
+func TestRecordUnmarshalJSONField(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(&core.JSONField{Name: "field"})
+
+ record := core.NewRecord(collection)
+
+ var testPointer *string
+ var testStr string
+ var testInt int
+ var testBool bool
+ var testSlice []int
+ var testMap map[string]any
+
+ scenarios := []struct {
+ value any
+ destination any
+ expectError bool
+ expectedJSON string
+ }{
+ {nil, testPointer, false, `null`},
+ {nil, testStr, false, `""`},
+ {"", testStr, false, `""`},
+ {1, testInt, false, `1`},
+ {true, testBool, false, `true`},
+ {[]int{1, 2, 3}, testSlice, false, `[1,2,3]`},
+ {map[string]any{"test": 123}, testMap, false, `{"test":123}`},
+ // json encoded values
+ {`null`, testPointer, false, `null`},
+ {`true`, testBool, false, `true`},
+ {`456`, testInt, false, `456`},
+ {`"test"`, testStr, false, `"test"`},
+ {`[4,5,6]`, testSlice, false, `[4,5,6]`},
+ {`{"test":456}`, testMap, false, `{"test":456}`},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
+ record.Set("field", s.value)
+
+ err := record.UnmarshalJSONField("field", &s.destination)
+ hasErr := err != nil
+
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
+ }
+
+ raw, _ := json.Marshal(s.destination, json.Deterministic(true))
+ if v := string(raw); v != s.expectedJSON {
+ t.Fatalf("Expected %q, got %q", s.expectedJSON, v)
+ }
+ })
+ }
+}
+
+func TestRecordFindFileFieldByFile(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(
+ &core.TextField{Name: "field1"},
+ &core.FileField{Name: "field2", MaxSelect: 1, MaxSize: 1},
+ &core.FileField{Name: "field3", MaxSelect: 2, MaxSize: 1},
+ )
+
+ m := core.NewRecord(collection)
+ m.Set("field1", "test")
+ m.Set("field2", "test.png")
+ m.Set("field3", []string{"test1.png", "test2.png"})
+
+ scenarios := []struct {
+ filename string
+ expectField string
+ }{
+ {"", ""},
+ {"test", ""},
+ {"test2", ""},
+ {"test.png", "field2"},
+ {"test2.png", "field3"},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.filename), func(t *testing.T) {
+ result := m.FindFileFieldByFile(s.filename)
+
+ var fieldName string
+ if result != nil {
+ fieldName = result.Name
+ }
+
+ if s.expectField != fieldName {
+ t.Fatalf("Expected field %v, got %v", s.expectField, result)
+ }
+ })
+ }
+}
+
+func TestRecordDBExport(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ f1 := &core.TextField{Name: "field1"}
+ f2 := &core.FileField{Name: "field2", MaxSelect: 1, MaxSize: 1}
+ f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}}
+ f4 := &core.RelationField{Name: "field4", MaxSelect: 2}
+
+ colBase := core.NewBaseCollection("test_base")
+ colBase.Fields.Add(f1, f2, f3, f4)
+
+ colAuth := core.NewAuthCollection("test_auth")
+ colAuth.Fields.Add(f1, f2, f3, f4)
+
+ scenarios := []struct {
+ collection *core.Collection
+ expected string
+ }{
+ {
+ colBase,
+ `{"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id"}`,
+ },
+ {
+ colAuth,
+ `{"email":"test_email","emailVisibility":true,"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","password":"_TEST_","tokenKey":"test_tokenKey","verified":false}`,
+ },
+ }
+
+ data := map[string]any{
+ "id": "test_id",
+ "field1": "test",
+ "field2": "test.png",
+ "field3": []string{"test1", "test2"},
+ "field4": []string{"test11", "test12", "test11"}, // strip duplicate,
+ "unknown": "test_unknown",
+ "password": "test_passwordHash",
+ "username": "test_username",
+ "emailVisibility": true,
+ "email": "test_email",
+ "verified": "invalid", // should be casted
+ "tokenKey": "test_tokenKey",
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.collection.Type, s.collection.Name), func(t *testing.T) {
+ record := core.NewRecord(s.collection)
+
+ record.Load(data)
+
+ result, err := record.DBExport(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(result, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ // replace _TEST_ placeholder with .+ regex pattern
+ pattern := regexp.MustCompile(strings.ReplaceAll(
+ "^"+regexp.QuoteMeta(s.expected)+"$",
+ "_TEST_",
+ `.+`,
+ ))
+
+ if !pattern.MatchString(rawStr) {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expected, rawStr)
+ }
+ })
+ }
+}
+
+func TestRecordIgnoreUnchangedFields(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ new := core.NewRecord(col)
+
+ existing, err := app.FindRecordById(col, "mk5fmymtx4wsprk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ existing.Set("title", "test_new")
+ existing.Set("files", existing.Get("files")) // no change
+
+ scenarios := []struct {
+ ignoreUnchangedFields bool
+ record *core.Record
+ expected []string
+ }{
+ {
+ false,
+ new,
+ []string{"id", "created", "updated", "title", "files"},
+ },
+ {
+ true,
+ new,
+ []string{"id", "created", "updated", "title", "files"},
+ },
+ {
+ false,
+ existing,
+ []string{"id", "created", "updated", "title", "files"},
+ },
+ {
+ true,
+ existing,
+ []string{"id", "title"},
+ },
+ }
+
+ for i, s := range scenarios {
+ action := "create"
+ if !s.record.IsNew() {
+ action = "update"
+ }
+
+ t.Run(fmt.Sprintf("%d_%s_%v", i, action, s.ignoreUnchangedFields), func(t *testing.T) {
+ s.record.IgnoreUnchangedFields(s.ignoreUnchangedFields)
+
+ result, err := s.record.DBExport(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(result) != len(s.expected) {
+ t.Fatalf("Expected %d keys, got %d:\n%v", len(s.expected), len(result), result)
+ }
+
+ for _, key := range s.expected {
+ if _, ok := result[key]; !ok {
+ t.Fatalf("Missing expected key %q in\n%v", key, result)
+ }
+ }
+ })
+ }
+}
+
+func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
+ t.Parallel()
+
+ f1 := &core.TextField{Name: "field1"}
+ f2 := &core.FileField{Name: "field2", MaxSelect: 1, MaxSize: 1}
+ f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}}
+ f4 := &core.TextField{Name: "field4", Hidden: true}
+ f5 := &core.TextField{Name: "field5", Hidden: true}
+ f6 := &core.JSONField{Name: "field6"}
+
+ colBase := core.NewBaseCollection("test_base")
+ colBase.Id = "_pbc_base_123"
+ colBase.Fields.Add(f1, f2, f3, f4, f5, f6)
+
+ colAuth := core.NewAuthCollection("test_auth")
+ colAuth.Id = "_pbc_auth_123"
+ colAuth.Fields.Add(f1, f2, f3, f4, f5, f6)
+
+ scenarios := []struct {
+ name string
+ collection *core.Collection
+ ignoreEmailVisibility bool
+ withCustomData bool
+ hideFields []string
+ unhideFields []string
+ expectedJSON string
+ }{
+ // base
+ {
+ "[base] no extra flags",
+ colBase,
+ false,
+ false,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
+ },
+ {
+ "[base] with email visibility",
+ colBase,
+ true, // should have no effect
+ false,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
+ },
+ {
+ "[base] with custom data",
+ colBase,
+ true, // should have no effect
+ true,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
+ },
+ {
+ "[base] with explicit hide and unhide fields",
+ colBase,
+ false,
+ true,
+ []string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "tokenKey", "unknown"},
+ []string{"field4", "@pbInternalAbc"},
+ `{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","verified":true}`,
+ },
+ {
+ "[base] trying to unhide custom fields without explicit WithCustomData",
+ colBase,
+ false,
+ true,
+ nil,
+ []string{"field5", "@pbInternalAbc", "email", "tokenKey", "unknown"},
+ `{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
+ },
+
+ // auth
+ {
+ "[auth] no extra flags",
+ colAuth,
+ false,
+ false,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
+ },
+ {
+ "[auth] with email visibility",
+ colAuth,
+ true,
+ false,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
+ },
+ {
+ "[auth] with custom data",
+ colAuth,
+ false,
+ true,
+ nil,
+ nil,
+ `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
+ },
+ {
+ "[auth] with explicit hide and unhide fields",
+ colAuth,
+ true,
+ true,
+ []string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "unknown"},
+ []string{"field4", "@pbInternalAbc"},
+ `{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
+ },
+ {
+ "[auth] trying to unhide custom fields without explicit WithCustomData",
+ colAuth,
+ false,
+ true,
+ nil,
+ []string{"field5", "@pbInternalAbc", "tokenKey", "unknown", "email"}, // emailVisibility:false has higher priority
+ `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
+ },
+ }
+
+ data := map[string]any{
+ "id": "test_id",
+ "field1": "field_1\xc3", /* invalid utf8 suffix to test mangling */
+ "field2": "field_2.png",
+ "field3": []string{"test1", "test2"},
+ "field4": "field_4",
+ "field5": "field_5",
+ "field6": types.JSONRaw(`{"a":1,"a":2}`), // intentionally duplicated to check serialization
+ "expand": map[string]any{"test": 123},
+ "collectionId": "m_id", // should be always ignored
+ "collectionName": "m_name", // should be always ignored
+ "unknown": "test_unknown",
+ "password": "test_passwordHash",
+ "emailVisibility": "test_invalid", // for auth collections should be casted to bool
+ "email": "test_email",
+ "verified": true,
+ "tokenKey": "test_tokenKey",
+ "@pbInternalAbc": "test_custom_inter", // always hidden
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ m := core.NewRecord(s.collection)
+
+ m.Load(data)
+ m.IgnoreEmailVisibility(s.ignoreEmailVisibility)
+ m.WithCustomData(s.withCustomData)
+ m.Unhide(s.unhideFields...)
+ m.Hide(s.hideFields...)
+
+ exportResult, err := json.Marshal(
+ m.PublicExport(),
+ json.Deterministic(true),
+ jsontext.AllowDuplicateNames(true),
+ jsontext.AllowInvalidUTF8(true),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ exportResultStr := string(exportResult)
+
+ // MarshalJSON and PublicExport should return the same
+ marshalResult, err := m.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ marshalResultStr := string(marshalResult)
+
+ if exportResultStr != marshalResultStr {
+ t.Fatalf("Expected the PublicExport to be the same as MarshalJSON, but got \n%v \nvs \n%v", exportResultStr, marshalResultStr)
+ }
+
+ if exportResultStr != s.expectedJSON {
+ t.Fatalf("Expected json \n%v \ngot \n%v", s.expectedJSON, exportResultStr)
+ }
+ })
+ }
+}
+
+func TestRecordUnmarshalJSON(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(&core.TextField{Name: "text"})
+
+ record := core.NewRecord(collection)
+
+ data := map[string]any{
+ "text": 123,
+ "custom": 456.789,
+ }
+ rawData, err := json.Marshal(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = record.UnmarshalJSON(rawData)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal: %v", err)
+ }
+
+ expected := map[string]any{
+ "text": "123",
+ "custom": 456.789,
+ }
+
+ for k, v := range expected {
+ get := record.Get(k)
+ if get != v {
+ t.Errorf("Expected %q to be %#v, got %#v", k, v, get)
+ }
+ }
+}
+
+func TestRecordReplaceModifiers(t *testing.T) {
+ t.Parallel()
+
+ collection := core.NewBaseCollection("test")
+ collection.Fields.Add(
+ &mockField{core.TextField{Name: "mock"}},
+ &core.NumberField{Name: "number"},
+ )
+
+ originalData := map[string]any{
+ "mock": "a",
+ "number": 2.1,
+ }
+
+ record := core.NewRecord(collection)
+ for k, v := range originalData {
+ record.Set(k, v)
+ }
+
+ result := record.ReplaceModifiers(map[string]any{
+ "mock:test": "b",
+ "number+": 3,
+ })
+
+ expected := map[string]any{
+ "mock": "modifier_set",
+ "number": 5.1,
+ }
+
+ if len(result) != len(expected) {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, result)
+ }
+
+ for k, v := range expected {
+ if result[k] != v {
+ t.Errorf("Expected %q %#v, got %#v", k, v, result[k])
+ }
+ }
+
+ // ensure that the original data hasn't changed
+ for k, v := range originalData {
+ rv := record.Get(k)
+ if rv != v {
+ t.Errorf("Expected original %q %#v, got %#v", k, v, rv)
+ }
+ }
+}
+
+func TestRecordValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // dummy collection to ensure that the specified field validators are triggered
+ collection := core.NewBaseCollection("validate_test")
+ collection.Fields.Add(
+ &core.TextField{Name: "f1", Min: 3},
+ &core.NumberField{Name: "f2", Required: true},
+ )
+ if err := app.Save(collection); err != nil {
+ t.Fatal(err)
+ }
+
+ record := core.NewRecord(collection)
+ record.Id = "!invalid"
+
+ t.Run("no data set", func(t *testing.T) {
+ tests.TestValidationErrors(t, app.Validate(record), []string{"id", "f2"})
+ })
+
+ t.Run("failing the text field min requirement", func(t *testing.T) {
+ record.Set("f1", "a")
+ tests.TestValidationErrors(t, app.Validate(record), []string{"id", "f1", "f2"})
+ })
+
+ t.Run("satisfying the fields validations", func(t *testing.T) {
+ record.Id = strings.Repeat("b", 15)
+ record.Set("f1", "abc")
+ record.Set("f2", 1)
+ tests.TestValidationErrors(t, app.Validate(record), nil)
+ })
+}
+
+func TestRecordModelEventSync(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ col, err := app.FindCollectionByNameOrId("demo3")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testRecords := make([]*core.Record, 4)
+ for i := 0; i < 4; i++ {
+ testRecords[i] = core.NewRecord(col)
+ testRecords[i].Set("title", "sync_test_"+strconv.Itoa(i))
+ if err := app.Save(testRecords[i]); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ createModelEvent := func() *core.ModelEvent {
+ event := new(core.ModelEvent)
+ event.App = app
+ event.Context = context.Background()
+ event.Type = "test_a"
+ event.Model = testRecords[0]
+ return event
+ }
+
+ createModelErrorEvent := func() *core.ModelErrorEvent {
+ event := new(core.ModelErrorEvent)
+ event.ModelEvent = *createModelEvent()
+ event.Error = errors.New("error_a")
+ return event
+ }
+
+ changeRecordEventBefore := func(e *core.RecordEvent) {
+ e.Type = "test_b"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 123)
+ e.Record = testRecords[1]
+ }
+
+ modelEventFinalizerChange := func(e *core.ModelEvent) {
+ e.Type = "test_c"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 456)
+ e.Model = testRecords[2]
+ }
+
+ changeRecordEventAfter := func(e *core.RecordEvent) {
+ e.Type = "test_d"
+ //nolint:staticcheck
+ e.Context = context.WithValue(context.Background(), "test", 789)
+ e.Record = testRecords[3]
+ }
+
+ expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
+ if e.Type != "test_a" {
+ t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != nil {
+ t.Fatalf("Expected context value %v, got %v", nil, v)
+ }
+
+ if e.Model.PK() != testRecords[0].Id {
+ t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Model.PK(), 0)
+ }
+ }
+
+ expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
+ if e.Type != "test_d" {
+ t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != 789 {
+ t.Fatalf("Expected context value %v, got %v", 789, v)
+ }
+
+ // note: currently the Model and Record values are not synced due to performance consideration
+ if e.Model.PK() != testRecords[2].Id {
+ t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[2].Id, e.Model.PK(), 2)
+ }
+ }
+
+ expectedBeforeRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
+ if e.Type != "test_a" {
+ t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != nil {
+ t.Fatalf("Expected context value %v, got %v", nil, v)
+ }
+
+ if e.Record.Id != testRecords[0].Id {
+ t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Record.Id, 2)
+ }
+ }
+
+ expectedAfterRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
+ if e.Type != "test_c" {
+ t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
+ }
+
+ if v := e.Context.Value("test"); v != 456 {
+ t.Fatalf("Expected context value %v, got %v", 456, v)
+ }
+
+ // note: currently the Model and Record values are not synced due to performance consideration
+ if e.Record.Id != testRecords[1].Id {
+ t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[1].Id, e.Record.Id, 1)
+ }
+ }
+
+ modelEventFinalizer := func(e *core.ModelEvent) error {
+ modelEventFinalizerChange(e)
+ return nil
+ }
+
+ modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
+ modelEventFinalizerChange(&e.ModelEvent)
+ e.Error = errors.New("error_c")
+ return nil
+ }
+
+ modelEventHandler := &hook.Handler[*core.ModelEvent]{
+ Priority: -999,
+ Func: func(e *core.ModelEvent) error {
+ t.Run("before model", func(t *testing.T) {
+ expectedBeforeModelEventHandlerChecks(t, e)
+ })
+
+ _ = e.Next()
+
+ t.Run("after model", func(t *testing.T) {
+ expectedAfterModelEventHandlerChecks(t, e)
+ })
+
+ return nil
+ },
+ }
+
+ modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
+ Priority: -999,
+ Func: func(e *core.ModelErrorEvent) error {
+ t.Run("before model error", func(t *testing.T) {
+ expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
+ if v := e.Error.Error(); v != "error_a" {
+ t.Fatalf("Expected error %q, got %q", "error_a", v)
+ }
+ })
+
+ _ = e.Next()
+
+ t.Run("after model error", func(t *testing.T) {
+ expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
+ if v := e.Error.Error(); v != "error_d" {
+ t.Fatalf("Expected error %q, got %q", "error_d", v)
+ }
+ })
+
+ return nil
+ },
+ }
+
+ recordEventHandler := &hook.Handler[*core.RecordEvent]{
+ Priority: -999,
+ Func: func(e *core.RecordEvent) error {
+ t.Run("before record", func(t *testing.T) {
+ expectedBeforeRecordEventHandlerChecks(t, e)
+ })
+
+ changeRecordEventBefore(e)
+
+ _ = e.Next()
+
+ t.Run("after record", func(t *testing.T) {
+ expectedAfterRecordEventHandlerChecks(t, e)
+ })
+
+ changeRecordEventAfter(e)
+
+ return nil
+ },
+ }
+
+ recordErrorEventHandler := &hook.Handler[*core.RecordErrorEvent]{
+ Priority: -999,
+ Func: func(e *core.RecordErrorEvent) error {
+ t.Run("before record error", func(t *testing.T) {
+ expectedBeforeRecordEventHandlerChecks(t, &e.RecordEvent)
+ if v := e.Error.Error(); v != "error_a" {
+ t.Fatalf("Expected error %q, got %q", "error_c", v)
+ }
+ })
+
+ changeRecordEventBefore(&e.RecordEvent)
+ e.Error = errors.New("error_b")
+
+ _ = e.Next()
+
+ t.Run("after record error", func(t *testing.T) {
+ expectedAfterRecordEventHandlerChecks(t, &e.RecordEvent)
+ if v := e.Error.Error(); v != "error_c" {
+ t.Fatalf("Expected error %q, got %q", "error_c", v)
+ }
+ })
+
+ changeRecordEventAfter(&e.RecordEvent)
+ e.Error = errors.New("error_d")
+
+ return nil
+ },
+ }
+
+ // OnModelValidate
+ app.OnRecordValidate().Bind(recordEventHandler)
+ app.OnModelValidate().Bind(modelEventHandler)
+ app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelCreate
+ app.OnRecordCreate().Bind(recordEventHandler)
+ app.OnModelCreate().Bind(modelEventHandler)
+ app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelCreateExecute
+ app.OnRecordCreateExecute().Bind(recordEventHandler)
+ app.OnModelCreateExecute().Bind(modelEventHandler)
+ app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterCreateSuccess
+ app.OnRecordAfterCreateSuccess().Bind(recordEventHandler)
+ app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
+ app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterCreateError
+ app.OnRecordAfterCreateError().Bind(recordErrorEventHandler)
+ app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
+ app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+
+ // OnModelUpdate
+ app.OnRecordUpdate().Bind(recordEventHandler)
+ app.OnModelUpdate().Bind(modelEventHandler)
+ app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelUpdateExecute
+ app.OnRecordUpdateExecute().Bind(recordEventHandler)
+ app.OnModelUpdateExecute().Bind(modelEventHandler)
+ app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterUpdateSuccess
+ app.OnRecordAfterUpdateSuccess().Bind(recordEventHandler)
+ app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
+ app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterUpdateError
+ app.OnRecordAfterUpdateError().Bind(recordErrorEventHandler)
+ app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
+ app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+
+ // OnModelDelete
+ app.OnRecordDelete().Bind(recordEventHandler)
+ app.OnModelDelete().Bind(modelEventHandler)
+ app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelDeleteExecute
+ app.OnRecordDeleteExecute().Bind(recordEventHandler)
+ app.OnModelDeleteExecute().Bind(modelEventHandler)
+ app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterDeleteSuccess
+ app.OnRecordAfterDeleteSuccess().Bind(recordEventHandler)
+ app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
+ app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
+
+ // OnModelAfterDeleteError
+ app.OnRecordAfterDeleteError().Bind(recordErrorEventHandler)
+ app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
+ app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
+}
+
+func TestRecordSave(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ record func(app core.App) (*core.Record, error)
+ expectError bool
+ }{
+ // trigger validators
+ {
+ name: "create - trigger validators",
+ record: func(app core.App) (*core.Record, error) {
+ c, _ := app.FindCollectionByNameOrId("demo2")
+ record := core.NewRecord(c)
+ return record, nil
+ },
+ expectError: true,
+ },
+ {
+ name: "update - trigger validators",
+ record: func(app core.App) (*core.Record, error) {
+ record, _ := app.FindFirstRecordByData("demo2", "title", "test1")
+ record.Set("title", "")
+ return record, nil
+ },
+ expectError: true,
+ },
+
+ // create
+ {
+ name: "create base record",
+ record: func(app core.App) (*core.Record, error) {
+ c, _ := app.FindCollectionByNameOrId("demo2")
+ record := core.NewRecord(c)
+ record.Set("title", "new_test")
+ return record, nil
+ },
+ expectError: false,
+ },
+ {
+ name: "create auth record",
+ record: func(app core.App) (*core.Record, error) {
+ c, _ := app.FindCollectionByNameOrId("nologin")
+ record := core.NewRecord(c)
+ record.Set("email", "test_new@example.com")
+ record.Set("password", "1234567890")
+ return record, nil
+ },
+ expectError: false,
+ },
+ {
+ name: "create view record",
+ record: func(app core.App) (*core.Record, error) {
+ c, _ := app.FindCollectionByNameOrId("view2")
+ record := core.NewRecord(c)
+ record.Set("state", true)
+ return record, nil
+ },
+ expectError: true, // view records are read-only
+ },
+
+ // update
+ {
+ name: "update base record",
+ record: func(app core.App) (*core.Record, error) {
+ record, _ := app.FindFirstRecordByData("demo2", "title", "test1")
+ record.Set("title", "test_new")
+ return record, nil
+ },
+ expectError: false,
+ },
+ {
+ name: "update auth record",
+ record: func(app core.App) (*core.Record, error) {
+ record, _ := app.FindAuthRecordByEmail("nologin", "test@example.com")
+ record.Set("name", "test_new")
+ record.Set("email", "test_new@example.com")
+ return record, nil
+ },
+ expectError: false,
+ },
+ {
+ name: "update view record",
+ record: func(app core.App) (*core.Record, error) {
+ record, _ := app.FindFirstRecordByData("view2", "state", true)
+ record.Set("state", false)
+ return record, nil
+ },
+ expectError: true, // view records are read-only
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record, err := s.record(app)
+ if err != nil {
+ t.Fatalf("Failed to retrieve test record: %v", err)
+ }
+
+ saveErr := app.Save(record)
+
+ hasErr := saveErr != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", hasErr, s.expectError, saveErr)
+ }
+
+ if hasErr {
+ return
+ }
+
+ // the record should always have an id after successful Save
+ if record.Id == "" {
+ t.Fatal("Expected record id to be set")
+ }
+
+ if record.IsNew() {
+ t.Fatal("Expected the record to be marked as not new")
+ }
+
+ // refetch and compare the serialization
+ refreshed, err := app.FindRecordById(record.Collection(), record.Id)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rawRefreshed, err := refreshed.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := record.MarshalJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !bytes.Equal(raw, rawRefreshed) {
+ t.Fatalf("Expected the refreshed record to be the same as the saved one, got\n%s\nVS\n%s", raw, rawRefreshed)
+ }
+ })
+ }
+}
+
+func TestRecordSaveIdFromOtherCollection(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ baseCollection, _ := app.FindCollectionByNameOrId("demo2")
+ authCollection, _ := app.FindCollectionByNameOrId("nologin")
+
+ // base collection test
+ r1 := core.NewRecord(baseCollection)
+ r1.Set("title", "test_new")
+ r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
+ if err := app.Save(r1); err != nil {
+ t.Fatalf("Expected nil, got error %v", err)
+ }
+
+ // auth collection test
+ r2 := core.NewRecord(authCollection)
+ r2.SetEmail("test_new@example.com")
+ r2.SetPassword("1234567890")
+ r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
+ if err := app.Save(r2); err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ // try again with unique id
+ r2.Set("id", strings.Repeat("a", 15))
+ if err := app.Save(r2); err != nil {
+ t.Fatalf("Expected nil, got error %v", err)
+ }
+}
+
+func TestRecordSaveIdUpdateNoValidation(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ rec, err := app.FindRecordById("demo3", "7nwo8tuiatetxdm")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rec.Id = strings.Repeat("a", 15)
+
+ err = app.SaveNoValidate(rec)
+ if err == nil {
+ t.Fatal("Expected save to fail, got nil")
+ }
+
+ // no changes
+ rec.Load(rec.Original().FieldsData())
+ err = app.SaveNoValidate(rec)
+ if err != nil {
+ t.Fatalf("Expected save to succeed, got error %v", err)
+ }
+}
+
+func TestRecordSaveWithAutoTokenKeyRefresh(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ payload map[string]any
+ expectedChange bool
+ }{
+ {
+ "no email or password change",
+ map[string]any{"name": "example"},
+ false,
+ },
+ {
+ "password change",
+ map[string]any{"password": "1234567890"},
+ true,
+ },
+ {
+ "email change",
+ map[string]any{"email": "test_update@example.com"},
+ true,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ record, err := app.FindFirstRecordByFilter("nologin", "1=1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ originalTokenKey := record.TokenKey()
+
+ record.Load(s.payload)
+
+ err = app.Save(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ newTokenKey := record.TokenKey()
+
+ hasChange := originalTokenKey != newTokenKey
+
+ if hasChange != s.expectedChange {
+ t.Fatalf("Expected hasChange %v, got %v", s.expectedChange, hasChange)
+ }
+ })
+ }
+}
+
+func TestRecordDelete(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demoCollection, _ := app.FindCollectionByNameOrId("demo2")
+
+ // delete unsaved record
+ // ---
+ newRec := core.NewRecord(demoCollection)
+ if err := app.Delete(newRec); err == nil {
+ t.Fatal("(newRec) Didn't expect to succeed deleting unsaved record")
+ }
+
+ // delete view record
+ // ---
+ viewRec, _ := app.FindRecordById("view2", "84nmscqy84lsi1t")
+ if err := app.Delete(viewRec); err == nil {
+ t.Fatal("(viewRec) Didn't expect to succeed deleting view record")
+ }
+ // check if it still exists
+ viewRec, _ = app.FindRecordById(viewRec.Collection().Id, viewRec.Id)
+ if viewRec == nil {
+ t.Fatal("(viewRec) Expected view record to still exists")
+ }
+
+ // delete existing record + external auths
+ // ---
+ rec1, _ := app.FindRecordById("users", "4q1xlclmfloku33")
+ if err := app.Delete(rec1); err != nil {
+ t.Fatalf("(rec1) Expected nil, got error %v", err)
+ }
+ // check if it was really deleted
+ if refreshed, _ := app.FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
+ t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
+ }
+ // check if the external auths were deleted
+ if auths, _ := app.FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
+ t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
+ }
+
+ // delete existing record while being part of a non-cascade required relation
+ // ---
+ rec2, _ := app.FindRecordById("demo3", "7nwo8tuiatetxdm")
+ if err := app.Delete(rec2); err == nil {
+ t.Fatalf("(rec2) Expected error, got nil")
+ }
+
+ // delete existing record + cascade
+ // ---
+ calledQueries := []string{}
+ app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ calledQueries = append(calledQueries, sql)
+ }
+ rec3, _ := app.FindRecordById("users", "oap640cot4yru2s")
+ // delete
+ if err := app.Delete(rec3); err != nil {
+ t.Fatalf("(rec3) Expected nil, got error %v", err)
+ }
+ // check if it was really deleted
+ rec3, _ = app.FindRecordById(rec3.Collection().Id, rec3.Id)
+ if rec3 != nil {
+ t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
+ }
+ // check if the operation cascaded
+ rel, _ := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if rel != nil {
+ t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
+ }
+ // ensure that the json rel fields were prefixed
+ joinedQueries := strings.Join(calledQueries, " ")
+ expectedRelManyPart := "SELECT `demo1`.`id` FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
+ if !strings.Contains(joinedQueries, expectedRelManyPart) {
+ t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
+ }
+ expectedRelOnePart := "SELECT `demo1`.`id` FROM `demo1` WHERE (`demo1`.`rel_one`='"
+ if !strings.Contains(joinedQueries, expectedRelOnePart) {
+ t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
+ }
+}
+
+func TestRecordDeleteWithMultipleRelationCascade(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // create a mock collection with self referencing multiple relation field
+ // ---
+ collection := core.NewBaseCollection("test")
+ err := app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // for simpler mocks
+ collection.Fields.GetByName("id").(*core.TextField).Min = 1
+
+ collection.Fields.Add(&core.RelationField{
+ Name: "rels",
+ CollectionId: collection.Id,
+ MaxSelect: 99,
+ CascadeDelete: true,
+ })
+
+ err = app.Save(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create mock records
+ // ---
+ relsData := map[string][]string{
+ "a": nil,
+ "b": {"a"},
+ "c": {"a", "b"},
+ "d": {},
+ "e": {"c", "d"},
+ }
+ for id, rels := range relsData {
+ record := core.NewRecord(collection)
+ record.Set("id", id)
+ record.Set("rels", rels)
+ err = app.SaveNoValidate(record) // map is not ordered
+ if err != nil {
+ t.Fatalf("failed to create mock record: %v", err)
+ }
+ }
+
+ // trigger cascade delete for the top record
+ // ---
+ aRecord, err := app.FindRecordById(collection, "a")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = app.Delete(aRecord)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // verify cascade delete
+ // ---
+ expectedRels := map[string][]string{
+ "d": {},
+ "e": {"d"},
+ }
+
+ allRecords, err := app.FindAllRecords(collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(allRecords) != len(expectedRels) {
+ t.Fatalf("Expected %d remaining records, got %d", len(expectedRels), len(allRecords))
+ }
+
+ for _, r := range allRecords {
+ expected, ok := expectedRels[r.Id]
+ if !ok {
+ t.Fatalf("Record %q wasn't found in %v", r.Id, expectedRels)
+ }
+
+ rels := r.GetStringSlice("rels")
+ if !slices.Equal(rels, expected) {
+ t.Fatalf("Record %q expected rels\n%v\ngot\n%v", r.Id, expected, rels)
+ }
+ }
+}
+
+func TestRecordDeleteBatchProcessing(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ if err := createMockBatchProcessingData(app); err != nil {
+ t.Fatal(err)
+ }
+
+ // find and delete the first c1 record to trigger cascade
+ mainRecord, _ := app.FindRecordById("c1", "a")
+ if err := app.Delete(mainRecord); err != nil {
+ t.Fatal(err)
+ }
+
+ // check if the main record was deleted
+ _, err := app.FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
+ if err == nil {
+ t.Fatal("The main record wasn't deleted")
+ }
+
+ // check if the c1 b rel field were updated
+ c1RecordB, err := app.FindRecordById("c1", "b")
+ if err != nil || c1RecordB.GetString("rel") != "" {
+ t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
+ }
+
+ // check if the c2 rel fields were updated
+ c2Records, err := app.FindAllRecords("c2", nil)
+ if err != nil || len(c2Records) == 0 {
+ t.Fatalf("Failed to fetch c2 records: %v", err)
+ }
+ for _, r := range c2Records {
+ ids := r.GetStringSlice("rel")
+ if len(ids) != 1 || ids[0] != "b" {
+ t.Fatalf("Expected only 'b' rel id, got %v", ids)
+ }
+ }
+
+ // check if all c3 relations were deleted
+ c3Records, err := app.FindAllRecords("c3", nil)
+ if err != nil {
+ t.Fatalf("Failed to fetch c3 records: %v", err)
+ }
+ if total := len(c3Records); total != 0 {
+ t.Fatalf("Expected c3 records to be deleted, found %d", total)
+ }
+}
+
+func createMockBatchProcessingData(app core.App) error {
+ // create mock collection without relation
+ c1 := core.NewBaseCollection("c1")
+ c1.Id = "c1"
+ c1.Fields.Add(
+ &core.TextField{Name: "text"},
+ &core.RelationField{
+ Name: "rel",
+ MaxSelect: 1,
+ CollectionId: "c1",
+ CascadeDelete: false, // should unset all rel fields
+ },
+ )
+ if err := app.SaveNoValidate(c1); err != nil {
+ return err
+ }
+
+ // create mock collection with a multi-rel field
+ c2 := core.NewBaseCollection("c2")
+ c2.Id = "c2"
+ c2.Fields.Add(
+ &core.TextField{Name: "text"},
+ &core.RelationField{
+ Name: "rel",
+ MaxSelect: 10,
+ CollectionId: "c1",
+ CascadeDelete: false, // should unset all rel fields
+ },
+ )
+ if err := app.SaveNoValidate(c2); err != nil {
+ return err
+ }
+
+ // create mock collection with a single-rel field
+ c3 := core.NewBaseCollection("c3")
+ c3.Id = "c3"
+ c3.Fields.Add(
+ &core.RelationField{
+ Name: "rel",
+ MaxSelect: 1,
+ CollectionId: "c1",
+ CascadeDelete: true, // should delete all c3 records
+ },
+ )
+ if err := app.SaveNoValidate(c3); err != nil {
+ return err
+ }
+
+ // insert mock records
+ c1RecordA := core.NewRecord(c1)
+ c1RecordA.Id = "a"
+ c1RecordA.Set("rel", c1RecordA.Id) // self reference
+ if err := app.SaveNoValidate(c1RecordA); err != nil {
+ return err
+ }
+ c1RecordB := core.NewRecord(c1)
+ c1RecordB.Id = "b"
+ c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
+ if err := app.SaveNoValidate(c1RecordB); err != nil {
+ return err
+ }
+ for i := 0; i < 4500; i++ {
+ c2Record := core.NewRecord(c2)
+ c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
+ if err := app.SaveNoValidate(c2Record); err != nil {
+ return err
+ }
+
+ c3Record := core.NewRecord(c3)
+ c3Record.Set("rel", c1RecordA.Id)
+ if err := app.SaveNoValidate(c3Record); err != nil {
+ return err
+ }
+ }
+
+ // set the same id as the relation for at least 1 record
+ // to check whether the correct condition will be added
+ c3Record := core.NewRecord(c3)
+ c3Record.Set("rel", c1RecordA.Id)
+ c3Record.Id = c1RecordA.Id
+ if err := app.SaveNoValidate(c3Record); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+type mockField struct {
+ core.TextField
+}
+
+func (f *mockField) FindGetter(key string) core.GetterFunc {
+ switch key {
+ case f.Name + ":test":
+ return func(record *core.Record) any {
+ return "modifier_get"
+ }
+ default:
+ return nil
+ }
+}
+
+func (f *mockField) FindSetter(key string) core.SetterFunc {
+ switch key {
+ case f.Name:
+ return func(record *core.Record, raw any) {
+ record.SetRaw(f.Name, cast.ToString(raw))
+ }
+ case f.Name + ":test":
+ return func(record *core.Record, raw any) {
+ record.SetRaw(f.Name, "modifier_set")
+ }
+ default:
+ return nil
+ }
+}
diff --git a/core/record_proxy.go b/core/record_proxy.go
new file mode 100644
index 0000000000..644ede7d47
--- /dev/null
+++ b/core/record_proxy.go
@@ -0,0 +1,32 @@
+package core
+
+// RecordProxy defines an interface for a Record proxy/project model,
+// aka. custom model struct that acts on behalve the proxied Record to
+// allow for example typed getter/setters for the Record fields.
+//
+// To implement the interface it is usually enough to embed the [BaseRecordProxy] struct.
+type RecordProxy interface {
+ // ProxyRecord returns the proxied Record model.
+ ProxyRecord() *Record
+
+ // SetProxyRecord loads the specified record model into the current proxy.
+ SetProxyRecord(record *Record)
+}
+
+var _ RecordProxy = (*BaseRecordProxy)(nil)
+
+// BaseRecordProxy implements the [RecordProxy] interface and it is intended
+// to be used as embed to custom user provided Record proxy structs.
+type BaseRecordProxy struct {
+ *Record
+}
+
+// ProxyRecord returns the proxied Record model.
+func (m *BaseRecordProxy) ProxyRecord() *Record {
+ return m.Record
+}
+
+// SetProxyRecord loads the specified record model into the current proxy.
+func (m *BaseRecordProxy) SetProxyRecord(record *Record) {
+ m.Record = record
+}
diff --git a/core/record_proxy_test.go b/core/record_proxy_test.go
new file mode 100644
index 0000000000..bc8f004b24
--- /dev/null
+++ b/core/record_proxy_test.go
@@ -0,0 +1,20 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func TestBaseRecordProxy(t *testing.T) {
+ p := core.BaseRecordProxy{}
+
+ record := core.NewRecord(core.NewBaseCollection("test"))
+ record.Id = "test"
+
+ p.SetProxyRecord(record)
+
+ if p.ProxyRecord() == nil || p.ProxyRecord().Id != p.Id || p.Id != "test" {
+ t.Fatalf("Expected proxy record to be set")
+ }
+}
diff --git a/core/record_query.go b/core/record_query.go
new file mode 100644
index 0000000000..8162575e85
--- /dev/null
+++ b/core/record_query.go
@@ -0,0 +1,639 @@
+package core
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/list"
+ "github.com/pocketbase/pocketbase/tools/search"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+var recordProxyType = reflect.TypeOf((*RecordProxy)(nil)).Elem()
+
+// RecordQuery returns a new Record select query from a collection model, id or name.
+//
+// In case a collection id or name is provided and that collection doesn't
+// actually exists, the generated query will be created with a cancelled context
+// and will fail once an executor (Row(), One(), All(), etc.) is called.
+func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
+ var tableName string
+
+ collection, collectionErr := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if collection != nil {
+ tableName = collection.Name
+ }
+ if tableName == "" {
+ // update with some fake table name for easier debugging
+ tableName = "@@__invalidCollectionModelOrIdentifier"
+ }
+
+ query := app.ConcurrentDB().Select(app.ConcurrentDB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
+
+ // in case of an error attach a new context and cancel it immediately with the error
+ if collectionErr != nil {
+ ctx, cancelFunc := context.WithCancelCause(context.Background())
+ query.WithContext(ctx)
+ cancelFunc(collectionErr)
+ }
+
+ return query.WithBuildHook(func(q *dbx.Query) {
+ q.WithExecHook(execLockRetry(app.config.QueryTimeout, defaultMaxLockRetries)).
+ WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
+ if a == nil {
+ return op(a)
+ }
+
+ switch v := a.(type) {
+ case *Record:
+ record, err := resolveRecordOneHook(collection, op)
+ if err != nil {
+ return err
+ }
+
+ *v = *record
+
+ return nil
+ case RecordProxy:
+ record, err := resolveRecordOneHook(collection, op)
+ if err != nil {
+ return err
+ }
+
+ v.SetProxyRecord(record)
+ return nil
+ default:
+ return op(a)
+ }
+ }).
+ WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
+ if sliceA == nil {
+ return op(sliceA)
+ }
+
+ switch v := sliceA.(type) {
+ case *[]*Record:
+ records, err := resolveRecordAllHook(collection, op)
+ if err != nil {
+ return err
+ }
+
+ *v = records
+
+ return nil
+ case *[]Record:
+ records, err := resolveRecordAllHook(collection, op)
+ if err != nil {
+ return err
+ }
+
+ nonPointers := make([]Record, len(records))
+ for i, r := range records {
+ nonPointers[i] = *r
+ }
+
+ *v = nonPointers
+
+ return nil
+ default: // expects []RecordProxy slice
+ rv := reflect.ValueOf(v)
+ if rv.Kind() != reflect.Pointer || rv.IsNil() {
+ return errors.New("must be a pointer")
+ }
+
+ rv = dereference(rv)
+
+ if rv.Kind() != reflect.Slice {
+ return errors.New("must be a slice of RecordSetters")
+ }
+
+ et := rv.Type().Elem()
+
+ var isSliceOfPointers bool
+ if et.Kind() == reflect.Pointer {
+ isSliceOfPointers = true
+ et = et.Elem()
+ }
+
+ if !reflect.PointerTo(et).Implements(recordProxyType) {
+ return op(sliceA)
+ }
+
+ records, err := resolveRecordAllHook(collection, op)
+ if err != nil {
+ return err
+ }
+
+ // create an empty slice
+ if rv.IsNil() {
+ rv.Set(reflect.MakeSlice(rv.Type(), 0, len(records)))
+ }
+
+ for _, record := range records {
+ ev := reflect.New(et)
+
+ if !ev.CanInterface() {
+ continue
+ }
+
+ ps, ok := ev.Interface().(RecordProxy)
+ if !ok {
+ continue
+ }
+
+ ps.SetProxyRecord(record)
+
+ ev = ev.Elem()
+ if isSliceOfPointers {
+ ev = ev.Addr()
+ }
+
+ rv.Set(reflect.Append(rv, ev))
+ }
+
+ return nil
+ }
+ })
+ })
+}
+
+func resolveRecordOneHook(collection *Collection, op func(dst any) error) (*Record, error) {
+ data := dbx.NullStringMap{}
+ if err := op(&data); err != nil {
+ return nil, err
+ }
+ return newRecordFromNullStringMap(collection, data)
+}
+
+func resolveRecordAllHook(collection *Collection, op func(dst any) error) ([]*Record, error) {
+ data := []dbx.NullStringMap{}
+ if err := op(&data); err != nil {
+ return nil, err
+ }
+ return newRecordsFromNullStringMaps(collection, data)
+}
+
+// dereference returns the underlying value v points to.
+func dereference(v reflect.Value) reflect.Value {
+ for v.Kind() == reflect.Pointer {
+ if v.IsNil() {
+ // initialize with a new value and continue searching
+ v.Set(reflect.New(v.Type().Elem()))
+ }
+ v = v.Elem()
+ }
+ return v
+}
+
+func getCollectionByModelOrIdentifier(app App, collectionModelOrIdentifier any) (*Collection, error) {
+ switch c := collectionModelOrIdentifier.(type) {
+ case *Collection:
+ return c, nil
+ case Collection:
+ return &c, nil
+ case string:
+ return app.FindCachedCollectionByNameOrId(c)
+ default:
+ return nil, errors.New("unknown collection identifier - must be collection model, id or name")
+ }
+}
+
+// -------------------------------------------------------------------
+
+// FindRecordById finds the Record model by its id.
+func (app *BaseApp) FindRecordById(
+ collectionModelOrIdentifier any,
+ recordId string,
+ optFilters ...func(q *dbx.SelectQuery) error,
+) (*Record, error) {
+ collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if err != nil {
+ return nil, err
+ }
+
+ record := &Record{}
+
+ query := app.RecordQuery(collection).
+ AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
+
+ // apply filter funcs (if any)
+ for _, filter := range optFilters {
+ if filter == nil {
+ continue
+ }
+ if err = filter(query); err != nil {
+ return nil, err
+ }
+ }
+
+ err = query.Limit(1).One(record)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// FindRecordsByIds finds all records by the specified ids.
+// If no records are found, returns an empty slice.
+func (app *BaseApp) FindRecordsByIds(
+ collectionModelOrIdentifier any,
+ recordIds []string,
+ optFilters ...func(q *dbx.SelectQuery) error,
+) ([]*Record, error) {
+ collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if err != nil {
+ return nil, err
+ }
+
+ query := app.RecordQuery(collection).
+ AndWhere(dbx.In(
+ collection.Name+".id",
+ list.ToInterfaceSlice(recordIds)...,
+ ))
+
+ for _, filter := range optFilters {
+ if filter == nil {
+ continue
+ }
+ if err = filter(query); err != nil {
+ return nil, err
+ }
+ }
+
+ records := make([]*Record, 0, len(recordIds))
+
+ err = query.All(&records)
+ if err != nil {
+ return nil, err
+ }
+
+ return records, nil
+}
+
+// FindAllRecords finds all records matching specified db expressions.
+//
+// Returns all collection records if no expression is provided.
+//
+// Returns an empty slice if no records are found.
+//
+// Example:
+//
+// // no extra expressions
+// app.FindAllRecords("example")
+//
+// // with extra expressions
+// expr1 := dbx.HashExp{"email": "test@example.com"}
+// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
+// app.FindAllRecords("example", expr1, expr2)
+func (app *BaseApp) FindAllRecords(collectionModelOrIdentifier any, exprs ...dbx.Expression) ([]*Record, error) {
+ query := app.RecordQuery(collectionModelOrIdentifier)
+
+ for _, expr := range exprs {
+ if expr != nil { // add only the non-nil expressions
+ query.AndWhere(expr)
+ }
+ }
+
+ var records []*Record
+
+ if err := query.All(&records); err != nil {
+ return nil, err
+ }
+
+ return records, nil
+}
+
+// FindFirstRecordByData returns the first found record matching
+// the provided key-value pair.
+func (app *BaseApp) FindFirstRecordByData(collectionModelOrIdentifier any, key string, value any) (*Record, error) {
+ collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if err != nil {
+ return nil, err
+ }
+
+ field := collection.Fields.GetByName(key)
+ if field == nil {
+ return nil, errors.New("invalid or missing field " + key)
+ }
+
+ record := &Record{}
+
+ err = app.RecordQuery(collection).
+ AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
+ Limit(1).
+ One(record)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// FindRecordsByFilter returns limit number of records matching the
+// provided string filter.
+//
+// NB! Use the last "params" argument to bind untrusted user variables!
+//
+// The filter argument is optional and can be empty string to target
+// all available records.
+//
+// The sort argument is optional and can be empty string OR the same format
+// used in the web APIs, ex. "-created,title".
+//
+// If the limit argument is <= 0, no limit is applied to the query and
+// all matching records are returned.
+//
+// Returns an empty slice if no records are found.
+//
+// Example:
+//
+// app.FindRecordsByFilter(
+// "posts",
+// "title ~ {:title} && visible = {:visible}",
+// "-created",
+// 10,
+// 0,
+// dbx.Params{"title": "lorem ipsum", "visible": true}
+// )
+func (app *BaseApp) FindRecordsByFilter(
+ collectionModelOrIdentifier any,
+ filter string,
+ sort string,
+ limit int,
+ offset int,
+ params ...dbx.Params,
+) ([]*Record, error) {
+ collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if err != nil {
+ return nil, err
+ }
+
+ q := app.RecordQuery(collection)
+
+ // build a fields resolver and attach the generated conditions to the query
+ // ---
+ resolver := NewRecordFieldResolver(
+ app,
+ collection, // the base collection
+ nil, // no request data
+ true, // allow searching hidden/protected fields like "email"
+ )
+
+ if filter != "" {
+ expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
+ if err != nil {
+ return nil, fmt.Errorf("invalid filter expression: %w", err)
+ }
+ q.AndWhere(expr)
+ }
+
+ if sort != "" {
+ for _, sortField := range search.ParseSortFromString(sort) {
+ expr, err := sortField.BuildExpr(resolver)
+ if err != nil {
+ return nil, err
+ }
+ if expr != "" {
+ q.AndOrderBy(expr)
+ }
+ }
+ }
+
+ err = resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
+ if err != nil {
+ return nil, err
+ }
+ // ---
+
+ if offset > 0 {
+ q.Offset(int64(offset))
+ }
+
+ if limit > 0 {
+ q.Limit(int64(limit))
+ }
+
+ records := []*Record{}
+
+ if err := q.All(&records); err != nil {
+ return nil, err
+ }
+
+ return records, nil
+}
+
+// FindFirstRecordByFilter returns the first available record matching the provided filter (if any).
+//
+// NB! Use the last params argument to bind untrusted user variables!
+//
+// Returns sql.ErrNoRows if no record is found.
+//
+// Example:
+//
+// app.FindFirstRecordByFilter("posts", "")
+// app.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
+func (app *BaseApp) FindFirstRecordByFilter(
+ collectionModelOrIdentifier any,
+ filter string,
+ params ...dbx.Params,
+) (*Record, error) {
+ result, err := app.FindRecordsByFilter(collectionModelOrIdentifier, filter, "", 1, 0, params...)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(result) == 0 {
+ return nil, sql.ErrNoRows
+ }
+
+ return result[0], nil
+}
+
+// CountRecords returns the total number of records in a collection.
+func (app *BaseApp) CountRecords(collectionModelOrIdentifier any, exprs ...dbx.Expression) (int64, error) {
+ var total int64
+
+ q := app.RecordQuery(collectionModelOrIdentifier).Select("count(*)")
+
+ for _, expr := range exprs {
+ if expr != nil { // add only the non-nil expressions
+ q.AndWhere(expr)
+ }
+ }
+
+ err := q.Row(&total)
+
+ return total, err
+}
+
+// FindAuthRecordByToken finds the auth record associated with the provided JWT
+// (auth, file, verifyEmail, changeEmail, passwordReset types).
+//
+// Optionally specify a list of validTypes to check tokens only from those types.
+//
+// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
+func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*Record, error) {
+ if token == "" {
+ return nil, errors.New("missing token")
+ }
+
+ unverifiedClaims, err := security.ParseUnverifiedJWT(token)
+ if err != nil {
+ return nil, err
+ }
+
+ // check required claims
+ id, _ := unverifiedClaims[TokenClaimId].(string)
+ collectionId, _ := unverifiedClaims[TokenClaimCollectionId].(string)
+ tokenType, _ := unverifiedClaims[TokenClaimType].(string)
+ if id == "" || collectionId == "" || tokenType == "" {
+ return nil, errors.New("missing or invalid token claims")
+ }
+
+ // check types (if explicitly set)
+ if len(validTypes) > 0 && !list.ExistInSlice(tokenType, validTypes) {
+ return nil, fmt.Errorf("invalid token type %q, expects %q", tokenType, strings.Join(validTypes, ","))
+ }
+
+ record, err := app.FindRecordById(collectionId, id)
+ if err != nil {
+ return nil, err
+ }
+
+ if !record.Collection().IsAuth() {
+ return nil, errors.New("the token is not associated to an auth collection record")
+ }
+
+ var baseTokenKey string
+ switch tokenType {
+ case TokenTypeAuth:
+ baseTokenKey = record.Collection().AuthToken.Secret
+ case TokenTypeFile:
+ baseTokenKey = record.Collection().FileToken.Secret
+ case TokenTypeVerification:
+ baseTokenKey = record.Collection().VerificationToken.Secret
+ case TokenTypePasswordReset:
+ baseTokenKey = record.Collection().PasswordResetToken.Secret
+ case TokenTypeEmailChange:
+ baseTokenKey = record.Collection().EmailChangeToken.Secret
+ default:
+ return nil, errors.New("unknown token type " + tokenType)
+ }
+
+ secret := record.TokenKey() + baseTokenKey
+
+ // verify token signature
+ _, err = security.ParseJWT(token, secret)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// FindAuthRecordByEmail finds the auth record associated with the provided email.
+//
+// The email check would be case-insensitive if the related collection
+// email unique index has COLLATE NOCASE specified for the email column.
+//
+// Returns an error if it is not an auth collection or the record is not found.
+func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
+ collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
+ }
+
+ if !collection.IsAuth() {
+ return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
+ }
+
+ record := &Record{}
+
+ var expr dbx.Expression
+
+ index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
+ if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
+ // case-insensitive search
+ expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
+ } else {
+ expr = dbx.HashExp{FieldNameEmail: email}
+ }
+
+ err = app.RecordQuery(collection).
+ AndWhere(expr).
+ Limit(1).
+ One(record)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// CanAccessRecord checks if a record is allowed to be accessed by the
+// specified requestInfo and accessRule.
+//
+// Rule and db checks are ignored in case requestInfo.Auth is a superuser.
+//
+// The returned error indicate that something unexpected happened during
+// the check (eg. invalid rule or db query error).
+//
+// The method always return false on invalid rule or db query error.
+//
+// Example:
+//
+// requestInfo, _ := e.RequestInfo()
+// record, _ := app.FindRecordById("example", "RECORD_ID")
+// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
+// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
+//
+// if ok, _ := app.CanAccessRecord(record, requestInfo, rule); ok { ... }
+func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, accessRule *string) (bool, error) {
+ // superusers can access everything
+ if requestInfo.HasSuperuserAuth() {
+ return true, nil
+ }
+
+ // only superusers can access this record
+ if accessRule == nil {
+ return false, nil
+ }
+
+ // empty public rule, aka. everyone can access
+ if *accessRule == "" {
+ return true, nil
+ }
+
+ var exists int
+
+ query := app.RecordQuery(record.Collection()).
+ Select("(1)").
+ AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
+
+ // parse and apply the access rule filter
+ resolver := NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
+ expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
+ if err != nil {
+ return false, err
+ }
+
+ err = resolver.UpdateQuery(query)
+ if err != nil {
+ return false, err
+ }
+
+ err = query.AndWhere(expr).Limit(1).Row(&exists)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return false, err
+ }
+
+ return exists > 0, nil
+}
diff --git a/core/record_query_expand.go b/core/record_query_expand.go
new file mode 100644
index 0000000000..7a87da5d26
--- /dev/null
+++ b/core/record_query_expand.go
@@ -0,0 +1,291 @@
+package core
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "regexp"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+// ExpandFetchFunc defines the function that is used to fetch the expanded relation records.
+type ExpandFetchFunc func(relCollection *Collection, relIds []string) ([]*Record, error)
+
+// ExpandRecord expands the relations of a single Record model.
+//
+// If optFetchFunc is not set, then a default function will be used
+// that returns all relation records.
+//
+// Returns a map with the failed expand parameters and their errors.
+func (app *BaseApp) ExpandRecord(record *Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
+ return app.ExpandRecords([]*Record{record}, expands, optFetchFunc)
+}
+
+// ExpandRecords expands the relations of the provided Record models list.
+//
+// If optFetchFunc is not set, then a default function will be used
+// that returns all relation records.
+//
+// Returns a map with the failed expand parameters and their errors.
+func (app *BaseApp) ExpandRecords(records []*Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
+ normalized := normalizeExpands(expands)
+
+ failed := map[string]error{}
+
+ for _, expand := range normalized {
+ if err := app.expandRecords(records, expand, optFetchFunc, 1); err != nil {
+ failed[expand] = err
+ }
+ }
+
+ return failed
+}
+
+// Deprecated
+var indirectExpandRegexOld = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
+
+var indirectExpandRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
+
+// notes:
+// - if fetchFunc is nil, dao.FindRecordsByIds will be used
+// - all records are expected to be from the same collection
+// - if maxNestedRels(6) is reached, the function returns nil ignoring the remaining expand path
+func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
+ if fetchFunc == nil {
+ // load a default fetchFunc
+ fetchFunc = func(relCollection *Collection, relIds []string) ([]*Record, error) {
+ return app.FindRecordsByIds(relCollection.Id, relIds)
+ }
+ }
+
+ if expandPath == "" || recursionLevel > maxNestedRels || len(records) == 0 {
+ return nil
+ }
+
+ mainCollection := records[0].Collection()
+
+ var relField *RelationField
+ var relCollection *Collection
+
+ parts := strings.SplitN(expandPath, ".", 2)
+ var matches []string
+
+ // @todo remove the old syntax support
+ if strings.Contains(parts[0], "(") {
+ matches = indirectExpandRegexOld.FindStringSubmatch(parts[0])
+ if len(matches) == 3 {
+ log.Printf(
+ "%s expand format is deprecated and will be removed in the future. Consider replacing it with %s_via_%s.\n",
+ matches[0],
+ matches[1],
+ matches[2],
+ )
+ }
+ } else {
+ matches = indirectExpandRegex.FindStringSubmatch(parts[0])
+ }
+
+ if len(matches) == 3 {
+ indirectRel, _ := getCollectionByModelOrIdentifier(app, matches[1])
+ if indirectRel == nil {
+ return fmt.Errorf("couldn't find back-related collection %q", matches[1])
+ }
+
+ indirectRelField, _ := indirectRel.Fields.GetByName(matches[2]).(*RelationField)
+ if indirectRelField == nil || indirectRelField.CollectionId != mainCollection.Id {
+ return fmt.Errorf("couldn't find back-relation field %q in collection %q", matches[2], indirectRel.Name)
+ }
+
+ // add the related id(s) as a dynamic relation field value to
+ // allow further expand checks at later stage in a more unified manner
+ prepErr := func() error {
+ q := app.ConcurrentDB().Select("id").
+ From(indirectRel.Name).
+ Limit(1000) // the limit is arbitrary chosen and may change in the future
+
+ if indirectRelField.IsMultiple() {
+ q.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
+ "SELECT 1 FROM %s je WHERE je.value = {:id}",
+ dbutils.JSONEach(indirectRelField.Name),
+ ))))
+ } else {
+ q.AndWhere(dbx.NewExp("[[" + indirectRelField.Name + "]] = {:id}"))
+ }
+
+ pq := q.Build().Prepare()
+
+ for _, record := range records {
+ var relIds []string
+
+ err := pq.Bind(dbx.Params{"id": record.Id}).Column(&relIds)
+ if err != nil {
+ return errors.Join(err, pq.Close())
+ }
+
+ if len(relIds) > 0 {
+ record.Set(parts[0], relIds)
+ }
+ }
+
+ return pq.Close()
+ }()
+ if prepErr != nil {
+ return prepErr
+ }
+
+ // indirect/back relation
+ relField = &RelationField{
+ Name: parts[0],
+ MaxSelect: 2147483647,
+ CollectionId: indirectRel.Id,
+ }
+ if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
+ relField.MaxSelect = 1
+ }
+ relCollection = indirectRel
+ } else {
+ // direct relation
+ relField, _ = mainCollection.Fields.GetByName(parts[0]).(*RelationField)
+ if relField == nil {
+ return fmt.Errorf("couldn't find relation field %q in collection %q", parts[0], mainCollection.Name)
+ }
+
+ relCollection, _ = getCollectionByModelOrIdentifier(app, relField.CollectionId)
+ if relCollection == nil {
+ return fmt.Errorf("couldn't find related collection %q", relField.CollectionId)
+ }
+ }
+
+ // ---------------------------------------------------------------
+
+ // extract the unique ids of the relations to expand
+ // (the initial size assumes that most of the relations are single and unique)
+ existsSet := make(map[string]struct{}, len(records))
+ relIds := make([]string, 0, len(records))
+ for _, record := range records {
+ ids := record.GetStringSlice(relField.Name)
+ for _, id := range ids {
+ if _, ok := existsSet[id]; !ok {
+ existsSet[id] = struct{}{}
+ relIds = append(relIds, id)
+ }
+ }
+ }
+ existsSet = nil //nolint:ineffassign
+
+ // fetch rels
+ rels, relsErr := fetchFunc(relCollection, relIds)
+ if relsErr != nil {
+ return relsErr
+ }
+
+ // expand nested fields
+ if len(parts) > 1 {
+ err := app.expandRecords(rels, parts[1], fetchFunc, recursionLevel+1)
+ if err != nil {
+ return err
+ }
+ }
+
+ // reindex with the rel id
+ indexedRels := make(map[string]*Record, len(rels))
+ for _, rel := range rels {
+ indexedRels[rel.Id] = rel
+ }
+
+ for _, model := range records {
+ // init expand if not already
+ // (this is done to ensure that the "expand" key will be returned in the response even if empty)
+ if model.expand == nil {
+ model.SetExpand(nil)
+ }
+
+ relIds := model.GetStringSlice(relField.Name)
+
+ validRels := make([]*Record, 0, len(relIds))
+ for _, id := range relIds {
+ if rel, ok := indexedRels[id]; ok {
+ validRels = append(validRels, rel)
+ }
+ }
+
+ if len(validRels) == 0 {
+ continue // no valid relations
+ }
+
+ expandData := model.Expand()
+
+ // normalize access to the previously expanded rel records (if any)
+ var oldExpandedRels []*Record
+ switch v := expandData[relField.Name].(type) {
+ case nil:
+ // no old expands
+ case *Record:
+ oldExpandedRels = []*Record{v}
+ case []*Record:
+ oldExpandedRels = v
+ }
+
+ // merge expands
+ for _, oldExpandedRel := range oldExpandedRels {
+ // find a matching rel record
+ for _, rel := range validRels {
+ if rel.Id != oldExpandedRel.Id {
+ continue
+ }
+
+ rel.MergeExpand(oldExpandedRel.Expand())
+ }
+ }
+
+ // update the expanded data
+ if relField.IsMultiple() {
+ expandData[relField.Name] = validRels
+ } else {
+ expandData[relField.Name] = validRels[0]
+ }
+
+ model.SetExpand(expandData)
+ }
+
+ return nil
+}
+
+// normalizeExpands normalizes expand strings and merges self containing paths
+// (eg. ["a.b.c", "a.b", " test ", " ", "test"] -> ["a.b.c", "test"]).
+func normalizeExpands(paths []string) []string {
+ // normalize paths
+ normalized := make([]string, 0, len(paths))
+ for _, p := range paths {
+ p = strings.ReplaceAll(p, " ", "") // replace spaces
+ p = strings.Trim(p, ".") // trim incomplete paths
+ if p != "" {
+ normalized = append(normalized, p)
+ }
+ }
+
+ // merge containing paths
+ result := make([]string, 0, len(normalized))
+ for i, p1 := range normalized {
+ var skip bool
+ for j, p2 := range normalized {
+ if i == j {
+ continue
+ }
+ if strings.HasPrefix(p2, p1+".") {
+ // skip because there is more detailed expand path
+ skip = true
+ break
+ }
+ }
+ if !skip {
+ result = append(result, p1)
+ }
+ }
+
+ return list.ToUniqueStringSlice(result)
+}
diff --git a/core/record_query_expand_test.go b/core/record_query_expand_test.go
new file mode 100644
index 0000000000..116ea78870
--- /dev/null
+++ b/core/record_query_expand_test.go
@@ -0,0 +1,528 @@
+package core_test
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json/v2"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func TestExpandRecords(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ testName string
+ collectionIdOrName string
+ recordIds []string
+ expands []string
+ fetchFunc core.ExpandFetchFunc
+ expectNonemptyExpandProps int
+ expectExpandFailures int
+ }{
+ {
+ "empty records",
+ "",
+ []string{},
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 0,
+ },
+ {
+ "empty expand",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 0,
+ },
+ {
+ "fetchFunc with error",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return nil, errors.New("test error")
+ },
+ 0,
+ 2,
+ },
+ {
+ "missing relation field",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"missing"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "existing, but non-relation type field",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"title"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "invalid/missing second level expand",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{"rel_one_no_cascade.title"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "with nil fetchfunc",
+ "users",
+ []string{
+ "bgs820n361vj1qd",
+ "4q1xlclmfloku33",
+ "oap640cot4yru2s", // no rels
+ },
+ []string{"rel"},
+ nil,
+ 2,
+ 0,
+ },
+ {
+ "expand normalizations",
+ "demo4",
+ []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
+ []string{
+ "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
+ "self_rel_many", "self_rel_many.",
+ " self_rel_many ", "",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 9,
+ 0,
+ },
+ {
+ "single expand",
+ "users",
+ []string{
+ "bgs820n361vj1qd",
+ "4q1xlclmfloku33",
+ "oap640cot4yru2s", // no rels
+ },
+ []string{"rel"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 2,
+ 0,
+ },
+ {
+ "with nil fetchfunc",
+ "users",
+ []string{
+ "bgs820n361vj1qd",
+ "4q1xlclmfloku33",
+ "oap640cot4yru2s", // no rels
+ },
+ []string{"rel"},
+ nil,
+ 2,
+ 0,
+ },
+ {
+ "maxExpandDepth reached",
+ "demo4",
+ []string{"qzaqccwrmva4o1n"},
+ []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 6,
+ 0,
+ },
+ {
+ "simple back single relation field expand (deprecated syntax)",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{"demo4(rel_one_no_cascade_required)"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "simple back expand via single relation field",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{"demo4_via_rel_one_no_cascade_required"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "nested back expand via single relation field",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{
+ "demo4_via_rel_one_no_cascade_required.self_rel_many.self_rel_many.self_rel_one",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 5,
+ 0,
+ },
+ {
+ "nested back expand via multiple relation field",
+ "demo3",
+ []string{"lcl9d87w22ml6jy"},
+ []string{
+ "demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 7,
+ 0,
+ },
+ {
+ "expand multiple relations sharing a common path",
+ "demo4",
+ []string{"qzaqccwrmva4o1n"},
+ []string{
+ "rel_one_no_cascade",
+ "rel_many_no_cascade",
+ "self_rel_many.self_rel_one.rel_many_cascade",
+ "self_rel_many.self_rel_one.rel_many_no_cascade_required",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 5,
+ 0,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ ids := list.ToUniqueStringSlice(s.recordIds)
+ records, _ := app.FindRecordsByIds(s.collectionIdOrName, ids)
+ failed := app.ExpandRecords(records, s.expands, s.fetchFunc)
+
+ if len(failed) != s.expectExpandFailures {
+ t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed)
+ }
+
+ encoded, _ := json.Marshal(records, json.Deterministic(true))
+ encodedStr := string(encoded)
+ totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`)
+ totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`)
+ totalNonemptyExpands := totalExpandProps - totalEmptyExpands
+
+ if s.expectNonemptyExpandProps != totalNonemptyExpands {
+ t.Errorf("Expected %d expand props, got %d\n%v", s.expectNonemptyExpandProps, totalNonemptyExpands, encodedStr)
+ }
+ })
+ }
+}
+
+func TestExpandRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ testName string
+ collectionIdOrName string
+ recordId string
+ expands []string
+ fetchFunc core.ExpandFetchFunc
+ expectNonemptyExpandProps int
+ expectExpandFailures int
+ }{
+ {
+ "empty expand",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 0,
+ },
+ {
+ "fetchFunc with error",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"self_rel_one", "self_rel_many.self_rel_one"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return nil, errors.New("test error")
+ },
+ 0,
+ 2,
+ },
+ {
+ "missing relation field",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"missing"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "existing, but non-relation type field",
+ "demo4",
+ "i9naidtvr6qsgb4",
+ []string{"title"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "invalid/missing second level expand",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{"rel_one_no_cascade.title"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 1,
+ },
+ {
+ "expand normalizations",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{
+ "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
+ "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
+ "self_rel_many", "self_rel_many.",
+ " self_rel_many ", "",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 8,
+ 0,
+ },
+ {
+ "no rels to expand",
+ "users",
+ "oap640cot4yru2s",
+ []string{"rel"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 0,
+ 0,
+ },
+ {
+ "maxExpandDepth reached",
+ "demo4",
+ "qzaqccwrmva4o1n",
+ []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 6,
+ 0,
+ },
+ {
+ "simple indirect expand via single relation field (deprecated syntax)",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{"demo4(rel_one_no_cascade_required)"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "simple indirect expand via single relation field",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{"demo4_via_rel_one_no_cascade_required"},
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 1,
+ 0,
+ },
+ {
+ "nested indirect expand via single relation field",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{
+ "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 5,
+ 0,
+ },
+ {
+ "nested indirect expand via single relation field",
+ "demo3",
+ "lcl9d87w22ml6jy",
+ []string{
+ "demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
+ },
+ func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ },
+ 7,
+ 0,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ record, _ := app.FindRecordById(s.collectionIdOrName, s.recordId)
+ failed := app.ExpandRecord(record, s.expands, s.fetchFunc)
+
+ if len(failed) != s.expectExpandFailures {
+ t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed)
+ }
+
+ encoded, _ := json.Marshal(record, json.Deterministic(true))
+ encodedStr := string(encoded)
+ totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`)
+ totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`)
+ totalNonemptyExpands := totalExpandProps - totalEmptyExpands
+
+ if s.expectNonemptyExpandProps != totalNonemptyExpands {
+ t.Errorf("Expected %d expand props, got %d\n%v", s.expectNonemptyExpandProps, totalNonemptyExpands, encodedStr)
+ }
+ })
+ }
+}
+
+func TestBackRelationExpandSingeVsArrayResult(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ record, err := app.FindRecordById("demo3", "7nwo8tuiatetxdm")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // non-unique indirect expand
+ {
+ errs := app.ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ })
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+
+ result, ok := record.Expand()["demo4_via_rel_one_cascade"].([]*core.Record)
+ if !ok {
+ t.Fatalf("Expected the expanded result to be a slice, got %v", result)
+ }
+ }
+
+ // unique indirect expand
+ {
+ // mock a unique constraint for the rel_one_cascade field
+ // ---
+ demo4, err := app.FindCollectionByNameOrId("demo4")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
+
+ if err := app.Save(demo4); err != nil {
+ t.Fatalf("Failed to mock unique constraint: %v", err)
+ }
+ // ---
+
+ errs := app.ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *core.Collection, ids []string) ([]*core.Record, error) {
+ return app.FindRecordsByIds(c.Id, ids, nil)
+ })
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+
+ result, ok := record.Expand()["demo4_via_rel_one_cascade"].(*core.Record)
+ if !ok {
+ t.Fatalf("Expected the expanded result to be a single model, got %v", result)
+ }
+ }
+}
+
+func TestExpandRecordsQuerySkipDuplicatedIds(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // fetch records that are known to have at least 1 common relation between them
+ records, err := app.FindRecordsByIds("demo1", []string{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // log selects
+ concurrentQueries := []string{}
+ app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+ app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
+ concurrentQueries = append(concurrentQueries, sql)
+ }
+
+ // expand
+ failed := app.ExpandRecords(records, []string{"rel_many"}, nil)
+ if len(failed) > 0 {
+ t.Fatalf("Expected no expand errors, got %v", failed)
+ }
+
+ if len(concurrentQueries) != 1 {
+ t.Fatalf("Expected exactly 1 expand query, got %d:\n%v", len(concurrentQueries), concurrentQueries)
+ }
+
+ // "oap640cot4yru2s" is used in both relations but must exists only once
+ expected := "SELECT `users`.* FROM `users` WHERE `users`.`id` IN ('oap640cot4yru2s', 'bgs820n361vj1qd', '4q1xlclmfloku33')"
+ if concurrentQueries[0] != expected {
+ t.Fatalf("Expected query\n%v\ngot\n%v", expected, concurrentQueries[0])
+ }
+}
diff --git a/core/record_query_test.go b/core/record_query_test.go
new file mode 100644
index 0000000000..02a9f40808
--- /dev/null
+++ b/core/record_query_test.go
@@ -0,0 +1,1204 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ collection any
+ expectedTotal int
+ expectError bool
+ }{
+ {"with nil value", nil, 0, true},
+ {"with invalid or missing collection id/name", "missing", 0, true},
+ {"with pointer model", collection, 3, false},
+ {"with value model", *collection, 3, false},
+ {"with name", "demo1", 3, false},
+ {"with id", "wsmn24bux7wo113", 3, false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ var records []*core.Record
+ err := app.RecordQuery(s.collection).All(&records)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasError %v, got %v", s.expectError, hasErr)
+ }
+
+ if total := len(records); total != s.expectedTotal {
+ t.Fatalf("Expected %d records, got %d", s.expectedTotal, total)
+ }
+ })
+ }
+}
+
+func TestRecordQueryOne(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ collection string
+ recordId string
+ model any
+ }{
+ {
+ "record model",
+ "demo1",
+ "84nmscqy84lsi1t",
+ &core.Record{},
+ },
+ {
+ "record proxy",
+ "demo1",
+ "84nmscqy84lsi1t",
+ &struct {
+ core.BaseRecordProxy
+ }{},
+ },
+ {
+ "custom struct",
+ "demo1",
+ "84nmscqy84lsi1t",
+ &struct {
+ Id string `db:"id" json:"id"`
+ }{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection, err := app.FindCollectionByNameOrId(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ q := app.RecordQuery(collection).
+ Where(dbx.HashExp{"id": s.recordId})
+
+ if err := q.One(s.model); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(s.model, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ if !strings.Contains(rawStr, fmt.Sprintf(`"id":%q`, s.recordId)) {
+ t.Fatalf("Missing id %q in\n%s", s.recordId, rawStr)
+ }
+ })
+ }
+}
+
+func TestRecordQueryAll(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ type customStructs struct {
+ Id string `db:"id" json:"id"`
+ }
+
+ type mockRecordProxy struct {
+ core.BaseRecordProxy
+ }
+
+ scenarios := []struct {
+ name string
+ collection string
+ recordIds []any
+ result any
+ }{
+ {
+ "slice of Record models",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]core.Record{},
+ },
+ {
+ "slice of pointer Record models",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]*core.Record{},
+ },
+ {
+ "slice of Record proxies",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]mockRecordProxy{},
+ },
+ {
+ "slice of pointer Record proxies",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]mockRecordProxy{},
+ },
+ {
+ "slice of custom structs",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]customStructs{},
+ },
+ {
+ "slice of pointer custom structs",
+ "demo1",
+ []any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
+ &[]customStructs{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ collection, err := app.FindCollectionByNameOrId(s.collection)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ q := app.RecordQuery(collection).
+ Where(dbx.HashExp{"id": s.recordIds})
+
+ if err := q.All(s.result); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, err := json.Marshal(s.result, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawStr := string(raw)
+
+ sliceOfMaps := []any{}
+ if err := json.Unmarshal(raw, &sliceOfMaps); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(sliceOfMaps) != len(s.recordIds) {
+ t.Fatalf("Expected %d items, got %d", len(s.recordIds), len(sliceOfMaps))
+ }
+
+ for _, id := range s.recordIds {
+ if !strings.Contains(rawStr, fmt.Sprintf(`"id":%q`, id)) {
+ t.Fatalf("Missing id %q in\n%s", id, rawStr)
+ }
+ }
+ })
+ }
+}
+
+func TestFindRecordById(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ id string
+ filters []func(q *dbx.SelectQuery) error
+ expectError bool
+ }{
+ {"demo2", "missing", nil, true},
+ {"missing", "0yxhwia2amd8gec", nil, true},
+ {"demo2", "0yxhwia2amd8gec", nil, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{}, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{nil, nil}, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ nil,
+ func(q *dbx.SelectQuery) error { return nil },
+ }, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "missing"})
+ return nil
+ },
+ }, true},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ return errors.New("test error")
+ },
+ }, true},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ },
+ }, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ },
+ nil,
+ }, false},
+ {"demo2", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ },
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": false})
+ return nil
+ },
+ }, true},
+ {"sz5l5z67tg7gku0", "0yxhwia2amd8gec", []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"title": "test3"})
+ return nil
+ },
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": true})
+ return nil
+ },
+ }, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s_%d", i, s.collectionIdOrName, s.id, len(s.filters)), func(t *testing.T) {
+ record, err := app.FindRecordById(
+ s.collectionIdOrName,
+ s.id,
+ s.filters...,
+ )
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if record != nil && record.Id != s.id {
+ t.Fatalf("Expected record with id %s, got %s", s.id, record.Id)
+ }
+ })
+ }
+}
+
+func TestFindRecordsByIds(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ ids []string
+ filters []func(q *dbx.SelectQuery) error
+ expectTotal int
+ expectError bool
+ }{
+ {"demo2", []string{}, nil, 0, false},
+ {"demo2", []string{""}, nil, 0, false},
+ {"demo2", []string{"missing"}, nil, 0, false},
+ {"missing", []string{"0yxhwia2amd8gec"}, nil, 0, true},
+ {"demo2", []string{"0yxhwia2amd8gec"}, nil, 1, false},
+ {"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, 1, false},
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ nil,
+ 2,
+ false,
+ },
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{},
+ 2,
+ false,
+ },
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{nil, nil},
+ 2,
+ false,
+ },
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ return nil // empty filter
+ },
+ },
+ 2,
+ false,
+ },
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ return nil // empty filter
+ },
+ func(q *dbx.SelectQuery) error {
+ return errors.New("test error")
+ },
+ },
+ 0,
+ true,
+ },
+ {
+ "demo2",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": true})
+ return nil
+ },
+ nil,
+ },
+ 1,
+ false,
+ },
+ {
+ "sz5l5z67tg7gku0",
+ []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
+ []func(q *dbx.SelectQuery) error{
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.HashExp{"active": true})
+ return nil
+ },
+ func(q *dbx.SelectQuery) error {
+ q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
+ return nil
+ },
+ },
+ 1,
+ false,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%v_%d", i, s.collectionIdOrName, s.ids, len(s.filters)), func(t *testing.T) {
+ records, err := app.FindRecordsByIds(
+ s.collectionIdOrName,
+ s.ids,
+ s.filters...,
+ )
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if len(records) != s.expectTotal {
+ t.Fatalf("Expected %d records, got %d", s.expectTotal, len(records))
+ }
+
+ for _, r := range records {
+ if !slices.Contains(s.ids, r.Id) {
+ t.Fatalf("Couldn't find id %s in %v", r.Id, s.ids)
+ }
+ }
+ })
+ }
+}
+
+func TestFindAllRecords(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ expressions []dbx.Expression
+ expectIds []string
+ expectError bool
+ }{
+ {
+ "missing",
+ nil,
+ []string{},
+ true,
+ },
+ {
+ "demo2",
+ nil,
+ []string{
+ "achvryl401bhse3",
+ "llvuca81nly1qls",
+ "0yxhwia2amd8gec",
+ },
+ false,
+ },
+ {
+ "demo2",
+ []dbx.Expression{
+ nil,
+ dbx.HashExp{"id": "123"},
+ },
+ []string{},
+ false,
+ },
+ {
+ "sz5l5z67tg7gku0",
+ []dbx.Expression{
+ dbx.Like("title", "test").Match(true, true),
+ dbx.HashExp{"active": true},
+ },
+ []string{
+ "achvryl401bhse3",
+ "0yxhwia2amd8gec",
+ },
+ false,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, s.collectionIdOrName), func(t *testing.T) {
+ records, err := app.FindAllRecords(s.collectionIdOrName, s.expressions...)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if len(records) != len(s.expectIds) {
+ t.Fatalf("Expected %d records, got %d", len(s.expectIds), len(records))
+ }
+
+ for _, r := range records {
+ if !slices.Contains(s.expectIds, r.Id) {
+ t.Fatalf("Couldn't find id %s in %v", r.Id, s.expectIds)
+ }
+ }
+ })
+ }
+}
+
+func TestFindFirstRecordByData(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ key string
+ value any
+ expectId string
+ expectError bool
+ }{
+ {
+ "missing",
+ "id",
+ "llvuca81nly1qls",
+ "llvuca81nly1qls",
+ true,
+ },
+ {
+ "demo2",
+ "",
+ "llvuca81nly1qls",
+ "",
+ true,
+ },
+ {
+ "demo2",
+ "invalid_or_missing",
+ "llvuca81nly1qls",
+ "",
+ true,
+ },
+ {
+ "demo2",
+ "id",
+ "invalid",
+ "",
+ true,
+ },
+ {
+ "demo2",
+ "id",
+ "llvuca81nly1qls",
+ "llvuca81nly1qls",
+ false,
+ },
+ {
+ "sz5l5z67tg7gku0",
+ "title",
+ "test3",
+ "0yxhwia2amd8gec",
+ false,
+ },
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s_%v", i, s.collectionIdOrName, s.key, s.value), func(t *testing.T) {
+ record, err := app.FindFirstRecordByData(s.collectionIdOrName, s.key, s.value)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if !s.expectError && record.Id != s.expectId {
+ t.Fatalf("Expected record with id %s, got %v", s.expectId, record.Id)
+ }
+ })
+ }
+}
+
+func TestFindRecordsByFilter(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ collectionIdOrName string
+ filter string
+ sort string
+ limit int
+ offset int
+ params []dbx.Params
+ expectError bool
+ expectRecordIds []string
+ }{
+ {
+ "missing collection",
+ "missing",
+ "id != ''",
+ "",
+ 0,
+ 0,
+ nil,
+ true,
+ nil,
+ },
+ {
+ "invalid filter",
+ "demo2",
+ "someMissingField > 1",
+ "",
+ 0,
+ 0,
+ nil,
+ true,
+ nil,
+ },
+ {
+ "empty filter",
+ "demo2",
+ "",
+ "",
+ 0,
+ 0,
+ nil,
+ false,
+ []string{
+ "llvuca81nly1qls",
+ "achvryl401bhse3",
+ "0yxhwia2amd8gec",
+ },
+ },
+ {
+ "simple filter",
+ "demo2",
+ "id != ''",
+ "",
+ 0,
+ 0,
+ nil,
+ false,
+ []string{
+ "llvuca81nly1qls",
+ "achvryl401bhse3",
+ "0yxhwia2amd8gec",
+ },
+ },
+ {
+ "multi-condition filter with sort",
+ "demo2",
+ "id != '' && active=true",
+ "-created,title",
+ -1, // should behave the same as 0
+ 0,
+ nil,
+ false,
+ []string{
+ "0yxhwia2amd8gec",
+ "achvryl401bhse3",
+ },
+ },
+ {
+ "with limit and offset",
+ "sz5l5z67tg7gku0",
+ "id != ''",
+ "title",
+ 2,
+ 1,
+ nil,
+ false,
+ []string{
+ "achvryl401bhse3",
+ "0yxhwia2amd8gec",
+ },
+ },
+ {
+ "with placeholder params",
+ "demo2",
+ "active = {:active}",
+ "",
+ 10,
+ 0,
+ []dbx.Params{{"active": false}},
+ false,
+ []string{
+ "llvuca81nly1qls",
+ },
+ },
+ {
+ "with json filter and sort",
+ "demo4",
+ "json_object != null && json_object.a.b = 'test'",
+ "-json_object.a",
+ 10,
+ 0,
+ []dbx.Params{{"active": false}},
+ false,
+ []string{
+ "i9naidtvr6qsgb4",
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ records, err := app.FindRecordsByFilter(
+ s.collectionIdOrName,
+ s.filter,
+ s.sort,
+ s.limit,
+ s.offset,
+ s.params...,
+ )
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if len(records) != len(s.expectRecordIds) {
+ t.Fatalf("Expected %d records, got %d", len(s.expectRecordIds), len(records))
+ }
+
+ for i, id := range s.expectRecordIds {
+ if id != records[i].Id {
+ t.Fatalf("Expected record with id %q, got %q at index %d", id, records[i].Id, i)
+ }
+ }
+ })
+ }
+}
+
+func TestFindFirstRecordByFilter(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ collectionIdOrName string
+ filter string
+ params []dbx.Params
+ expectError bool
+ expectRecordId string
+ }{
+ {
+ "missing collection",
+ "missing",
+ "id != ''",
+ nil,
+ true,
+ "",
+ },
+ {
+ "invalid filter",
+ "demo2",
+ "someMissingField > 1",
+ nil,
+ true,
+ "",
+ },
+ {
+ "empty filter",
+ "demo2",
+ "",
+ nil,
+ false,
+ "llvuca81nly1qls",
+ },
+ {
+ "valid filter but no matches",
+ "demo2",
+ "id = 'test'",
+ nil,
+ true,
+ "",
+ },
+ {
+ "valid filter and multiple matches",
+ "sz5l5z67tg7gku0",
+ "id != ''",
+ nil,
+ false,
+ "llvuca81nly1qls",
+ },
+ {
+ "with placeholder params",
+ "demo2",
+ "active = {:active}",
+ []dbx.Params{{"active": false}},
+ false,
+ "llvuca81nly1qls",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ record, err := app.FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if record.Id != s.expectRecordId {
+ t.Fatalf("Expected record with id %q, got %q", s.expectRecordId, record.Id)
+ }
+ })
+ }
+}
+
+func TestCountRecords(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ collectionIdOrName string
+ expressions []dbx.Expression
+ expectTotal int64
+ expectError bool
+ }{
+ {
+ "missing collection",
+ "missing",
+ nil,
+ 0,
+ true,
+ },
+ {
+ "valid collection name",
+ "demo2",
+ nil,
+ 3,
+ false,
+ },
+ {
+ "valid collection id",
+ "sz5l5z67tg7gku0",
+ nil,
+ 3,
+ false,
+ },
+ {
+ "nil expression",
+ "demo2",
+ []dbx.Expression{nil},
+ 3,
+ false,
+ },
+ {
+ "no matches",
+ "demo2",
+ []dbx.Expression{
+ nil,
+ dbx.Like("title", "missing"),
+ dbx.HashExp{"active": true},
+ },
+ 0,
+ false,
+ },
+ {
+ "with matches",
+ "demo2",
+ []dbx.Expression{
+ nil,
+ dbx.Like("title", "test"),
+ dbx.HashExp{"active": true},
+ },
+ 2,
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ total, err := app.CountRecords(s.collectionIdOrName, s.expressions...)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if total != s.expectTotal {
+ t.Fatalf("Expected total %d, got %d", s.expectTotal, total)
+ }
+ })
+ }
+}
+
+func TestFindAuthRecordByToken(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ token string
+ types []string
+ expectedId string
+ }{
+ {
+ "empty token",
+ "",
+ nil,
+ "",
+ },
+ {
+ "invalid token",
+ "invalid",
+ nil,
+ "",
+ },
+ {
+ "expired token",
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.2D3tmqPn3vc5LoqqCz8V-iCDVXo9soYiH0d32G7FQT4",
+ nil,
+ "",
+ },
+ {
+ "valid auth token",
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ nil,
+ "4q1xlclmfloku33",
+ },
+ {
+ "valid verification token",
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.5GmuZr4vmwk3Cb_3ZZWNxwbE75KZC-j71xxIPR9AsVw",
+ nil,
+ "dc49k6jgejn40h3",
+ },
+ {
+ "auth token with file type only check",
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ []string{core.TokenTypeFile},
+ "",
+ },
+ {
+ "auth token with file and auth type check",
+ "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
+ []string{core.TokenTypeFile, core.TokenTypeAuth},
+ "4q1xlclmfloku33",
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ record, err := app.FindAuthRecordByToken(s.token, s.types...)
+
+ hasErr := err != nil
+ expectErr := s.expectedId == ""
+ if hasErr != expectErr {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", expectErr, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if record.Id != s.expectedId {
+ t.Fatalf("Expected record with id %q, got %q", s.expectedId, record.Id)
+ }
+ })
+ }
+}
+
+func TestFindAuthRecordByEmail(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ collectionIdOrName string
+ email string
+ nocaseIndex bool
+ expectError bool
+ }{
+ {"missing", "test@example.com", false, true},
+ {"demo2", "test@example.com", false, true},
+ {"users", "missing@example.com", false, true},
+ {"users", "test@example.com", false, false},
+ {"clients", "test2@example.com", false, false},
+ // case-insensitive tests
+ {"clients", "TeSt2@example.com", false, true},
+ {"clients", "TeSt2@example.com", true, false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ collection, _ := app.FindCollectionByNameOrId(s.collectionIdOrName)
+ if collection != nil {
+ emailIndex, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, core.FieldNameEmail)
+ if ok {
+ if s.nocaseIndex {
+ emailIndex.Columns[0].Collate = "nocase"
+ } else {
+ emailIndex.Columns[0].Collate = ""
+ }
+
+ collection.RemoveIndex(emailIndex.IndexName)
+ collection.Indexes = append(collection.Indexes, emailIndex.Build())
+ err := app.Save(collection)
+ if err != nil {
+ t.Fatalf("Failed to update email index: %v", err)
+ }
+ }
+ }
+
+ record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if !strings.EqualFold(record.Email(), s.email) {
+ t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
+ }
+ })
+ }
+}
+
+func TestCanAccessRecord(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record, err := app.FindRecordById("demo1", "imy661ixudk5izi")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ name string
+ record *core.Record
+ requestInfo *core.RequestInfo
+ rule *string
+ expected bool
+ expectError bool
+ }{
+ {
+ "as superuser with nil rule",
+ record,
+ &core.RequestInfo{
+ Auth: superuser,
+ },
+ nil,
+ true,
+ false,
+ },
+ {
+ "as superuser with non-empty rule",
+ record,
+ &core.RequestInfo{
+ Auth: superuser,
+ },
+ types.Pointer("id = ''"), // the filter rule should be ignored
+ true,
+ false,
+ },
+ {
+ "as superuser with invalid rule",
+ record,
+ &core.RequestInfo{
+ Auth: superuser,
+ },
+ types.Pointer("id ?!@ 1"), // the filter rule should be ignored
+ true,
+ false,
+ },
+ {
+ "as guest with nil rule",
+ record,
+ &core.RequestInfo{},
+ nil,
+ false,
+ false,
+ },
+ {
+ "as guest with empty rule",
+ record,
+ &core.RequestInfo{},
+ types.Pointer(""),
+ true,
+ false,
+ },
+ {
+ "as guest with invalid rule",
+ record,
+ &core.RequestInfo{},
+ types.Pointer("id ?!@ 1"),
+ false,
+ true,
+ },
+ {
+ "as guest with mismatched rule",
+ record,
+ &core.RequestInfo{},
+ types.Pointer("@request.auth.id != ''"),
+ false,
+ false,
+ },
+ {
+ "as guest with matched rule",
+ record,
+ &core.RequestInfo{
+ Body: map[string]any{"test": 1},
+ },
+ types.Pointer("@request.auth.id != '' || @request.body.test = 1"),
+ true,
+ false,
+ },
+ {
+ "as auth record with nil rule",
+ record,
+ &core.RequestInfo{
+ Auth: user,
+ },
+ nil,
+ false,
+ false,
+ },
+ {
+ "as auth record with empty rule",
+ record,
+ &core.RequestInfo{
+ Auth: user,
+ },
+ types.Pointer(""),
+ true,
+ false,
+ },
+ {
+ "as auth record with invalid rule",
+ record,
+ &core.RequestInfo{
+ Auth: user,
+ },
+ types.Pointer("id ?!@ 1"),
+ false,
+ true,
+ },
+ {
+ "as auth record with mismatched rule",
+ record,
+ &core.RequestInfo{
+ Auth: user,
+ Body: map[string]any{"test": 1},
+ },
+ types.Pointer("@request.auth.id != '' && @request.body.test > 1"),
+ false,
+ false,
+ },
+ {
+ "as auth record with matched rule",
+ record,
+ &core.RequestInfo{
+ Auth: user,
+ Body: map[string]any{"test": 2},
+ },
+ types.Pointer("@request.auth.id != '' && @request.body.test > 1"),
+ true,
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result, err := app.CanAccessRecord(s.record, s.requestInfo, s.rule)
+
+ if result != s.expected {
+ t.Fatalf("Expected %v, got %v", s.expected, result)
+ }
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
diff --git a/core/record_tokens.go b/core/record_tokens.go
new file mode 100644
index 0000000000..0eead2b1f0
--- /dev/null
+++ b/core/record_tokens.go
@@ -0,0 +1,165 @@
+package core
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+// Supported record token types
+const (
+ TokenTypeAuth = "auth"
+ TokenTypeFile = "file"
+ TokenTypeVerification = "verification"
+ TokenTypePasswordReset = "passwordReset"
+ TokenTypeEmailChange = "emailChange"
+)
+
+// List with commonly used record token claims
+const (
+ TokenClaimId = "id"
+ TokenClaimType = "type"
+ TokenClaimCollectionId = "collectionId"
+ TokenClaimEmail = "email"
+ TokenClaimNewEmail = "newEmail"
+ TokenClaimRefreshable = "refreshable"
+)
+
+// Common token related errors
+var (
+ ErrNotAuthRecord = errors.New("not an auth collection record")
+ ErrMissingSigningKey = errors.New("missing or invalid signing key")
+)
+
+// NewStaticAuthToken generates and returns a new static record authentication token.
+//
+// Static auth tokens are similar to the regular auth tokens, but are
+// non-refreshable and support custom duration.
+//
+// Zero or negative duration will fallback to the duration from the auth collection settings.
+func (m *Record) NewStaticAuthToken(duration time.Duration) (string, error) {
+ return m.newAuthToken(duration, false)
+}
+
+// NewAuthToken generates and returns a new record authentication token.
+func (m *Record) NewAuthToken() (string, error) {
+ return m.newAuthToken(0, true)
+}
+
+func (m *Record) newAuthToken(duration time.Duration, refreshable bool) (string, error) {
+ if !m.Collection().IsAuth() {
+ return "", ErrNotAuthRecord
+ }
+
+ key := (m.TokenKey() + m.Collection().AuthToken.Secret)
+ if key == "" {
+ return "", ErrMissingSigningKey
+ }
+
+ claims := jwt.MapClaims{
+ TokenClaimType: TokenTypeAuth,
+ TokenClaimId: m.Id,
+ TokenClaimCollectionId: m.Collection().Id,
+ TokenClaimRefreshable: refreshable,
+ }
+
+ if duration <= 0 {
+ duration = m.Collection().AuthToken.DurationTime()
+ }
+
+ return security.NewJWT(claims, key, duration)
+}
+
+// NewVerificationToken generates and returns a new record verification token.
+func (m *Record) NewVerificationToken() (string, error) {
+ if !m.Collection().IsAuth() {
+ return "", ErrNotAuthRecord
+ }
+
+ key := (m.TokenKey() + m.Collection().VerificationToken.Secret)
+ if key == "" {
+ return "", ErrMissingSigningKey
+ }
+
+ return security.NewJWT(
+ jwt.MapClaims{
+ TokenClaimType: TokenTypeVerification,
+ TokenClaimId: m.Id,
+ TokenClaimCollectionId: m.Collection().Id,
+ TokenClaimEmail: m.Email(),
+ },
+ key,
+ m.Collection().VerificationToken.DurationTime(),
+ )
+}
+
+// NewPasswordResetToken generates and returns a new auth record password reset request token.
+func (m *Record) NewPasswordResetToken() (string, error) {
+ if !m.Collection().IsAuth() {
+ return "", ErrNotAuthRecord
+ }
+
+ key := (m.TokenKey() + m.Collection().PasswordResetToken.Secret)
+ if key == "" {
+ return "", ErrMissingSigningKey
+ }
+
+ return security.NewJWT(
+ jwt.MapClaims{
+ TokenClaimType: TokenTypePasswordReset,
+ TokenClaimId: m.Id,
+ TokenClaimCollectionId: m.Collection().Id,
+ TokenClaimEmail: m.Email(),
+ },
+ key,
+ m.Collection().PasswordResetToken.DurationTime(),
+ )
+}
+
+// NewEmailChangeToken generates and returns a new auth record change email request token.
+func (m *Record) NewEmailChangeToken(newEmail string) (string, error) {
+ if !m.Collection().IsAuth() {
+ return "", ErrNotAuthRecord
+ }
+
+ key := (m.TokenKey() + m.Collection().EmailChangeToken.Secret)
+ if key == "" {
+ return "", ErrMissingSigningKey
+ }
+
+ return security.NewJWT(
+ jwt.MapClaims{
+ TokenClaimType: TokenTypeEmailChange,
+ TokenClaimId: m.Id,
+ TokenClaimCollectionId: m.Collection().Id,
+ TokenClaimEmail: m.Email(),
+ TokenClaimNewEmail: newEmail,
+ },
+ key,
+ m.Collection().EmailChangeToken.DurationTime(),
+ )
+}
+
+// NewFileToken generates and returns a new record private file access token.
+func (m *Record) NewFileToken() (string, error) {
+ if !m.Collection().IsAuth() {
+ return "", ErrNotAuthRecord
+ }
+
+ key := (m.TokenKey() + m.Collection().FileToken.Secret)
+ if key == "" {
+ return "", ErrMissingSigningKey
+ }
+
+ return security.NewJWT(
+ jwt.MapClaims{
+ TokenClaimType: TokenTypeFile,
+ TokenClaimId: m.Id,
+ TokenClaimCollectionId: m.Collection().Id,
+ },
+ key,
+ m.Collection().FileToken.DurationTime(),
+ )
+}
diff --git a/core/record_tokens_test.go b/core/record_tokens_test.go
new file mode 100644
index 0000000000..34ad909a04
--- /dev/null
+++ b/core/record_tokens_test.go
@@ -0,0 +1,176 @@
+package core_test
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/spf13/cast"
+)
+
+func TestNewStaticAuthToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypeAuth, func(record *core.Record) (string, error) {
+ return record.NewStaticAuthToken(0)
+ }, map[string]any{
+ core.TokenClaimRefreshable: false,
+ })
+}
+
+func TestNewStaticAuthTokenWithCustomDuration(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var tolerance int64 = 1 // in sec
+
+ durations := []int64{-100, 0, 100}
+
+ for i, d := range durations {
+ t.Run(fmt.Sprintf("%d_%d", i, d), func(t *testing.T) {
+ now := time.Now()
+
+ duration := time.Duration(d) * time.Second
+
+ token, err := user.NewStaticAuthToken(duration)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ claims, err := security.ParseUnverifiedJWT(token)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ exp := cast.ToInt64(claims["exp"])
+
+ expectedDuration := duration
+ // should fallback to the collection setting
+ if expectedDuration <= 0 {
+ expectedDuration = user.Collection().AuthToken.DurationTime()
+ }
+ expectedMinExp := now.Add(expectedDuration).Unix() - tolerance
+ expectedMaxExp := now.Add(expectedDuration).Unix() + tolerance
+
+ if exp < expectedMinExp {
+ t.Fatalf("Expected token exp to be greater than %d, got %d", expectedMinExp, exp)
+ }
+
+ if exp > expectedMaxExp {
+ t.Fatalf("Expected token exp to be less than %d, got %d", expectedMaxExp, exp)
+ }
+ })
+ }
+}
+
+func TestNewAuthToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypeAuth, func(record *core.Record) (string, error) {
+ return record.NewAuthToken()
+ }, map[string]any{
+ core.TokenClaimRefreshable: true,
+ })
+}
+
+func TestNewVerificationToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypeVerification, func(record *core.Record) (string, error) {
+ return record.NewVerificationToken()
+ }, nil)
+}
+
+func TestNewPasswordResetToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypePasswordReset, func(record *core.Record) (string, error) {
+ return record.NewPasswordResetToken()
+ }, nil)
+}
+
+func TestNewEmailChangeToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypeEmailChange, func(record *core.Record) (string, error) {
+ return record.NewEmailChangeToken("new@example.com")
+ }, nil)
+}
+
+func TestNewFileToken(t *testing.T) {
+ t.Parallel()
+
+ testRecordToken(t, core.TokenTypeFile, func(record *core.Record) (string, error) {
+ return record.NewFileToken()
+ }, nil)
+}
+
+func testRecordToken(
+ t *testing.T,
+ tokenType string,
+ tokenFunc func(record *core.Record) (string, error),
+ expectedClaims map[string]any,
+) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ demo1, err := app.FindRecordById("demo1", "84nmscqy84lsi1t")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ user, err := app.FindAuthRecordByEmail("users", "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("non-auth record", func(t *testing.T) {
+ _, err = tokenFunc(demo1)
+ if err == nil {
+ t.Fatal("Expected error for non-auth records")
+ }
+ })
+
+ t.Run("auth record", func(t *testing.T) {
+ token, err := tokenFunc(user)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tokenRecord, _ := app.FindAuthRecordByToken(token, tokenType)
+ if tokenRecord == nil || tokenRecord.Id != user.Id {
+ t.Fatalf("Expected auth record\n%v\ngot\n%v", user, tokenRecord)
+ }
+
+ if len(expectedClaims) > 0 {
+ claims, _ := security.ParseUnverifiedJWT(token)
+ for k, v := range expectedClaims {
+ if claims[k] != v {
+ t.Errorf("Expected claim %q with value %#v, got %#v", k, v, claims[k])
+ }
+ }
+ }
+ })
+
+ t.Run("empty signing key", func(t *testing.T) {
+ user.SetTokenKey("")
+ collection := user.Collection()
+ *collection = core.Collection{}
+ collection.Type = core.CollectionTypeAuth
+
+ _, err := tokenFunc(user)
+ if err == nil {
+ t.Fatal("Expected empty signing key error")
+ }
+ })
+}
diff --git a/core/settings_model.go b/core/settings_model.go
new file mode 100644
index 0000000000..5344fbd795
--- /dev/null
+++ b/core/settings_model.go
@@ -0,0 +1,742 @@
+package core
+
+import (
+ "context"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/cron"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/mailer"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+const (
+ paramsTable = "_params"
+
+ paramsKeySettings = "settings"
+
+ systemHookIdSettings = "__pbSettingsSystemHook__"
+)
+
+func (app *BaseApp) registerSettingsHooks() {
+ saveFunc := func(me *ModelEvent) error {
+ if err := me.Next(); err != nil {
+ return err
+ }
+
+ if me.Model.PK() == paramsKeySettings {
+ // auto reload the app settings because we don't know whether
+ // the Settings model is the app one or a different one
+ return errors.Join(
+ me.App.Settings().PostScan(),
+ me.App.ReloadSettings(),
+ )
+ }
+
+ return nil
+ }
+
+ app.OnModelAfterCreateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdSettings,
+ Func: saveFunc,
+ Priority: -999,
+ })
+
+ app.OnModelAfterUpdateSuccess(paramsTable).Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdSettings,
+ Func: saveFunc,
+ Priority: -999,
+ })
+
+ app.OnModelDelete(paramsTable).Bind(&hook.Handler[*ModelEvent]{
+ Id: systemHookIdSettings,
+ Func: func(me *ModelEvent) error {
+ if me.Model.PK() == paramsKeySettings {
+ return errors.New("the app params settings cannot be deleted")
+ }
+
+ return me.Next()
+ },
+ Priority: -999,
+ })
+
+ app.OnCollectionUpdate().Bind(&hook.Handler[*CollectionEvent]{
+ Id: systemHookIdSettings,
+ Func: func(e *CollectionEvent) error {
+ oldCollection, err := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
+ if err != nil {
+ return fmt.Errorf("failed to retrieve old cached collection: %w", err)
+ }
+
+ err = e.Next()
+ if err != nil {
+ return err
+ }
+
+ // update existing rate limit rules on collection rename
+ if oldCollection.Name != e.Collection.Name {
+ var hasChange bool
+
+ rules := e.App.Settings().RateLimits.Rules
+ for i := 0; i < len(rules); i++ {
+ if strings.HasPrefix(rules[i].Label, oldCollection.Name+":") {
+ rules[i].Label = strings.Replace(rules[i].Label, oldCollection.Name+":", e.Collection.Name+":", 1)
+ hasChange = true
+ }
+ }
+
+ if hasChange {
+ e.App.Settings().RateLimits.Rules = rules
+ err = e.App.Save(e.App.Settings())
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ },
+ Priority: 99,
+ })
+}
+
+var (
+ _ Model = (*Settings)(nil)
+ _ PostValidator = (*Settings)(nil)
+ _ DBExporter = (*Settings)(nil)
+)
+
+type settings struct {
+ // SuperuserIPs defines an optional list of the superuser allowed
+ // individual IPs and subnets (in CIDR notation).
+ SuperuserIPs []string `form:"superuserIPs" json:"superuserIPs"`
+
+ SMTP SMTPConfig `form:"smtp" json:"smtp"`
+ Backups BackupsConfig `form:"backups" json:"backups"`
+ S3 S3Config `form:"s3" json:"s3"`
+ Meta MetaConfig `form:"meta" json:"meta"`
+ RateLimits RateLimitsConfig `form:"rateLimits" json:"rateLimits"`
+ TrustedProxy TrustedProxyConfig `form:"trustedProxy" json:"trustedProxy"`
+ Batch BatchConfig `form:"batch" json:"batch"`
+ Logs LogsConfig `form:"logs" json:"logs"`
+}
+
+// Settings defines the PocketBase app settings.
+type Settings struct {
+ settings
+
+ mu sync.RWMutex
+ isNew bool
+}
+
+func newDefaultSettings() *Settings {
+ return &Settings{
+ isNew: true,
+ settings: settings{
+ Meta: MetaConfig{
+ AccentColor: "#1055c9",
+ AppName: "Acme",
+ AppURL: "http://localhost:8090",
+ HideControls: false,
+ SenderName: "Support",
+ SenderAddress: "support@example.com",
+ },
+ Logs: LogsConfig{
+ MaxDays: 5,
+ LogIP: true,
+ },
+ SMTP: SMTPConfig{
+ Enabled: false,
+ Host: "smtp.example.com",
+ Port: 587,
+ Username: "",
+ Password: "",
+ TLS: false,
+ },
+ Backups: BackupsConfig{
+ CronMaxKeep: 3,
+ },
+ Batch: BatchConfig{
+ Enabled: false,
+ MaxRequests: 50,
+ Timeout: 3,
+ },
+ RateLimits: RateLimitsConfig{
+ Enabled: false, // @todo once tested enough enable by default for new installations
+ Rules: []RateLimitRule{
+ {Label: "*:auth", MaxRequests: 2, Duration: 3},
+ {Label: "*:create", MaxRequests: 20, Duration: 5},
+ {Label: "/api/batch", MaxRequests: 3, Duration: 1},
+ {Label: "/api/", MaxRequests: 300, Duration: 10},
+ },
+ },
+ },
+ }
+}
+
+// TableName implements [Model.TableName] interface method.
+func (s *Settings) TableName() string {
+ return paramsTable
+}
+
+// PK implements [Model.LastSavedPK] interface method.
+func (s *Settings) LastSavedPK() any {
+ return paramsKeySettings
+}
+
+// PK implements [Model.PK] interface method.
+func (s *Settings) PK() any {
+ return paramsKeySettings
+}
+
+// IsNew implements [Model.IsNew] interface method.
+func (s *Settings) IsNew() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return s.isNew
+}
+
+// MarkAsNew implements [Model.MarkAsNew] interface method.
+func (s *Settings) MarkAsNew() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.isNew = true
+}
+
+// MarkAsNew implements [Model.MarkAsNotNew] interface method.
+func (s *Settings) MarkAsNotNew() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.isNew = false
+}
+
+// PostScan implements [Model.PostScan] interface method.
+func (s *Settings) PostScan() error {
+ s.MarkAsNotNew()
+ return nil
+}
+
+// String returns a serialized string representation of the current settings.
+func (s *Settings) String() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ raw, _ := s.MarshalJSON()
+ return string(raw)
+}
+
+// DBExport prepares and exports the current settings for db persistence.
+func (s *Settings) DBExport(app App) (map[string]any, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ now := types.NowDateTime()
+
+ result := map[string]any{
+ "id": s.PK(),
+ }
+
+ if s.IsNew() {
+ result["created"] = now
+ }
+ result["updated"] = now
+
+ encoded, err := json.Marshal(s.settings, json.Deterministic(true))
+ if err != nil {
+ return nil, err
+ }
+
+ encryptionKey := os.Getenv(app.EncryptionEnv())
+ if encryptionKey != "" {
+ encryptVal, encryptErr := security.Encrypt(encoded, encryptionKey)
+ if encryptErr != nil {
+ return nil, encryptErr
+ }
+
+ result["value"] = encryptVal
+ } else {
+ result["value"] = encoded
+ }
+
+ return result, nil
+}
+
+// PostValidate implements the [PostValidator] interface and defines
+// the Settings model validations.
+func (s *Settings) PostValidate(ctx context.Context, app App) error {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return validation.ValidateStructWithContext(ctx, s,
+ validation.Field(&s.SuperuserIPs, validation.Each(validation.Required, validation.By(validators.IPOrSubnet))),
+ validation.Field(&s.Meta),
+ validation.Field(&s.Logs),
+ validation.Field(&s.SMTP),
+ validation.Field(&s.S3),
+ validation.Field(&s.Backups),
+ validation.Field(&s.Batch),
+ validation.Field(&s.RateLimits),
+ validation.Field(&s.TrustedProxy),
+ )
+}
+
+// Merge merges the "other" settings into the current one.
+func (s *Settings) Merge(other *Settings) error {
+ other.mu.RLock()
+ defer other.mu.RUnlock()
+
+ raw, err := json.Marshal(other.settings)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return json.Unmarshal(raw, &s)
+}
+
+// Clone creates a new deep copy of the current settings.
+func (s *Settings) Clone() (*Settings, error) {
+ clone := &Settings{
+ isNew: s.isNew,
+ }
+
+ if err := clone.Merge(s); err != nil {
+ return nil, err
+ }
+
+ return clone, nil
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+//
+// Note that sensitive fields (S3 secret, SMTP password, etc.) are excluded.
+func (s *Settings) MarshalJSON() ([]byte, error) {
+ s.mu.RLock()
+ copy := s.settings
+ s.mu.RUnlock()
+
+ copy.SMTP.hidePassword = true
+
+ sensitiveFields := []*string{
+ ©.SMTP.Password,
+ ©.S3.Secret,
+ ©.Backups.S3.Secret,
+ }
+
+ // mask all sensitive fields
+ for _, v := range sensitiveFields {
+ if v != nil && *v != "" {
+ *v = ""
+ }
+ }
+
+ return json.Marshal(copy, json.Deterministic(true))
+}
+
+// -------------------------------------------------------------------
+
+type SMTPConfig struct {
+ // @todo temp workaround to avoid introducing breaking changes;
+ // consider refactoring and/or normalizing with the other Settings sensitive fields
+ //
+ // hidePassword specifies whether to hide the password field from the struct JSON serialization.
+ hidePassword bool
+
+ Enabled bool `form:"enabled" json:"enabled"`
+ Port int `form:"port" json:"port"`
+ Host string `form:"host" json:"host"`
+ Username string `form:"username" json:"username"`
+ Password string `form:"password" json:"password"`
+
+ // SMTP AUTH - PLAIN (default) or LOGIN
+ AuthMethod string `form:"authMethod" json:"authMethod"`
+
+ // Whether to enforce TLS encryption for the mail server connection.
+ //
+ // When set to false StartTLS command is send, leaving the server
+ // to decide whether to upgrade the connection or not.
+ TLS bool `form:"tls" json:"tls"`
+
+ // LocalName is optional domain name or IP address used for the
+ // EHLO/HELO exchange (if not explicitly set, defaults to "localhost").
+ //
+ // This is required only by some SMTP servers, such as Gmail SMTP-relay.
+ LocalName string `form:"localName" json:"localName"`
+}
+
+// Validate makes SMTPConfig validatable by implementing [validation.Validatable] interface.
+func (c SMTPConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(
+ &c.Host,
+ validation.When(c.Enabled, validation.Required),
+ is.Host,
+ ),
+ validation.Field(
+ &c.Port,
+ validation.When(c.Enabled, validation.Required),
+ validation.Min(0),
+ ),
+ validation.Field(
+ &c.AuthMethod,
+ // don't require it for backward compatibility
+ // (fallback internally to PLAIN)
+ // validation.When(c.Enabled, validation.Required),
+ validation.In(mailer.SMTPAuthLogin, mailer.SMTPAuthPlain),
+ ),
+ validation.Field(&c.LocalName, is.Host),
+ )
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (c SMTPConfig) MarshalJSON() ([]byte, error) {
+ type alias SMTPConfig
+
+ if c.hidePassword {
+ v := struct {
+ alias
+ Password string `json:"password,omitempty"`
+ }{alias(c), ""}
+
+ return json.Marshal(v)
+ }
+
+ return json.Marshal(alias(c))
+}
+
+// -------------------------------------------------------------------
+
+type S3Config struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+ Bucket string `form:"bucket" json:"bucket"`
+ Region string `form:"region" json:"region"`
+ Endpoint string `form:"endpoint" json:"endpoint"`
+ AccessKey string `form:"accessKey" json:"accessKey"`
+ Secret string `form:"secret" json:"secret,omitempty"`
+ ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
+}
+
+// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
+func (c S3Config) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
+ validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
+ validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
+ validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
+ validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type BatchConfig struct {
+ Enabled bool `form:"enabled" json:"enabled"`
+
+ // MaxRequests is the maximum allowed batch request to execute.
+ MaxRequests int `form:"maxRequests" json:"maxRequests"`
+
+ // Timeout is the max duration in seconds to wait before cancelling the batch transaction.
+ Timeout int64 `form:"timeout" json:"timeout"`
+
+ // MaxBodySize is the maximum allowed batch request body size in bytes.
+ //
+ // If not set, fallbacks to max ~128MB.
+ MaxBodySize int64 `form:"maxBodySize" json:"maxBodySize"`
+}
+
+// Validate makes BatchConfig validatable by implementing [validation.Validatable] interface.
+func (c BatchConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.MaxRequests, validation.When(c.Enabled, validation.Required), validation.Min(0)),
+ validation.Field(&c.Timeout, validation.When(c.Enabled, validation.Required), validation.Min(0)),
+ validation.Field(&c.MaxBodySize, validation.Min(0)),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type BackupsConfig struct {
+ // Cron is a cron expression to schedule auto backups, eg. "* * * * *".
+ //
+ // Leave it empty to disable the auto backups functionality.
+ Cron string `form:"cron" json:"cron"`
+
+ // CronMaxKeep is the max number of cron generated backups to
+ // keep before removing older entries.
+ //
+ // This field works only when the cron config has valid cron expression.
+ CronMaxKeep int `form:"cronMaxKeep" json:"cronMaxKeep"`
+
+ // S3 is an optional S3 storage config specifying where to store the app backups.
+ S3 S3Config `form:"s3" json:"s3"`
+}
+
+// Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface.
+func (c BackupsConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.S3),
+ validation.Field(&c.Cron, validation.By(checkCronExpression)),
+ validation.Field(
+ &c.CronMaxKeep,
+ validation.When(c.Cron != "", validation.Required),
+ validation.Min(1),
+ ),
+ )
+}
+
+func checkCronExpression(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ _, err := cron.NewSchedule(v)
+ if err != nil {
+ return validation.NewError("validation_invalid_cron", err.Error())
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+type MetaConfig struct {
+ // @todo experimental
+ //
+ // AccentColor specify the UI "accent" color (HEX).
+ AccentColor string `form:"accentColor" json:"accentColor"`
+
+ AppName string `form:"appName" json:"appName"`
+ AppURL string `form:"appURL" json:"appURL"`
+ SenderName string `form:"senderName" json:"senderName"`
+ SenderAddress string `form:"senderAddress" json:"senderAddress"`
+ HideControls bool `form:"hideControls" json:"hideControls"`
+}
+
+// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
+func (c MetaConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.AccentColor, validation.Length(7, 7), is.HexColor),
+ validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
+ // @todo when replacing the URL validator we may need a system migration to normalize values without protocol
+ validation.Field(&c.AppURL, validation.Required, is.URL),
+ validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
+ validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type LogsConfig struct {
+ // MaxDataSize specifies the maximum allowed serialized log data
+ // size before it gets truncated (see [Log.DBExport]).
+ //
+ // If zero, fallbacks to ~16kb by default.
+ MaxDataSize int64 `form:"maxDataSize" json:"maxDataSize"`
+
+ MaxDays int `form:"maxDays" json:"maxDays"`
+ MinLevel int `form:"minLevel" json:"minLevel"`
+ LogIP bool `form:"logIP" json:"logIP"`
+ LogAuthId bool `form:"logAuthId" json:"logAuthId"`
+}
+
+// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
+func (c LogsConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.MaxDataSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ validation.Field(&c.MaxDays, validation.Min(0), validation.Max(maxSafeJSONInt)),
+ validation.Field(&c.MinLevel, validation.Max(maxSafeJSONInt)),
+ )
+}
+
+// -------------------------------------------------------------------
+
+type TrustedProxyConfig struct {
+ // Headers is a list of explicit trusted header(s) to check.
+ Headers []string `form:"headers" json:"headers"`
+
+ // UseLeftmostIP specifies to use the left-mostish IP from the trusted headers.
+ //
+ // Note that this could be insecure when used with X-Forwarded-For header
+ // because some proxies like AWS ELB allow users to prepend their own header value
+ // before appending the trusted ones.
+ UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
+}
+
+// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
+func (c TrustedProxyConfig) Validate() error {
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+type RateLimitsConfig struct {
+ Rules []RateLimitRule `form:"rules" json:"rules"`
+ ExcludedIPs []string `form:"excludedIPs" json:"excludedIPs"`
+ Enabled bool `form:"enabled" json:"enabled"`
+}
+
+// FindRateLimitRule returns the first matching rule based on the provided labels.
+//
+// Optionally you can further specify a list of valid RateLimitRule.Audience values to further filter the matching rule
+// (aka. the rule Audience will have to exist in one of the specified options).
+func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudience ...string) (RateLimitRule, bool) {
+ var prefixRules []int
+
+ for i, label := range searchLabels {
+ // check for direct match
+ for j := range c.Rules {
+ if label == c.Rules[j].Label &&
+ (len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[j].Audience)) {
+ return c.Rules[j], true
+ }
+
+ if i == 0 && strings.HasSuffix(c.Rules[j].Label, "/") {
+ prefixRules = append(prefixRules, j)
+ }
+ }
+
+ // check for prefix match
+ if len(prefixRules) > 0 {
+ for j := range prefixRules {
+ if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) &&
+ (len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[prefixRules[j]].Audience)) {
+ return c.Rules[prefixRules[j]], true
+ }
+ }
+ }
+ }
+
+ return RateLimitRule{}, false
+}
+
+// Validate makes RateLimitsConfig validatable by implementing [validation.Validatable] interface.
+func (c RateLimitsConfig) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(
+ &c.Rules,
+ validation.When(c.Enabled, validation.Required),
+ validation.By(checkUniqueRuleLabel),
+ ),
+ validation.Field(
+ &c.ExcludedIPs,
+ validation.Each(validation.Required, validation.By(validators.IPOrSubnet)),
+ ),
+ )
+}
+
+func checkUniqueRuleLabel(value any) error {
+ rules, ok := value.([]RateLimitRule)
+ if !ok {
+ return validators.ErrUnsupportedValueType
+ }
+
+ existing := make([]string, 0, len(rules))
+
+ for i, rule := range rules {
+ fullKey := rule.Label + "@@" + rule.Audience
+
+ var conflicts bool
+ for _, key := range existing {
+ if strings.HasPrefix(key, fullKey) || strings.HasPrefix(fullKey, key) {
+ conflicts = true
+ break
+ }
+ }
+
+ if conflicts {
+ return validation.Errors{
+ strconv.Itoa(i): validation.Errors{
+ "label": validation.NewError("validation_conflicting_rate_limit_rule", "Rate limit rule configuration with label {{.label}} already exists or conflicts with another rule.").
+ SetParams(map[string]any{"label": rule.Label}),
+ },
+ }
+ } else {
+ existing = append(existing, fullKey)
+ }
+ }
+
+ return nil
+}
+
+var rateLimitRuleLabelRegex = regexp.MustCompile(`^(\w+\ \/[\w\/-]*|\/[\w\/-]*|^\w+\:\w+|\*\:\w+|\w+)$`)
+
+// The allowed RateLimitRule.Audience values
+const (
+ RateLimitRuleAudienceAll = ""
+ RateLimitRuleAudienceGuest = "@guest"
+ RateLimitRuleAudienceAuth = "@auth"
+)
+
+type RateLimitRule struct {
+ // Label is the identifier of the current rule.
+ //
+ // It could be a tag, complete path or path prerefix (when ends with `/`).
+ //
+ // Example supported labels:
+ // - test_a (plain text "tag")
+ // - users:create
+ // - *:create
+ // - /
+ // - /api
+ // - POST /api/collections/
+ Label string `form:"label" json:"label"`
+
+ // Audience specifies the auth group the rule should apply for:
+ // - "" - both guests and authenticated users (default)
+ // - "@guest" - only for guests
+ // - "@auth" - only for authenticated users
+ Audience string `form:"audience" json:"audience"`
+
+ // Duration specifies the interval (in seconds) per which to reset
+ // the counted/accumulated rate limiter tokens.
+ Duration int64 `form:"duration" json:"duration"`
+
+ // MaxRequests is the max allowed number of requests per Duration.
+ MaxRequests int `form:"maxRequests" json:"maxRequests"`
+}
+
+// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
+func (c RateLimitRule) Validate() error {
+ return validation.ValidateStruct(&c,
+ validation.Field(&c.Label, validation.Required, validation.Match(rateLimitRuleLabelRegex)),
+ validation.Field(&c.MaxRequests, validation.Required, validation.Min(1)),
+ validation.Field(&c.Duration, validation.Required, validation.Min(1)),
+ validation.Field(&c.Audience,
+ validation.In(RateLimitRuleAudienceAll, RateLimitRuleAudienceGuest, RateLimitRuleAudienceAuth),
+ ),
+ )
+}
+
+// DurationTime returns the tag's Duration as [time.Duration].
+func (c RateLimitRule) DurationTime() time.Duration {
+ return time.Duration(c.Duration) * time.Second
+}
+
+// String returns a string representation of the rule.
+func (c RateLimitRule) String() string {
+ raw, err := json.Marshal(c)
+ if err != nil {
+ return c.Label // extremely rare case
+ }
+
+ return string(raw)
+}
diff --git a/core/settings_model_test.go b/core/settings_model_test.go
new file mode 100644
index 0000000000..e5a6747ccf
--- /dev/null
+++ b/core/settings_model_test.go
@@ -0,0 +1,952 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/mailer"
+ "github.com/pocketbase/pocketbase/tools/security"
+)
+
+func TestSettingsDelete(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ err := app.Delete(app.Settings())
+ if err == nil {
+ t.Fatal("Exected settings delete to fail")
+ }
+}
+
+func TestSettings_DBExport(t *testing.T) {
+ scenarios := []struct {
+ name string
+ encryption bool
+ }{
+ {"no encryption", false},
+ {"with encryption", true},
+ }
+
+ encryptionKey := strings.Repeat("a", 32)
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ originalEnv := os.Getenv(app.EncryptionEnv())
+ defer func() {
+ os.Setenv(app.EncryptionEnv(), originalEnv)
+ }()
+
+ settings := &core.Settings{}
+ settings.Meta.AppName = "test_app_name"
+ settings.Logs.MaxDays = 123
+ settings.SMTP.Host = "smtp_host"
+ settings.SMTP.Username = "smtp_username"
+ settings.SMTP.Password = "" // ensures that empty password is exported
+ settings.S3.Endpoint = "s3_endpoint"
+ settings.S3.Secret = "s3_secret"
+ settings.Backups.Cron = "* * * * *"
+ settings.Backups.S3.Enabled = true
+ settings.Backups.S3.Secret = ""
+ settings.Batch.Timeout = 15
+ settings.RateLimits.Enabled = true
+ settings.TrustedProxy.UseLeftmostIP = true
+
+ if s.encryption {
+ os.Setenv(app.EncryptionEnv(), encryptionKey)
+ }
+
+ export, err := settings.DBExport(app)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var valueStr string
+
+ if s.encryption {
+ decrypted, err := security.Decrypt(export["value"].(string), encryptionKey)
+ if err != nil {
+ t.Fatalf("failed to decrypt test value: %v", err)
+ }
+
+ valueStr = string(decrypted)
+ } else {
+ valueStr = string(export["value"].([]byte))
+ }
+
+ expected := `{"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":{"maxDataSize":0,"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
+ if valueStr != expected {
+ t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
+ }
+ })
+ }
+}
+
+func TestSettingsMerge(t *testing.T) {
+ t.Parallel()
+
+ s1 := &core.Settings{}
+ s1.Meta.AppURL = "app_url" // should be unset
+
+ s2 := &core.Settings{}
+ s2.Meta.AppName = "test"
+ s2.Logs.MaxDays = 123
+ s2.SMTP.Host = "test"
+ s2.SMTP.Enabled = true
+ s2.S3.Enabled = true
+ s2.S3.Endpoint = "test"
+ s2.Backups.Cron = "* * * * *"
+ s2.Batch.Timeout = 15
+
+ if err := s1.Merge(s2); err != nil {
+ t.Fatal(err)
+ }
+
+ s1Encoded, err := json.Marshal(s1, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s2Encoded, err := json.Marshal(s2, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(s1Encoded) != string(s2Encoded) {
+ t.Fatalf("Expected the same serialization, got\n%v\nVS\n%v", string(s1Encoded), string(s2Encoded))
+ }
+}
+
+func TestSettingsClone(t *testing.T) {
+ t.Parallel()
+
+ s1 := &core.Settings{}
+ s1.Meta.AppName = "test_name"
+
+ s2, err := s1.Clone()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s1Bytes, err := json.Marshal(s1, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s2Bytes, err := json.Marshal(s2, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(s1Bytes) != string(s2Bytes) {
+ t.Fatalf("Expected equivalent serialization, got %v VS %v", string(s1Bytes), string(s2Bytes))
+ }
+
+ // verify that it is a deep copy
+ s2.Meta.AppName = "new_test_name"
+ if s1.Meta.AppName == s2.Meta.AppName {
+ t.Fatalf("Expected s1 and s2 to have different Meta.AppName, got %s", s1.Meta.AppName)
+ }
+}
+
+func TestSettingsMarshalJSON(t *testing.T) {
+ t.Parallel()
+
+ settings := &core.Settings{}
+
+ // control fields
+ settings.Meta.AppName = "test123"
+ settings.SMTP.Username = "abc"
+
+ // secrets
+ testSecret := "test_secret"
+ settings.SMTP.Password = testSecret
+ settings.S3.Secret = testSecret
+ settings.Backups.S3.Secret = testSecret
+
+ raw, err := json.Marshal(settings, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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":{"maxDataSize":0,"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
+
+ if rawStr != expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
+ }
+}
+
+func TestSettingsValidate(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ s := app.Settings()
+
+ // set invalid settings data
+ s.SuperuserIPs = []string{"127.0.0.1", ""}
+ s.Meta.AppName = ""
+ s.Logs.MaxDays = -10
+ s.SMTP.Enabled = true
+ s.SMTP.Host = ""
+ s.S3.Enabled = true
+ s.S3.Endpoint = "invalid"
+ s.Backups.Cron = "invalid"
+ s.Backups.CronMaxKeep = -10
+ s.Batch.Enabled = true
+ s.Batch.MaxRequests = -1
+ s.Batch.Timeout = -1
+ s.RateLimits.Enabled = true
+ s.RateLimits.Rules = nil
+
+ // check if Validate() is triggering the members validate methods.
+ err := app.Validate(s)
+ if err == nil {
+ t.Fatalf("Expected error, got nil")
+ }
+
+ expectations := []string{
+ `"superuserIPs":{`,
+ `"meta":{`,
+ `"logs":{`,
+ `"smtp":{`,
+ `"s3":{`,
+ `"backups":{`,
+ `"batch":{`,
+ `"rateLimits":{`,
+ }
+
+ errBytes, _ := json.Marshal(err, json.Deterministic(true))
+ jsonErr := string(errBytes)
+ for _, expected := range expectations {
+ if !strings.Contains(jsonErr, expected) {
+ t.Errorf("Expected error key %s in %v", expected, jsonErr)
+ }
+ }
+}
+
+func TestMetaConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.MetaConfig
+ expectedErrors []string
+ }{
+ {
+ "zero values",
+ core.MetaConfig{},
+ []string{
+ "appName",
+ "appURL",
+ "senderName",
+ "senderAddress",
+ },
+ },
+ {
+ "invalid data",
+ core.MetaConfig{
+ AccentColor: "#fff",
+ AppName: strings.Repeat("a", 300),
+ AppURL: "test",
+ SenderName: strings.Repeat("a", 300),
+ SenderAddress: "invalid_email",
+ },
+ []string{
+ "accentColor",
+ "appName",
+ "appURL",
+ "senderName",
+ "senderAddress",
+ },
+ },
+ {
+ "valid data",
+ core.MetaConfig{
+ AccentColor: "#ffffff",
+ AppName: "test",
+ AppURL: "https://example.com",
+ SenderName: "test",
+ SenderAddress: "test@example.com",
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestLogsConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.LogsConfig
+ expectedErrors []string
+ }{
+ {
+ "zero values",
+ core.LogsConfig{},
+ []string{},
+ },
+ {
+ "invalid data",
+ core.LogsConfig{
+ MaxDays: -1,
+ MaxDataSize: -1,
+ },
+ []string{"maxDays", "maxDataSize"},
+ },
+ {
+ "valid data",
+ core.LogsConfig{MaxDays: 2},
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestSMTPConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.SMTPConfig
+ expectedErrors []string
+ }{
+ {
+ "zero values (disabled)",
+ core.SMTPConfig{},
+ []string{},
+ },
+ {
+ "zero values (enabled)",
+ core.SMTPConfig{Enabled: true},
+ []string{"host", "port"},
+ },
+ {
+ "invalid data",
+ core.SMTPConfig{
+ Enabled: true,
+ Host: "test:test:test",
+ Port: -10,
+ LocalName: "invalid!",
+ AuthMethod: "invalid",
+ },
+ []string{"host", "port", "authMethod", "localName"},
+ },
+ {
+ "valid data (no explicit auth method and localName)",
+ core.SMTPConfig{
+ Enabled: true,
+ Host: "example.com",
+ Port: 100,
+ TLS: true,
+ },
+ []string{},
+ },
+ {
+ "valid data (explicit auth method and localName)",
+ core.SMTPConfig{
+ Enabled: true,
+ Host: "example.com",
+ Port: 100,
+ AuthMethod: mailer.SMTPAuthLogin,
+ LocalName: "example.com",
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestS3ConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.S3Config
+ expectedErrors []string
+ }{
+ {
+ "zero values (disabled)",
+ core.S3Config{},
+ []string{},
+ },
+ {
+ "zero values (enabled)",
+ core.S3Config{Enabled: true},
+ []string{
+ "bucket",
+ "region",
+ "endpoint",
+ "accessKey",
+ "secret",
+ },
+ },
+ {
+ "invalid data",
+ core.S3Config{
+ Enabled: true,
+ Endpoint: "test:test:test",
+ },
+ []string{
+ "bucket",
+ "region",
+ "endpoint",
+ "accessKey",
+ "secret",
+ },
+ },
+ {
+ "valid data (url endpoint)",
+ core.S3Config{
+ Enabled: true,
+ Endpoint: "https://localhost:8090",
+ Bucket: "test",
+ Region: "test",
+ AccessKey: "test",
+ Secret: "test",
+ },
+ []string{},
+ },
+ {
+ "valid data (hostname endpoint)",
+ core.S3Config{
+ Enabled: true,
+ Endpoint: "example.com",
+ Bucket: "test",
+ Region: "test",
+ AccessKey: "test",
+ Secret: "test",
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestBackupsConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.BackupsConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.BackupsConfig{},
+ []string{},
+ },
+ {
+ "invalid cron",
+ core.BackupsConfig{
+ Cron: "invalid",
+ CronMaxKeep: 0,
+ },
+ []string{"cron", "cronMaxKeep"},
+ },
+ {
+ "invalid enabled S3",
+ core.BackupsConfig{
+ S3: core.S3Config{
+ Enabled: true,
+ },
+ },
+ []string{"s3"},
+ },
+ {
+ "valid data",
+ core.BackupsConfig{
+ S3: core.S3Config{
+ Enabled: true,
+ Endpoint: "example.com",
+ Bucket: "test",
+ Region: "test",
+ AccessKey: "test",
+ Secret: "test",
+ },
+ Cron: "*/10 * * * *",
+ CronMaxKeep: 1,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestBatchConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.BatchConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.BatchConfig{},
+ []string{},
+ },
+ {
+ "zero value (enabled)",
+ core.BatchConfig{Enabled: true},
+ []string{"maxRequests", "timeout"},
+ },
+ {
+ "invalid data (negative values)",
+ core.BatchConfig{
+ MaxRequests: -1,
+ Timeout: -1,
+ MaxBodySize: -1,
+ },
+ []string{"maxRequests", "timeout", "maxBodySize"},
+ },
+ {
+ "min fields valid data",
+ core.BatchConfig{
+ Enabled: true,
+ MaxRequests: 1,
+ Timeout: 1,
+ },
+ []string{},
+ },
+ {
+ "all fields valid data",
+ core.BatchConfig{
+ Enabled: true,
+ MaxRequests: 10,
+ Timeout: 1,
+ MaxBodySize: 1,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestRateLimitsConfigValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ config core.RateLimitsConfig
+ expectedErrors []string
+ }{
+ {
+ "zero value (disabled)",
+ core.RateLimitsConfig{},
+ []string{},
+ },
+ {
+ "zero value (enabled)",
+ core.RateLimitsConfig{Enabled: true},
+ []string{"rules"},
+ },
+ {
+ "invalid data",
+ core.RateLimitsConfig{
+ Enabled: true,
+ ExcludedIPs: []string{"", "127.0.0.1"},
+ Rules: []core.RateLimitRule{
+ {
+ Label: "/123abc/",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ {
+ Label: "!abc",
+ Duration: -1,
+ MaxRequests: -1,
+ },
+ },
+ },
+ []string{"rules", "excludedIPs"},
+ },
+ {
+ "valid data",
+ core.RateLimitsConfig{
+ Enabled: true,
+ ExcludedIPs: []string{"127.0.0.1", "10.0.0.1/20"},
+ Rules: []core.RateLimitRule{
+ {
+ Label: "123_abc",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ {
+ Label: "/456-abc",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ },
+ },
+ []string{},
+ },
+ {
+ "duplicated rules with the same audience",
+ core.RateLimitsConfig{
+ Enabled: true,
+ Rules: []core.RateLimitRule{
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ {
+ Label: "/a",
+ Duration: 2,
+ MaxRequests: 3,
+ },
+ },
+ },
+ []string{"rules"},
+ },
+ {
+ "duplicated rule with conflicting audience (A)",
+ core.RateLimitsConfig{
+ Enabled: true,
+ Rules: []core.RateLimitRule{
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ Audience: core.RateLimitRuleAudienceGuest,
+ },
+ },
+ },
+ []string{"rules"},
+ },
+ {
+ "duplicated rule with conflicting audience (B)",
+ core.RateLimitsConfig{
+ Enabled: true,
+ Rules: []core.RateLimitRule{
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ Audience: core.RateLimitRuleAudienceAuth,
+ },
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ },
+ },
+ },
+ []string{"rules"},
+ },
+ {
+ "duplicated rule with non-conflicting audience",
+ core.RateLimitsConfig{
+ Enabled: true,
+ Rules: []core.RateLimitRule{
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ Audience: core.RateLimitRuleAudienceAuth,
+ },
+ {
+ Label: "/a",
+ Duration: 1,
+ MaxRequests: 2,
+ Audience: core.RateLimitRuleAudienceGuest,
+ },
+ },
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.config.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestRateLimitsFindRateLimitRule(t *testing.T) {
+ t.Parallel()
+
+ limits := core.RateLimitsConfig{
+ Rules: []core.RateLimitRule{
+ {Label: "abc"},
+ {Label: "def", Audience: core.RateLimitRuleAudienceGuest},
+ {Label: "/test/a", Audience: core.RateLimitRuleAudienceGuest},
+ {Label: "POST /test/a"},
+ {Label: "/test/a/", Audience: core.RateLimitRuleAudienceAuth},
+ {Label: "POST /test/a/"},
+ },
+ }
+
+ scenarios := []struct {
+ labels []string
+ audience []string
+ expected string
+ }{
+ {[]string{}, []string{}, ""},
+ {[]string{"missing"}, []string{}, ""},
+ {[]string{"abc"}, []string{}, "abc"},
+ {[]string{"abc"}, []string{core.RateLimitRuleAudienceGuest}, ""},
+ {[]string{"abc"}, []string{core.RateLimitRuleAudienceAuth}, ""},
+ {[]string{"def"}, []string{core.RateLimitRuleAudienceGuest}, "def"},
+ {[]string{"def"}, []string{core.RateLimitRuleAudienceAuth}, ""},
+ {[]string{"/test"}, []string{}, ""},
+ {[]string{"/test/a"}, []string{}, "/test/a"},
+ {[]string{"/test/a"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
+ {[]string{"/test/a"}, []string{core.RateLimitRuleAudienceGuest}, "/test/a"},
+ {[]string{"GET /test/a"}, []string{}, ""},
+ {[]string{"POST /test/a"}, []string{}, "POST /test/a"},
+ {[]string{"/test/a/b/c"}, []string{}, "/test/a/"},
+ {[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
+ {[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceGuest}, ""},
+ {[]string{"GET /test/a/b/c"}, []string{}, ""},
+ {[]string{"POST /test/a/b/c"}, []string{}, "POST /test/a/"},
+ {[]string{"/test/a", "abc"}, []string{}, "/test/a"}, // priority checks
+ }
+
+ for _, s := range scenarios {
+ t.Run(strings.Join(s.labels, "_")+":"+strings.Join(s.audience, "_"), func(t *testing.T) {
+ rule, ok := limits.FindRateLimitRule(s.labels, s.audience...)
+
+ hasLabel := rule.Label != ""
+ if hasLabel != ok {
+ t.Fatalf("Expected hasLabel %v, got %v", hasLabel, ok)
+ }
+
+ if rule.Label != s.expected {
+ t.Fatalf("Expected rule with label %q, got %q", s.expected, rule.Label)
+ }
+ })
+ }
+}
+
+func TestRateLimitRuleValidate(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ rule core.RateLimitRule
+ expectedErrors []string
+ }{
+ {
+ "zero value",
+ core.RateLimitRule{},
+ []string{"label", "duration", "maxRequests"},
+ },
+ {
+ "invalid data",
+ core.RateLimitRule{
+ Label: "@abc",
+ Duration: -1,
+ MaxRequests: -1,
+ Audience: "invalid",
+ },
+ []string{"label", "duration", "maxRequests", "audience"},
+ },
+ {
+ "valid data (name)",
+ core.RateLimitRule{
+ Label: "abc:123",
+ Duration: 1,
+ MaxRequests: 1,
+ },
+ []string{},
+ },
+ {
+ "valid data (name:action)",
+ core.RateLimitRule{
+ Label: "abc:123",
+ Duration: 1,
+ MaxRequests: 1,
+ },
+ []string{},
+ },
+ {
+ "valid data (*:action)",
+ core.RateLimitRule{
+ Label: "*:123",
+ Duration: 1,
+ MaxRequests: 1,
+ },
+ []string{},
+ },
+ {
+ "valid data (path /a/b)",
+ core.RateLimitRule{
+ Label: "/a/b",
+ Duration: 1,
+ MaxRequests: 1,
+ },
+ []string{},
+ },
+ {
+ "valid data (path POST /a/b)",
+ core.RateLimitRule{
+ Label: "POST /a/b/",
+ Duration: 1,
+ MaxRequests: 1,
+ },
+ []string{},
+ },
+ {
+ "invalid audience",
+ core.RateLimitRule{
+ Label: "/a/b/",
+ Duration: 1,
+ MaxRequests: 1,
+ Audience: "invalid",
+ },
+ []string{"audience"},
+ },
+ {
+ "valid audience - " + core.RateLimitRuleAudienceGuest,
+ core.RateLimitRule{
+ Label: "POST /a/b/",
+ Duration: 1,
+ MaxRequests: 1,
+ Audience: core.RateLimitRuleAudienceGuest,
+ },
+ []string{},
+ },
+ {
+ "valid audience - " + core.RateLimitRuleAudienceAuth,
+ core.RateLimitRule{
+ Label: "POST /a/b/",
+ Duration: 1,
+ MaxRequests: 1,
+ Audience: core.RateLimitRuleAudienceAuth,
+ },
+ []string{},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.rule.Validate()
+
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
+ }
+}
+
+func TestRateLimitRuleDurationTime(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ rule core.RateLimitRule
+ expected time.Duration
+ }{
+ {core.RateLimitRule{}, 0 * time.Second},
+ {core.RateLimitRule{Duration: 1234}, 1234 * time.Second},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%d", i, s.rule.Duration), func(t *testing.T) {
+ result := s.rule.DurationTime()
+
+ if result != s.expected {
+ t.Fatalf("Expected duration %d, got %d", s.expected, result)
+ }
+ })
+ }
+}
+
+func TestRateLimitRuleString(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ rule core.RateLimitRule
+ expected string
+ }{
+ {
+ "empty",
+ core.RateLimitRule{},
+ `{"label":"","audience":"","duration":0,"maxRequests":0}`,
+ },
+ {
+ "all fields",
+ core.RateLimitRule{
+ Label: "POST /a/b/",
+ Duration: 1,
+ MaxRequests: 2,
+ Audience: core.RateLimitRuleAudienceAuth,
+ },
+ `{"label":"POST /a/b/","audience":"@auth","duration":1,"maxRequests":2}`,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := s.rule.String()
+
+ if result != s.expected {
+ t.Fatalf("Expected string\n%s\ngot\n%s", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/settings_query.go b/core/settings_query.go
new file mode 100644
index 0000000000..d71eb2dfec
--- /dev/null
+++ b/core/settings_query.go
@@ -0,0 +1,88 @@
+package core
+
+import (
+ "database/sql"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+type Param struct {
+ BaseModel
+
+ Created types.DateTime `db:"created" json:"created"`
+ Updated types.DateTime `db:"updated" json:"updated"`
+ Value types.JSONRaw `db:"value" json:"value"`
+}
+
+func (m *Param) TableName() string {
+ return paramsTable
+}
+
+// ReloadSettings initializes and reloads the stored application settings.
+//
+// If no settings were stored it will persist the current app ones.
+func (app *BaseApp) ReloadSettings() error {
+ param := &Param{}
+ err := app.ModelQuery(param).Model(paramsKeySettings, param)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+
+ // no settings were previously stored -> save
+ // (ReloadSettings() will be invoked again by a system hook after successful save)
+ if param.Id == "" {
+ // force insert in case the param entry was deleted manually after application start
+ app.Settings().MarkAsNew()
+ return app.Save(app.Settings())
+ }
+
+ event := new(SettingsReloadEvent)
+ event.App = app
+
+ return app.OnSettingsReload().Trigger(event, func(e *SettingsReloadEvent) error {
+ return e.App.Settings().loadParam(e.App, param)
+ })
+}
+
+// loadParam loads the settings from the stored param into the app ones.
+//
+// @todo note that the encryption may get removed in the future since it doesn't
+// really accomplish much and it might be better to find a way to encrypt the backups
+// or implement support for resolving env variables.
+func (s *Settings) loadParam(app App, param *Param) error {
+ // try first without decryption
+ s.mu.Lock()
+ plainDecodeErr := json.Unmarshal(param.Value, s)
+ s.mu.Unlock()
+
+ // failed, try to decrypt
+ if plainDecodeErr != nil {
+ encryptionKey := os.Getenv(app.EncryptionEnv())
+
+ // load without decryption has failed and there is no encryption key to use for decrypt
+ if encryptionKey == "" {
+ return fmt.Errorf("invalid settings db data or missing encryption key %q", app.EncryptionEnv())
+ }
+
+ // decrypt
+ decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
+ if decryptErr != nil {
+ return decryptErr
+ }
+
+ // decode again
+ s.mu.Lock()
+ decryptedDecodeErr := json.Unmarshal(decrypted, s)
+ s.mu.Unlock()
+ if decryptedDecodeErr != nil {
+ return decryptedDecodeErr
+ }
+ }
+
+ return s.PostScan()
+}
diff --git a/core/settings_query_test.go b/core/settings_query_test.go
new file mode 100644
index 0000000000..a7542889a5
--- /dev/null
+++ b/core/settings_query_test.go
@@ -0,0 +1,159 @@
+package core_test
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+ "github.com/pocketbase/pocketbase/tools/types"
+)
+
+func TestReloadSettings(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ // cleanup all stored settings
+ // ---
+ if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
+ t.Fatalf("Failed to delete all test settings: %v", err)
+ }
+
+ // check if the new settings are saved in the db
+ // ---
+ app.Settings().Meta.AppName = "test_name_after_delete"
+
+ app.ResetEventCalls()
+ if err := app.ReloadSettings(); err != nil {
+ t.Fatalf("Failed to reload the settings after delete: %v", err)
+ }
+ testEventCalls(t, app, map[string]int{
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnSettingsReload": 1,
+ })
+
+ param := &core.Param{}
+ err := app.ModelQuery(param).Model("settings", param)
+ if err != nil {
+ t.Fatalf("Expected new settings to be persisted, got %v", err)
+ }
+
+ if !strings.Contains(param.Value.String(), "test_name_after_delete") {
+ t.Fatalf("Expected to find AppName test_name_after_delete in\n%s", param.Value.String())
+ }
+
+ // change the db entry and reload the app settings (ensure that there was no db update)
+ // ---
+ param.Value = types.JSONRaw([]byte(`{"meta": {"appName":"test_name_after_update"}}`))
+ if err := app.Save(param); err != nil {
+ t.Fatalf("Failed to update the test settings: %v", err)
+ }
+
+ app.ResetEventCalls()
+ if err := app.ReloadSettings(); err != nil {
+ t.Fatalf("Failed to reload app settings: %v", err)
+ }
+ testEventCalls(t, app, map[string]int{
+ "OnSettingsReload": 1,
+ })
+
+ // try to reload again without doing any changes
+ // ---
+ app.ResetEventCalls()
+ if err := app.ReloadSettings(); err != nil {
+ t.Fatalf("Failed to reload app settings without change: %v", err)
+ }
+ testEventCalls(t, app, map[string]int{
+ "OnSettingsReload": 1,
+ })
+
+ if app.Settings().Meta.AppName != "test_name_after_update" {
+ t.Fatalf("Expected AppName %q, got %q", "test_name_after_update", app.Settings().Meta.AppName)
+ }
+}
+
+func TestReloadSettingsWithEncryption(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ os.Setenv("pb_test_env", strings.Repeat("a", 32))
+
+ // cleanup all stored settings
+ // ---
+ if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
+ t.Fatalf("Failed to delete all test settings: %v", err)
+ }
+
+ // check if the new settings are saved in the db
+ // ---
+ app.Settings().Meta.AppName = "test_name_after_delete"
+
+ app.ResetEventCalls()
+ if err := app.ReloadSettings(); err != nil {
+ t.Fatalf("Failed to reload the settings after delete: %v", err)
+ }
+ testEventCalls(t, app, map[string]int{
+ "OnModelCreate": 1,
+ "OnModelCreateExecute": 1,
+ "OnModelAfterCreateSuccess": 1,
+ "OnModelValidate": 1,
+ "OnSettingsReload": 1,
+ })
+
+ param := &core.Param{}
+ err := app.ModelQuery(param).Model("settings", param)
+ if err != nil {
+ t.Fatalf("Expected new settings to be persisted, got %v", err)
+ }
+ rawValue := param.Value.String()
+ if rawValue == "" || strings.Contains(rawValue, "test_name") {
+ t.Fatalf("Expected inserted settings to be encrypted, found\n%s", rawValue)
+ }
+
+ // change and reload the app settings (ensure that there was no db update)
+ // ---
+ app.Settings().Meta.AppName = "test_name_after_update"
+ if err := app.Save(app.Settings()); err != nil {
+ t.Fatalf("Failed to update app settings: %v", err)
+ }
+
+ // try to reload again without doing any changes
+ // ---
+ app.ResetEventCalls()
+ if err := app.ReloadSettings(); err != nil {
+ t.Fatalf("Failed to reload app settings: %v", err)
+ }
+ testEventCalls(t, app, map[string]int{
+ "OnSettingsReload": 1,
+ })
+
+ // refetch the settings param to ensure that the new value was stored encrypted
+ err = app.ModelQuery(param).Model("settings", param)
+ if err != nil {
+ t.Fatalf("Expected new settings to be persisted, got %v", err)
+ }
+ rawValue = param.Value.String()
+ if rawValue == "" || strings.Contains(rawValue, "test_name") {
+ t.Fatalf("Expected updated settings to be encrypted, found\n%s", rawValue)
+ }
+
+ if app.Settings().Meta.AppName != "test_name_after_update" {
+ t.Fatalf("Expected AppName %q, got %q", "test_name_after_update", app.Settings().Meta.AppName)
+ }
+}
+
+func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
+ if len(events) != len(app.EventCalls) {
+ t.Fatalf("Expected events doesn't match:\n%v\ngot\n%v", events, app.EventCalls)
+ }
+
+ for name, total := range events {
+ if v, ok := app.EventCalls[name]; !ok || v != total {
+ t.Fatalf("Expected events doesn't exist or match:\n%v\ngot\n%v", events, app.EventCalls)
+ }
+ }
+}
diff --git a/core/syscall.go b/core/syscall.go
new file mode 100644
index 0000000000..c4bb38d913
--- /dev/null
+++ b/core/syscall.go
@@ -0,0 +1,10 @@
+//go:build !(js && wasm)
+
+package core
+
+import "syscall"
+
+// execve invokes the execve(2) system call.
+func execve(argv0 string, argv []string, envv []string) error {
+ return syscall.Exec(argv0, argv, envv)
+}
diff --git a/core/syscall_wasm.go b/core/syscall_wasm.go
new file mode 100644
index 0000000000..75c5a0514a
--- /dev/null
+++ b/core/syscall_wasm.go
@@ -0,0 +1,10 @@
+//go:build js && wasm
+
+package core
+
+import "errors"
+
+// https://github.com/pocketbase/pocketbase/pull/7116
+func execve(argv0 string, argv []string, envv []string) error {
+ return errors.ErrUnsupported
+}
diff --git a/core/system_alert.go b/core/system_alert.go
new file mode 100644
index 0000000000..87be2cd9bc
--- /dev/null
+++ b/core/system_alert.go
@@ -0,0 +1,131 @@
+package core
+
+import (
+ "bytes"
+ "errors"
+ "html"
+ "html/template"
+ "net/mail"
+
+ "github.com/pocketbase/pocketbase/tools/mailer"
+)
+
+const systemAlertHTML = `
+
+
+
+
+
+
+
+ {{.AppName}} system alert occurred:
+ {{.AlertDetails}}
+ For more information you could explore the logs in the dashboard of your application.
+
+`
+
+// 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)
+}
diff --git a/core/system_alert_test.go b/core/system_alert_test.go
new file mode 100644
index 0000000000..122f6b0f26
--- /dev/null
+++ b/core/system_alert_test.go
@@ -0,0 +1,124 @@
+package core
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestSendSystemAlert(t *testing.T) {
+ t.Parallel()
+
+ testDataDir, err := os.MkdirTemp("", "sendSystemAlert_pb_data")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(testDataDir)
+
+ testApp := NewBaseApp(BaseAppConfig{
+ DataDir: testDataDir,
+ })
+ defer testApp.ClearBootstrap()
+
+ 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.ClearBootstrap()
+
+ 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
+}
diff --git a/core/validators/db.go b/core/validators/db.go
new file mode 100644
index 0000000000..051a33a980
--- /dev/null
+++ b/core/validators/db.go
@@ -0,0 +1,80 @@
+package validators
+
+import (
+ "database/sql"
+ "errors"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+)
+
+// UniqueId checks whether a field string id already exists in the specified table.
+//
+// Example:
+//
+// validation.Field(&form.RelId, validation.By(validators.UniqueId(form.app.DB(), "tbl_example"))
+func UniqueId(db dbx.Builder, tableName string) validation.RuleFunc {
+ return func(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ var foundId string
+
+ err := db.
+ Select("id").
+ From(tableName).
+ Where(dbx.HashExp{"id": v}).
+ Limit(1).
+ Row(&foundId)
+
+ if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
+ return validation.NewError("validation_invalid_or_existing_id", "The model id is invalid or already exists.")
+ }
+
+ return nil
+ }
+}
+
+// NormalizeUniqueIndexError attempts to convert a
+// "unique constraint failed" error into a validation.Errors.
+//
+// The provided err is returned as it is without changes if:
+// - err is nil
+// - err is already validation.Errors
+// - err is not "unique constraint failed" error
+func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []string) error {
+ if err == nil {
+ return err
+ }
+
+ if _, ok := err.(validation.Errors); ok {
+ return err
+ }
+
+ msg := strings.ToLower(err.Error())
+
+ // check for unique constraint failure
+ if strings.Contains(msg, "unique constraint failed") {
+ // note: extra space to unify multi-columns lookup
+ msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ") + " "
+
+ normalizedErrs := validation.Errors{}
+
+ for _, name := range fieldNames {
+ // note: extra spaces to exclude table name with suffix matching the current one
+ // OR other fields starting with the current field name
+ if strings.Contains(msg, strings.ToLower(" "+tableOrAlias+"."+name+" ")) {
+ normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
+ }
+ }
+
+ if len(normalizedErrs) > 0 {
+ return normalizedErrs
+ }
+ }
+
+ return err
+}
diff --git a/core/validators/db_test.go b/core/validators/db_test.go
new file mode 100644
index 0000000000..47ff5b04ee
--- /dev/null
+++ b/core/validators/db_test.go
@@ -0,0 +1,125 @@
+package validators_test
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestUniqueId(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ id string
+ tableName string
+ expectError bool
+ }{
+ {"", "", false},
+ {"test", "", true},
+ {"wsmn24bux7wo113", "_collections", true},
+ {"test_unique_id", "unknown_table", true},
+ {"test_unique_id", "_collections", false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s_%s", i, s.id, s.tableName), func(t *testing.T) {
+ err := validators.UniqueId(app.DB(), s.tableName)(s.id)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestNormalizeUniqueIndexError(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ name string
+ err error
+ table string
+ names []string
+ expectedKeys []string
+ }{
+ {
+ "nil error (no changes)",
+ nil,
+ "test",
+ []string{"a", "b"},
+ nil,
+ },
+ {
+ "non-unique index error (no changes)",
+ errors.New("abc"),
+ "test",
+ []string{"a", "b"},
+ nil,
+ },
+ {
+ "validation error (no changes)",
+ validation.Errors{"c": errors.New("abc")},
+ "test",
+ []string{"a", "b"},
+ []string{"c"},
+ },
+ {
+ "unique index error but mismatched table name",
+ errors.New("UNIQUE constraint failed for fields test.a,test.b"),
+ "example",
+ []string{"a", "b"},
+ nil,
+ },
+ {
+ "unique index error with table name suffix matching the specified one",
+ errors.New("UNIQUE constraint failed for fields test_suffix.a,test_suffix.b"),
+ "suffix",
+ []string{"a", "b", "c"},
+ nil,
+ },
+ {
+ "unique index error but mismatched fields",
+ errors.New("UNIQUE constraint failed for fields test.a,test.b"),
+ "test",
+ []string{"c", "d"},
+ nil,
+ },
+ {
+ "unique index error with matching table name and fields",
+ errors.New("UNIQUE constraint failed for fields test.a,test.b"),
+ "test",
+ []string{"a", "b", "c"},
+ []string{"a", "b"},
+ },
+ {
+ "unique index error with matching table name and field starting with the name of another non-unique field",
+ errors.New("UNIQUE constraint failed for fields test.a_2,test.c"),
+ "test",
+ []string{"a", "a_2", "c"},
+ []string{"a_2", "c"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result := validators.NormalizeUniqueIndexError(s.err, s.table, s.names)
+
+ if len(s.expectedKeys) == 0 {
+ if result != s.err {
+ t.Fatalf("Expected no error change, got %v", result)
+ }
+ return
+ }
+
+ tests.TestValidationErrors(t, result, s.expectedKeys)
+ })
+ }
+}
diff --git a/core/validators/equal.go b/core/validators/equal.go
new file mode 100644
index 0000000000..26035f6ebd
--- /dev/null
+++ b/core/validators/equal.go
@@ -0,0 +1,85 @@
+package validators
+
+import (
+ "reflect"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+)
+
+// Equal checks whether the validated value matches another one from the same type.
+//
+// It expects the compared values to be from the same type and works
+// with booleans, numbers, strings and their pointer variants.
+//
+// If one of the value is pointer, the comparison is based on its
+// underlying value (when possible to determine).
+//
+// Note that empty/zero values are also compared (this differ from other validation.RuleFunc).
+//
+// Example:
+//
+// validation.Field(&form.PasswordConfirm, validation.By(validators.Equal(form.Password)))
+func Equal[T comparable](valueToCompare T) validation.RuleFunc {
+ return func(value any) error {
+ if compareValues(value, valueToCompare) {
+ return nil
+ }
+
+ return validation.NewError("validation_values_mismatch", "Values don't match.")
+ }
+}
+
+func compareValues(a, b any) bool {
+ if a == b {
+ return true
+ }
+
+ if checkIsNil(a) && checkIsNil(b) {
+ return true
+ }
+
+ var result bool
+
+ defer func() {
+ if err := recover(); err != nil {
+ result = false
+ }
+ }()
+
+ reflectA := reflect.ValueOf(a)
+ reflectB := reflect.ValueOf(b)
+
+ dereferencedA := dereference(reflectA)
+ dereferencedB := dereference(reflectB)
+ if dereferencedA.CanInterface() && dereferencedB.CanInterface() {
+ result = dereferencedA.Interface() == dereferencedB.Interface()
+ }
+
+ return result
+}
+
+// note https://github.com/golang/go/issues/51649
+func checkIsNil(value any) bool {
+ if value == nil {
+ return true
+ }
+
+ var result bool
+
+ defer func() {
+ if err := recover(); err != nil {
+ result = false
+ }
+ }()
+
+ result = reflect.ValueOf(value).IsNil()
+
+ return result
+}
+
+func dereference(v reflect.Value) reflect.Value {
+ for v.Kind() == reflect.Pointer {
+ v = v.Elem()
+ }
+ return v
+}
diff --git a/core/validators/equal_test.go b/core/validators/equal_test.go
new file mode 100644
index 0000000000..dedb37639f
--- /dev/null
+++ b/core/validators/equal_test.go
@@ -0,0 +1,62 @@
+package validators_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core/validators"
+)
+
+func Equal(t *testing.T) {
+ t.Parallel()
+
+ strA := "abc"
+ strB := "abc"
+ strC := "123"
+ var strNilPtr *string
+ var strNilPtr2 *string
+
+ scenarios := []struct {
+ valA any
+ valB any
+ expectError bool
+ }{
+ {nil, nil, false},
+ {"", "", false},
+ {"", "456", true},
+ {"123", "", true},
+ {"123", "456", true},
+ {"123", "123", false},
+ {true, false, true},
+ {false, true, true},
+ {false, false, false},
+ {true, true, false},
+ {0, 0, false},
+ {0, 1, true},
+ {1, 2, true},
+ {1, 1, false},
+ {&strA, &strA, false},
+ {&strA, &strB, false},
+ {&strA, &strC, true},
+ {"abc", &strA, false},
+ {&strA, "abc", false},
+ {"abc", &strC, true},
+ {"test", 123, true},
+ {nil, 123, true},
+ {nil, strA, true},
+ {nil, &strA, true},
+ {nil, strNilPtr, false},
+ {strNilPtr, strNilPtr2, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%v_%v", i, s.valA, s.valB), func(t *testing.T) {
+ err := validators.Equal(s.valA)(s.valB)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
diff --git a/core/validators/file.go b/core/validators/file.go
new file mode 100644
index 0000000000..51626c30cd
--- /dev/null
+++ b/core/validators/file.go
@@ -0,0 +1,96 @@
+package validators
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gabriel-vasile/mimetype"
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+)
+
+// UploadedFileSize checks whether the validated [*filesystem.File]
+// size is no more than the provided maxBytes.
+//
+// Example:
+//
+// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
+func UploadedFileSize(maxBytes int64) validation.RuleFunc {
+ return func(value any) error {
+ v, ok := value.(*filesystem.File)
+ if !ok {
+ return ErrUnsupportedValueType
+ }
+
+ if v == nil {
+ return nil // nothing to validate
+ }
+
+ if v.Size > maxBytes {
+ return validation.NewError(
+ "validation_file_size_limit",
+ "Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
+ ).SetParams(map[string]any{
+ "file": cutStr(v.OriginalName, 300),
+ "maxSize": maxBytes,
+ })
+ }
+
+ return nil
+ }
+}
+
+// UploadedFileMimeType checks whether the validated [*filesystem.File]
+// mimetype is within the provided allowed mime types.
+//
+// Example:
+//
+// validMimeTypes := []string{"test/plain","image/jpeg"}
+// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
+func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
+ return func(value any) error {
+ v, ok := value.(*filesystem.File)
+ if !ok {
+ return ErrUnsupportedValueType
+ }
+
+ if v == nil {
+ return nil // nothing to validate
+ }
+
+ baseErr := validation.NewError(
+ "validation_invalid_mime_type",
+ fmt.Sprintf("Failed to upload %q due to unsupported file type.", cutStr(v.OriginalName, 300)),
+ )
+
+ if len(validTypes) == 0 {
+ return baseErr
+ }
+
+ f, err := v.Reader.Open()
+ if err != nil {
+ return baseErr
+ }
+ defer f.Close()
+
+ filetype, err := mimetype.DetectReader(f)
+ if err != nil {
+ return baseErr
+ }
+
+ for _, t := range validTypes {
+ if filetype.Is(t) {
+ return nil // valid
+ }
+ }
+
+ return validation.NewError(
+ "validation_invalid_mime_type",
+ fmt.Sprintf(
+ "%q mime type must be one of: %s.",
+ v.Name,
+ strings.Join(validTypes, ", "),
+ ),
+ )
+ }
+}
diff --git a/core/validators/file_test.go b/core/validators/file_test.go
new file mode 100644
index 0000000000..3af6fe5384
--- /dev/null
+++ b/core/validators/file_test.go
@@ -0,0 +1,75 @@
+package validators_test
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core/validators"
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+)
+
+func TestUploadedFileSize(t *testing.T) {
+ t.Parallel()
+
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ maxBytes int64
+ file *filesystem.File
+ expectError bool
+ }{
+ {0, nil, false},
+ {4, nil, false},
+ {3, file, true}, // all test files have "test" as content
+ {4, file, false},
+ {5, file, false},
+ }
+
+ for _, s := range scenarios {
+ t.Run(fmt.Sprintf("%d", s.maxBytes), func(t *testing.T) {
+ err := validators.UploadedFileSize(s.maxBytes)(s.file)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+func TestUploadedFileMimeType(t *testing.T) {
+ t.Parallel()
+
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.png") // the extension shouldn't matter
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ scenarios := []struct {
+ types []string
+ file *filesystem.File
+ expectError bool
+ }{
+ {nil, nil, false},
+ {[]string{"image/jpeg"}, nil, false},
+ {[]string{}, file, true},
+ {[]string{"image/jpeg"}, file, true},
+ // test files are detected as "text/plain; charset=utf-8" content type
+ {[]string{"image/jpeg", "text/plain; charset=utf-8"}, file, false},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%s", i, strings.Join(s.types, ";")), func(t *testing.T) {
+ err := validators.UploadedFileMimeType(s.types)(s.file)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
diff --git a/core/validators/string.go b/core/validators/string.go
new file mode 100644
index 0000000000..5b36ab68b8
--- /dev/null
+++ b/core/validators/string.go
@@ -0,0 +1,57 @@
+package validators
+
+import (
+ "net/netip"
+ "regexp"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+)
+
+// IsRegex checks whether the validated value is a valid regular expression pattern.
+//
+// Example:
+//
+// validation.Field(&form.Pattern, validation.By(validators.IsRegex))
+func IsRegex(value any) error {
+ v, ok := value.(string)
+ if !ok {
+ return ErrUnsupportedValueType
+ }
+
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ if _, err := regexp.Compile(v); err != nil {
+ return validation.NewError("validation_invalid_regex", err.Error())
+ }
+
+ return nil
+}
+
+// IPOrSubnet checks whether the validated value is an individual
+// IPv4/IPv6 or CIDR subnet.
+func IPOrSubnet(value any) error {
+ v, ok := value.(string)
+ if !ok {
+ return ErrUnsupportedValueType
+ }
+
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ // subnet
+ _, err := netip.ParsePrefix(v)
+ if err == nil {
+ return nil
+ }
+
+ // individual IP
+ _, err = netip.ParseAddr(v)
+ if err == nil {
+ return nil
+ }
+
+ return validation.NewError("validation_invlaid_ip_or_subnet", "invalid IP or CIDR subnet")
+}
diff --git a/core/validators/string_test.go b/core/validators/string_test.go
new file mode 100644
index 0000000000..da876e942a
--- /dev/null
+++ b/core/validators/string_test.go
@@ -0,0 +1,62 @@
+package validators_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/core/validators"
+)
+
+func TestIsRegex(t *testing.T) {
+ t.Parallel()
+
+ scenarios := []struct {
+ val string
+ expectError bool
+ }{
+ {"", false},
+ {`abc`, false},
+ {`\w+`, false},
+ {`\w*((abc+`, true},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) {
+ err := validators.IsRegex(s.val)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+ })
+ }
+}
+
+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)
+ }
+ })
+ }
+}
diff --git a/core/validators/validators.go b/core/validators/validators.go
new file mode 100644
index 0000000000..50d84ae529
--- /dev/null
+++ b/core/validators/validators.go
@@ -0,0 +1,47 @@
+// Package validators implements some common custom PocketBase validators.
+package validators
+
+import (
+ "errors"
+ "maps"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+)
+
+var ErrUnsupportedValueType = validation.NewError("validation_unsupported_value_type", "Invalid or unsupported value type.")
+
+// JoinValidationErrors attempts to join the provided [validation.Errors] arguments.
+//
+// If only one of the arguments is [validation.Errors], it returns the first non-empty [validation.Errors].
+//
+// If both arguments are not [validation.Errors] then it returns a combined [errors.Join] error.
+func JoinValidationErrors(errA, errB error) error {
+ vErrA, okA := errA.(validation.Errors)
+ vErrB, okB := errB.(validation.Errors)
+
+ // merge
+ if okA && okB {
+ result := maps.Clone(vErrA)
+ maps.Copy(result, vErrB)
+ if len(result) > 0 {
+ return result
+ }
+ }
+
+ if okA && len(vErrA) > 0 {
+ return vErrA
+ }
+
+ if okB && len(vErrB) > 0 {
+ return vErrB
+ }
+
+ return errors.Join(errA, errB)
+}
+
+func cutStr(str string, max int) string {
+ if len(str) > max {
+ return str[:max] + "..."
+ }
+ return str
+}
diff --git a/core/validators/validators_test.go b/core/validators/validators_test.go
new file mode 100644
index 0000000000..173467998f
--- /dev/null
+++ b/core/validators/validators_test.go
@@ -0,0 +1,39 @@
+package validators_test
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/pocketbase/core/validators"
+)
+
+func TestJoinValidationErrors(t *testing.T) {
+ scenarios := []struct {
+ errA error
+ errB error
+ expected string
+ }{
+ {nil, nil, ""},
+ {errors.New("abc"), nil, "abc"},
+ {nil, errors.New("abc"), "abc"},
+ {errors.New("abc"), errors.New("456"), "abc\n456"},
+ {validation.Errors{"test1": errors.New("test1_err")}, nil, "test1: test1_err."},
+ {nil, validation.Errors{"test2": errors.New("test2_err")}, "test2: test2_err."},
+ {validation.Errors{}, errors.New("456"), "\n456"},
+ {errors.New("456"), validation.Errors{}, "456\n"},
+ {validation.Errors{"test1": errors.New("test1_err")}, errors.New("456"), "test1: test1_err."},
+ {errors.New("456"), validation.Errors{"test2": errors.New("test2_err")}, "test2: test2_err."},
+ {validation.Errors{"test1": errors.New("test1_err")}, validation.Errors{"test2": errors.New("test2_err")}, "test1: test1_err; test2: test2_err."},
+ }
+
+ for i, s := range scenarios {
+ t.Run(fmt.Sprintf("%d_%#T_%T", i, s.errA, s.errB), func(t *testing.T) {
+ result := fmt.Sprintf("%v", validators.JoinValidationErrors(s.errA, s.errB))
+ if result != s.expected {
+ t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result)
+ }
+ })
+ }
+}
diff --git a/core/view.go b/core/view.go
new file mode 100644
index 0000000000..33995dab2b
--- /dev/null
+++ b/core/view.go
@@ -0,0 +1,739 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+ "github.com/pocketbase/pocketbase/tools/inflector"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/tokenizer"
+)
+
+// DeleteView drops the specified view name.
+//
+// This method is a no-op if a view with the provided name doesn't exist.
+//
+// NB! Be aware that this method is vulnerable to SQL injection and the
+// "dangerousViewName" argument must come only from trusted input!
+func (app *BaseApp) DeleteView(dangerousViewName string) error {
+ _, err := app.DB().NewQuery(fmt.Sprintf(
+ "DROP VIEW IF EXISTS {{%s}}",
+ dangerousViewName,
+ )).Execute()
+
+ return err
+}
+
+// SaveView creates (or updates already existing) persistent SQL view.
+//
+// NB! Be aware that this method is vulnerable to SQL injection and
+// its arguments must come only from trusted input!
+func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery string) error {
+ return app.RunInTransaction(func(txApp App) error {
+ // delete old view (if exists)
+ err := txApp.DeleteView(dangerousViewName)
+ if err != nil {
+ return err
+ }
+
+ dangerousSelectQuery, err = normalizeViewSelectQuery(dangerousSelectQuery)
+ if err != nil {
+ return err
+ }
+
+ // (re)create the view
+ //
+ // note: the query is wrapped in a secondary SELECT as a rudimentary
+ // measure to discourage multiple inline sql statements execution
+ viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
+ _, err = txApp.DB().NewQuery(viewQuery).Execute()
+ if err != nil {
+ return err
+ }
+
+ // fetch the view table info to ensure that the view was created
+ // because missing tables or columns won't return an error
+ if _, err := txApp.TableInfo(dangerousViewName); err != nil {
+ // manually cleanup previously created view in case the func
+ // is called in a nested transaction and the error is discarded
+ txApp.DeleteView(dangerousViewName)
+
+ return err
+ }
+
+ return nil
+ })
+}
+
+// CreateViewFields creates a new FieldsList from the provided select query.
+//
+// There are some caveats:
+// - The select query must have an "id" column.
+// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
+//
+// NB! Be aware that this method is vulnerable to SQL injection and the
+// "dangerousSelectQuery" argument must come only from trusted input!
+func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, error) {
+ result := NewFieldsList()
+
+ suggestedFields, err := parseQueryToFields(app, dangerousSelectQuery)
+ if err != nil {
+ return result, err
+ }
+
+ // note wrap in a transaction in case the dangerousSelectQuery contains
+ // multiple statements allowing us to rollback on any error
+ txErr := app.RunInTransaction(func(txApp App) error {
+ info, err := getQueryTableInfo(txApp, dangerousSelectQuery)
+ if err != nil {
+ return err
+ }
+
+ var hasId bool
+
+ for _, row := range info {
+ if row.Name == FieldNameId {
+ hasId = true
+ }
+
+ var field Field
+
+ if f, ok := suggestedFields[row.Name]; ok {
+ field = f.field
+ } else {
+ field = defaultViewField(row.Name)
+ }
+
+ result.Add(field)
+ }
+
+ if !hasId {
+ return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
+ }
+
+ return nil
+ })
+
+ return result, txErr
+}
+
+type DryRunViewResult struct {
+ Fields FieldsList `json:"fields"`
+ Sample []*Record `json:"sample"`
+}
+
+// DryRunView executes the provided query by creating a temporary view
+// collection and returning a sample of the resulting query records (if valid).
+//
+// The same caveats from CreateViewFields apply here too.
+//
+// NB! Be aware that this method is vulnerable to SQL injection and the
+// "dangerousSelectQuery" argument must come only from trusted input!
+func (app *BaseApp) DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error) {
+ dangerousSelectQuery, err := normalizeViewSelectQuery(dangerousSelectQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ fields, err := app.CreateViewFields(dangerousSelectQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ tempName := "temp_view_" + security.RandomString(5)
+ tempCollection := NewViewCollection(tempName)
+ tempCollection.Fields = fields
+
+ // validate generated view fields
+ ctx := context.Background()
+ for i, f := range fields {
+ err = f.ValidateSettings(ctx, app, tempCollection)
+ if err != nil {
+ return nil, fmt.Errorf("invalid field %q (%d): %w", f.GetName(), i, err)
+ }
+ }
+
+ // apply the same normalization as in the collection view query
+ dangerousSelectQuery, err = normalizeViewQueryId(app, dangerousSelectQuery)
+ if err != nil {
+ return nil, fmt.Errorf("failed to normalize view query id: %w", err)
+ }
+
+ records := []*Record{}
+
+ err = app.RecordQuery(tempCollection).
+ // note: the query is wrapped in a secondary SELECT as a rudimentary
+ // measure to discourage multiple inline sql statements execution
+ From("(SELECT * FROM (" + dangerousSelectQuery + ")) as " + tempName).
+ Limit(int64(sampleSize)).
+ All(&records)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve query records: %w", err)
+ }
+
+ // warn for possible empty or duplicated record ids found in the sample
+ // (it is not intended for security and it is here to quickly provide a
+ // helpful error message without doing multiple query executions)
+ ids := make(map[string]struct{}, len(records))
+ for _, r := range records {
+ if r.Id == "" {
+ return nil, errors.New("the query could return records with empty or invalid ids")
+ }
+
+ if _, ok := ids[r.Id]; ok {
+ return nil, errors.New("the query could return records with non-unique ids")
+ }
+
+ ids[r.Id] = struct{}{}
+ }
+
+ return &DryRunViewResult{
+ Fields: fields,
+ Sample: records,
+ }, nil
+}
+
+// FindRecordByViewFile returns the original Record of the provided view collection file.
+func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
+ view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
+ if err != nil {
+ return nil, err
+ }
+
+ if !view.IsView() {
+ return nil, errors.New("not a view collection")
+ }
+
+ var findFirstNonViewQueryFileField func(int) (*queryField, error)
+ findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
+ // check the level depth to prevent infinite circular recursion
+ // (the limit is arbitrary and may change in the future)
+ if level > 5 {
+ return nil, errors.New("reached the max recursion level of view collection file field queries")
+ }
+
+ queryFields, err := parseQueryToFields(app, view.ViewQuery)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, item := range queryFields {
+ if item.collection == nil ||
+ item.original == nil ||
+ item.field.GetName() != fileFieldName {
+ continue
+ }
+
+ if item.collection.IsView() {
+ view = item.collection
+ fileFieldName = item.original.GetName()
+ return findFirstNonViewQueryFileField(level + 1)
+ }
+
+ return item, nil
+ }
+
+ return nil, errors.New("no query file field found")
+ }
+
+ qf, err := findFirstNonViewQueryFileField(1)
+ if err != nil {
+ return nil, err
+ }
+
+ cleanFieldName := inflector.Columnify(qf.original.GetName())
+
+ record := &Record{}
+
+ query := app.RecordQuery(qf.collection).Limit(1)
+
+ if opt, ok := qf.original.(MultiValuer); !ok || !opt.IsMultiple() {
+ query.AndWhere(dbx.HashExp{cleanFieldName: filename})
+ } else {
+ query.InnerJoin(
+ fmt.Sprintf(`%s as {{_je_file}}`, dbutils.JSONEach(cleanFieldName)),
+ dbx.HashExp{"_je_file.value": filename},
+ )
+ }
+
+ if err := query.One(record); err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
+
+// -------------------------------------------------------------------
+// Raw query to schema helpers
+// -------------------------------------------------------------------
+
+// loosely normalizes the specified view query and warn against multiple inline statements
+// (the check is not perfect and it is NOT intended as a security measure; it is done primarily to provide a helpful error message)
+func normalizeViewSelectQuery(dangerousSelectQuery string) (string, error) {
+ dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
+
+ tk := tokenizer.NewFromString(dangerousSelectQuery)
+ tk.Separators(';')
+ if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
+ return "", errors.New("multiple statements are not supported")
+ }
+
+ return dangerousSelectQuery, nil
+}
+
+type queryField struct {
+ // field is the final resolved field.
+ field Field
+
+ // collection refers to the original field's collection model.
+ // It could be nil if the found query field is not from a collection
+ collection *Collection
+
+ // original is the original found collection field.
+ // It could be nil if the found query field is not from a collection
+ original Field
+}
+
+func defaultViewField(name string) Field {
+ if name == FieldNameId {
+ return defaultViewIdField()
+ }
+
+ return &JSONField{
+ Name: name,
+ MaxSize: 1, // unused for views
+ }
+}
+
+func defaultViewIdField() Field {
+ return &TextField{
+ Name: FieldNameId,
+ System: true,
+ Required: true,
+ PrimaryKey: true,
+ Pattern: `^[a-z0-9]+$`,
+ }
+}
+
+var castRegex = regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
+
+func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
+ p := new(identifiersParser)
+ if err := p.parse(selectQuery); err != nil {
+ return nil, err
+ }
+
+ collections, err := findCollectionsByIdentifiers(app, p.tables)
+ if err != nil {
+ return nil, err
+ }
+
+ result := make(map[string]*queryField, len(p.columns))
+
+ var mainTable identifier
+
+ if len(p.tables) > 0 {
+ mainTable = p.tables[0]
+ }
+
+ for _, col := range p.columns {
+ // note: it should be safe to use the already parsed alias as there
+ // is no valid SQL where * column can be aliased to something else
+ if col.alias == "*" {
+ return nil, errors.New("wildcard columns (*) are not supported - manually type the collection field names you want the view query to have")
+ }
+
+ colLower := strings.ToLower(col.original)
+
+ // pk (always assume text field for now)
+ if col.alias == FieldNameId {
+ result[col.alias] = &queryField{
+ field: defaultViewIdField(),
+ }
+ continue
+ }
+
+ // numeric aggregations
+ if strings.HasPrefix(colLower, "count(") {
+ result[col.alias] = &queryField{
+ field: &NumberField{
+ Name: col.alias,
+ OnlyInt: true,
+ },
+ }
+ continue
+ }
+ if strings.HasPrefix(colLower, "total(") {
+ result[col.alias] = &queryField{
+ field: &NumberField{
+ Name: col.alias,
+ },
+ }
+ continue
+ }
+
+ castMatch := castRegex.FindStringSubmatch(colLower)
+
+ // casts
+ if len(castMatch) == 2 {
+ switch castMatch[1] {
+ case "real", "decimal", "numeric":
+ result[col.alias] = &queryField{
+ field: &NumberField{
+ Name: col.alias,
+ },
+ }
+ continue
+ case "int", "integer":
+ result[col.alias] = &queryField{
+ field: &NumberField{
+ Name: col.alias,
+ OnlyInt: true,
+ },
+ }
+ continue
+ case "text":
+ result[col.alias] = &queryField{
+ field: &TextField{
+ Name: col.alias,
+ },
+ }
+ continue
+ case "boolean", "bool":
+ result[col.alias] = &queryField{
+ field: &BoolField{
+ Name: col.alias,
+ },
+ }
+ continue
+ }
+ }
+
+ parts := strings.Split(col.original, ".")
+
+ var fieldName string
+ var collection *Collection
+
+ if len(parts) == 2 {
+ fieldName = parts[1]
+ collection = collections[parts[0]]
+ } else {
+ fieldName = parts[0]
+ collection = collections[mainTable.alias]
+ }
+
+ // fallback to the default field
+ if collection == nil {
+ result[col.alias] = &queryField{
+ field: defaultViewField(col.alias),
+ }
+ continue
+ }
+
+ // find the first field by name (case insensitive)
+ var field Field
+ for _, f := range collection.Fields {
+ if strings.EqualFold(f.GetName(), fieldName) {
+ field = f
+ break
+ }
+ }
+
+ // fallback to the default field
+ if field == nil {
+ result[col.alias] = &queryField{
+ field: defaultViewField(col.alias),
+ collection: collection,
+ }
+ continue
+ }
+
+ // convert to relation since it is an id reference
+ if strings.EqualFold(fieldName, FieldNameId) {
+ result[col.alias] = &queryField{
+ field: &RelationField{
+ Name: col.alias,
+ MaxSelect: 1,
+ CollectionId: collection.Id,
+ },
+ collection: collection,
+ }
+ continue
+ }
+
+ // we fetch a brand new collection object to avoid using reflection
+ // or having a dedicated Clone method for each field type
+ tempCollection, err := app.FindCollectionByNameOrId(collection.Id)
+ if err != nil {
+ return nil, err
+ }
+
+ clone := tempCollection.Fields.GetById(field.GetId())
+ if clone == nil {
+ return nil, fmt.Errorf("missing expected field %q (%q) in collection %q", field.GetName(), field.GetId(), tempCollection.Name)
+ }
+ // set new random id to prevent duplications if the same field is aliased multiple times
+ clone.SetId("_clone_" + security.PseudorandomString(4))
+ clone.SetName(col.alias)
+
+ result[col.alias] = &queryField{
+ original: field,
+ field: clone,
+ collection: collection,
+ }
+ }
+
+ return result, nil
+}
+
+func findCollectionsByIdentifiers(app App, tables []identifier) (map[string]*Collection, error) {
+ names := make([]any, 0, len(tables))
+
+ for _, table := range tables {
+ if strings.Contains(table.alias, "(") {
+ continue // skip expressions
+ }
+ names = append(names, table.original)
+ }
+
+ if len(names) == 0 {
+ return nil, nil
+ }
+
+ result := make(map[string]*Collection, len(names))
+ collections := make([]*Collection, 0, len(names))
+
+ err := app.CollectionQuery().
+ AndWhere(dbx.In("name", names...)).
+ All(&collections)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, table := range tables {
+ for _, collection := range collections {
+ if collection.Name == table.original {
+ result[table.alias] = collection
+ }
+ }
+ }
+
+ return result, nil
+}
+
+func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
+ tempView := "_temp_" + security.PseudorandomString(6)
+
+ var info []*TableInfoRow
+
+ txErr := app.RunInTransaction(func(txApp App) error {
+ // create a temp view with the provided query
+ err := txApp.SaveView(tempView, selectQuery)
+ if err != nil {
+ return err
+ }
+
+ // extract the generated view table info
+ info, err = txApp.TableInfo(tempView)
+
+ return errors.Join(err, txApp.DeleteView(tempView))
+ })
+
+ if txErr != nil {
+ return nil, txErr
+ }
+
+ return info, nil
+}
+
+// -------------------------------------------------------------------
+// Raw query identifiers parser
+// -------------------------------------------------------------------
+
+var (
+ joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
+ discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
+ commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
+)
+
+type identifier struct {
+ original string
+ alias string
+}
+
+type identifiersParser struct {
+ columns []identifier
+ tables []identifier
+}
+
+func (p *identifiersParser) parse(selectQuery string) error {
+ str := strings.Trim(strings.TrimSpace(selectQuery), ";")
+ str = commentsReplaceRegex.ReplaceAllString(str, " ")
+ str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
+ str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
+
+ tk := tokenizer.NewFromString(str)
+ tk.Separators(',', ' ', '\n', '\t')
+ tk.KeepSeparator(true)
+
+ var skip bool
+ var partType string
+ var activeBuilder *strings.Builder
+ var selectParts strings.Builder
+ var fromParts strings.Builder
+ var joinParts strings.Builder
+
+ for {
+ token, err := tk.Scan()
+ if err != nil {
+ if err != io.EOF {
+ return err
+ }
+ break
+ }
+
+ trimmed := strings.ToLower(strings.TrimSpace(token))
+
+ switch trimmed {
+ case "select":
+ skip = false
+ partType = "select"
+ activeBuilder = &selectParts
+ case "distinct":
+ continue // ignore as it is not important for the identifiers parsing
+ case "from":
+ skip = false
+ partType = "from"
+ activeBuilder = &fromParts
+ case "__pb_join__":
+ skip = false
+
+ // the previous part was also a join
+ if partType == "join" {
+ joinParts.WriteString(",")
+ }
+
+ partType = "join"
+ activeBuilder = &joinParts
+ case "__pb_discard__":
+ // skip following tokens
+ skip = true
+ default:
+ isJoin := partType == "join"
+
+ if isJoin && trimmed == "on" {
+ skip = true
+ }
+
+ if !skip && activeBuilder != nil {
+ activeBuilder.WriteString(" ")
+ activeBuilder.WriteString(token)
+ }
+ }
+ }
+
+ selects, err := extractIdentifiers(selectParts.String())
+ if err != nil {
+ return err
+ }
+
+ froms, err := extractIdentifiers(fromParts.String())
+ if err != nil {
+ return err
+ }
+
+ joins, err := extractIdentifiers(joinParts.String())
+ if err != nil {
+ return err
+ }
+
+ p.columns = selects
+ p.tables = froms
+ p.tables = append(p.tables, joins...)
+
+ return nil
+}
+
+func extractIdentifiers(rawExpression string) ([]identifier, error) {
+ rawTk := tokenizer.NewFromString(rawExpression)
+ rawTk.Separators(',')
+
+ rawIdentifiers, err := rawTk.ScanAll()
+ if err != nil {
+ return nil, err
+ }
+
+ result := make([]identifier, 0, len(rawIdentifiers))
+
+ for _, rawIdentifier := range rawIdentifiers {
+ tk := tokenizer.NewFromString(rawIdentifier)
+ tk.Separators(' ', '\n', '\t')
+
+ parts, err := tk.ScanAll()
+ if err != nil {
+ return nil, err
+ }
+
+ resolved, err := identifierFromParts(parts)
+ if err != nil {
+ return nil, err
+ }
+
+ result = append(result, resolved)
+ }
+
+ return result, nil
+}
+
+func identifierFromParts(parts []string) (identifier, error) {
+ var result identifier
+
+ switch len(parts) {
+ case 3:
+ if !strings.EqualFold(parts[1], "as") {
+ return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
+ }
+
+ result.original = parts[0]
+ result.alias = parts[2]
+ case 2:
+ result.original = parts[0]
+ result.alias = parts[1]
+ case 1:
+ subParts := strings.Split(parts[0], ".")
+ result.original = parts[0]
+ result.alias = subParts[len(subParts)-1]
+ default:
+ return result, fmt.Errorf(`invalid identifier parts %v`, parts)
+ }
+
+ result.original = trimRawIdentifier(result.original)
+
+ // we trim the single quote even though it is not a valid column quote character
+ // because SQLite allows it if the context expects an identifier and not string literal
+ // (https://www.sqlite.org/lang_keywords.html)
+ result.alias = trimRawIdentifier(result.alias, "'")
+
+ return result, nil
+}
+
+func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
+ trimChars := "`\"[];"
+ if len(extraTrimChars) > 0 {
+ trimChars += strings.Join(extraTrimChars, "")
+ }
+
+ parts := strings.Split(rawIdentifier, ".")
+
+ for i := range parts {
+ parts[i] = strings.Trim(parts[i], trimChars)
+ }
+
+ return strings.Join(parts, ".")
+}
diff --git a/core/view_test.go b/core/view_test.go
new file mode 100644
index 0000000000..56685579ef
--- /dev/null
+++ b/core/view_test.go
@@ -0,0 +1,866 @@
+package core_test
+
+import (
+ "encoding/json/v2"
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func ensureNoTempViews(app core.App, t *testing.T) {
+ var total int
+
+ err := app.DB().Select("count(*)").
+ From("sqlite_schema").
+ AndWhere(dbx.HashExp{"type": "view"}).
+ AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
+ Limit(1).
+ Row(&total)
+ if err != nil {
+ t.Fatalf("Failed to check for temp views: %v", err)
+ }
+
+ if total > 0 {
+ t.Fatalf("Expected all temp views to be deleted, got %d", total)
+ }
+}
+
+func TestDeleteView(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ viewName string
+ expectError bool
+ }{
+ {"", true},
+ {"demo1", true}, // not a view table
+ {"missing", false}, // missing or already deleted
+ {"view1", false}, // existing
+ {"VieW1", false}, // view names are case insensitives
+ }
+
+ for i, s := range scenarios {
+ err := app.DeleteView(s.viewName)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
+ }
+ }
+
+ ensureNoTempViews(app, t)
+}
+
+func TestSaveView(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ scenarioName string
+ viewName string
+ query string
+ expectError bool
+ expectColumns []string
+ }{
+ {
+ "empty name and query",
+ "",
+ "",
+ true,
+ nil,
+ },
+ {
+ "empty name",
+ "",
+ "select * from " + core.CollectionNameSuperusers,
+ true,
+ nil,
+ },
+ {
+ "empty query",
+ "123Test",
+ "",
+ true,
+ nil,
+ },
+ {
+ "invalid query",
+ "123Test",
+ "123 456",
+ true,
+ nil,
+ },
+ {
+ "missing table",
+ "123Test",
+ "select id from missing",
+ true,
+ nil,
+ },
+ {
+ "non select query",
+ "123Test",
+ "drop table " + core.CollectionNameSuperusers,
+ true,
+ nil,
+ },
+ {
+ "multiple select queries",
+ "123Test",
+ "select *, count(id) as c from " + core.CollectionNameSuperusers + "; select * from demo1;",
+ true,
+ nil,
+ },
+ {
+ "try to break the parent parenthesis",
+ "123Test",
+ "select *, count(id) as c from `" + core.CollectionNameSuperusers + "`)",
+ true,
+ nil,
+ },
+ {
+ "simple select query (+ trimmed semicolon)",
+ "123Test",
+ ";select *, count(id) as c from " + core.CollectionNameSuperusers + ";",
+ false,
+ []string{
+ "id", "created", "updated",
+ "password", "tokenKey", "email",
+ "emailVisibility", "verified",
+ "c",
+ },
+ },
+ {
+ "update old view with new query",
+ "123Test",
+ "select 1 as test from " + core.CollectionNameSuperusers,
+ false,
+ []string{"test"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.scenarioName, func(t *testing.T) {
+ err := app.SaveView(s.viewName, s.query)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ infoRows, err := app.TableInfo(s.viewName)
+ if err != nil {
+ t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
+ }
+
+ if len(s.expectColumns) != len(infoRows) {
+ t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
+ }
+
+ for _, row := range infoRows {
+ if !slices.Contains(s.expectColumns, row.Name) {
+ t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
+ }
+ }
+ })
+ }
+
+ ensureNoTempViews(app, t)
+}
+
+func TestCreateViewFieldsWithDiscardedNestedTransaction(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ app.RunInTransaction(func(txApp core.App) error {
+ _, err := txApp.CreateViewFields("select id from missing")
+ if err == nil {
+ t.Fatal("Expected error, got nil")
+ }
+
+ return nil
+ })
+
+ ensureNoTempViews(app, t)
+}
+
+func TestCreateViewFields(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ query string
+ expectError bool
+ expectFields map[string]string // name-type pairs
+ }{
+ {
+ "empty query",
+ "",
+ true,
+ nil,
+ },
+ {
+ "invalid query",
+ "test 123456",
+ true,
+ nil,
+ },
+ {
+ "missing table",
+ "select id from missing",
+ true,
+ nil,
+ },
+ {
+ "query with wildcard column",
+ "select a.id, a.* from demo1 a",
+ true,
+ nil,
+ },
+ {
+ "wrapped query with wildcard column",
+ "select * from (select 1 as id)",
+ true,
+ nil,
+ },
+ {
+ "query without id",
+ "select text, url, created, updated from demo1",
+ true,
+ nil,
+ },
+ {
+ "query with comments",
+ `
+ select
+ -- test single line
+ demo1.id,
+ demo1.text,
+ /* multi
+ * line comment block */
+ demo1.url, demo1.created, demo1.updated from/* inline comment block with no spaces between the identifiers */demo1
+ -- comment before join
+ join demo2 ON (
+ -- comment inside join
+ demo2.id = demo1.id
+ )
+ -- comment before where
+ where (
+ -- comment inside where
+ demo2.id = demo1.id
+ )
+ `,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "text": core.FieldTypeText,
+ "url": core.FieldTypeURL,
+ "created": core.FieldTypeAutodate,
+ "updated": core.FieldTypeAutodate,
+ },
+ },
+ {
+ "query with all fields and quoted identifiers",
+ `
+ select
+ "id",
+ "created",
+ "updated",
+ [text],
+ ` + "`bool`" + `,
+ "url",
+ "select_one",
+ "select_many",
+ "file_one",
+ "demo1"."file_many",
+ ` + "`demo1`." + "`number`" + ` number_alias,
+ "email",
+ "datetime",
+ "json",
+ "rel_one",
+ "rel_many",
+ 'single_quoted_custom_literal' as 'single_quoted_column'
+ from demo1
+ `,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "created": core.FieldTypeAutodate,
+ "updated": core.FieldTypeAutodate,
+ "text": core.FieldTypeText,
+ "bool": core.FieldTypeBool,
+ "url": core.FieldTypeURL,
+ "select_one": core.FieldTypeSelect,
+ "select_many": core.FieldTypeSelect,
+ "file_one": core.FieldTypeFile,
+ "file_many": core.FieldTypeFile,
+ "number_alias": core.FieldTypeNumber,
+ "email": core.FieldTypeEmail,
+ "datetime": core.FieldTypeDate,
+ "json": core.FieldTypeJSON,
+ "rel_one": core.FieldTypeRelation,
+ "rel_many": core.FieldTypeRelation,
+ "single_quoted_column": core.FieldTypeJSON,
+ },
+ },
+ {
+ "query with indirect relations fields",
+ "select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "bid": core.FieldTypeRelation,
+ "created": core.FieldTypeAutodate,
+ },
+ },
+ {
+ "query with multiple froms, joins and style of aliases",
+ `
+ select
+ a.id as id,
+ b.id as bid,
+ lj.id cid,
+ ij.id as did,
+ a.bool,
+ ` + core.CollectionNameSuperusers + `.id as eid,
+ ` + core.CollectionNameSuperusers + `.email
+ from demo1 a, demo2 as b
+ left join demo3 lj on lj.id = 123
+ inner join demo4 as ij on ij.id = 123
+ join ` + core.CollectionNameSuperusers + `
+ where 1=1
+ group by a.id
+ limit 10
+ `,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "bid": core.FieldTypeRelation,
+ "cid": core.FieldTypeRelation,
+ "did": core.FieldTypeRelation,
+ "bool": core.FieldTypeBool,
+ "eid": core.FieldTypeRelation,
+ "email": core.FieldTypeEmail,
+ },
+ },
+ {
+ "query with casts",
+ `select
+ a.id,
+ count(a.id) count,
+ cast(a.id as int) cast_int,
+ cast(a.id as integer) cast_integer,
+ cast(a.id as real) cast_real,
+ cast(a.id as decimal) cast_decimal,
+ cast(a.id as numeric) cast_numeric,
+ cast(a.id as text) cast_text,
+ cast(a.id as bool) cast_bool,
+ cast(a.id as boolean) cast_boolean,
+ avg(a.id) avg,
+ sum(a.id) sum,
+ total(a.id) total,
+ min(a.id) min,
+ max(a.id) max
+ from demo1 a`,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "count": core.FieldTypeNumber,
+ "total": core.FieldTypeNumber,
+ "cast_int": core.FieldTypeNumber,
+ "cast_integer": core.FieldTypeNumber,
+ "cast_real": core.FieldTypeNumber,
+ "cast_decimal": core.FieldTypeNumber,
+ "cast_numeric": core.FieldTypeNumber,
+ "cast_text": core.FieldTypeText,
+ "cast_bool": core.FieldTypeBool,
+ "cast_boolean": core.FieldTypeBool,
+ // json because they are nullable
+ "sum": core.FieldTypeJSON,
+ "avg": core.FieldTypeJSON,
+ "min": core.FieldTypeJSON,
+ "max": core.FieldTypeJSON,
+ },
+ },
+ {
+ "query with multiline cast",
+ `select
+ id,
+ cast(
+ (
+ case
+ when count(a.id) = 1 then 21
+ when count(a.id) = 2 then 18
+ else 0
+ end
+ ) as int
+ ) as cast_int
+ from demo1 a`,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "cast_int": core.FieldTypeNumber,
+ },
+ },
+ {
+ "query with case-insensitive and extra-spaced cast",
+ `select
+ id,
+ CaSt( a.id aS iNt ) as cast_int
+ from demo1 a`,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "cast_int": core.FieldTypeNumber,
+ },
+ },
+ {
+ "query with reserved auth collection fields",
+ `
+ select
+ a.id,
+ a.username,
+ a.email,
+ a.emailVisibility,
+ a.verified,
+ demo1.id relid
+ from users a
+ left join demo1
+ `,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "username": core.FieldTypeText,
+ "email": core.FieldTypeEmail,
+ "emailVisibility": core.FieldTypeBool,
+ "verified": core.FieldTypeBool,
+ "relid": core.FieldTypeRelation,
+ },
+ },
+ {
+ "query with unknown fields and aliases",
+ `select
+ id,
+ id as id2,
+ text as text_alias,
+ url as url_alias,
+ "demo1"."bool" as bool_alias,
+ number as number_alias,
+ created created_alias,
+ updated updated_alias,
+ 123 as custom
+ from demo1`,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "id2": core.FieldTypeRelation,
+ "text_alias": core.FieldTypeText,
+ "url_alias": core.FieldTypeURL,
+ "bool_alias": core.FieldTypeBool,
+ "number_alias": core.FieldTypeNumber,
+ "created_alias": core.FieldTypeAutodate,
+ "updated_alias": core.FieldTypeAutodate,
+ "custom": core.FieldTypeJSON,
+ },
+ },
+ {
+ "query with distinct and reordered id column",
+ `select distinct
+ id as id2,
+ id,
+ 123 as custom
+ from demo1`,
+ false,
+ map[string]string{
+ "id2": core.FieldTypeRelation,
+ "id": core.FieldTypeText,
+ "custom": core.FieldTypeJSON,
+ },
+ },
+ {
+ "query with aliasing the same field multiple times",
+ `select
+ a.id as id,
+ a.text as alias1,
+ a.text as alias2,
+ b.text as alias3,
+ b.text as alias4
+ from demo1 a
+ left join demo1 as b`,
+ false,
+ map[string]string{
+ "id": core.FieldTypeText,
+ "alias1": core.FieldTypeText,
+ "alias2": core.FieldTypeText,
+ "alias3": core.FieldTypeText,
+ "alias4": core.FieldTypeText,
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result, err := app.CreateViewFields(s.query)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if len(s.expectFields) != len(result) {
+ serialized, _ := json.Marshal(result, json.Deterministic(true))
+ t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result), serialized)
+ }
+
+ for name, typ := range s.expectFields {
+ field := result.GetByName(name)
+
+ if field == nil {
+ t.Fatalf("Expected to find field %s, got nil", name)
+ }
+
+ if field.Type() != typ {
+ t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type())
+ }
+ }
+ })
+ }
+
+ ensureNoTempViews(app, t)
+}
+
+func TestCreateViewFieldsWithNumberOnlyInt(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ sql := `select
+ a.id,
+ count(a.id) count,
+ total(a.id) total,
+ cast(a.id as int) cast_int,
+ cast(a.id as integer) cast_integer,
+ cast(a.id as real) cast_real,
+ cast(a.id as decimal) cast_decimal,
+ cast(a.id as numeric) cast_numeric
+ from demo1 a`
+
+ result, err := app.CreateViewFields(sql)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ onlyInts := map[string]bool{
+ "count": true,
+ "total": false,
+ "cast_int": true,
+ "cast_integer": true,
+ "cast_real": false,
+ "cast_decimal": false,
+ "cast_numeric": false,
+ }
+
+ totalExpected := len(onlyInts) + 1
+ if total := len(result); total != totalExpected {
+ t.Fatalf("Expected %d, got %d", totalExpected, total)
+ }
+
+ for _, f := range result {
+ if f.GetName() == "id" {
+ continue
+ }
+
+ t.Run(f.GetName(), func(t *testing.T) {
+ nf, ok := f.(*core.NumberField)
+ if !ok {
+ t.Fatalf("Expected *core.NumberField, got %v", f)
+ }
+
+ if nf.OnlyInt != onlyInts[nf.Name] {
+ t.Fatalf("Expected OnlyInt %v, got %v", onlyInts[nf.Name], nf.OnlyInt)
+ }
+ })
+ }
+}
+
+func TestFindRecordByViewFile(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ prevCollection, err := app.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ totalLevels := 6
+
+ // create collection view mocks
+ fileOneAlias := "file_one one0"
+ fileManyAlias := "file_many many0"
+ mockCollections := make([]*core.Collection, 0, totalLevels)
+ for i := 0; i <= totalLevels; i++ {
+ view := new(core.Collection)
+ view.Type = core.CollectionTypeView
+ view.Name = fmt.Sprintf("_test_view%d", i)
+ view.ViewQuery = fmt.Sprintf(
+ "select id, %s, %s from %s",
+ fileOneAlias,
+ fileManyAlias,
+ prevCollection.Name,
+ )
+
+ // save view
+ if err := app.Save(view); err != nil {
+ t.Fatalf("Failed to save view%d: %v", i, err)
+ }
+
+ mockCollections = append(mockCollections, view)
+ prevCollection = view
+ fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
+ fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
+ }
+
+ fileOneName := "test_d61b33QdDU.txt"
+ fileManyName := "test_QZFjKjXchk.txt"
+ expectedRecordId := "84nmscqy84lsi1t"
+
+ scenarios := []struct {
+ name string
+ collectionNameOrId string
+ fileFieldName string
+ filename string
+ expectError bool
+ expectRecordId string
+ }{
+ {
+ "missing collection",
+ "missing",
+ "a",
+ fileOneName,
+ true,
+ "",
+ },
+ {
+ "non-view collection",
+ "demo1",
+ "file_one",
+ fileOneName,
+ true,
+ "",
+ },
+ {
+ "view collection after the max recursion limit",
+ mockCollections[totalLevels-1].Name,
+ fmt.Sprintf("one%d", totalLevels-1),
+ fileOneName,
+ true,
+ "",
+ },
+ {
+ "first view collection (single file)",
+ mockCollections[0].Name,
+ "one0",
+ fileOneName,
+ false,
+ expectedRecordId,
+ },
+ {
+ "first view collection (many files)",
+ mockCollections[0].Name,
+ "many0",
+ fileManyName,
+ false,
+ expectedRecordId,
+ },
+ {
+ "last view collection before the recursion limit (single file)",
+ mockCollections[totalLevels-2].Name,
+ fmt.Sprintf("one%d", totalLevels-2),
+ fileOneName,
+ false,
+ expectedRecordId,
+ },
+ {
+ "last view collection before the recursion limit (many files)",
+ mockCollections[totalLevels-2].Name,
+ fmt.Sprintf("many%d", totalLevels-2),
+ fileManyName,
+ false,
+ expectedRecordId,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ record, err := app.FindRecordByViewFile(
+ s.collectionNameOrId,
+ s.fileFieldName,
+ s.filename,
+ )
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ if record.Id != s.expectRecordId {
+ t.Fatalf("Expected recordId %q, got %q", s.expectRecordId, record.Id)
+ }
+ })
+ }
+}
+
+func TestDryRunView(t *testing.T) {
+ t.Parallel()
+
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ scenarios := []struct {
+ name string
+ query string
+ sampleSize int
+ expectError bool
+ expectFields map[string]string // name-type pairs
+ expectSampleIds []string // record ids of the resulting sample
+ }{
+ {
+ "empty query",
+ "",
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "non-select query",
+ "CREATE TABLE t1(x INT)",
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "multiple inline select statements",
+ "select 'a' as id; select 'b' as id",
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "select with invalid formatted field name",
+ "select 'a' as id, count(*)", // missing field alias
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "select resolving to records with missing id",
+ "select id from (select 'a' as id UNION ALL select null as id UNION ALL select 'c' as id)",
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "select resolving to records with duplicated ids",
+ "select id from (select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
+ 10,
+ true,
+ nil,
+ nil,
+ },
+ {
+ "no sample size and valid select query but with invalid records result",
+ "select id from (select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
+ 0,
+ false, // still "valid" because there is no sample to check
+ map[string]string{"id": "text"},
+ nil,
+ },
+ {
+ "sample size < total select records",
+ "select id from (select 'a' as id UNION ALL select 'b' as id UNION ALL select 'c' as id UNION ALL select 'd' as id)",
+ 3,
+ false,
+ map[string]string{"id": "text"},
+ []string{"a", "b", "c"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ result, err := app.DryRunView(s.query, s.sampleSize)
+
+ hasErr := err != nil
+ if hasErr != s.expectError {
+ t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
+ }
+
+ if hasErr {
+ return
+ }
+
+ // check fields
+ // ---
+ if len(s.expectFields) != len(result.Fields) {
+ serialized, _ := json.Marshal(result.Fields, json.Deterministic(true))
+ t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized)
+ }
+ for name, typ := range s.expectFields {
+ field := result.Fields.GetByName(name)
+ if field == nil {
+ t.Fatalf("Expected to find field %s, got nil", name)
+ }
+
+ if field.Type() != typ {
+ t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type())
+ }
+ }
+
+ // check sample ids
+ // ---
+ if len(s.expectSampleIds) != len(result.Sample) {
+ t.Fatalf("Expected %d sample records, got %d", len(s.expectSampleIds), len(result.Sample))
+ }
+ for i, r := range result.Sample {
+ if s.expectSampleIds[i] != r.Id {
+ t.Fatalf("Expected sample record id %q, got %q at %d", s.expectSampleIds[i], r.Id, i)
+ }
+ }
+ })
+ }
+
+ ensureNoTempViews(app, t)
+}
diff --git a/daos/admin.go b/daos/admin.go
deleted file mode 100644
index 9695273cce..0000000000
--- a/daos/admin.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package daos
-
-import (
- "errors"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// AdminQuery returns a new Admin select query.
-func (dao *Dao) AdminQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.Admin{})
-}
-
-// FindAdminById finds the admin with the provided id.
-func (dao *Dao) FindAdminById(id string) (*models.Admin, error) {
- model := &models.Admin{}
-
- err := dao.AdminQuery().
- AndWhere(dbx.HashExp{"id": id}).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// FindAdminByEmail finds the admin with the provided email address.
-func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
- model := &models.Admin{}
-
- err := dao.AdminQuery().
- AndWhere(dbx.HashExp{"email": email}).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// FindAdminByToken finds the admin associated with the provided JWT token.
-//
-// Returns an error if the JWT token is invalid or expired.
-func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
- // @todo consider caching the unverified claims
- unverifiedClaims, err := security.ParseUnverifiedJWT(token)
- if err != nil {
- return nil, err
- }
-
- // check required claims
- id, _ := unverifiedClaims["id"].(string)
- if id == "" {
- return nil, errors.New("Missing or invalid token claims.")
- }
-
- admin, err := dao.FindAdminById(id)
- if err != nil || admin == nil {
- return nil, err
- }
-
- verificationKey := admin.TokenKey + baseTokenKey
-
- // verify token signature
- if _, err := security.ParseJWT(token, verificationKey); err != nil {
- return nil, err
- }
-
- return admin, nil
-}
-
-// TotalAdmins returns the number of existing admin records.
-func (dao *Dao) TotalAdmins() (int, error) {
- var total int
-
- err := dao.AdminQuery().Select("count(*)").Row(&total)
-
- return total, err
-}
-
-// IsAdminEmailUnique checks if the provided email address is not
-// already in use by other admins.
-func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
- if email == "" {
- return false
- }
-
- query := dao.AdminQuery().Select("count(*)").
- AndWhere(dbx.HashExp{"email": email}).
- Limit(1)
-
- if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
- query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
- }
-
- var exists bool
-
- return query.Row(&exists) == nil && !exists
-}
-
-// DeleteAdmin deletes the provided Admin model.
-//
-// Returns an error if there is only 1 admin.
-func (dao *Dao) DeleteAdmin(admin *models.Admin) error {
- total, err := dao.TotalAdmins()
- if err != nil {
- return err
- }
-
- if total == 1 {
- return errors.New("You cannot delete the only existing admin.")
- }
-
- return dao.Delete(admin)
-}
-
-// SaveAdmin upserts the provided Admin model.
-func (dao *Dao) SaveAdmin(admin *models.Admin) error {
- return dao.Save(admin)
-}
diff --git a/daos/admin_test.go b/daos/admin_test.go
deleted file mode 100644
index d49f5972b8..0000000000
--- a/daos/admin_test.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package daos_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestAdminQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_admins}}.* FROM `_admins`"
-
- sql := app.Dao().AdminQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestFindAdminById(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- expectError bool
- }{
- {" ", true},
- {"missing", true},
- {"9q2trqumvlyr3bd", false},
- }
-
- for i, scenario := range scenarios {
- admin, err := app.Dao().FindAdminById(scenario.id)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if admin != nil && admin.Id != scenario.id {
- t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id)
- }
- }
-}
-
-func TestFindAdminByEmail(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- email string
- expectError bool
- }{
- {"", true},
- {"invalid", true},
- {"missing@example.com", true},
- {"test@example.com", false},
- }
-
- for i, scenario := range scenarios {
- admin, err := app.Dao().FindAdminByEmail(scenario.email)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && admin.Email != scenario.email {
- t.Errorf("(%d) Expected admin with email %s, got %s", i, scenario.email, admin.Email)
- }
- }
-}
-
-func TestFindAdminByToken(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- token string
- baseKey string
- expectedEmail string
- expectError bool
- }{
- // invalid auth token
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
- app.Settings().AdminAuthToken.Secret,
- "",
- true,
- },
- // expired token
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
- app.Settings().AdminAuthToken.Secret,
- "",
- true,
- },
- // wrong base token (password reset token secret instead of auth secret)
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- app.Settings().AdminPasswordResetToken.Secret,
- "",
- true,
- },
- // valid token
- {
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
- app.Settings().AdminAuthToken.Secret,
- "test@example.com",
- false,
- },
- }
-
- for i, scenario := range scenarios {
- admin, err := app.Dao().FindAdminByToken(scenario.token, scenario.baseKey)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && admin.Email != scenario.expectedEmail {
- t.Errorf("(%d) Expected admin model %s, got %s", i, scenario.expectedEmail, admin.Email)
- }
- }
-}
-
-func TestTotalAdmins(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- result1, err := app.Dao().TotalAdmins()
- if err != nil {
- t.Fatal(err)
- }
- if result1 != 3 {
- t.Fatalf("Expected 3 admins, got %d", result1)
- }
-
- // delete all
- app.Dao().DB().NewQuery("delete from {{_admins}}").Execute()
-
- result2, err := app.Dao().TotalAdmins()
- if err != nil {
- t.Fatal(err)
- }
- if result2 != 0 {
- t.Fatalf("Expected 0 admins, got %d", result2)
- }
-}
-
-func TestIsAdminEmailUnique(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- email string
- excludeId string
- expected bool
- }{
- {"", "", false},
- {"test@example.com", "", false},
- {"test2@example.com", "", false},
- {"test3@example.com", "", false},
- {"new@example.com", "", true},
- {"test@example.com", "sywbhecnh46rhm0", true},
- }
-
- for i, scenario := range scenarios {
- result := app.Dao().IsAdminEmailUnique(scenario.email, scenario.excludeId)
- if result != scenario.expected {
- t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
- }
- }
-}
-
-func TestDeleteAdmin(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // try to delete unsaved admin model
- deleteErr0 := app.Dao().DeleteAdmin(&models.Admin{})
- if deleteErr0 == nil {
- t.Fatal("Expected error, got nil")
- }
-
- admin1, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- admin2, err := app.Dao().FindAdminByEmail("test2@example.com")
- if err != nil {
- t.Fatal(err)
- }
- admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- deleteErr1 := app.Dao().DeleteAdmin(admin1)
- if deleteErr1 != nil {
- t.Fatal(deleteErr1)
- }
-
- deleteErr2 := app.Dao().DeleteAdmin(admin2)
- if deleteErr2 != nil {
- t.Fatal(deleteErr2)
- }
-
- // cannot delete the only remaining admin
- deleteErr3 := app.Dao().DeleteAdmin(admin3)
- if deleteErr3 == nil {
- t.Fatal("Expected delete error, got nil")
- }
-
- total, _ := app.Dao().TotalAdmins()
- if total != 1 {
- t.Fatalf("Expected only 1 admin, got %d", total)
- }
-}
-
-func TestSaveAdmin(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create
- newAdmin := &models.Admin{}
- newAdmin.Email = "new@example.com"
- newAdmin.SetPassword("123456")
- saveErr1 := app.Dao().SaveAdmin(newAdmin)
- if saveErr1 != nil {
- t.Fatal(saveErr1)
- }
- if newAdmin.Id == "" {
- t.Fatal("Expected admin id to be set")
- }
-
- // update
- existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
- updatedEmail := "test_update@example.com"
- existingAdmin.Email = updatedEmail
- saveErr2 := app.Dao().SaveAdmin(existingAdmin)
- if saveErr2 != nil {
- t.Fatal(saveErr2)
- }
- existingAdmin, _ = app.Dao().FindAdminById(existingAdmin.Id)
- if existingAdmin.Email != updatedEmail {
- t.Fatalf("Expected admin email to be %s, got %s", updatedEmail, existingAdmin.Email)
- }
-}
diff --git a/daos/base.go b/daos/base.go
deleted file mode 100644
index 48303f23da..0000000000
--- a/daos/base.go
+++ /dev/null
@@ -1,379 +0,0 @@
-// Package daos handles common PocketBase DB model manipulations.
-//
-// Think of daos as DB repository and service layer in one.
-package daos
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// New creates a new Dao instance with the provided db builder
-// (for both async and sync db operations).
-func New(db dbx.Builder) *Dao {
- return NewMultiDB(db, db)
-}
-
-// New creates a new Dao instance with the provided dedicated
-// async and sync db builders.
-func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
- return &Dao{
- concurrentDB: concurrentDB,
- nonconcurrentDB: nonconcurrentDB,
- MaxLockRetries: 8,
- ModelQueryTimeout: 30 * time.Second,
- }
-}
-
-// Dao handles various db operations.
-//
-// You can think of Dao as a repository and service layer in one.
-type Dao struct {
- // in a transaction both refer to the same *dbx.TX instance
- concurrentDB dbx.Builder
- nonconcurrentDB dbx.Builder
-
- // MaxLockRetries specifies the default max "database is locked" auto retry attempts.
- MaxLockRetries int
-
- // ModelQueryTimeout is the default max duration of a running ModelQuery().
- //
- // This field has no effect if an explicit query context is already specified.
- ModelQueryTimeout time.Duration
-
- // write hooks
- BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
- AfterCreateFunc func(eventDao *Dao, m models.Model) error
- BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
- AfterUpdateFunc func(eventDao *Dao, m models.Model) error
- BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
- AfterDeleteFunc func(eventDao *Dao, m models.Model) error
-}
-
-// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
-//
-// Currently the default db builder is dao.concurrentDB but that may change in the future.
-func (dao *Dao) DB() dbx.Builder {
- return dao.ConcurrentDB()
-}
-
-// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
-// db builder (*dbx.DB or *dbx.TX).
-//
-// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
-func (dao *Dao) ConcurrentDB() dbx.Builder {
- return dao.concurrentDB
-}
-
-// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
-// db builder (*dbx.DB or *dbx.TX).
-//
-// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
-func (dao *Dao) NonconcurrentDB() dbx.Builder {
- return dao.nonconcurrentDB
-}
-
-// Clone returns a new Dao with the same configuration options as the current one.
-func (dao *Dao) Clone() *Dao {
- clone := *dao
-
- return &clone
-}
-
-// WithoutHooks returns a new Dao with the same configuration options
-// as the current one, but without create/update/delete hooks.
-func (dao *Dao) WithoutHooks() *Dao {
- new := dao.Clone()
-
- new.BeforeCreateFunc = nil
- new.AfterCreateFunc = nil
- new.BeforeUpdateFunc = nil
- new.AfterUpdateFunc = nil
- new.BeforeDeleteFunc = nil
- new.AfterDeleteFunc = nil
-
- return new
-}
-
-// ModelQuery creates a new preconfigured select query with preset
-// SELECT, FROM and other common fields based on the provided model.
-func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
- tableName := m.TableName()
-
- return dao.DB().
- Select("{{" + tableName + "}}.*").
- From(tableName).
- WithBuildHook(func(query *dbx.Query) {
- query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries))
- })
-}
-
-// FindById finds a single db record with the specified id and
-// scans the result into m.
-func (dao *Dao) FindById(m models.Model, id string) error {
- return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m)
-}
-
-type afterCallGroup struct {
- Action string
- EventDao *Dao
- Model models.Model
-}
-
-// RunInTransaction wraps fn into a transaction.
-//
-// It is safe to nest RunInTransaction calls as long as you use the txDao.
-func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
- switch txOrDB := dao.NonconcurrentDB().(type) {
- case *dbx.Tx:
- // nested transactions are not supported by default
- // so execute the function within the current transaction
- // ---
- // create a new dao with the same hooks to avoid semaphore deadlock when nesting
- txDao := New(txOrDB)
- txDao.MaxLockRetries = dao.MaxLockRetries
- txDao.ModelQueryTimeout = dao.ModelQueryTimeout
- txDao.BeforeCreateFunc = dao.BeforeCreateFunc
- txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
- txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
- txDao.AfterCreateFunc = dao.AfterCreateFunc
- txDao.AfterUpdateFunc = dao.AfterUpdateFunc
- txDao.AfterDeleteFunc = dao.AfterDeleteFunc
-
- return fn(txDao)
- case *dbx.DB:
- afterCalls := []afterCallGroup{}
-
- txError := txOrDB.Transactional(func(tx *dbx.Tx) error {
- txDao := New(tx)
-
- if dao.BeforeCreateFunc != nil {
- txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
- return dao.BeforeCreateFunc(eventDao, m, action)
- }
- }
- if dao.BeforeUpdateFunc != nil {
- txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
- return dao.BeforeUpdateFunc(eventDao, m, action)
- }
- }
- if dao.BeforeDeleteFunc != nil {
- txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
- return dao.BeforeDeleteFunc(eventDao, m, action)
- }
- }
-
- if dao.AfterCreateFunc != nil {
- txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
- afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
- return nil
- }
- }
- if dao.AfterUpdateFunc != nil {
- txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
- afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
- return nil
- }
- }
- if dao.AfterDeleteFunc != nil {
- txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
- afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
- return nil
- }
- }
-
- return fn(txDao)
- })
- if txError != nil {
- return txError
- }
-
- // execute after event calls on successful transaction
- // (note: using the non-transaction dao to allow following queries in the after hooks)
- var errs []error
- for _, call := range afterCalls {
- var err error
- switch call.Action {
- case "create":
- err = dao.AfterCreateFunc(dao, call.Model)
- case "update":
- err = dao.AfterUpdateFunc(dao, call.Model)
- case "delete":
- err = dao.AfterDeleteFunc(dao, call.Model)
- }
-
- if err != nil {
- errs = append(errs, err)
- }
- }
- if len(errs) > 0 {
- // @todo after go 1.20+ upgrade consider replacing with errors.Join()
- var errsMsg strings.Builder
- for _, err := range errs {
- errsMsg.WriteString(err.Error())
- errsMsg.WriteString("; ")
- }
- return fmt.Errorf("after transaction errors: %s", errsMsg.String())
- }
-
- return nil
- }
-
- return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
-}
-
-// Delete deletes the provided model.
-func (dao *Dao) Delete(m models.Model) error {
- if !m.HasId() {
- return errors.New("ID is not set")
- }
-
- return dao.lockRetry(func(retryDao *Dao) error {
- action := func() error {
- if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
- return err
- }
-
- if retryDao.AfterDeleteFunc != nil {
- retryDao.AfterDeleteFunc(retryDao, m)
- }
-
- return nil
- }
-
- if retryDao.BeforeDeleteFunc != nil {
- return retryDao.BeforeDeleteFunc(retryDao, m, action)
- }
-
- return action()
- })
-}
-
-// Save persists the provided model in the database.
-//
-// If m.IsNew() is true, the method will perform a create, otherwise an update.
-// To explicitly mark a model for update you can use m.MarkAsNotNew().
-func (dao *Dao) Save(m models.Model) error {
- if m.IsNew() {
- return dao.lockRetry(func(retryDao *Dao) error {
- return retryDao.create(m)
- })
- }
-
- return dao.lockRetry(func(retryDao *Dao) error {
- return retryDao.update(m)
- })
-}
-
-func (dao *Dao) update(m models.Model) error {
- if !m.HasId() {
- return errors.New("ID is not set")
- }
-
- if m.GetCreated().IsZero() {
- m.RefreshCreated()
- }
-
- m.RefreshUpdated()
-
- action := func() error {
- if v, ok := any(m).(models.ColumnValueMapper); ok {
- dataMap := v.ColumnValueMap()
-
- _, err := dao.NonconcurrentDB().Update(
- m.TableName(),
- dataMap,
- dbx.HashExp{"id": m.GetId()},
- ).Execute()
-
- if err != nil {
- return err
- }
- } else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
- return err
- }
-
- if dao.AfterUpdateFunc != nil {
- return dao.AfterUpdateFunc(dao, m)
- }
-
- return nil
- }
-
- if dao.BeforeUpdateFunc != nil {
- return dao.BeforeUpdateFunc(dao, m, action)
- }
-
- return action()
-}
-
-func (dao *Dao) create(m models.Model) error {
- if !m.HasId() {
- // auto generate id
- m.RefreshId()
- }
-
- // mark the model as "new" since the model now always has an ID
- m.MarkAsNew()
-
- if m.GetCreated().IsZero() {
- m.RefreshCreated()
- }
-
- if m.GetUpdated().IsZero() {
- m.RefreshUpdated()
- }
-
- action := func() error {
- if v, ok := any(m).(models.ColumnValueMapper); ok {
- dataMap := v.ColumnValueMap()
- if _, ok := dataMap["id"]; !ok {
- dataMap["id"] = m.GetId()
- }
-
- _, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
- if err != nil {
- return err
- }
- } else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
- return err
- }
-
- // clears the "new" model flag
- m.MarkAsNotNew()
-
- if dao.AfterCreateFunc != nil {
- return dao.AfterCreateFunc(dao, m)
- }
-
- return nil
- }
-
- if dao.BeforeCreateFunc != nil {
- return dao.BeforeCreateFunc(dao, m, action)
- }
-
- return action()
-}
-
-func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
- retryDao := dao
-
- return baseLockRetry(func(attempt int) error {
- if attempt == 2 {
- // assign new Dao without the before hooks to avoid triggering
- // the already fired before callbacks multiple times
- retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
- retryDao.AfterCreateFunc = dao.AfterCreateFunc
- retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
- retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
- }
-
- return op(retryDao)
- }, dao.MaxLockRetries)
-}
diff --git a/daos/base_retry.go b/daos/base_retry.go
deleted file mode 100644
index 05905c0758..0000000000
--- a/daos/base_retry.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package daos
-
-import (
- "context"
- "strings"
- "time"
-
- "github.com/pocketbase/dbx"
-)
-
-// default retries intervals (in ms)
-var defaultRetryIntervals = []int{100, 250, 350, 500, 700, 1000}
-
-func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
- return func(q *dbx.Query, op func() error) error {
- if q.Context() == nil {
- cancelCtx, cancel := context.WithTimeout(context.Background(), timeout)
- defer func() {
- cancel()
- //nolint:staticcheck
- q.WithContext(nil) // reset
- }()
- q.WithContext(cancelCtx)
- }
-
- return baseLockRetry(func(attempt int) error {
- return op()
- }, maxRetries)
- }
-}
-
-func baseLockRetry(op func(attempt int) error, maxRetries int) error {
- attempt := 1
-
-Retry:
- err := op(attempt)
-
- if err != nil &&
- attempt <= maxRetries &&
- // we are checking the err message to handle both the cgo and noncgo errors
- strings.Contains(err.Error(), "database is locked") {
- // wait and retry
- time.Sleep(getDefaultRetryInterval(attempt))
- attempt++
- goto Retry
- }
-
- return err
-}
-
-func getDefaultRetryInterval(attempt int) time.Duration {
- if attempt < 0 || attempt > len(defaultRetryIntervals)-1 {
- return time.Duration(defaultRetryIntervals[len(defaultRetryIntervals)-1]) * time.Millisecond
- }
-
- return time.Duration(defaultRetryIntervals[attempt]) * time.Millisecond
-}
diff --git a/daos/base_retry_test.go b/daos/base_retry_test.go
deleted file mode 100644
index ddf84162c6..0000000000
--- a/daos/base_retry_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package daos
-
-import (
- "errors"
- "testing"
-)
-
-func TestGetDefaultRetryInterval(t *testing.T) {
- if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
- t.Fatalf("Expected 1000ms, got %v", i)
- }
-
- if i := getDefaultRetryInterval(999); i.Milliseconds() != 1000 {
- t.Fatalf("Expected 1000ms, got %v", i)
- }
-
- if i := getDefaultRetryInterval(3); i.Milliseconds() != 500 {
- t.Fatalf("Expected 500ms, got %v", i)
- }
-}
-
-func TestBaseLockRetry(t *testing.T) {
- scenarios := []struct {
- err error
- failUntilAttempt int
- expectedAttempts int
- }{
- {nil, 3, 1},
- {errors.New("test"), 3, 1},
- {errors.New("database is locked"), 3, 3},
- }
-
- for i, s := range scenarios {
- lastAttempt := 0
-
- err := baseLockRetry(func(attempt int) error {
- lastAttempt = attempt
-
- if attempt < s.failUntilAttempt {
- return s.err
- }
-
- return nil
- }, s.failUntilAttempt+2)
-
- if lastAttempt != s.expectedAttempts {
- t.Errorf("[%d] Expected lastAttempt to be %d, got %d", i, s.expectedAttempts, lastAttempt)
- }
-
- if s.failUntilAttempt == s.expectedAttempts && err != nil {
- t.Errorf("[%d] Expected nil, got err %v", i, err)
- continue
- }
-
- if s.failUntilAttempt != s.expectedAttempts && s.err != nil && err == nil {
- t.Errorf("[%d] Expected error %q, got nil", i, s.err)
- continue
- }
- }
-}
diff --git a/daos/base_test.go b/daos/base_test.go
deleted file mode 100644
index 9de1ba9d14..0000000000
--- a/daos/base_test.go
+++ /dev/null
@@ -1,870 +0,0 @@
-package daos_test
-
-import (
- "errors"
- "testing"
- "time"
-
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestNew(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- dao := daos.New(testApp.DB())
-
- if dao.DB() != testApp.DB() {
- t.Fatal("The 2 db instances are different")
- }
-}
-
-func TestNewMultiDB(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
-
- if dao.DB() != testApp.Dao().ConcurrentDB() {
- t.Fatal("[db-concurrentDB] The 2 db instances are different")
- }
-
- if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
- t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
- }
-
- if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
- t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
- }
-}
-
-func TestDaoClone(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- hookCalls := map[string]int{}
-
- dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
- dao.MaxLockRetries = 1
- dao.ModelQueryTimeout = 2
- dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeDeleteFunc"]++
- return action()
- }
- dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeUpdateFunc"]++
- return action()
- }
- dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeCreateFunc"]++
- return action()
- }
- dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterDeleteFunc"]++
- return nil
- }
- dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterUpdateFunc"]++
- return nil
- }
- dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterCreateFunc"]++
- return nil
- }
-
- clone := dao.Clone()
- clone.MaxLockRetries = 3
- clone.ModelQueryTimeout = 4
- clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["NewAfterCreateFunc"]++
- return nil
- }
-
- if dao.MaxLockRetries == clone.MaxLockRetries {
- t.Fatal("Expected different MaxLockRetries")
- }
-
- if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
- t.Fatal("Expected different ModelQueryTimeout")
- }
-
- emptyAction := func() error { return nil }
-
- // trigger hooks
- dao.BeforeDeleteFunc(nil, nil, emptyAction)
- dao.BeforeUpdateFunc(nil, nil, emptyAction)
- dao.BeforeCreateFunc(nil, nil, emptyAction)
- dao.AfterDeleteFunc(nil, nil)
- dao.AfterUpdateFunc(nil, nil)
- dao.AfterCreateFunc(nil, nil)
- clone.BeforeDeleteFunc(nil, nil, emptyAction)
- clone.BeforeUpdateFunc(nil, nil, emptyAction)
- clone.BeforeCreateFunc(nil, nil, emptyAction)
- clone.AfterDeleteFunc(nil, nil)
- clone.AfterUpdateFunc(nil, nil)
- clone.AfterCreateFunc(nil, nil)
-
- expectations := []struct {
- hook string
- total int
- }{
- {"BeforeDeleteFunc", 2},
- {"BeforeUpdateFunc", 2},
- {"BeforeCreateFunc", 2},
- {"AfterDeleteFunc", 2},
- {"AfterUpdateFunc", 2},
- {"AfterCreateFunc", 1},
- {"NewAfterCreateFunc", 1},
- }
-
- for _, e := range expectations {
- if hookCalls[e.hook] != e.total {
- t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
- }
- }
-}
-
-func TestDaoWithoutHooks(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- hookCalls := map[string]int{}
-
- dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
- dao.MaxLockRetries = 1
- dao.ModelQueryTimeout = 2
- dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeDeleteFunc"]++
- return action()
- }
- dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeUpdateFunc"]++
- return action()
- }
- dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- hookCalls["BeforeCreateFunc"]++
- return action()
- }
- dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterDeleteFunc"]++
- return nil
- }
- dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterUpdateFunc"]++
- return nil
- }
- dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- hookCalls["AfterCreateFunc"]++
- return nil
- }
-
- new := dao.WithoutHooks()
-
- if new.MaxLockRetries != dao.MaxLockRetries {
- t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
- }
-
- if new.ModelQueryTimeout != dao.ModelQueryTimeout {
- t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
- }
-
- if new.BeforeDeleteFunc != nil {
- t.Fatal("Expected BeforeDeleteFunc to be nil")
- }
-
- if new.BeforeUpdateFunc != nil {
- t.Fatal("Expected BeforeUpdateFunc to be nil")
- }
-
- if new.BeforeCreateFunc != nil {
- t.Fatal("Expected BeforeCreateFunc to be nil")
- }
-
- if new.AfterDeleteFunc != nil {
- t.Fatal("Expected AfterDeleteFunc to be nil")
- }
-
- if new.AfterUpdateFunc != nil {
- t.Fatal("Expected AfterUpdateFunc to be nil")
- }
-
- if new.AfterCreateFunc != nil {
- t.Fatal("Expected AfterCreateFunc to be nil")
- }
-}
-
-func TestDaoModelQuery(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- dao := daos.New(testApp.DB())
-
- scenarios := []struct {
- model models.Model
- expected string
- }{
- {
- &models.Collection{},
- "SELECT {{_collections}}.* FROM `_collections`",
- },
- {
- &models.Admin{},
- "SELECT {{_admins}}.* FROM `_admins`",
- },
- {
- &models.Request{},
- "SELECT {{_requests}}.* FROM `_requests`",
- },
- }
-
- for i, scenario := range scenarios {
- sql := dao.ModelQuery(scenario.model).Build().SQL()
- if sql != scenario.expected {
- t.Errorf("(%d) Expected select %s, got %s", i, scenario.expected, sql)
- }
- }
-}
-
-func TestDaoModelQueryCancellation(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- dao := daos.New(testApp.DB())
-
- m := &models.Admin{}
-
- if err := dao.ModelQuery(m).One(m); err != nil {
- t.Fatalf("Failed to execute control query: %v", err)
- }
-
- dao.ModelQueryTimeout = 0 * time.Millisecond
- if err := dao.ModelQuery(m).One(m); err == nil {
- t.Fatal("Expected to be cancelled, got nil")
- }
-}
-
-func TestDaoFindById(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- scenarios := []struct {
- model models.Model
- id string
- expectError bool
- }{
- // missing id
- {
- &models.Collection{},
- "missing",
- true,
- },
- // existing collection id
- {
- &models.Collection{},
- "wsmn24bux7wo113",
- false,
- },
- // existing admin id
- {
- &models.Admin{},
- "sbmbsdb40jyxf7h",
- false,
- },
- }
-
- for i, scenario := range scenarios {
- err := testApp.Dao().FindById(scenario.model, scenario.id)
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected %v, got %v", i, scenario.expectError, err)
- }
-
- if !scenario.expectError && scenario.id != scenario.model.GetId() {
- t.Errorf("(%d) Expected model with id %v, got %v", i, scenario.id, scenario.model.GetId())
- }
- }
-}
-
-func TestDaoRunInTransaction(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- // failed nested transaction
- testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error {
- admin, _ := txDao.FindAdminByEmail("test@example.com")
-
- return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error {
- if err := tx2Dao.DeleteAdmin(admin); err != nil {
- t.Fatal(err)
- }
- return errors.New("test error")
- })
- })
-
- // admin should still exist
- admin1, _ := testApp.Dao().FindAdminByEmail("test@example.com")
- if admin1 == nil {
- t.Fatal("Expected admin test@example.com to not be deleted")
- }
-
- // successful nested transaction
- testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error {
- admin, _ := txDao.FindAdminByEmail("test@example.com")
-
- return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error {
- return tx2Dao.DeleteAdmin(admin)
- })
- })
-
- // admin should have been deleted
- admin2, _ := testApp.Dao().FindAdminByEmail("test@example.com")
- if admin2 != nil {
- t.Fatalf("Expected admin test@example.com to be deleted, found %v", admin2)
- }
-}
-
-func TestDaoSaveCreate(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model := &models.Admin{}
- model.Email = "test_new@example.com"
- model.Avatar = 8
- if err := testApp.Dao().Save(model); err != nil {
- t.Fatal(err)
- }
-
- // refresh
- model, _ = testApp.Dao().FindAdminByEmail("test_new@example.com")
-
- if model.Avatar != 8 {
- t.Fatalf("Expected model avatar field to be 8, got %v", model.Avatar)
- }
-
- expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"}
- for _, h := range expectedHooks {
- if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
- t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
- }
- }
-}
-
-func TestDaoSaveWithInsertId(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model := &models.Admin{}
- model.Id = "test"
- model.Email = "test_new@example.com"
- model.MarkAsNew()
- if err := testApp.Dao().Save(model); err != nil {
- t.Fatal(err)
- }
-
- // refresh
- model, _ = testApp.Dao().FindAdminById("test")
-
- if model == nil {
- t.Fatal("Failed to find admin with id 'test'")
- }
-
- expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"}
- for _, h := range expectedHooks {
- if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
- t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
- }
- }
-}
-
-func TestDaoSaveUpdate(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- model.Avatar = 8
- if err := testApp.Dao().Save(model); err != nil {
- t.Fatal(err)
- }
-
- // refresh
- model, _ = testApp.Dao().FindAdminByEmail("test@example.com")
-
- if model.Avatar != 8 {
- t.Fatalf("Expected model avatar field to be updated to 8, got %v", model.Avatar)
- }
-
- expectedHooks := []string{"OnModelBeforeUpdate", "OnModelAfterUpdate"}
- for _, h := range expectedHooks {
- if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
- t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
- }
- }
-}
-
-type dummyColumnValueMapper struct {
- models.Admin
-}
-
-func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any {
- return map[string]any{
- "email": a.Email,
- "passwordHash": a.PasswordHash,
- "tokenKey": "custom_token_key",
- }
-}
-
-func TestDaoSaveWithColumnValueMapper(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model := &dummyColumnValueMapper{}
- model.Id = "test_mapped_id" // explicitly set an id
- model.Email = "test_mapped_create@example.com"
- model.TokenKey = "test_unmapped_token_key" // not used in the map
- model.SetPassword("123456")
- model.MarkAsNew()
- if err := testApp.Dao().Save(model); err != nil {
- t.Fatal(err)
- }
-
- createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
- if createdModel == nil {
- t.Fatal("[create] Failed to find model with id 'test_mapped_id'")
- }
- if createdModel.Email != model.Email {
- t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
- }
- if createdModel.TokenKey != "custom_token_key" {
- t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey)
- }
-
- model.Email = "test_mapped_update@example.com"
- model.Avatar = 9 // not mapped and expect to be ignored
- if err := testApp.Dao().Save(model); err != nil {
- t.Fatal(err)
- }
-
- updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
- if updatedModel == nil {
- t.Fatal("[update] Failed to find model with id 'test_mapped_id'")
- }
- if updatedModel.Email != model.Email {
- t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
- }
- if updatedModel.Avatar != 0 {
- t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar)
- }
-}
-
-func TestDaoDelete(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- if err := testApp.Dao().Delete(model); err != nil {
- t.Fatal(err)
- }
-
- model, _ = testApp.Dao().FindAdminByEmail("test@example.com")
- if model != nil {
- t.Fatalf("Expected model to be deleted, found %v", model)
- }
-
- expectedHooks := []string{"OnModelBeforeDelete", "OnModelAfterDelete"}
- for _, h := range expectedHooks {
- if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
- t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
- }
- }
-}
-
-func TestDaoRetryCreate(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- // init mock retry dao
- retryBeforeCreateHookCalls := 0
- retryAfterCreateHookCalls := 0
- retryDao := daos.New(testApp.DB())
- retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeCreateHookCalls++
- return errors.New("database is locked")
- }
- retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- retryAfterCreateHookCalls++
- return nil
- }
-
- model := &models.Admin{Email: "new@example.com"}
- if err := retryDao.Save(model); err != nil {
- t.Fatalf("Expected nil after retry, got error: %v", err)
- }
-
- // the before hook is expected to be called only once because
- // it is ignored after the first "database is locked" error
- if retryBeforeCreateHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
- }
-
- if retryAfterCreateHookCalls != 1 {
- t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
- }
-
- // with non-locking error
- retryBeforeCreateHookCalls = 0
- retryAfterCreateHookCalls = 0
- retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeCreateHookCalls++
- return errors.New("non-locking error")
- }
-
- dummy := &models.Admin{Email: "test@example.com"}
- if err := retryDao.Save(dummy); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- if retryBeforeCreateHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
- }
-
- if retryAfterCreateHookCalls != 0 {
- t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
- }
-}
-
-func TestDaoRetryUpdate(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- model, err := testApp.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- // init mock retry dao
- retryBeforeUpdateHookCalls := 0
- retryAfterUpdateHookCalls := 0
- retryDao := daos.New(testApp.DB())
- retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeUpdateHookCalls++
- return errors.New("database is locked")
- }
- retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- retryAfterUpdateHookCalls++
- return nil
- }
-
- if err := retryDao.Save(model); err != nil {
- t.Fatalf("Expected nil after retry, got error: %v", err)
- }
-
- // the before hook is expected to be called only once because
- // it is ignored after the first "database is locked" error
- if retryBeforeUpdateHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
- }
-
- if retryAfterUpdateHookCalls != 1 {
- t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
- }
-
- // with non-locking error
- retryBeforeUpdateHookCalls = 0
- retryAfterUpdateHookCalls = 0
- retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeUpdateHookCalls++
- return errors.New("non-locking error")
- }
-
- if err := retryDao.Save(model); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- if retryBeforeUpdateHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
- }
-
- if retryAfterUpdateHookCalls != 0 {
- t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
- }
-}
-
-func TestDaoRetryDelete(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- // init mock retry dao
- retryBeforeDeleteHookCalls := 0
- retryAfterDeleteHookCalls := 0
- retryDao := daos.New(testApp.DB())
- retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeDeleteHookCalls++
- return errors.New("database is locked")
- }
- retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- retryAfterDeleteHookCalls++
- return nil
- }
-
- model, _ := retryDao.FindAdminByEmail("test@example.com")
- if err := retryDao.Delete(model); err != nil {
- t.Fatalf("Expected nil after retry, got error: %v", err)
- }
-
- // the before hook is expected to be called only once because
- // it is ignored after the first "database is locked" error
- if retryBeforeDeleteHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
- }
-
- if retryAfterDeleteHookCalls != 1 {
- t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
- }
-
- // with non-locking error
- retryBeforeDeleteHookCalls = 0
- retryAfterDeleteHookCalls = 0
- retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- retryBeforeDeleteHookCalls++
- return errors.New("non-locking error")
- }
-
- dummy := &models.Admin{}
- dummy.RefreshId()
- dummy.MarkAsNotNew()
- if err := retryDao.Delete(dummy); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- if retryBeforeDeleteHookCalls != 1 {
- t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
- }
-
- if retryAfterDeleteHookCalls != 0 {
- t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
- }
-}
-
-func TestDaoBeforeHooksError(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- baseDao := testApp.Dao()
-
- baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- return errors.New("before_create")
- }
- baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- return errors.New("before_update")
- }
- baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- return errors.New("before_delete")
- }
-
- existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- // test create error
- // ---
- newModel := &models.Admin{}
- if err := baseDao.Save(newModel); err.Error() != "before_create" {
- t.Fatalf("Expected before_create error, got %v", err)
- }
-
- // test update error
- // ---
- if err := baseDao.Save(existingModel); err.Error() != "before_update" {
- t.Fatalf("Expected before_update error, got %v", err)
- }
-
- // test delete error
- // ---
- if err := baseDao.Delete(existingModel); err.Error() != "before_delete" {
- t.Fatalf("Expected before_delete error, got %v", err)
- }
-}
-
-func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- beforeCreateFuncCalls := 0
- beforeUpdateFuncCalls := 0
- beforeDeleteFuncCalls := 0
- afterCreateFuncCalls := 0
- afterUpdateFuncCalls := 0
- afterDeleteFuncCalls := 0
-
- baseDao := testApp.Dao()
-
- baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeCreateFuncCalls++
- return action()
- }
- baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeUpdateFuncCalls++
- return action()
- }
- baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeDeleteFuncCalls++
- return action()
- }
-
- baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterCreateFuncCalls++
- return nil
- }
- baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterUpdateFuncCalls++
- return nil
- }
- baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterDeleteFuncCalls++
- return nil
- }
-
- existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
- return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
- // test create
- // ---
- newModel := &models.Admin{}
- newModel.Email = "test_new1@example.com"
- newModel.SetPassword("123456")
- if err := txDao2.Save(newModel); err != nil {
- t.Fatal(err)
- }
-
- // test update (twice)
- // ---
- if err := txDao2.Save(existingModel); err != nil {
- t.Fatal(err)
- }
- if err := txDao2.Save(existingModel); err != nil {
- t.Fatal(err)
- }
-
- // test delete
- // ---
- if err := txDao2.Delete(existingModel); err != nil {
- t.Fatal(err)
- }
-
- return errors.New("test_tx_error")
- })
- })
-
- if beforeCreateFuncCalls != 1 {
- t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
- }
- if beforeUpdateFuncCalls != 2 {
- t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
- }
- if beforeDeleteFuncCalls != 1 {
- t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
- }
- if afterCreateFuncCalls != 0 {
- t.Fatalf("Expected afterCreateFuncCalls to be called 0 times, got %d", afterCreateFuncCalls)
- }
- if afterUpdateFuncCalls != 0 {
- t.Fatalf("Expected afterUpdateFuncCalls to be called 0 times, got %d", afterUpdateFuncCalls)
- }
- if afterDeleteFuncCalls != 0 {
- t.Fatalf("Expected afterDeleteFuncCalls to be called 0 times, got %d", afterDeleteFuncCalls)
- }
-}
-
-func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- beforeCreateFuncCalls := 0
- beforeUpdateFuncCalls := 0
- beforeDeleteFuncCalls := 0
- afterCreateFuncCalls := 0
- afterUpdateFuncCalls := 0
- afterDeleteFuncCalls := 0
-
- baseDao := testApp.Dao()
-
- baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeCreateFuncCalls++
- return action()
- }
- baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeUpdateFuncCalls++
- return action()
- }
- baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- beforeDeleteFuncCalls++
- return action()
- }
-
- baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterCreateFuncCalls++
- return nil
- }
- baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterUpdateFuncCalls++
- return nil
- }
- baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
- afterDeleteFuncCalls++
- return nil
- }
-
- existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
- return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
- // test create
- // ---
- newModel := &models.Admin{}
- newModel.Email = "test_new1@example.com"
- newModel.SetPassword("123456")
- if err := txDao2.Save(newModel); err != nil {
- t.Fatal(err)
- }
-
- // test update (twice)
- // ---
- if err := txDao2.Save(existingModel); err != nil {
- t.Fatal(err)
- }
- if err := txDao2.Save(existingModel); err != nil {
- t.Fatal(err)
- }
-
- // test delete
- // ---
- if err := txDao2.Delete(existingModel); err != nil {
- t.Fatal(err)
- }
-
- return nil
- })
- })
-
- if beforeCreateFuncCalls != 1 {
- t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
- }
- if beforeUpdateFuncCalls != 2 {
- t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
- }
- if beforeDeleteFuncCalls != 1 {
- t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
- }
- if afterCreateFuncCalls != 1 {
- t.Fatalf("Expected afterCreateFuncCalls to be called 1 times, got %d", afterCreateFuncCalls)
- }
- if afterUpdateFuncCalls != 2 {
- t.Fatalf("Expected afterUpdateFuncCalls to be called 2 times, got %d", afterUpdateFuncCalls)
- }
- if afterDeleteFuncCalls != 1 {
- t.Fatalf("Expected afterDeleteFuncCalls to be called 1 times, got %d", afterDeleteFuncCalls)
- }
-}
diff --git a/daos/collection.go b/daos/collection.go
deleted file mode 100644
index fee14f6cc0..0000000000
--- a/daos/collection.go
+++ /dev/null
@@ -1,498 +0,0 @@
-package daos
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-// CollectionQuery returns a new Collection select query.
-func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.Collection{})
-}
-
-// FindCollectionsByType finds all collections by the given type.
-func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
- collections := []*models.Collection{}
-
- err := dao.CollectionQuery().
- AndWhere(dbx.HashExp{"type": collectionType}).
- OrderBy("created ASC").
- All(&collections)
-
- if err != nil {
- return nil, err
- }
-
- return collections, nil
-}
-
-// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
-func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
- model := &models.Collection{}
-
- err := dao.CollectionQuery().
- AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
- "id": nameOrId,
- "name": strings.ToLower(nameOrId),
- })).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// IsCollectionNameUnique checks that there is no existing collection
-// with the provided name (case insensitive!).
-//
-// Note: case insensitive check because the name is used also as a table name for the records.
-func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
- if name == "" {
- return false
- }
-
- query := dao.CollectionQuery().
- Select("count(*)").
- AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
- Limit(1)
-
- if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
- query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
- }
-
- var exists bool
-
- return query.Row(&exists) == nil && !exists
-}
-
-// FindCollectionReferences returns information for all
-// relation schema fields referencing the provided collection.
-//
-// If the provided collection has reference to itself then it will be
-// also included in the result. To exclude it, pass the collection id
-// as the excludeId argument.
-func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
- collections := []*models.Collection{}
-
- query := dao.CollectionQuery()
-
- if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
- query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
- }
-
- if err := query.All(&collections); err != nil {
- return nil, err
- }
-
- result := map[*models.Collection][]*schema.SchemaField{}
-
- for _, c := range collections {
- for _, f := range c.Schema.Fields() {
- if f.Type != schema.FieldTypeRelation {
- continue
- }
- f.InitOptions()
- options, _ := f.Options.(*schema.RelationOptions)
- if options != nil && options.CollectionId == collection.Id {
- result[c] = append(result[c], f)
- }
- }
- }
-
- return result, nil
-}
-
-// DeleteCollection deletes the provided Collection model.
-// This method automatically deletes the related collection records table.
-//
-// NB! The collection cannot be deleted, if:
-// - is system collection (aka. collection.System is true)
-// - is referenced as part of a relation field in another collection
-func (dao *Dao) DeleteCollection(collection *models.Collection) error {
- if collection.System {
- return fmt.Errorf("System collection %q cannot be deleted.", collection.Name)
- }
-
- // ensure that there aren't any existing references.
- // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
- result, err := dao.FindCollectionReferences(collection, collection.Id)
- if err != nil {
- return err
- }
- if total := len(result); total > 0 {
- names := make([]string, 0, len(result))
- for ref := range result {
- names = append(names, ref.Name)
- }
- return fmt.Errorf("The collection %q has external relation field references (%s).", collection.Name, strings.Join(names, ", "))
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- // delete the related view or records table
- if collection.IsView() {
- if err := txDao.DeleteView(collection.Name); err != nil {
- return err
- }
- } else {
- if err := txDao.DeleteTable(collection.Name); err != nil {
- return err
- }
- }
-
- // trigger views resave to check for dependencies
- if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
- return fmt.Errorf("The collection has a view dependency - %w", err)
- }
-
- return txDao.Delete(collection)
- })
-}
-
-// SaveCollection persists the provided Collection model and updates
-// its related records table schema.
-//
-// If collecction.IsNew() is true, the method will perform a create, otherwise an update.
-// To explicitly mark a collection for update you can use collecction.MarkAsNotNew().
-func (dao *Dao) SaveCollection(collection *models.Collection) error {
- var oldCollection *models.Collection
-
- if !collection.IsNew() {
- // get the existing collection state to compare with the new one
- // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
- var findErr error
- oldCollection, findErr = dao.FindCollectionByNameOrId(collection.Id)
- if findErr != nil {
- return findErr
- }
- }
-
- txErr := dao.RunInTransaction(func(txDao *Dao) error {
- // set default collection type
- if collection.Type == "" {
- collection.Type = models.CollectionTypeBase
- }
-
- switch collection.Type {
- case models.CollectionTypeView:
- if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
- return err
- }
- default:
- // persist the collection model
- if err := txDao.Save(collection); err != nil {
- return err
- }
-
- // sync the changes with the related records table
- if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
- return err
- }
- }
-
- return nil
- })
-
- if txErr != nil {
- return txErr
- }
-
- // trigger an update for all views with changed schema as a result of the current collection save
- // (ignoring view errors to allow users to update the query from the UI)
- dao.resaveViewsWithChangedSchema(collection.Id)
-
- return nil
-}
-
-// ImportCollections imports the provided collections list within a single transaction.
-//
-// NB1! If deleteMissing is set, all local collections and schema fields, that are not present
-// in the imported configuration, WILL BE DELETED (including their related records data).
-//
-// NB2! This method doesn't perform validations on the imported collections data!
-// If you need validations, use [forms.CollectionsImport].
-func (dao *Dao) ImportCollections(
- importedCollections []*models.Collection,
- deleteMissing bool,
- afterSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
-) error {
- if len(importedCollections) == 0 {
- return errors.New("No collections to import")
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- existingCollections := []*models.Collection{}
- if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&existingCollections); err != nil {
- return err
- }
- mappedExisting := make(map[string]*models.Collection, len(existingCollections))
- for _, existing := range existingCollections {
- mappedExisting[existing.GetId()] = existing
- }
-
- mappedImported := make(map[string]*models.Collection, len(importedCollections))
- for _, imported := range importedCollections {
- // generate id if not set
- if !imported.HasId() {
- imported.MarkAsNew()
- imported.RefreshId()
- }
-
- // set default type if missing
- if imported.Type == "" {
- imported.Type = models.CollectionTypeBase
- }
-
- if existing, ok := mappedExisting[imported.GetId()]; ok {
- imported.MarkAsNotNew()
-
- // preserve original created date
- if !existing.Created.IsZero() {
- imported.Created = existing.Created
- }
-
- // extend existing schema
- if !deleteMissing {
- schema, _ := existing.Schema.Clone()
- for _, f := range imported.Schema.Fields() {
- schema.AddField(f) // add or replace
- }
- imported.Schema = *schema
- }
- } else {
- imported.MarkAsNew()
- }
-
- mappedImported[imported.GetId()] = imported
- }
-
- // delete old collections not available in the new configuration
- // (before saving the imports in case a deleted collection name is being reused)
- if deleteMissing {
- for _, existing := range existingCollections {
- if mappedImported[existing.GetId()] != nil {
- continue // exist
- }
-
- if existing.System {
- return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
- }
-
- // delete the related records table or view
- if existing.IsView() {
- if err := txDao.DeleteView(existing.Name); err != nil {
- return err
- }
- } else {
- if err := txDao.DeleteTable(existing.Name); err != nil {
- return err
- }
- }
-
- // delete the collection
- if err := txDao.Delete(existing); err != nil {
- return err
- }
- }
- }
-
- // upsert imported collections
- for _, imported := range importedCollections {
- if err := txDao.Save(imported); err != nil {
- return err
- }
- }
-
- // sync record tables
- for _, imported := range importedCollections {
- if imported.IsView() {
- continue
- }
-
- existing := mappedExisting[imported.GetId()]
-
- if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
- return err
- }
- }
-
- // sync views
- for _, imported := range importedCollections {
- if !imported.IsView() {
- continue
- }
-
- existing := mappedExisting[imported.GetId()]
-
- if err := txDao.saveViewCollection(imported, existing); err != nil {
- return err
- }
- }
-
- if afterSync != nil {
- if err := afterSync(txDao, mappedImported, mappedExisting); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
-
-// 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 a new schema based on newCollection.Options.Query
-// - updates newCollection.Schema based on the generated view table info and query
-// - saves the newCollection
-//
-// This method returns an error if newCollection is not a "view".
-func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
- if !newCollection.IsView() {
- return errors.New("not a view collection")
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- query := newCollection.ViewOptions().Query
-
- // generate collection schema from the query
- viewSchema, err := txDao.CreateViewSchema(query)
- if err != nil {
- return err
- }
-
- // delete old renamed view
- if oldCollection != nil {
- if err := txDao.DeleteView(oldCollection.Name); err != nil {
- return err
- }
- }
-
- // wrap view query if necessary
- query, err = txDao.normalizeViewQueryId(query)
- if err != nil {
- return fmt.Errorf("failed to normalize view query id: %w", err)
- }
-
- // (re)create the view
- if err := txDao.SaveView(newCollection.Name, query); err != nil {
- return err
- }
-
- newCollection.Schema = viewSchema
-
- return txDao.Save(newCollection)
- })
-}
-
-// @todo consider removing once custom id types are supported
-//
-// normalizeViewQueryId wraps (if necessary) the provided view query
-// with a subselect to ensure that the id column is a text since
-// currently we don't support non-string model ids
-// (see https://github.com/pocketbase/pocketbase/issues/3110).
-func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
- parsed, err := dao.parseQueryToFields(query)
- if err != nil {
- return "", err
- }
-
- needWrapping := true
-
- idField := parsed[schema.FieldNameId]
- if idField != nil && idField.field != nil &&
- idField.field.Type != schema.FieldTypeJson &&
- idField.field.Type != schema.FieldTypeNumber &&
- idField.field.Type != schema.FieldTypeBool {
- needWrapping = false
- }
-
- if !needWrapping {
- return query, nil // no changes needed
- }
-
- // raw parse to preserve the columns order
- rawParsed := new(identifiersParser)
- if err := rawParsed.parse(query); err != nil {
- return "", err
- }
-
- columns := make([]string, 0, len(rawParsed.columns))
- for _, col := range rawParsed.columns {
- if col.alias == schema.FieldNameId {
- columns = append(columns, fmt.Sprintf("cast([[%s]] as text) [[%s]]", col.alias, col.alias))
- } else {
- columns = append(columns, "[["+col.alias+"]]")
- }
- }
-
- query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
-
- return query, nil
-}
-
-// resaveViewsWithChangedSchema updates all view collections with changed schemas.
-func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
- collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
- if err != nil {
- return err
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- for _, collection := range collections {
- if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
- continue
- }
-
- // clone the existing schema so that it is safe for temp modifications
- oldSchema, err := collection.Schema.Clone()
- if err != nil {
- return err
- }
-
- // generate a new schema from the query
- newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
- if err != nil {
- return err
- }
-
- // unset the schema field ids to exclude from the comparison
- for _, f := range oldSchema.Fields() {
- f.Id = ""
- }
- for _, f := range newSchema.Fields() {
- f.Id = ""
- }
-
- encodedNewSchema, err := json.Marshal(newSchema)
- if err != nil {
- return err
- }
-
- encodedOldSchema, err := json.Marshal(oldSchema)
- if err != nil {
- return err
- }
-
- if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
- continue // no changes
- }
-
- if err := txDao.saveViewCollection(collection, nil); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
diff --git a/daos/collection_test.go b/daos/collection_test.go
deleted file mode 100644
index 6677436398..0000000000
--- a/daos/collection_test.go
+++ /dev/null
@@ -1,788 +0,0 @@
-package daos_test
-
-import (
- "encoding/json"
- "errors"
- "strings"
- "testing"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestCollectionQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_collections}}.* FROM `_collections`"
-
- sql := app.Dao().CollectionQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestFindCollectionsByType(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionType string
- expectError bool
- expectTotal int
- }{
- {"", false, 0},
- {"unknown", false, 0},
- {models.CollectionTypeAuth, false, 3},
- {models.CollectionTypeBase, false, 5},
- }
-
- for i, scenario := range scenarios {
- collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if len(collections) != scenario.expectTotal {
- t.Errorf("[%d] Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
- }
-
- for _, c := range collections {
- if c.Type != scenario.collectionType {
- t.Errorf("[%d] Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
- }
- }
- }
-}
-
-func TestFindCollectionByNameOrId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- nameOrId string
- expectError bool
- }{
- {"", true},
- {"missing", true},
- {"wsmn24bux7wo113", false},
- {"demo1", false},
- {"DEMO1", false}, // case insensitive check
- }
-
- for i, scenario := range scenarios {
- model, err := app.Dao().FindCollectionByNameOrId(scenario.nameOrId)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
- t.Errorf("[%d] Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
- }
- }
-}
-
-func TestIsCollectionNameUnique(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- excludeId string
- expected bool
- }{
- {"", "", false},
- {"demo1", "", false},
- {"Demo1", "", false},
- {"new", "", true},
- {"demo1", "wsmn24bux7wo113", true},
- }
-
- for i, scenario := range scenarios {
- result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId)
- if result != scenario.expected {
- t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
- }
- }
-}
-
-func TestFindCollectionReferences(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo3")
- if err != nil {
- t.Fatal(err)
- }
-
- result, err := app.Dao().FindCollectionReferences(
- collection,
- collection.Id,
- // test whether "nonempty" exclude ids condition will be skipped
- "",
- "",
- )
- if err != nil {
- t.Fatal(err)
- }
-
- if len(result) != 1 {
- t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
- }
-
- expectedFields := []string{
- "rel_one_no_cascade",
- "rel_one_no_cascade_required",
- "rel_one_cascade",
- "rel_many_no_cascade",
- "rel_many_no_cascade_required",
- "rel_many_cascade",
- }
-
- for col, fields := range result {
- if col.Name != "demo4" {
- t.Fatalf("Expected collection demo4, got %s", col.Name)
- }
- if len(fields) != len(expectedFields) {
- t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
- }
- for i, f := range fields {
- if !list.ExistInSlice(f.Name, expectedFields) {
- t.Fatalf("[%d] Didn't expect field %v", i, f)
- }
- }
- }
-}
-
-func TestDeleteCollection(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- colUnsaved := &models.Collection{}
-
- colAuth, err := app.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- colReferenced, err := app.Dao().FindCollectionByNameOrId("demo2")
- if err != nil {
- t.Fatal(err)
- }
-
- colSystem, err := app.Dao().FindCollectionByNameOrId("demo3")
- if err != nil {
- t.Fatal(err)
- }
- colSystem.System = true
- if err := app.Dao().Save(colSystem); err != nil {
- t.Fatal(err)
- }
-
- colBase, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- colView1, err := app.Dao().FindCollectionByNameOrId("view1")
- if err != nil {
- t.Fatal(err)
- }
-
- colView2, err := app.Dao().FindCollectionByNameOrId("view2")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- model *models.Collection
- expectError bool
- }{
- {colUnsaved, true},
- {colReferenced, true},
- {colSystem, true},
- {colBase, true}, // depend on view1, view2 and view2
- {colView1, true}, // view2 depend on it
- {colView2, false},
- {colView1, false}, // no longer has dependent collections
- {colBase, false}, // no longer has dependent views
- {colAuth, false}, // should delete also its related external auths
- }
-
- for i, s := range scenarios {
- err := app.Dao().DeleteCollection(s.model)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%d] Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- if app.Dao().HasTable(s.model.Name) {
- t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
- }
-
- // check if the external auths were deleted
- if s.model.IsAuth() {
- var total int
- err := app.Dao().ExternalAuthQuery().
- Select("count(*)").
- AndWhere(dbx.HashExp{"collectionId": s.model.Id}).
- Row(&total)
-
- if err != nil || total > 0 {
- t.Fatalf("[%d] Expected external auths to be deleted, got %v (%v)", i, total, err)
- }
- }
- }
-}
-
-func TestSaveCollectionCreate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection := &models.Collection{
- Name: "new_test",
- Type: models.CollectionTypeBase,
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Type: schema.FieldTypeText,
- Name: "test",
- },
- ),
- }
-
- err := app.Dao().SaveCollection(collection)
- if err != nil {
- t.Fatal(err)
- }
-
- if collection.Id == "" {
- t.Fatal("Expected collection id to be set")
- }
-
- // check if the records table was created
- hasTable := app.Dao().HasTable(collection.Name)
- if !hasTable {
- t.Fatalf("Expected records table %s to be created", collection.Name)
- }
-
- // check if the records table has the schema fields
- columns, err := app.Dao().TableColumns(collection.Name)
- if err != nil {
- t.Fatal(err)
- }
- expectedColumns := []string{"id", "created", "updated", "test"}
- if len(columns) != len(expectedColumns) {
- t.Fatalf("Expected columns %v, got %v", expectedColumns, columns)
- }
- for i, c := range columns {
- if !list.ExistInSlice(c, expectedColumns) {
- t.Fatalf("[%d] Didn't expect record column %s", i, c)
- }
- }
-}
-
-func TestSaveCollectionUpdate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo3")
- if err != nil {
- t.Fatal(err)
- }
-
- // rename an existing schema field and add a new one
- oldField := collection.Schema.GetFieldByName("title")
- oldField.Name = "title_update"
- collection.Schema.AddField(&schema.SchemaField{
- Type: schema.FieldTypeText,
- Name: "test",
- })
-
- saveErr := app.Dao().SaveCollection(collection)
- if saveErr != nil {
- t.Fatal(saveErr)
- }
-
- // check if the records table has the schema fields
- expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
- columns, err := app.Dao().TableColumns(collection.Name)
- if err != nil {
- t.Fatal(err)
- }
- if len(columns) != len(expectedColumns) {
- t.Fatalf("Expected columns %v, got %v", expectedColumns, columns)
- }
- for i, c := range columns {
- if !list.ExistInSlice(c, expectedColumns) {
- t.Fatalf("[%d] Didn't expect record column %s", i, c)
- }
- }
-}
-
-// indirect update of a field used in view should cause view(s) update
-func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- // update MaxSelect fields
- {
- relMany := collection.Schema.GetFieldByName("rel_many")
- relManyOpt := relMany.Options.(*schema.RelationOptions)
- relManyOpt.MaxSelect = types.Pointer(1)
-
- fileOne := collection.Schema.GetFieldByName("file_one")
- fileOneOpt := fileOne.Options.(*schema.FileOptions)
- fileOneOpt.MaxSelect = 10
-
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
- }
-
- // check view1 schema
- {
- view1, err := app.Dao().FindCollectionByNameOrId("view1")
- if err != nil {
- t.Fatal(err)
- }
-
- relMany := view1.Schema.GetFieldByName("rel_many")
- relManyOpt := relMany.Options.(*schema.RelationOptions)
- if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
- t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
- }
-
- fileOne := view1.Schema.GetFieldByName("file_one")
- fileOneOpt := fileOne.Options.(*schema.FileOptions)
- if fileOneOpt.MaxSelect != 10 {
- t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
- }
- }
-
- // check view2 schema
- {
- view2, err := app.Dao().FindCollectionByNameOrId("view2")
- if err != nil {
- t.Fatal(err)
- }
-
- relMany := view2.Schema.GetFieldByName("rel_many")
- relManyOpt := relMany.Options.(*schema.RelationOptions)
- if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
- t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
- }
- }
-}
-
-func TestSaveCollectionViewWrapping(t *testing.T) {
- viewName := "test_wrapping"
-
- scenarios := []struct {
- name string
- query string
- expected string
- }{
- {
- "no wrapping - text field",
- "select text as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
- },
- {
- "no wrapping - id field",
- "select text as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
- },
- {
- "no wrapping - relation field",
- "select rel_one as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
- },
- {
- "no wrapping - select field",
- "select select_many as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
- },
- {
- "no wrapping - email field",
- "select email as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
- },
- {
- "no wrapping - datetime field",
- "select datetime as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
- },
- {
- "no wrapping - url field",
- "select url as id, bool from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
- },
- {
- "wrapping - bool field",
- "select bool as id, text as txt, url from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
- },
- {
- "wrapping - bool field (different order)",
- "select text as txt, url, bool as id from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,cast(`id` as text) `id` FROM (select text as txt, url, bool as id from demo1))",
- },
- {
- "wrapping - json field",
- "select json as id, text, url from demo1",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
- },
- {
- "wrapping - numeric id",
- "select 1 as id",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select 1 as id))",
- },
- {
- "wrapping - expresion",
- "select ('test') as id",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select ('test') as id))",
- },
- {
- "no wrapping - cast as text",
- "select cast('test' as text) as id",
- "CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
- },
- }
-
- for _, s := range scenarios {
- t.Run(s.name, func(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection := &models.Collection{
- Name: viewName,
- Type: models.CollectionTypeView,
- Options: types.JsonMap{
- "query": s.query,
- },
- }
-
- err := app.Dao().SaveCollection(collection)
- if err != nil {
- t.Fatal(err)
- }
-
- var sql string
-
- rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
- Bind(dbx.Params{"name": viewName}).
- Row(&sql)
- if rowErr != nil {
- t.Fatalf("Failed to retrieve view sql: %v", rowErr)
- }
-
- if sql != s.expected {
- t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
- }
- })
- }
-}
-
-func TestImportCollections(t *testing.T) {
- totalCollections := 11
-
- scenarios := []struct {
- name string
- jsonData string
- deleteMissing bool
- beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
- expectError bool
- expectCollectionsCount int
- beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
- afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
- }{
- {
- name: "empty collections",
- jsonData: `[]`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- },
- {
- name: "minimal collection import",
- jsonData: `[
- {"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
- {"name": "import_test2", "type": "auth"}
- ]`,
- deleteMissing: false,
- expectError: false,
- expectCollectionsCount: totalCollections + 2,
- },
- {
- name: "minimal collection import + failed beforeRecordsSync",
- jsonData: `[
- {"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
- ]`,
- beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
- return errors.New("test_error")
- },
- deleteMissing: false,
- expectError: true,
- expectCollectionsCount: totalCollections,
- },
- {
- name: "minimal collection import + successful beforeRecordsSync",
- jsonData: `[
- {"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
- ]`,
- beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
- return nil
- },
- deleteMissing: false,
- expectError: false,
- expectCollectionsCount: totalCollections + 1,
- },
- {
- name: "new + update + delete system collection",
- jsonData: `[
- {
- "id":"wsmn24bux7wo113",
- "name":"demo",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- }
- ]
- },
- {
- "name": "import1",
- "schema": [
- {
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]`,
- deleteMissing: true,
- expectError: true,
- expectCollectionsCount: totalCollections,
- },
- {
- name: "new + update + delete non-system collection",
- jsonData: `[
- {
- "id": "kpv709sk2lqbqk8",
- "system": true,
- "name": "nologin",
- "type": "auth",
- "options": {
- "allowEmailAuth": false,
- "allowOAuth2Auth": false,
- "allowUsernameAuth": false,
- "exceptEmailDomains": [],
- "manageRule": "@request.auth.collectionName = 'users'",
- "minPasswordLength": 8,
- "onlyEmailDomains": [],
- "requireEmail": true
- },
- "listRule": "",
- "viewRule": "",
- "createRule": "",
- "updateRule": "",
- "deleteRule": "",
- "schema": [
- {
- "id": "x8zzktwe",
- "name": "name",
- "type": "text",
- "system": false,
- "required": false,
- "unique": false,
- "options": {
- "min": null,
- "max": null,
- "pattern": ""
- }
- }
- ]
- },
- {
- "id":"wsmn24bux7wo113",
- "name":"demo1_rename",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- }
- ]
- },
- {
- "id": "test_deleted_collection_name_reuse",
- "name": "demo2",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "id": "test_new_view",
- "name": "new_view",
- "type": "view",
- "options": {
- "query": "select id from demo2"
- }
- }
- ]`,
- deleteMissing: true,
- expectError: false,
- expectCollectionsCount: 4,
- },
- {
- name: "test with deleteMissing: false",
- jsonData: `[
- {
- "id":"wsmn24bux7wo113",
- "name":"demo1",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- },
- {
- "id":"_2hlxbmp",
- "name":"field_with_duplicate_id",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- },
- {
- "id":"abcd_import",
- "name":"new_field",
- "type":"text"
- }
- ]
- },
- {
- "name": "new_import",
- "schema": [
- {
- "id":"abcd_import",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]`,
- deleteMissing: false,
- expectError: false,
- expectCollectionsCount: totalCollections + 1,
- afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
- expectedCollectionFields := map[string]int{
- "nologin": 1,
- "demo1": 15,
- "demo2": 2,
- "demo3": 2,
- "demo4": 11,
- "demo5": 6,
- "new_import": 1,
- }
- for name, expectedCount := range expectedCollectionFields {
- collection, err := testApp.Dao().FindCollectionByNameOrId(name)
- if err != nil {
- t.Fatal(err)
- }
-
- if totalFields := len(collection.Schema.Fields()); totalFields != expectedCount {
- t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields)
- }
- }
- },
- },
- }
-
- for _, scenario := range scenarios {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- importedCollections := []*models.Collection{}
-
- // load data
- loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
- if loadErr != nil {
- t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
- continue
- }
-
- err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
- }
-
- // check collections count
- collections := []*models.Collection{}
- if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
- t.Fatal(err)
- }
- if len(collections) != scenario.expectCollectionsCount {
- t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
- }
-
- if scenario.afterTestFunc != nil {
- scenario.afterTestFunc(testApp, collections)
- }
- }
-}
diff --git a/daos/external_auth.go b/daos/external_auth.go
deleted file mode 100644
index 7e3708badd..0000000000
--- a/daos/external_auth.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package daos
-
-import (
- "errors"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// ExternalAuthQuery returns a new ExternalAuth select query.
-func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.ExternalAuth{})
-}
-
-// FindAllExternalAuthsByRecord returns all ExternalAuth models
-// linked to the provided auth record.
-func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
- auths := []*models.ExternalAuth{}
-
- err := dao.ExternalAuthQuery().
- AndWhere(dbx.HashExp{
- "collectionId": authRecord.Collection().Id,
- "recordId": authRecord.Id,
- }).
- OrderBy("created ASC").
- All(&auths)
-
- if err != nil {
- return nil, err
- }
-
- return auths, nil
-}
-
-// FindExternalAuthByProvider returns the first available
-// ExternalAuth model for the specified provider and providerId.
-func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
- model := &models.ExternalAuth{}
-
- err := dao.ExternalAuthQuery().
- AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
- AndWhere(dbx.HashExp{
- "provider": provider,
- "providerId": providerId,
- }).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// FindExternalAuthByRecordAndProvider returns the first available
-// ExternalAuth model for the specified record data and provider.
-func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
- model := &models.ExternalAuth{}
-
- err := dao.ExternalAuthQuery().
- AndWhere(dbx.HashExp{
- "collectionId": authRecord.Collection().Id,
- "recordId": authRecord.Id,
- "provider": provider,
- }).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-// SaveExternalAuth upserts the provided ExternalAuth model.
-func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
- // extra check the model data in case the provider's API response
- // has changed and no longer returns the expected fields
- if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
- return errors.New("Missing required ExternalAuth fields.")
- }
-
- return dao.Save(model)
-}
-
-// DeleteExternalAuth deletes the provided ExternalAuth model.
-func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
- return dao.Delete(model)
-}
diff --git a/daos/external_auth_test.go b/daos/external_auth_test.go
deleted file mode 100644
index f4d05c080d..0000000000
--- a/daos/external_auth_test.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package daos_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestExternalAuthQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_externalAuths}}.* FROM `_externalAuths`"
-
- sql := app.Dao().ExternalAuthQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestFindAllExternalAuthsByRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- userId string
- expectedCount int
- }{
- {"oap640cot4yru2s", 0},
- {"4q1xlclmfloku33", 2},
- }
-
- for i, s := range scenarios {
- record, err := app.Dao().FindRecordById("users", s.userId)
- if err != nil {
- t.Errorf("(%d) Unexpected record fetch error %v", i, err)
- continue
- }
-
- auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
- if err != nil {
- t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
- continue
- }
-
- if len(auths) != s.expectedCount {
- t.Errorf("(%d) Expected %d auths, got %d", i, s.expectedCount, len(auths))
- }
-
- for _, auth := range auths {
- if auth.RecordId != record.Id {
- t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
- }
- }
- }
-}
-
-func TestFindExternalAuthByProvider(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- provider string
- providerId string
- expectedId string
- }{
- {"", "", ""},
- {"github", "", ""},
- {"github", "id1", ""},
- {"github", "id2", ""},
- {"google", "test123", "clmflokuq1xl341"},
- {"gitlab", "test123", "dlmflokuq1xl342"},
- }
-
- for i, s := range scenarios {
- auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
-
- hasErr := err != nil
- expectErr := s.expectedId == ""
- if hasErr != expectErr {
- t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
- continue
- }
-
- if auth != nil && auth.Id != s.expectedId {
- t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
- }
- }
-}
-
-func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- userId string
- provider string
- expectedId string
- }{
- {"bgs820n361vj1qd", "google", ""},
- {"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
- {"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
- }
-
- for i, s := range scenarios {
- record, err := app.Dao().FindRecordById("users", s.userId)
- if err != nil {
- t.Errorf("(%d) Unexpected record fetch error %v", i, err)
- continue
- }
-
- auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
-
- hasErr := err != nil
- expectErr := s.expectedId == ""
- if hasErr != expectErr {
- t.Errorf("(%d) Expected hasErr %v, got %v", i, expectErr, err)
- continue
- }
-
- if auth != nil && auth.Id != s.expectedId {
- t.Errorf("(%d) Expected external auth with ID %s, got \n%v", i, s.expectedId, auth)
- }
- }
-}
-
-func TestSaveExternalAuth(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // save with empty provider data
- emptyAuth := &models.ExternalAuth{}
- if err := app.Dao().SaveExternalAuth(emptyAuth); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- auth := &models.ExternalAuth{
- RecordId: "o1y0dd0spd786md",
- CollectionId: "v851q4r790rhknl",
- Provider: "test",
- ProviderId: "test_id",
- }
-
- if err := app.Dao().SaveExternalAuth(auth); err != nil {
- t.Fatal(err)
- }
-
- // check if it was really saved
- foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
- if err != nil {
- t.Fatal(err)
- }
-
- if auth.Id != foundAuth.Id {
- t.Fatalf("Expected ExternalAuth with id %s, got \n%v", auth.Id, foundAuth)
- }
-}
-
-func TestDeleteExternalAuth(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
- if err != nil {
- t.Fatal(err)
- }
-
- auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
- if err != nil {
- t.Fatal(err)
- }
-
- for _, auth := range auths {
- if err := app.Dao().DeleteExternalAuth(auth); err != nil {
- t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
- }
- }
-
- // check if the relations were really deleted
- newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(newAuths) != 0 {
- t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
- }
-}
diff --git a/daos/param.go b/daos/param.go
deleted file mode 100644
index 23ba07dd49..0000000000
--- a/daos/param.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package daos
-
-import (
- "encoding/json"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// ParamQuery returns a new Param select query.
-func (dao *Dao) ParamQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.Param{})
-}
-
-// FindParamByKey finds the first Param model with the provided key.
-func (dao *Dao) FindParamByKey(key string) (*models.Param, error) {
- param := &models.Param{}
-
- err := dao.ParamQuery().
- AndWhere(dbx.HashExp{"key": key}).
- Limit(1).
- One(param)
-
- if err != nil {
- return nil, err
- }
-
- return param, nil
-}
-
-// SaveParam creates or updates a Param model by the provided key-value pair.
-// The value argument will be encoded as json string.
-//
-// If `optEncryptionKey` is provided it will encrypt the value before storing it.
-func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) error {
- param, _ := dao.FindParamByKey(key)
- if param == nil {
- param = &models.Param{Key: key}
- }
-
- normalizedValue := value
-
- // encrypt if optEncryptionKey is set
- if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
- encoded, encodingErr := json.Marshal(value)
- if encodingErr != nil {
- return encodingErr
- }
-
- encryptVal, encryptErr := security.Encrypt(encoded, optEncryptionKey[0])
- if encryptErr != nil {
- return encryptErr
- }
-
- normalizedValue = encryptVal
- }
-
- encodedValue := types.JsonRaw{}
- if err := encodedValue.Scan(normalizedValue); err != nil {
- return err
- }
-
- param.Value = encodedValue
-
- return dao.Save(param)
-}
-
-// DeleteParam deletes the provided Param model.
-func (dao *Dao) DeleteParam(param *models.Param) error {
- return dao.Delete(param)
-}
diff --git a/daos/param_test.go b/daos/param_test.go
deleted file mode 100644
index b364608584..0000000000
--- a/daos/param_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package daos_test
-
-import (
- "encoding/json"
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestParamQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_params}}.* FROM `_params`"
-
- sql := app.Dao().ParamQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestFindParamByKey(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- key string
- expectError bool
- }{
- {"", true},
- {"missing", true},
- {models.ParamAppSettings, false},
- }
-
- for i, scenario := range scenarios {
- param, err := app.Dao().FindParamByKey(scenario.key)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if param != nil && param.Key != scenario.key {
- t.Errorf("(%d) Expected param with identifier %s, got %v", i, scenario.key, param.Key)
- }
- }
-}
-
-func TestSaveParam(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- key string
- value any
- }{
- {"", "demo"},
- {"test", nil},
- {"test", ""},
- {"test", 1},
- {"test", 123},
- {models.ParamAppSettings, map[string]any{"test": 123}},
- }
-
- for i, scenario := range scenarios {
- err := app.Dao().SaveParam(scenario.key, scenario.value)
- if err != nil {
- t.Errorf("(%d) %v", i, err)
- }
-
- jsonRaw := types.JsonRaw{}
- jsonRaw.Scan(scenario.value)
- encodedScenarioValue, err := jsonRaw.MarshalJSON()
- if err != nil {
- t.Errorf("(%d) Encoded error %v", i, err)
- }
-
- // check if the param was really saved
- param, _ := app.Dao().FindParamByKey(scenario.key)
- encodedParamValue, err := param.Value.MarshalJSON()
- if err != nil {
- t.Errorf("(%d) Encoded error %v", i, err)
- }
-
- if string(encodedParamValue) != string(encodedScenarioValue) {
- t.Errorf("(%d) Expected the two values to be equal, got %v vs %v", i, string(encodedParamValue), string(encodedScenarioValue))
- }
- }
-}
-
-func TestSaveParamEncrypted(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- encryptionKey := security.RandomString(32)
- data := map[string]int{"test": 123}
- expected := map[string]int{}
-
- err := app.Dao().SaveParam("test", data, encryptionKey)
- if err != nil {
- t.Fatal(err)
- }
-
- // check if the param was really saved
- param, _ := app.Dao().FindParamByKey("test")
-
- // decrypt
- decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
- if decryptErr != nil {
- t.Fatal(decryptErr)
- }
-
- // decode
- decryptedDecodeErr := json.Unmarshal(decrypted, &expected)
- if decryptedDecodeErr != nil {
- t.Fatal(decryptedDecodeErr)
- }
-
- // check if the decoded value is correct
- if len(expected) != len(data) || expected["test"] != data["test"] {
- t.Fatalf("Expected %v, got %v", expected, data)
- }
-}
-
-func TestDeleteParam(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // unsaved param
- err1 := app.Dao().DeleteParam(&models.Param{})
- if err1 == nil {
- t.Fatal("Expected error, got nil")
- }
-
- // existing param
- param, _ := app.Dao().FindParamByKey(models.ParamAppSettings)
- err2 := app.Dao().DeleteParam(param)
- if err2 != nil {
- t.Fatalf("Expected nil, got error %v", err2)
- }
-
- // check if it was really deleted
- paramCheck, _ := app.Dao().FindParamByKey(models.ParamAppSettings)
- if paramCheck != nil {
- t.Fatalf("Expected param to be deleted, got %v", paramCheck)
- }
-}
diff --git a/daos/record.go b/daos/record.go
deleted file mode 100644
index 4f4a67b878..0000000000
--- a/daos/record.go
+++ /dev/null
@@ -1,763 +0,0 @@
-package daos
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/inflector"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/search"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
- "github.com/spf13/cast"
-)
-
-// RecordQuery returns a new Record select query from a collection model, id or name.
-//
-// In case a collection id or name is provided and that collection doesn't
-// actually exists, the generated query will be created with a cancelled context
-// and will fail once an executor (Row(), One(), All(), etc.) is called.
-func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
- var tableName string
- var collection *models.Collection
- var collectionErr error
- switch c := collectionModelOrIdentifier.(type) {
- case *models.Collection:
- collection = c
- tableName = collection.Name
- case models.Collection:
- collection = &c
- tableName = collection.Name
- case string:
- collection, collectionErr = dao.FindCollectionByNameOrId(c)
- if collection != nil {
- tableName = collection.Name
- } else {
- // update with some fake table name for easier debugging
- tableName = "@@__missing_" + c
- }
- default:
- // update with some fake table name for easier debugging
- tableName = "@@__invalidCollectionModelOrIdentifier"
- collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
- }
-
- selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
-
- query := dao.DB().Select(selectCols).From(tableName)
-
- // in case of an error attach a new context and cancel it immediately with the error
- if collectionErr != nil {
- // @todo consider changing to WithCancelCause when upgrading
- // the min Go requirement to 1.20, so that we can pass the error
- ctx, cancelFunc := context.WithCancel(context.Background())
- query.WithContext(ctx)
- cancelFunc()
- }
-
- return query.WithBuildHook(func(q *dbx.Query) {
- q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
- WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
- switch v := a.(type) {
- case *models.Record:
- if v == nil {
- return op(a)
- }
-
- row := dbx.NullStringMap{}
- if err := op(&row); err != nil {
- return err
- }
-
- record := models.NewRecordFromNullStringMap(collection, row)
-
- *v = *record
-
- return nil
- default:
- return op(a)
- }
- }).
- WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
- switch v := sliceA.(type) {
- case *[]*models.Record:
- if v == nil {
- return op(sliceA)
- }
-
- rows := []dbx.NullStringMap{}
- if err := op(&rows); err != nil {
- return err
- }
-
- records := models.NewRecordsFromNullStringMaps(collection, rows)
-
- *v = records
-
- return nil
- case *[]models.Record:
- if v == nil {
- return op(sliceA)
- }
-
- rows := []dbx.NullStringMap{}
- if err := op(&rows); err != nil {
- return err
- }
-
- records := models.NewRecordsFromNullStringMaps(collection, rows)
-
- nonPointers := make([]models.Record, len(records))
- for i, r := range records {
- nonPointers[i] = *r
- }
-
- *v = nonPointers
-
- return nil
- default:
- return op(sliceA)
- }
- })
- })
-}
-
-// FindRecordById finds the Record model by its id.
-func (dao *Dao) FindRecordById(
- collectionNameOrId string,
- recordId string,
- optFilters ...func(q *dbx.SelectQuery) error,
-) (*models.Record, error) {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return nil, err
- }
-
- query := dao.RecordQuery(collection).
- AndWhere(dbx.HashExp{collection.Name + ".id": recordId})
-
- for _, filter := range optFilters {
- if filter == nil {
- continue
- }
- if err := filter(query); err != nil {
- return nil, err
- }
- }
-
- record := &models.Record{}
-
- if err := query.Limit(1).One(record); err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// FindRecordsByIds finds all Record models by the provided ids.
-// If no records are found, returns an empty slice.
-func (dao *Dao) FindRecordsByIds(
- collectionNameOrId string,
- recordIds []string,
- optFilters ...func(q *dbx.SelectQuery) error,
-) ([]*models.Record, error) {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return nil, err
- }
-
- query := dao.RecordQuery(collection).
- AndWhere(dbx.In(
- collection.Name+".id",
- list.ToInterfaceSlice(recordIds)...,
- ))
-
- for _, filter := range optFilters {
- if filter == nil {
- continue
- }
- if err := filter(query); err != nil {
- return nil, err
- }
- }
-
- records := make([]*models.Record, 0, len(recordIds))
-
- if err := query.All(&records); err != nil {
- return nil, err
- }
-
- return records, nil
-}
-
-// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
-//
-// FindRecordsByExpr finds all records by the specified db expression.
-//
-// Returns all collection records if no expressions are provided.
-//
-// Returns an empty slice if no records are found.
-//
-// Example:
-//
-// expr1 := dbx.HashExp{"email": "test@example.com"}
-// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
-// dao.FindRecordsByExpr("example", expr1, expr2)
-func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
- query := dao.RecordQuery(collectionNameOrId)
-
- // add only the non-nil expressions
- for _, expr := range exprs {
- if expr != nil {
- query.AndWhere(expr)
- }
- }
-
- var records []*models.Record
-
- if err := query.All(&records); err != nil {
- return nil, err
- }
-
- return records, nil
-}
-
-// FindFirstRecordByData returns the first found record matching
-// the provided key-value pair.
-func (dao *Dao) FindFirstRecordByData(
- collectionNameOrId string,
- key string,
- value any,
-) (*models.Record, error) {
- record := &models.Record{}
-
- err := dao.RecordQuery(collectionNameOrId).
- AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
- Limit(1).
- One(record)
- if err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// FindRecordsByFilter returns limit number of records matching the
-// provided string filter.
-//
-// NB! Use the last "params" argument to bind untrusted user variables!
-//
-// The sort argument is optional and can be empty string OR the same format
-// used in the web APIs, eg. "-created,title".
-//
-// If the limit argument is <= 0, no limit is applied to the query and
-// all matching records are returned.
-//
-// Example:
-//
-// dao.FindRecordsByFilter(
-// "posts",
-// "title ~ {:title} && visible = {:visible}",
-// "-created",
-// 10,
-// 0,
-// dbx.Params{"title": "lorem ipsum", "visible": true}
-// )
-func (dao *Dao) FindRecordsByFilter(
- collectionNameOrId string,
- filter string,
- sort string,
- limit int,
- offset int,
- params ...dbx.Params,
-) ([]*models.Record, error) {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return nil, err
- }
-
- q := dao.RecordQuery(collection)
-
- // build a fields resolver and attach the generated conditions to the query
- // ---
- resolver := resolvers.NewRecordFieldResolver(
- dao,
- collection, // the base collection
- nil, // no request data
- true, // allow searching hidden/protected fields like "email"
- )
-
- expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
- if err != nil || expr == nil {
- return nil, errors.New("invalid or empty filter expression")
- }
- q.AndWhere(expr)
-
- if sort != "" {
- for _, sortField := range search.ParseSortFromString(sort) {
- expr, err := sortField.BuildExpr(resolver)
- if err != nil {
- return nil, err
- }
- if expr != "" {
- q.AndOrderBy(expr)
- }
- }
- }
-
- resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
- // ---
-
- if offset > 0 {
- q.Offset(int64(offset))
- }
-
- if limit > 0 {
- q.Limit(int64(limit))
- }
-
- records := []*models.Record{}
-
- if err := q.All(&records); err != nil {
- return nil, err
- }
-
- return records, nil
-}
-
-// FindFirstRecordByFilter returns the first available record matching the provided filter.
-//
-// NB! Use the last params argument to bind untrusted user variables!
-//
-// Example:
-//
-// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
-func (dao *Dao) FindFirstRecordByFilter(
- collectionNameOrId string,
- filter string,
- params ...dbx.Params,
-) (*models.Record, error) {
- result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1, 0, params...)
- if err != nil {
- return nil, err
- }
-
- if len(result) == 0 {
- return nil, sql.ErrNoRows
- }
-
- return result[0], nil
-}
-
-// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
-//
-// For correctness, if the collection is "auth" and the key is "username",
-// the unique check will be case insensitive.
-//
-// NB! Array values (eg. from multiple select fields) are matched
-// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
-// depends on the elements order. Or in other words the following values
-// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
-func (dao *Dao) IsRecordValueUnique(
- collectionNameOrId string,
- key string,
- value any,
- excludeIds ...string,
-) bool {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return false
- }
-
- var expr dbx.Expression
- if collection.IsAuth() && key == schema.FieldNameUsername {
- expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
- "username": strings.ToLower(cast.ToString(value)),
- })
- } else {
- var normalizedVal any
- switch val := value.(type) {
- case []string:
- normalizedVal = append(types.JsonArray[string]{}, val...)
- case []any:
- normalizedVal = append(types.JsonArray[any]{}, val...)
- default:
- normalizedVal = val
- }
-
- expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
- }
-
- query := dao.RecordQuery(collection).
- Select("count(*)").
- AndWhere(expr).
- Limit(1)
-
- if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
- query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
- }
-
- var exists bool
-
- return query.Row(&exists) == nil && !exists
-}
-
-// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
-//
-// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
-func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
- unverifiedClaims, err := security.ParseUnverifiedJWT(token)
- if err != nil {
- return nil, err
- }
-
- // check required claims
- id, _ := unverifiedClaims["id"].(string)
- collectionId, _ := unverifiedClaims["collectionId"].(string)
- if id == "" || collectionId == "" {
- return nil, errors.New("missing or invalid token claims")
- }
-
- record, err := dao.FindRecordById(collectionId, id)
- if err != nil {
- return nil, err
- }
-
- if !record.Collection().IsAuth() {
- return nil, errors.New("The token is not associated to an auth collection record.")
- }
-
- verificationKey := record.TokenKey() + baseTokenKey
-
- // verify token signature
- if _, err := security.ParseJWT(token, verificationKey); err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// FindAuthRecordByEmail finds the auth record associated with the provided email.
-//
-// Returns an error if it is not an auth collection or the record is not found.
-func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
- }
- if !collection.IsAuth() {
- return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
- }
-
- record := &models.Record{}
-
- err = dao.RecordQuery(collection).
- AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
- Limit(1).
- One(record)
- if err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
-//
-// Returns an error if it is not an auth collection or the record is not found.
-func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
- collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
- }
- if !collection.IsAuth() {
- return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
- }
-
- record := &models.Record{}
-
- err = dao.RecordQuery(collection).
- AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
- "username": strings.ToLower(username),
- })).
- Limit(1).
- One(record)
- if err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// SuggestUniqueAuthRecordUsername checks if the provided username is unique
-// and return a new "unique" username with appended random numeric part
-// (eg. "existingName" -> "existingName583").
-//
-// The same username will be returned if the provided string is already unique.
-func (dao *Dao) SuggestUniqueAuthRecordUsername(
- collectionNameOrId string,
- baseUsername string,
- excludeIds ...string,
-) string {
- username := baseUsername
-
- for i := 0; i < 10; i++ { // max 10 attempts
- isUnique := dao.IsRecordValueUnique(
- collectionNameOrId,
- schema.FieldNameUsername,
- username,
- excludeIds...,
- )
- if isUnique {
- break // already unique
- }
- username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
- }
-
- return username
-}
-
-// CanAccessRecord checks if a record is allowed to be accessed by the
-// specified requestInfo and accessRule.
-//
-// Rule and db checks are ignored in case requestInfo.Admin is set.
-//
-// The returned error indicate that something unexpected happened during
-// the check (eg. invalid rule or db error).
-//
-// The method always return false on invalid access rule or db error.
-//
-// Example:
-//
-// requestInfo := apis.RequestInfo(c /* echo.Context */)
-// record, _ := dao.FindRecordById("example", "RECORD_ID")
-// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
-// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
-//
-// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
-func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
- if requestInfo.Admin != nil {
- // admins can access everything
- return true, nil
- }
-
- if accessRule == nil {
- // only admins can access this record
- return false, nil
- }
-
- if *accessRule == "" {
- // empty public rule, aka. everyone can access
- return true, nil
- }
-
- var exists bool
-
- query := dao.RecordQuery(record.Collection()).
- Select("(1)").
- AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
-
- // parse and apply the access rule filter
- resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
- expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
- if err != nil {
- return false, err
- }
- resolver.UpdateQuery(query)
- query.AndWhere(expr)
-
- if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
- return false, err
- }
-
- return exists, nil
-}
-
-// SaveRecord persists the provided Record model in the database.
-//
-// If record.IsNew() is true, the method will perform a create, otherwise an update.
-// To explicitly mark a record for update you can use record.MarkAsNotNew().
-func (dao *Dao) SaveRecord(record *models.Record) error {
- if record.Collection().IsAuth() {
- if record.Username() == "" {
- return errors.New("unable to save auth record without username")
- }
-
- // Cross-check that the auth record id is unique for all auth collections.
- // This is to make sure that the filter `@request.auth.id` always returns a unique id.
- authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
- if err != nil {
- return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
- }
- for _, collection := range authCollections {
- if record.Collection().Id == collection.Id {
- continue // skip current collection (sqlite will do the check for us)
- }
- isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
- if !isUnique {
- return errors.New("the auth record ID must be unique across all auth collections")
- }
- }
- }
-
- return dao.Save(record)
-}
-
-// DeleteRecord deletes the provided Record model.
-//
-// This method will also cascade the delete operation to all linked
-// relational records (delete or unset, depending on the rel settings).
-//
-// The delete operation may fail if the record is part of a required
-// reference in another record (aka. cannot be deleted or unset).
-func (dao *Dao) DeleteRecord(record *models.Record) error {
- // fetch rel references (if any)
- //
- // note: the select is outside of the transaction to minimize
- // SQLITE_BUSY errors when mixing read&write in a single transaction
- refs, err := dao.FindCollectionReferences(record.Collection())
- if err != nil {
- return err
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- // manually trigger delete on any linked external auth to ensure
- // that the `OnModel*` hooks are triggered
- if record.Collection().IsAuth() {
- // note: the select is outside of the transaction to minimize
- // SQLITE_BUSY errors when mixing read&write in a single transaction
- externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
- if err != nil {
- return err
- }
- for _, auth := range externalAuths {
- if err := txDao.DeleteExternalAuth(auth); err != nil {
- return err
- }
- }
- }
-
- // delete the record before the relation references to ensure that there
- // will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
- if err := txDao.Delete(record); err != nil {
- return err
- }
-
- return txDao.cascadeRecordDelete(record, refs)
- })
-}
-
-// cascadeRecordDelete triggers cascade deletion for the provided references.
-//
-// NB! This method is expected to be called inside a transaction.
-func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
- uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
-
- for refCollection, fields := range refs {
- if refCollection.IsView() {
- continue // skip view collections
- }
-
- for _, field := range fields {
- recordTableName := inflector.Columnify(refCollection.Name)
- prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
-
- query := dao.RecordQuery(refCollection).Distinct(true)
-
- if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
- query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
- } else {
- query.InnerJoin(fmt.Sprintf(
- `json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
- prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
- ), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
- }
-
- if refCollection.Id == mainRecord.Collection().Id {
- query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
- }
-
- // trigger cascade for each batchSize rel items until there is none
- batchSize := 4000
- rows := make([]dbx.NullStringMap, 0, batchSize)
- for {
- if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
- return err
- }
-
- total := len(rows)
- if total == 0 {
- break
- }
-
- refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
-
- err := dao.deleteRefRecords(mainRecord, refRecords, field)
- if err != nil {
- return err
- }
-
- if total < batchSize {
- break // no more items
- }
-
- rows = rows[:0] // keep allocated memory
- }
- }
- }
-
- return nil
-}
-
-// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
-// OR
-// just unset the record id from any relation field values (if they are not required).
-//
-// NB! This method is expected to be called inside a transaction.
-func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
- options, _ := field.Options.(*schema.RelationOptions)
- if options == nil {
- return errors.New("relation field options are not initialized")
- }
-
- for _, refRecord := range refRecords {
- ids := refRecord.GetStringSlice(field.Name)
-
- // unset the record id
- for i := len(ids) - 1; i >= 0; i-- {
- if ids[i] == mainRecord.Id {
- ids = append(ids[:i], ids[i+1:]...)
- break
- }
- }
-
- // cascade delete the reference
- // (only if there are no other active references in case of multiple select)
- if options.CascadeDelete && len(ids) == 0 {
- if err := dao.DeleteRecord(refRecord); err != nil {
- return err
- }
- // no further actions are needed (the reference is deleted)
- continue
- }
-
- if field.Required && len(ids) == 0 {
- return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
- }
-
- // save the reference changes
- refRecord.Set(field.Name, field.PrepareValue(ids))
- if err := dao.SaveRecord(refRecord); err != nil {
- return err
- }
- }
-
- return nil
-}
diff --git a/daos/record_expand.go b/daos/record_expand.go
deleted file mode 100644
index 033364e6f8..0000000000
--- a/daos/record_expand.go
+++ /dev/null
@@ -1,291 +0,0 @@
-package daos
-
-import (
- "fmt"
- "regexp"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/dbutils"
- "github.com/pocketbase/pocketbase/tools/inflector"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// MaxExpandDepth specifies the max allowed nested expand depth path.
-const MaxExpandDepth = 6
-
-// ExpandFetchFunc defines the function that is used to fetch the expanded relation records.
-type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error)
-
-// ExpandRecord expands the relations of a single Record model.
-//
-// If optFetchFunc is not set, then a default function will be used
-// that returns all relation records.
-//
-// Returns a map with the failed expand parameters and their errors.
-func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
- return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
-}
-
-// ExpandRecords expands the relations of the provided Record models list.
-//
-// If optFetchFunc is not set, then a default function will be used
-// that returns all relation records.
-//
-// Returns a map with the failed expand parameters and their errors.
-func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
- normalized := normalizeExpands(expands)
-
- failed := map[string]error{}
-
- for _, expand := range normalized {
- if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
- failed[expand] = err
- }
- }
-
- return failed
-}
-
-var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
-
-// notes:
-// - if fetchFunc is nil, dao.FindRecordsByIds will be used
-// - all records are expected to be from the same collection
-// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
-// - indirect expands are supported only with single relation fields
-func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
- if fetchFunc == nil {
- // load a default fetchFunc
- fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
- return dao.FindRecordsByIds(relCollection.Id, relIds)
- }
- }
-
- if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
- return nil
- }
-
- mainCollection := records[0].Collection()
-
- var relField *schema.SchemaField
- var relFieldOptions *schema.RelationOptions
- var relCollection *models.Collection
-
- parts := strings.SplitN(expandPath, ".", 2)
- matches := indirectExpandRegex.FindStringSubmatch(parts[0])
-
- if len(matches) == 3 {
- indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
- if indirectRel == nil {
- return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
- }
-
- indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
- if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
- return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
- }
-
- indirectRelField.InitOptions()
- indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
- if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
- return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
- }
- if indirectRelFieldOptions.IsMultiple() {
- // for now don't allow multi-relation indirect fields expand
- // due to eventual poor query performance with large data sets.
- return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
- }
-
- recordIds := make([]any, len(records))
- for i, record := range records {
- recordIds[i] = record.Id
- }
-
- // @todo after the index optimizations consider allowing
- // indirect expand for multi-relation fields
- indirectRecords, err := dao.FindRecordsByExpr(
- indirectRel.Id,
- dbx.In(inflector.Columnify(matches[2]), recordIds...),
- )
- if err != nil {
- return err
- }
- mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
- for _, indirectRecord := range indirectRecords {
- recId := indirectRecord.GetString(matches[2])
- if recId != "" {
- mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
- }
- }
-
- // add the indirect relation ids as a new relation field value
- for _, record := range records {
- relIds, ok := mappedIndirectRecordIds[record.Id]
- if ok && len(relIds) > 0 {
- record.Set(parts[0], relIds)
- }
- }
-
- relFieldOptions = &schema.RelationOptions{
- MaxSelect: nil,
- CollectionId: indirectRel.Id,
- }
- if isRelFieldUnique(indirectRel, indirectRelField.Name) {
- relFieldOptions.MaxSelect = types.Pointer(1)
- }
- // indirect relation
- relField = &schema.SchemaField{
- Id: "indirect_" + security.PseudorandomString(5),
- Type: schema.FieldTypeRelation,
- Name: parts[0],
- Options: relFieldOptions,
- }
- relCollection = indirectRel
- } else {
- // direct relation
- relField = mainCollection.Schema.GetFieldByName(parts[0])
- if relField == nil || relField.Type != schema.FieldTypeRelation {
- return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
- }
- relField.InitOptions()
- relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
- if relFieldOptions == nil {
- return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
- }
-
- relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
- if relCollection == nil {
- return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
- }
- }
-
- // ---------------------------------------------------------------
-
- // extract the id of the relations to expand
- relIds := make([]string, 0, len(records))
- for _, record := range records {
- relIds = append(relIds, record.GetStringSlice(relField.Name)...)
- }
-
- // fetch rels
- rels, relsErr := fetchFunc(relCollection, relIds)
- if relsErr != nil {
- return relsErr
- }
-
- // expand nested fields
- if len(parts) > 1 {
- err := dao.expandRecords(rels, parts[1], fetchFunc, recursionLevel+1)
- if err != nil {
- return err
- }
- }
-
- // reindex with the rel id
- indexedRels := make(map[string]*models.Record, len(rels))
- for _, rel := range rels {
- indexedRels[rel.GetId()] = rel
- }
-
- for _, model := range records {
- relIds := model.GetStringSlice(relField.Name)
-
- validRels := make([]*models.Record, 0, len(relIds))
- for _, id := range relIds {
- if rel, ok := indexedRels[id]; ok {
- validRels = append(validRels, rel)
- }
- }
-
- if len(validRels) == 0 {
- continue // no valid relations
- }
-
- expandData := model.Expand()
-
- // normalize access to the previously expanded rel records (if any)
- var oldExpandedRels []*models.Record
- switch v := expandData[relField.Name].(type) {
- case nil:
- // no old expands
- case *models.Record:
- oldExpandedRels = []*models.Record{v}
- case []*models.Record:
- oldExpandedRels = v
- }
-
- // merge expands
- for _, oldExpandedRel := range oldExpandedRels {
- // find a matching rel record
- for _, rel := range validRels {
- if rel.Id != oldExpandedRel.Id {
- continue
- }
-
- rel.MergeExpand(oldExpandedRel.Expand())
- }
- }
-
- // update the expanded data
- if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
- expandData[relField.Name] = validRels[0]
- } else {
- expandData[relField.Name] = validRels
- }
-
- model.SetExpand(expandData)
- }
-
- return nil
-}
-
-// normalizeExpands normalizes expand strings and merges self containing paths
-// (eg. ["a.b.c", "a.b", " test ", " ", "test"] -> ["a.b.c", "test"]).
-func normalizeExpands(paths []string) []string {
- // normalize paths
- normalized := make([]string, 0, len(paths))
- for _, p := range paths {
- p = strings.ReplaceAll(p, " ", "") // replace spaces
- p = strings.Trim(p, ".") // trim incomplete paths
- if p != "" {
- normalized = append(normalized, p)
- }
- }
-
- // merge containing paths
- result := make([]string, 0, len(normalized))
- for i, p1 := range normalized {
- var skip bool
- for j, p2 := range normalized {
- if i == j {
- continue
- }
- if strings.HasPrefix(p2, p1+".") {
- // skip because there is more detailed expand path
- skip = true
- break
- }
- }
- if !skip {
- result = append(result, p1)
- }
- }
-
- return list.ToUniqueStringSlice(result)
-}
-
-func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
- for _, idx := range collection.Indexes {
- parsed := dbutils.ParseIndex(idx)
- if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
- return true
- }
- }
-
- return false
-}
diff --git a/daos/record_expand_test.go b/daos/record_expand_test.go
deleted file mode 100644
index 5439754fe1..0000000000
--- a/daos/record_expand_test.go
+++ /dev/null
@@ -1,426 +0,0 @@
-package daos_test
-
-import (
- "encoding/json"
- "errors"
- "strings"
- "testing"
-
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-func TestExpandRecords(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- testName string
- collectionIdOrName string
- recordIds []string
- expands []string
- fetchFunc daos.ExpandFetchFunc
- expectExpandProps int
- expectExpandFailures int
- }{
- {
- "empty records",
- "",
- []string{},
- []string{"self_rel_one", "self_rel_many.self_rel_one"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 0,
- },
- {
- "empty expand",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 0,
- },
- {
- "fetchFunc with error",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{"self_rel_one", "self_rel_many.self_rel_one"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return nil, errors.New("test error")
- },
- 0,
- 2,
- },
- {
- "missing relation field",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{"missing"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "existing, but non-relation type field",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{"title"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "invalid/missing second level expand",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{"rel_one_no_cascade.title"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "with nil fetchfunc",
- "users",
- []string{
- "bgs820n361vj1qd",
- "4q1xlclmfloku33",
- "oap640cot4yru2s", // no rels
- },
- []string{"rel"},
- nil,
- 2,
- 0,
- },
- {
- "expand normalizations",
- "demo4",
- []string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
- []string{
- "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
- "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
- "self_rel_many", "self_rel_many.",
- " self_rel_many ", "",
- },
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 9,
- 0,
- },
- {
- "single expand",
- "users",
- []string{
- "bgs820n361vj1qd",
- "4q1xlclmfloku33",
- "oap640cot4yru2s", // no rels
- },
- []string{"rel"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 2,
- 0,
- },
- {
- "with nil fetchfunc",
- "users",
- []string{
- "bgs820n361vj1qd",
- "4q1xlclmfloku33",
- "oap640cot4yru2s", // no rels
- },
- []string{"rel"},
- nil,
- 2,
- 0,
- },
- {
- "maxExpandDepth reached",
- "demo4",
- []string{"qzaqccwrmva4o1n"},
- []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 6,
- 0,
- },
- {
- "simple indirect expand",
- "demo3",
- []string{"lcl9d87w22ml6jy"},
- []string{"demo4(rel_one_no_cascade_required)"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 1,
- 0,
- },
- {
- "nested indirect expand",
- "demo3",
- []string{"lcl9d87w22ml6jy"},
- []string{
- "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
- },
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 5,
- 0,
- },
- {
- "expand multiple relations sharing a common path",
- "demo4",
- []string{"qzaqccwrmva4o1n"},
- []string{
- "rel_one_no_cascade",
- "rel_many_no_cascade",
- "self_rel_many.self_rel_one.rel_many_cascade",
- "self_rel_many.self_rel_one.rel_many_no_cascade_required",
- },
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 5,
- 0,
- },
- }
-
- for _, s := range scenarios {
- ids := list.ToUniqueStringSlice(s.recordIds)
- records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
- failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
-
- if len(failed) != s.expectExpandFailures {
- t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
- }
-
- encoded, _ := json.Marshal(records)
- encodedStr := string(encoded)
- totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
-
- if s.expectExpandProps != totalExpandProps {
- t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
- }
- }
-}
-
-func TestExpandRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- testName string
- collectionIdOrName string
- recordId string
- expands []string
- fetchFunc daos.ExpandFetchFunc
- expectExpandProps int
- expectExpandFailures int
- }{
- {
- "empty expand",
- "demo4",
- "i9naidtvr6qsgb4",
- []string{},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 0,
- },
- {
- "fetchFunc with error",
- "demo4",
- "i9naidtvr6qsgb4",
- []string{"self_rel_one", "self_rel_many.self_rel_one"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return nil, errors.New("test error")
- },
- 0,
- 2,
- },
- {
- "missing relation field",
- "demo4",
- "i9naidtvr6qsgb4",
- []string{"missing"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "existing, but non-relation type field",
- "demo4",
- "i9naidtvr6qsgb4",
- []string{"title"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "invalid/missing second level expand",
- "demo4",
- "qzaqccwrmva4o1n",
- []string{"rel_one_no_cascade.title"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 1,
- },
- {
- "expand normalizations",
- "demo4",
- "qzaqccwrmva4o1n",
- []string{
- "self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
- "self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
- "self_rel_many", "self_rel_many.",
- " self_rel_many ", "",
- },
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 8,
- 0,
- },
- {
- "no rels to expand",
- "users",
- "oap640cot4yru2s",
- []string{"rel"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 0,
- 0,
- },
- {
- "maxExpandDepth reached",
- "demo4",
- "qzaqccwrmva4o1n",
- []string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 6,
- 0,
- },
- {
- "simple indirect expand",
- "demo3",
- "lcl9d87w22ml6jy",
- []string{"demo4(rel_one_no_cascade_required)"},
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 1,
- 0,
- },
- {
- "nested indirect expand",
- "demo3",
- "lcl9d87w22ml6jy",
- []string{
- "demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
- },
- func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- },
- 5,
- 0,
- },
- }
-
- for _, s := range scenarios {
- record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
- failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
-
- if len(failed) != s.expectExpandFailures {
- t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
- }
-
- encoded, _ := json.Marshal(record)
- encodedStr := string(encoded)
- totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
-
- if s.expectExpandProps != totalExpandProps {
- t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
- }
- }
-}
-
-func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
- if err != nil {
- t.Fatal(err)
- }
-
- // non-unique indirect expand
- {
- errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- })
- if len(errs) > 0 {
- t.Fatal(errs)
- }
-
- result, ok := record.Expand()["demo4(rel_one_cascade)"].([]*models.Record)
- if !ok {
- t.Fatalf("Expected the expanded result to be a slice, got %v", result)
- }
- }
-
- // mock a unique constraint for the rel_one_cascade field
- {
- demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
- if err != nil {
- t.Fatal(err)
- }
-
- demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
-
- if err := app.Dao().SaveCollection(demo4); err != nil {
- t.Fatalf("Failed to mock unique constraint: %v", err)
- }
- }
-
- // non-unique indirect expand
- {
- errs := app.Dao().ExpandRecord(record, []string{"demo4(rel_one_cascade)"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
- return app.Dao().FindRecordsByIds(c.Id, ids, nil)
- })
- if len(errs) > 0 {
- t.Fatal(errs)
- }
-
- result, ok := record.Expand()["demo4(rel_one_cascade)"].(*models.Record)
- if !ok {
- t.Fatalf("Expected the expanded result to be a single model, got %v", result)
- }
- }
-}
diff --git a/daos/record_table_sync.go b/daos/record_table_sync.go
deleted file mode 100644
index c853fa247b..0000000000
--- a/daos/record_table_sync.go
+++ /dev/null
@@ -1,360 +0,0 @@
-package daos
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/dbutils"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// SyncRecordTableSchema compares the two provided collections
-// and applies the necessary related record table changes.
-//
-// If `oldCollection` is null, then only `newCollection` is used to create the record table.
-func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
- return dao.RunInTransaction(func(txDao *Dao) error {
- // create
- // -----------------------------------------------------------
- if oldCollection == nil {
- cols := map[string]string{
- schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
- schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
- schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
- }
-
- if newCollection.IsAuth() {
- cols[schema.FieldNameUsername] = "TEXT NOT NULL"
- cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
- cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
- cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
- cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
- cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
- cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
- cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
- }
-
- // ensure that the new collection has an id
- if !newCollection.HasId() {
- newCollection.RefreshId()
- newCollection.MarkAsNew()
- }
-
- tableName := newCollection.Name
-
- // add schema field definitions
- for _, field := range newCollection.Schema.Fields() {
- cols[field.Name] = field.ColDefinition()
- }
-
- // create table
- if _, err := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
- return err
- }
-
- // add named unique index on the email and tokenKey columns
- if newCollection.IsAuth() {
- _, err := txDao.DB().NewQuery(fmt.Sprintf(
- `
- CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
- CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
- CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
- `,
- newCollection.Id, tableName,
- newCollection.Id, tableName,
- newCollection.Id, tableName,
- )).Execute()
- if err != nil {
- return err
- }
- }
-
- return txDao.createCollectionIndexes(newCollection)
- }
-
- // update
- // -----------------------------------------------------------
- oldTableName := oldCollection.Name
- newTableName := newCollection.Name
- oldSchema := oldCollection.Schema
- newSchema := newCollection.Schema
- deletedFieldNames := []string{}
- renamedFieldNames := map[string]string{}
-
- // drop old indexes (if any)
- if err := txDao.dropCollectionIndex(oldCollection); err != nil {
- return err
- }
-
- // check for renamed table
- if !strings.EqualFold(oldTableName, newTableName) {
- _, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
- if err != nil {
- return err
- }
- }
-
- // check for deleted columns
- for _, oldField := range oldSchema.Fields() {
- if f := newSchema.GetFieldById(oldField.Id); f != nil {
- continue // exist
- }
-
- _, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
- if err != nil {
- return fmt.Errorf("failed to drop column %s - %w", oldField.Name, err)
- }
-
- deletedFieldNames = append(deletedFieldNames, oldField.Name)
- }
-
- // check for new or renamed columns
- toRename := map[string]string{}
- for _, field := range newSchema.Fields() {
- oldField := oldSchema.GetFieldById(field.Id)
- // Note:
- // We are using a temporary column name when adding or renaming columns
- // to ensure that there are no name collisions in case there is
- // names switch/reuse of existing columns (eg. name, title -> title, name).
- // This way we are always doing 1 more rename operation but it provides better dev experience.
-
- if oldField == nil {
- tempName := field.Name + security.PseudorandomString(5)
- toRename[tempName] = field.Name
-
- // add
- _, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
- if err != nil {
- return fmt.Errorf("failed to add column %s - %w", field.Name, err)
- }
- } else if oldField.Name != field.Name {
- tempName := field.Name + security.PseudorandomString(5)
- toRename[tempName] = field.Name
-
- // rename
- _, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
- if err != nil {
- return fmt.Errorf("failed to rename column %s - %w", oldField.Name, err)
- }
-
- renamedFieldNames[oldField.Name] = field.Name
- }
- }
-
- // set the actual columns name
- for tempName, actualName := range toRename {
- _, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
- if err != nil {
- return err
- }
- }
-
- if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
- return err
- }
-
- return txDao.createCollectionIndexes(newCollection)
- })
-}
-
-func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
- if newCollection.IsView() || oldCollection == nil {
- return nil // view or not an update
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- // temporary disable the schema error checks to prevent view and trigger errors
- // when "altering" (aka. deleting and recreating) the non-normalized columns
- if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
- return err
- }
- // executed with defer to make sure that the pragma is always reverted
- // in case of an error and when nested transactions are used
- defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
-
- for _, newField := range newCollection.Schema.Fields() {
- // allow to continue even if there is no old field for the cases
- // when a new field is added and there are already inserted data
- var isOldMultiple bool
- if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
- if opt, ok := oldField.Options.(schema.MultiValuer); ok {
- isOldMultiple = opt.IsMultiple()
- }
- }
-
- var isNewMultiple bool
- if opt, ok := newField.Options.(schema.MultiValuer); ok {
- isNewMultiple = opt.IsMultiple()
- }
-
- if isOldMultiple == isNewMultiple {
- continue // no change
- }
-
- // update the column definition by:
- // 1. inserting a new column with the new definition
- // 2. copy normalized values from the original column to the new one
- // 3. drop the original column
- // 4. rename the new column to the original column
- // -------------------------------------------------------
-
- originalName := newField.Name
- tempName := "_" + newField.Name + security.PseudorandomString(5)
-
- _, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
- if err != nil {
- return err
- }
-
- var copyQuery *dbx.Query
-
- if !isOldMultiple && isNewMultiple {
- // single -> multiple (convert to array)
- copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
- `UPDATE {{%s}} set [[%s]] = (
- CASE
- WHEN COALESCE([[%s]], '') = ''
- THEN '[]'
- ELSE (
- CASE
- WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
- THEN [[%s]]
- ELSE json_array([[%s]])
- END
- )
- END
- )`,
- newCollection.Name,
- tempName,
- originalName,
- originalName,
- originalName,
- originalName,
- originalName,
- ))
- } else {
- // multiple -> single (keep only the last element)
- //
- // note: for file fields the actual file objects are not
- // deleted allowing additional custom handling via migration
- copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
- `UPDATE {{%s}} set [[%s]] = (
- CASE
- WHEN COALESCE([[%s]], '[]') = '[]'
- THEN ''
- ELSE (
- CASE
- WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
- THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
- ELSE [[%s]]
- END
- )
- END
- )`,
- newCollection.Name,
- tempName,
- originalName,
- originalName,
- originalName,
- originalName,
- originalName,
- ))
- }
-
- // copy the normalized values
- if _, err := copyQuery.Execute(); err != nil {
- return err
- }
-
- // drop the original column
- if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
- return err
- }
-
- // rename the new column back to the original
- if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
- return err
- }
- }
-
- // revert the pragma and reload the schema
- _, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
-
- return revertErr
- })
-}
-
-func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
- if collection.IsView() {
- return nil // views don't have indexes
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- for _, raw := range collection.Indexes {
- parsed := dbutils.ParseIndex(raw)
-
- if !parsed.IsValid() {
- continue
- }
-
- if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
-
-func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
- if collection.IsView() {
- return nil // views don't have indexes
- }
-
- return dao.RunInTransaction(func(txDao *Dao) error {
- // drop new indexes in case a duplicated index name is used
- if err := txDao.dropCollectionIndex(collection); err != nil {
- return err
- }
-
- // upsert new indexes
- //
- // note: we are returning validation errors because the indexes cannot be
- // validated in a form, aka. before persisting the related collection
- // record table changes
- errs := validation.Errors{}
- for i, idx := range collection.Indexes {
- parsed := dbutils.ParseIndex(idx)
-
- // ensure that the index is always for the current collection
- parsed.TableName = collection.Name
-
- if !parsed.IsValid() {
- errs[strconv.Itoa(i)] = validation.NewError(
- "validation_invalid_index_expression",
- "Invalid CREATE INDEX expression.",
- )
- continue
- }
-
- if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
- errs[strconv.Itoa(i)] = validation.NewError(
- "validation_invalid_index_expression",
- fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
- )
- continue
- }
- }
-
- if len(errs) > 0 {
- return validation.Errors{"indexes": errs}
- }
-
- return nil
- })
-}
diff --git a/daos/record_table_sync_test.go b/daos/record_table_sync_test.go
deleted file mode 100644
index 2912bdd34a..0000000000
--- a/daos/record_table_sync_test.go
+++ /dev/null
@@ -1,319 +0,0 @@
-package daos_test
-
-import (
- "bytes"
- "encoding/json"
- "testing"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestSyncRecordTableSchema(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
- if err != nil {
- t.Fatal(err)
- }
- updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
- if err != nil {
- t.Fatal(err)
- }
- updatedCollection.Name = "demo_renamed"
- updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
- updatedCollection.Schema.AddField(
- &schema.SchemaField{
- Name: "new_field",
- Type: schema.FieldTypeEmail,
- },
- )
- updatedCollection.Schema.AddField(
- &schema.SchemaField{
- Id: updatedCollection.Schema.GetFieldByName("title").Id,
- Name: "title_renamed",
- Type: schema.FieldTypeEmail,
- },
- )
- updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
-
- scenarios := []struct {
- name string
- newCollection *models.Collection
- oldCollection *models.Collection
- expectedColumns []string
- expectedIndexesCount int
- }{
- {
- "new base collection",
- &models.Collection{
- Name: "new_table",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "test",
- Type: schema.FieldTypeText,
- },
- ),
- },
- nil,
- []string{"id", "created", "updated", "test"},
- 0,
- },
- {
- "new auth collection",
- &models.Collection{
- Name: "new_table_auth",
- Type: models.CollectionTypeAuth,
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "test",
- Type: schema.FieldTypeText,
- },
- ),
- Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
- },
- nil,
- []string{
- "id", "created", "updated", "test",
- "username", "email", "verified", "emailVisibility",
- "tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
- },
- 4,
- },
- {
- "no changes",
- oldCollection,
- oldCollection,
- []string{"id", "created", "updated", "title", "active"},
- 3,
- },
- {
- "renamed table, deleted column, renamed columnd and new column",
- updatedCollection,
- oldCollection,
- []string{"id", "created", "updated", "title_renamed", "new_field"},
- 1,
- },
- }
-
- for _, s := range scenarios {
- err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
- if err != nil {
- t.Errorf("[%s] %v", s.name, err)
- continue
- }
-
- if !app.Dao().HasTable(s.newCollection.Name) {
- t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
- }
-
- cols, _ := app.Dao().TableColumns(s.newCollection.Name)
- if len(cols) != len(s.expectedColumns) {
- t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
- }
-
- for _, c := range cols {
- if !list.ExistInSlice(c, s.expectedColumns) {
- t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
- }
- }
-
- indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
-
- if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
- t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
- }
- }
-}
-
-func TestSingleVsMultipleValuesNormalization(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- // mock field changes
- {
- selectOneField := collection.Schema.GetFieldByName("select_one")
- opt := selectOneField.Options.(*schema.SelectOptions)
- opt.MaxSelect = 2
- }
- {
- selectManyField := collection.Schema.GetFieldByName("select_many")
- opt := selectManyField.Options.(*schema.SelectOptions)
- opt.MaxSelect = 1
- }
- {
- fileOneField := collection.Schema.GetFieldByName("file_one")
- opt := fileOneField.Options.(*schema.FileOptions)
- opt.MaxSelect = 2
- }
- {
- fileManyField := collection.Schema.GetFieldByName("file_many")
- opt := fileManyField.Options.(*schema.FileOptions)
- opt.MaxSelect = 1
- }
- {
- relOneField := collection.Schema.GetFieldByName("rel_one")
- opt := relOneField.Options.(*schema.RelationOptions)
- opt.MaxSelect = types.Pointer(2)
- }
- {
- relManyField := collection.Schema.GetFieldByName("rel_many")
- opt := relManyField.Options.(*schema.RelationOptions)
- opt.MaxSelect = types.Pointer(1)
- }
- {
- // new multivaluer field to check whether the array normalization
- // will be applied for already inserted data
- collection.Schema.AddField(&schema.SchemaField{
- Name: "new_multiple",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"a", "b", "c"},
- MaxSelect: 3,
- },
- })
- }
-
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // ensures that the writable schema was reverted to its expected default
- var writableSchema bool
- app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
- if writableSchema == true {
- t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
- }
-
- // check whether the columns DEFAULT definition was updated
- // ---------------------------------------------------------------
- tableInfo, err := app.Dao().TableInfo(collection.Name)
- if err != nil {
- t.Fatal(err)
- }
-
- tableInfoExpectations := map[string]string{
- "select_one": `'[]'`,
- "select_many": `''`,
- "file_one": `'[]'`,
- "file_many": `''`,
- "rel_one": `'[]'`,
- "rel_many": `''`,
- "new_multiple": `'[]'`,
- }
- for col, dflt := range tableInfoExpectations {
- t.Run("check default for "+col, func(t *testing.T) {
- var row *models.TableInfoRow
- for _, r := range tableInfo {
- if r.Name == col {
- row = r
- break
- }
- }
- if row == nil {
- t.Fatalf("Missing info for column %q", col)
- }
-
- if v := row.DefaultValue.String(); v != dflt {
- t.Fatalf("Expected default value %q, got %q", dflt, v)
- }
- })
- }
-
- // check whether the values were normalized
- // ---------------------------------------------------------------
- type fieldsExpectation struct {
- SelectOne string `db:"select_one"`
- SelectMany string `db:"select_many"`
- FileOne string `db:"file_one"`
- FileMany string `db:"file_many"`
- RelOne string `db:"rel_one"`
- RelMany string `db:"rel_many"`
- NewMultiple string `db:"new_multiple"`
- }
-
- fieldsScenarios := []struct {
- recordId string
- expected fieldsExpectation
- }{
- {
- "imy661ixudk5izi",
- fieldsExpectation{
- SelectOne: `[]`,
- SelectMany: ``,
- FileOne: `[]`,
- FileMany: ``,
- RelOne: `[]`,
- RelMany: ``,
- NewMultiple: `[]`,
- },
- },
- {
- "al1h9ijdeojtsjy",
- fieldsExpectation{
- SelectOne: `["optionB"]`,
- SelectMany: `optionB`,
- FileOne: `["300_Jsjq7RdBgA.png"]`,
- FileMany: ``,
- RelOne: `["84nmscqy84lsi1t"]`,
- RelMany: `oap640cot4yru2s`,
- NewMultiple: `[]`,
- },
- },
- {
- "84nmscqy84lsi1t",
- fieldsExpectation{
- SelectOne: `["optionB"]`,
- SelectMany: `optionC`,
- FileOne: `["test_d61b33QdDU.txt"]`,
- FileMany: `test_tC1Yc87DfC.txt`,
- RelOne: `[]`,
- RelMany: `oap640cot4yru2s`,
- NewMultiple: `[]`,
- },
- },
- }
-
- for _, s := range fieldsScenarios {
- t.Run("check fields for record "+s.recordId, func(t *testing.T) {
- result := new(fieldsExpectation)
-
- err := app.Dao().DB().Select(
- "select_one",
- "select_many",
- "file_one",
- "file_many",
- "rel_one",
- "rel_many",
- "new_multiple",
- ).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
- if err != nil {
- t.Fatalf("Failed to load record: %v", err)
- }
-
- encodedResult, err := json.Marshal(result)
- if err != nil {
- t.Fatalf("Failed to encode result: %v", err)
- }
-
- encodedExpectation, err := json.Marshal(s.expected)
- if err != nil {
- t.Fatalf("Failed to encode expectation: %v", err)
- }
-
- if !bytes.EqualFold(encodedExpectation, encodedResult) {
- t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
- }
- })
- }
-}
diff --git a/daos/record_test.go b/daos/record_test.go
deleted file mode 100644
index 3f5e8cc439..0000000000
--- a/daos/record_test.go
+++ /dev/null
@@ -1,1317 +0,0 @@
-package daos_test
-
-import (
- "context"
- "database/sql"
- "errors"
- "regexp"
- "strings"
- "testing"
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- name any
- collection any
- expectedTotal int
- expectError bool
- }{
- {"with nil value", nil, 0, true},
- {"with invalid or missing collection id/name", "missing", 0, true},
- {"with pointer model", collection, 3, false},
- {"with value model", *collection, 3, false},
- {"with name", "demo1", 3, false},
- {"with id", "wsmn24bux7wo113", 3, false},
- }
-
- for _, s := range scenarios {
- var records []*models.Record
- err := app.Dao().RecordQuery(s.collection).All(&records)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
- continue
- }
-
- if total := len(records); total != s.expectedTotal {
- t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
- }
- }
-}
-
-func TestRecordQueryOneWithRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- id := "84nmscqy84lsi1t"
-
- q := app.Dao().RecordQuery(collection).
- Where(dbx.HashExp{"id": id})
-
- record := &models.Record{}
- if err := q.One(record); err != nil {
- t.Fatal(err)
- }
-
- if record.GetString("id") != id {
- t.Fatalf("Expected record with id %q, got %q", id, record.GetString("id"))
- }
-}
-
-func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- id1 := "84nmscqy84lsi1t"
- id2 := "al1h9ijdeojtsjy"
-
- {
- records := []models.Record{}
-
- q := app.Dao().RecordQuery(collection).
- Where(dbx.HashExp{"id": []any{id1, id2}}).
- OrderBy("created asc")
-
- if err := q.All(&records); err != nil {
- t.Fatal(err)
- }
-
- if len(records) != 2 {
- t.Fatalf("Expected %d records, got %d", 2, len(records))
- }
-
- if records[0].Id != id1 {
- t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
- }
-
- if records[1].Id != id2 {
- t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
- }
- }
-
- {
- records := []*models.Record{}
-
- q := app.Dao().RecordQuery(collection).
- Where(dbx.HashExp{"id": []any{id1, id2}}).
- OrderBy("created asc")
-
- if err := q.All(&records); err != nil {
- t.Fatal(err)
- }
-
- if len(records) != 2 {
- t.Fatalf("Expected %d records, got %d", 2, len(records))
- }
-
- if records[0].Id != id1 {
- t.Fatalf("Expected record with id %q, got %q", id1, records[0].Id)
- }
-
- if records[1].Id != id2 {
- t.Fatalf("Expected record with id %q, got %q", id2, records[1].Id)
- }
- }
-}
-
-func TestFindRecordById(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- id string
- filter1 func(q *dbx.SelectQuery) error
- filter2 func(q *dbx.SelectQuery) error
- expectError bool
- }{
- {"demo2", "missing", nil, nil, true},
- {"missing", "0yxhwia2amd8gec", nil, nil, true},
- {"demo2", "0yxhwia2amd8gec", nil, nil, false},
- {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"title": "missing"})
- return nil
- }, nil, true},
- {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
- return errors.New("test error")
- }, nil, true},
- {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"title": "test3"})
- return nil
- }, nil, false},
- {"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"title": "test3"})
- return nil
- }, func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"active": false})
- return nil
- }, true},
- {"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"title": "test3"})
- return nil
- }, func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"active": true})
- return nil
- }, false},
- }
-
- for i, scenario := range scenarios {
- record, err := app.Dao().FindRecordById(
- scenario.collectionIdOrName,
- scenario.id,
- scenario.filter1,
- scenario.filter2,
- )
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if record != nil && record.Id != scenario.id {
- t.Errorf("(%d) Expected record with id %s, got %s", i, scenario.id, record.Id)
- }
- }
-}
-
-func TestFindRecordsByIds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- ids []string
- filter1 func(q *dbx.SelectQuery) error
- filter2 func(q *dbx.SelectQuery) error
- expectTotal int
- expectError bool
- }{
- {"demo2", []string{}, nil, nil, 0, false},
- {"demo2", []string{""}, nil, nil, 0, false},
- {"demo2", []string{"missing"}, nil, nil, 0, false},
- {"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
- {"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
- {"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
- {
- "demo2",
- []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
- nil,
- nil,
- 2,
- false,
- },
- {
- "demo2",
- []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
- func(q *dbx.SelectQuery) error {
- return nil // empty filter
- },
- func(q *dbx.SelectQuery) error {
- return errors.New("test error")
- },
- 0,
- true,
- },
- {
- "demo2",
- []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
- func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"active": true})
- return nil
- },
- nil,
- 1,
- false,
- },
- {
- "sz5l5z67tg7gku0",
- []string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
- func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.HashExp{"active": true})
- return nil
- },
- func(q *dbx.SelectQuery) error {
- q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
- return nil
- },
- 1,
- false,
- },
- }
-
- for i, scenario := range scenarios {
- records, err := app.Dao().FindRecordsByIds(
- scenario.collectionIdOrName,
- scenario.ids,
- scenario.filter1,
- scenario.filter2,
- )
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if len(records) != scenario.expectTotal {
- t.Errorf("(%d) Expected %d records, got %d", i, scenario.expectTotal, len(records))
- continue
- }
-
- for _, r := range records {
- if !list.ExistInSlice(r.Id, scenario.ids) {
- t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.ids)
- }
- }
- }
-}
-
-func TestFindRecordsByExpr(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- expressions []dbx.Expression
- expectIds []string
- expectError bool
- }{
- {
- "missing",
- nil,
- []string{},
- true,
- },
- {
- "demo2",
- nil,
- []string{
- "achvryl401bhse3",
- "llvuca81nly1qls",
- "0yxhwia2amd8gec",
- },
- false,
- },
- {
- "demo2",
- []dbx.Expression{
- nil,
- dbx.HashExp{"id": "123"},
- },
- []string{},
- false,
- },
- {
- "sz5l5z67tg7gku0",
- []dbx.Expression{
- dbx.Like("title", "test").Match(true, true),
- dbx.HashExp{"active": true},
- },
- []string{
- "achvryl401bhse3",
- "0yxhwia2amd8gec",
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if len(records) != len(scenario.expectIds) {
- t.Errorf("(%d) Expected %d records, got %d", i, len(scenario.expectIds), len(records))
- continue
- }
-
- for _, r := range records {
- if !list.ExistInSlice(r.Id, scenario.expectIds) {
- t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.expectIds)
- }
- }
- }
-}
-
-func TestFindFirstRecordByData(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- key string
- value any
- expectId string
- expectError bool
- }{
- {
- "missing",
- "id",
- "llvuca81nly1qls",
- "llvuca81nly1qls",
- true,
- },
- {
- "demo2",
- "",
- "llvuca81nly1qls",
- "",
- true,
- },
- {
- "demo2",
- "id",
- "invalid",
- "",
- true,
- },
- {
- "demo2",
- "id",
- "llvuca81nly1qls",
- "llvuca81nly1qls",
- false,
- },
- {
- "sz5l5z67tg7gku0",
- "title",
- "test3",
- "0yxhwia2amd8gec",
- false,
- },
- }
-
- for i, scenario := range scenarios {
- record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && record.Id != scenario.expectId {
- t.Errorf("(%d) Expected record with id %s, got %v", i, scenario.expectId, record.Id)
- }
- }
-}
-
-func TestFindRecordsByFilter(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- collectionIdOrName string
- filter string
- sort string
- limit int
- offset int
- params []dbx.Params
- expectError bool
- expectRecordIds []string
- }{
- {
- "missing collection",
- "missing",
- "id != ''",
- "",
- 0,
- 0,
- nil,
- true,
- nil,
- },
- {
- "missing filter",
- "demo2",
- "",
- "",
- 0,
- 0,
- nil,
- true,
- nil,
- },
- {
- "invalid filter",
- "demo2",
- "someMissingField > 1",
- "",
- 0,
- 0,
- nil,
- true,
- nil,
- },
- {
- "simple filter",
- "demo2",
- "id != ''",
- "",
- 0,
- 0,
- nil,
- false,
- []string{
- "llvuca81nly1qls",
- "achvryl401bhse3",
- "0yxhwia2amd8gec",
- },
- },
- {
- "multi-condition filter with sort",
- "demo2",
- "id != '' && active=true",
- "-created,title",
- -1, // should behave the same as 0
- 0,
- nil,
- false,
- []string{
- "0yxhwia2amd8gec",
- "achvryl401bhse3",
- },
- },
- {
- "with limit and offset",
- "demo2",
- "id != ''",
- "title",
- 2,
- 1,
- nil,
- false,
- []string{
- "achvryl401bhse3",
- "0yxhwia2amd8gec",
- },
- },
- {
- "with placeholder params",
- "demo2",
- "active = {:active}",
- "",
- 10,
- 0,
- []dbx.Params{{"active": false}},
- false,
- []string{
- "llvuca81nly1qls",
- },
- },
- }
-
- for _, s := range scenarios {
- t.Run(s.name, func(t *testing.T) {
- records, err := app.Dao().FindRecordsByFilter(
- s.collectionIdOrName,
- s.filter,
- s.sort,
- s.limit,
- s.offset,
- s.params...,
- )
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
-
- if hasErr {
- return
- }
-
- if len(records) != len(s.expectRecordIds) {
- t.Fatalf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
- }
-
- for i, id := range s.expectRecordIds {
- if id != records[i].Id {
- t.Fatalf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
- }
- }
- })
- }
-}
-
-func TestFindFirstRecordByFilter(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- collectionIdOrName string
- filter string
- params []dbx.Params
- expectError bool
- expectRecordId string
- }{
- {
- "missing collection",
- "missing",
- "id != ''",
- nil,
- true,
- "",
- },
- {
- "missing filter",
- "demo2",
- "",
- nil,
- true,
- "",
- },
- {
- "invalid filter",
- "demo2",
- "someMissingField > 1",
- nil,
- true,
- "",
- },
- {
- "valid filter but no matches",
- "demo2",
- "id = 'test'",
- nil,
- true,
- "",
- },
- {
- "valid filter and multiple matches",
- "demo2",
- "id != ''",
- nil,
- false,
- "llvuca81nly1qls",
- },
- {
- "with placeholder params",
- "demo2",
- "active = {:active}",
- []dbx.Params{{"active": false}},
- false,
- "llvuca81nly1qls",
- },
- }
-
- for _, s := range scenarios {
- record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- if record.Id != s.expectRecordId {
- t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
- }
- }
-}
-
-func TestCanAccessRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- admin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- name string
- record *models.Record
- requestInfo *models.RequestInfo
- rule *string
- expected bool
- expectError bool
- }{
- {
- "as admin with nil rule",
- record,
- &models.RequestInfo{
- Admin: admin,
- },
- nil,
- true,
- false,
- },
- {
- "as admin with non-empty rule",
- record,
- &models.RequestInfo{
- Admin: admin,
- },
- types.Pointer("id = ''"), // the filter rule should be ignored
- true,
- false,
- },
- {
- "as admin with invalid rule",
- record,
- &models.RequestInfo{
- Admin: admin,
- },
- types.Pointer("id ?!@ 1"), // the filter rule should be ignored
- true,
- false,
- },
- {
- "as guest with nil rule",
- record,
- &models.RequestInfo{},
- nil,
- false,
- false,
- },
- {
- "as guest with empty rule",
- record,
- &models.RequestInfo{},
- types.Pointer(""),
- true,
- false,
- },
- {
- "as guest with invalid rule",
- record,
- &models.RequestInfo{},
- types.Pointer("id ?!@ 1"),
- false,
- true,
- },
- {
- "as guest with mismatched rule",
- record,
- &models.RequestInfo{},
- types.Pointer("@request.auth.id != ''"),
- false,
- false,
- },
- {
- "as guest with matched rule",
- record,
- &models.RequestInfo{
- Data: map[string]any{"test": 1},
- },
- types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
- true,
- false,
- },
- {
- "as auth record with nil rule",
- record,
- &models.RequestInfo{
- AuthRecord: authRecord,
- },
- nil,
- false,
- false,
- },
- {
- "as auth record with empty rule",
- record,
- &models.RequestInfo{
- AuthRecord: authRecord,
- },
- types.Pointer(""),
- true,
- false,
- },
- {
- "as auth record with invalid rule",
- record,
- &models.RequestInfo{
- AuthRecord: authRecord,
- },
- types.Pointer("id ?!@ 1"),
- false,
- true,
- },
- {
- "as auth record with mismatched rule",
- record,
- &models.RequestInfo{
- AuthRecord: authRecord,
- Data: map[string]any{"test": 1},
- },
- types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
- false,
- false,
- },
- {
- "as auth record with matched rule",
- record,
- &models.RequestInfo{
- AuthRecord: authRecord,
- Data: map[string]any{"test": 2},
- },
- types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
- true,
- false,
- },
- }
-
- for _, s := range scenarios {
- result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
-
- if result != s.expected {
- t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestIsRecordValueUnique(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- testManyRelsId1 := "bgs820n361vj1qd"
- testManyRelsId2 := "4q1xlclmfloku33"
- testManyRelsId3 := "oap640cot4yru2s"
-
- scenarios := []struct {
- collectionIdOrName string
- key string
- value any
- excludeIds []string
- expected bool
- }{
- {"demo2", "", "", nil, false},
- {"demo2", "", "", []string{""}, false},
- {"demo2", "missing", "unique", nil, false},
- {"demo2", "title", "unique", nil, true},
- {"demo2", "title", "unique", []string{}, true},
- {"demo2", "title", "unique", []string{""}, true},
- {"demo2", "title", "test1", []string{""}, false},
- {"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
- {"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
- {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
- {"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
- // mixed json array order
- {"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
- // username special case-insensitive match
- {"users", "username", "test2_username", nil, false},
- {"users", "username", "TEST2_USERNAME", nil, false},
- {"users", "username", "new_username", nil, true},
- {"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
- }
-
- for i, scenario := range scenarios {
- result := app.Dao().IsRecordValueUnique(
- scenario.collectionIdOrName,
- scenario.key,
- scenario.value,
- scenario.excludeIds...,
- )
-
- if result != scenario.expected {
- t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
- }
- }
-}
-
-func TestFindAuthRecordByToken(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- token string
- baseKey string
- expectedEmail string
- expectError bool
- }{
- // invalid auth token
- {
- "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
- app.Settings().RecordAuthToken.Secret,
- "",
- true,
- },
- // expired token
- {
- "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
- app.Settings().RecordAuthToken.Secret,
- "",
- true,
- },
- // wrong base key (password reset token secret instead of auth secret)
- {
- "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- app.Settings().RecordPasswordResetToken.Secret,
- "",
- true,
- },
- // valid token and base key but with deleted/missing collection
- {
- "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
- app.Settings().RecordAuthToken.Secret,
- "test@example.com",
- true,
- },
- // valid token
- {
- "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
- app.Settings().RecordAuthToken.Secret,
- "test@example.com",
- false,
- },
- }
-
- for i, scenario := range scenarios {
- record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && record.Email() != scenario.expectedEmail {
- t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
- }
- }
-}
-
-func TestFindAuthRecordByEmail(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- email string
- expectError bool
- }{
- {"missing", "test@example.com", true},
- {"demo2", "test@example.com", true},
- {"users", "missing@example.com", true},
- {"users", "test@example.com", false},
- {"clients", "test2@example.com", false},
- }
-
- for i, scenario := range scenarios {
- record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && record.Email() != scenario.email {
- t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
- }
- }
-}
-
-func TestFindAuthRecordByUsername(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- username string
- expectError bool
- }{
- {"missing", "test_username", true},
- {"demo2", "test_username", true},
- {"users", "missing", true},
- {"users", "test2_username", false},
- {"users", "TEST2_USERNAME", false}, // case insensitive check
- {"clients", "clients43362", false},
- }
-
- for i, scenario := range scenarios {
- record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- continue
- }
-
- if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
- t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
- }
- }
-}
-
-func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- collectionIdOrName string
- baseUsername string
- expectedPattern string
- }{
- // missing collection
- {"missing", "test2_username", `^test2_username\d{12}$`},
- // not an auth collection
- {"demo2", "test2_username", `^test2_username\d{12}$`},
- // auth collection with unique base username
- {"users", "new_username", `^new_username$`},
- {"users", "NEW_USERNAME", `^NEW_USERNAME$`},
- // auth collection with existing username
- {"users", "test2_username", `^test2_username\d{3}$`},
- {"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
- }
-
- for i, scenario := range scenarios {
- username := app.Dao().SuggestUniqueAuthRecordUsername(
- scenario.collectionIdOrName,
- scenario.baseUsername,
- )
-
- pattern, err := regexp.Compile(scenario.expectedPattern)
- if err != nil {
- t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
- }
- if !pattern.MatchString(username) {
- t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
- }
- }
-}
-
-func TestSaveRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
-
- // create
- // ---
- r1 := models.NewRecord(collection)
- r1.Set("title", "test_new")
- err1 := app.Dao().SaveRecord(r1)
- if err1 != nil {
- t.Fatal(err1)
- }
- newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
- if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
- t.Fatalf("Expected to find record %v, got %v", r1, newR1)
- }
-
- // update
- // ---
- r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
- r2.Set("title", "test_update")
- err2 := app.Dao().SaveRecord(r2)
- if err2 != nil {
- t.Fatal(err2)
- }
- newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
- if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
- t.Fatalf("Expected to find record %v, got %v", r2, newR2)
- }
-}
-
-func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
- authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
-
- // base collection test
- r1 := models.NewRecord(baseCollection)
- r1.Set("title", "test_new")
- r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
- r1.MarkAsNew()
- if err := app.Dao().SaveRecord(r1); err != nil {
- t.Fatalf("Expected nil, got error %v", err)
- }
-
- // auth collection test
- r2 := models.NewRecord(authCollection)
- r2.Set("username", "test_new")
- r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
- r2.MarkAsNew()
- if err := app.Dao().SaveRecord(r2); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- // try again with unique id
- r2.Set("id", "unique_id")
- if err := app.Dao().SaveRecord(r2); err != nil {
- t.Fatalf("Expected nil, got error %v", err)
- }
-}
-
-func TestDeleteRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
-
- // delete unsaved record
- // ---
- rec0 := models.NewRecord(demoCollection)
- if err := app.Dao().DeleteRecord(rec0); err == nil {
- t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
- }
-
- // delete existing record + external auths
- // ---
- rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
- if err := app.Dao().DeleteRecord(rec1); err != nil {
- t.Fatalf("(rec1) Expected nil, got error %v", err)
- }
- // check if it was really deleted
- if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
- t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
- }
- // check if the external auths were deleted
- if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
- t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
- }
-
- // delete existing record while being part of a non-cascade required relation
- // ---
- rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
- if err := app.Dao().DeleteRecord(rec2); err == nil {
- t.Fatalf("(rec2) Expected error, got nil")
- }
-
- // delete existing record + cascade
- // ---
- calledQueries := []string{}
- app.Dao().NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
- calledQueries = append(calledQueries, sql)
- }
- app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
- calledQueries = append(calledQueries, sql)
- }
- app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
- calledQueries = append(calledQueries, sql)
- }
- app.Dao().ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
- calledQueries = append(calledQueries, sql)
- }
- rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
- // delete
- if err := app.Dao().DeleteRecord(rec3); err != nil {
- t.Fatalf("(rec3) Expected nil, got error %v", err)
- }
- // check if it was really deleted
- rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
- if rec3 != nil {
- t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
- }
- // check if the operation cascaded
- rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
- if rel != nil {
- t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
- }
- // ensure that the json rel fields were prefixed
- joinedQueries := strings.Join(calledQueries, " ")
- expectedRelManyPart := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
- if !strings.Contains(joinedQueries, expectedRelManyPart) {
- t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
- }
- expectedRelOnePart := "SELECT DISTINCT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`="
- if !strings.Contains(joinedQueries, expectedRelOnePart) {
- t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
- }
-}
-
-func TestDeleteRecordBatchProcessing(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- if err := createMockBatchProcessingData(app.Dao()); err != nil {
- t.Fatal(err)
- }
-
- // find and delete the first c1 record to trigger cascade
- mainRecord, _ := app.Dao().FindRecordById("c1", "a")
- if err := app.Dao().DeleteRecord(mainRecord); err != nil {
- t.Fatal(err)
- }
-
- // check if the main record was deleted
- _, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
- if err == nil {
- t.Fatal("The main record wasn't deleted")
- }
-
- // check if the c1 b rel field were updated
- c1RecordB, err := app.Dao().FindRecordById("c1", "b")
- if err != nil || c1RecordB.GetString("rel") != "" {
- t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
- }
-
- // check if the c2 rel fields were updated
- c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
- if err != nil || len(c2Records) == 0 {
- t.Fatalf("Failed to fetch c2 records: %v", err)
- }
- for _, r := range c2Records {
- ids := r.GetStringSlice("rel")
- if len(ids) != 1 || ids[0] != "b" {
- t.Fatalf("Expected only 'b' rel id, got %v", ids)
- }
- }
-
- // check if all c3 relations were deleted
- c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
- if err != nil {
- t.Fatalf("Failed to fetch c3 records: %v", err)
- }
- if total := len(c3Records); total != 0 {
- t.Fatalf("Expected c3 records to be deleted, found %d", total)
- }
-}
-
-func createMockBatchProcessingData(dao *daos.Dao) error {
- // create mock collection without relation
- c1 := &models.Collection{}
- c1.Id = "c1"
- c1.Name = c1.Id
- c1.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "text",
- Type: schema.FieldTypeText,
- },
- // self reference
- &schema.SchemaField{
- Name: "rel",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(1),
- CollectionId: "c1",
- CascadeDelete: false, // should unset all rel fields
- },
- },
- )
- if err := dao.SaveCollection(c1); err != nil {
- return err
- }
-
- // create mock collection with a multi-rel field
- c2 := &models.Collection{}
- c2.Id = "c2"
- c2.Name = c2.Id
- c2.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "rel",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(10),
- CollectionId: "c1",
- CascadeDelete: false, // should unset all rel fields
- },
- },
- )
- if err := dao.SaveCollection(c2); err != nil {
- return err
- }
-
- // create mock collection with a single-rel field
- c3 := &models.Collection{}
- c3.Id = "c3"
- c3.Name = c3.Id
- c3.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "rel",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(1),
- CollectionId: "c1",
- CascadeDelete: true, // should delete all c3 records
- },
- },
- )
- if err := dao.SaveCollection(c3); err != nil {
- return err
- }
-
- // insert mock records
- c1RecordA := models.NewRecord(c1)
- c1RecordA.Id = "a"
- c1RecordA.Set("rel", c1RecordA.Id) // self reference
- if err := dao.Save(c1RecordA); err != nil {
- return err
- }
- c1RecordB := models.NewRecord(c1)
- c1RecordB.Id = "b"
- c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
- if err := dao.Save(c1RecordB); err != nil {
- return err
- }
- for i := 0; i < 4500; i++ {
- c2Record := models.NewRecord(c2)
- c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
- if err := dao.Save(c2Record); err != nil {
- return err
- }
-
- c3Record := models.NewRecord(c3)
- c3Record.Set("rel", c1RecordA.Id)
- if err := dao.Save(c3Record); err != nil {
- return err
- }
- }
-
- // set the same id as the relation for at least 1 record
- // to check whether the correct condition will be added
- c3Record := models.NewRecord(c3)
- c3Record.Set("rel", c1RecordA.Id)
- c3Record.Id = c1RecordA.Id
- if err := dao.Save(c3Record); err != nil {
- return err
- }
-
- return nil
-}
diff --git a/daos/request.go b/daos/request.go
deleted file mode 100644
index 573b810063..0000000000
--- a/daos/request.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package daos
-
-import (
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// RequestQuery returns a new Request logs select query.
-func (dao *Dao) RequestQuery() *dbx.SelectQuery {
- return dao.ModelQuery(&models.Request{})
-}
-
-// FindRequestById finds a single Request log by its id.
-func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
- model := &models.Request{}
-
- err := dao.RequestQuery().
- AndWhere(dbx.HashExp{"id": id}).
- Limit(1).
- One(model)
-
- if err != nil {
- return nil, err
- }
-
- return model, nil
-}
-
-type RequestsStatsItem struct {
- Total int `db:"total" json:"total"`
- Date types.DateTime `db:"date" json:"date"`
-}
-
-// RequestsStats returns hourly grouped requests logs statistics.
-func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
- result := []*RequestsStatsItem{}
-
- query := dao.RequestQuery().
- Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
- GroupBy("date")
-
- if expr != nil {
- query.AndWhere(expr)
- }
-
- err := query.All(&result)
-
- return result, err
-}
-
-// DeleteOldRequests delete all requests that are created before createdBefore.
-func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
- m := models.Request{}
- tableName := m.TableName()
-
- formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
- expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
-
- _, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
-
- return err
-}
-
-// SaveRequest upserts the provided Request model.
-func (dao *Dao) SaveRequest(request *models.Request) error {
- return dao.Save(request)
-}
diff --git a/daos/request_test.go b/daos/request_test.go
deleted file mode 100644
index e41b8e399d..0000000000
--- a/daos/request_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package daos_test
-
-import (
- "encoding/json"
- "testing"
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestRequestQuery(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- expected := "SELECT {{_requests}}.* FROM `_requests`"
-
- sql := app.Dao().RequestQuery().Build().SQL()
- if sql != expected {
- t.Errorf("Expected sql %s, got %s", expected, sql)
- }
-}
-
-func TestFindRequestById(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- tests.MockRequestLogsData(app)
-
- scenarios := []struct {
- id string
- expectError bool
- }{
- {"", true},
- {"invalid", true},
- {"00000000-9f38-44fb-bf82-c8f53b310d91", true},
- {"873f2133-9f38-44fb-bf82-c8f53b310d91", false},
- }
-
- for i, scenario := range scenarios {
- admin, err := app.LogsDao().FindRequestById(scenario.id)
-
- hasErr := err != nil
- if hasErr != scenario.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
- }
-
- if admin != nil && admin.Id != scenario.id {
- t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id)
- }
- }
-}
-
-func TestRequestsStats(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- tests.MockRequestLogsData(app)
-
- expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
-
- now := time.Now().UTC().Format(types.DefaultDateLayout)
- exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
- result, err := app.LogsDao().RequestsStats(exp)
- if err != nil {
- t.Fatal(err)
- }
-
- encoded, _ := json.Marshal(result)
- if string(encoded) != expected {
- t.Fatalf("Expected %s, got %s", expected, string(encoded))
- }
-}
-
-func TestDeleteOldRequests(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- tests.MockRequestLogsData(app)
-
- scenarios := []struct {
- date string
- expectedTotal int
- }{
- {"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
- {"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
- {"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
- {"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
- }
-
- for i, scenario := range scenarios {
- date, dateErr := time.Parse(types.DefaultDateLayout, scenario.date)
- if dateErr != nil {
- t.Errorf("(%d) Date error %v", i, dateErr)
- }
-
- deleteErr := app.LogsDao().DeleteOldRequests(date)
- if deleteErr != nil {
- t.Errorf("(%d) Delete error %v", i, deleteErr)
- }
-
- // check total remaining requests
- var total int
- countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total)
- if countErr != nil {
- t.Errorf("(%d) Count error %v", i, countErr)
- }
-
- if total != scenario.expectedTotal {
- t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total)
- }
- }
-}
-
-func TestSaveRequest(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- tests.MockRequestLogsData(app)
-
- // create new request
- newRequest := &models.Request{}
- newRequest.Method = "get"
- newRequest.Meta = types.JsonMap{}
- createErr := app.LogsDao().SaveRequest(newRequest)
- if createErr != nil {
- t.Fatal(createErr)
- }
-
- // check if it was really created
- existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id)
- if fetchErr != nil {
- t.Fatal(fetchErr)
- }
-
- existingRequest.Method = "post"
- updateErr := app.LogsDao().SaveRequest(existingRequest)
- if updateErr != nil {
- t.Fatal(updateErr)
- }
- // refresh instance to check if it was really updated
- existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id)
- if existingRequest.Method != "post" {
- t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method)
- }
-}
diff --git a/daos/settings.go b/daos/settings.go
deleted file mode 100644
index f4830e7be7..0000000000
--- a/daos/settings.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package daos
-
-import (
- "encoding/json"
- "errors"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// FindSettings returns and decode the serialized app settings param value.
-//
-// The method will first try to decode the param value without decryption.
-// If it fails and optEncryptionKey is set, it will try again by first
-// decrypting the value and then decode it again.
-//
-// Returns an error if it fails to decode the stored serialized param value.
-func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
- param, err := dao.FindParamByKey(models.ParamAppSettings)
- if err != nil {
- return nil, err
- }
-
- result := settings.New()
-
- // try first without decryption
- plainDecodeErr := json.Unmarshal(param.Value, result)
-
- // failed, try to decrypt
- if plainDecodeErr != nil {
- var encryptionKey string
- if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
- encryptionKey = optEncryptionKey[0]
- }
-
- // load without decrypt has failed and there is no encryption key to use for decrypt
- if encryptionKey == "" {
- return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key")
- }
-
- // decrypt
- decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
- if decryptErr != nil {
- return nil, decryptErr
- }
-
- // decode again
- decryptedDecodeErr := json.Unmarshal(decrypted, result)
- if decryptedDecodeErr != nil {
- return nil, decryptedDecodeErr
- }
- }
-
- return result, nil
-}
-
-// SaveSettings persists the specified settings configuration.
-//
-// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
-func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
- return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
-}
diff --git a/daos/settings_test.go b/daos/settings_test.go
deleted file mode 100644
index 3791f42b8b..0000000000
--- a/daos/settings_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package daos_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestSaveAndFindSettings(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- encryptionKey := security.PseudorandomString(32)
-
- // change unencrypted app settings
- app.Settings().Meta.AppName = "save_unencrypted"
- if err := app.Dao().SaveSettings(app.Settings()); err != nil {
- t.Fatal(err)
- }
-
- // check if the change was persisted
- s1, err := app.Dao().FindSettings()
- if err != nil {
- t.Fatalf("Failed to fetch settings: %v", err)
- }
- if s1.Meta.AppName != "save_unencrypted" {
- t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
- }
-
- // make another change but this time provide an encryption key
- app.Settings().Meta.AppName = "save_encrypted"
- if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
- t.Fatal(err)
- }
-
- // try to fetch the settings without encryption key (should fail)
- if s2, err := app.Dao().FindSettings(); err == nil {
- t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
- }
-
- // try again but this time with an encryption key
- s3, err := app.Dao().FindSettings(encryptionKey)
- if err != nil {
- t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
- }
- if s3.Meta.AppName != "save_encrypted" {
- t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
- }
-}
diff --git a/daos/table.go b/daos/table.go
deleted file mode 100644
index 1c2c2ac340..0000000000
--- a/daos/table.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package daos
-
-import (
- "fmt"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// HasTable checks if a table (or view) with the provided name exists (case insensitive).
-func (dao *Dao) HasTable(tableName string) bool {
- var exists bool
-
- err := dao.DB().Select("count(*)").
- From("sqlite_schema").
- AndWhere(dbx.HashExp{"type": []any{"table", "view"}}).
- AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})).
- Limit(1).
- Row(&exists)
-
- return err == nil && exists
-}
-
-// TableColumns returns all column names of a single table by its name.
-func (dao *Dao) TableColumns(tableName string) ([]string, error) {
- columns := []string{}
-
- err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
- Bind(dbx.Params{"tableName": tableName}).
- Column(&columns)
-
- return columns, err
-}
-
-// TableInfo returns the `table_info` pragma result for the specified table.
-func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
- info := []*models.TableInfoRow{}
-
- err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
- Bind(dbx.Params{"tableName": tableName}).
- All(&info)
- if err != nil {
- return nil, err
- }
-
- // mattn/go-sqlite3 doesn't throw an error on invalid or missing table
- // so we additionally have to check whether the loaded info result is nonempty
- if len(info) == 0 {
- return nil, fmt.Errorf("empty table info probably due to invalid or missing table %s", tableName)
- }
-
- return info, nil
-}
-
-// TableIndexes returns a name grouped map with all non empty index of the specified table.
-//
-// Note: This method doesn't return an error on nonexisting table.
-func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
- indexes := []struct {
- Name string
- Sql string
- }{}
-
- err := dao.DB().Select("name", "sql").
- From("sqlite_master").
- AndWhere(dbx.NewExp("sql is not null")).
- AndWhere(dbx.HashExp{
- "type": "index",
- "tbl_name": tableName,
- }).
- All(&indexes)
- if err != nil {
- return nil, err
- }
-
- result := make(map[string]string, len(indexes))
-
- for _, idx := range indexes {
- result[idx.Name] = idx.Sql
- }
-
- return result, nil
-}
-
-// DeleteTable drops the specified table.
-//
-// This method is a no-op if a table with the provided name doesn't exist.
-//
-// Be aware that this method is vulnerable to SQL injection and the
-// "tableName" argument must come only from trusted input!
-func (dao *Dao) DeleteTable(tableName string) error {
- _, err := dao.DB().NewQuery(fmt.Sprintf(
- "DROP TABLE IF EXISTS {{%s}}",
- tableName,
- )).Execute()
-
- return err
-}
-
-// Vacuum executes VACUUM on the current dao.DB() instance in order to
-// reclaim unused db disk space.
-func (dao *Dao) Vacuum() error {
- _, err := dao.DB().NewQuery("VACUUM").Execute()
-
- return err
-}
diff --git a/daos/table_test.go b/daos/table_test.go
deleted file mode 100644
index 943b693c64..0000000000
--- a/daos/table_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package daos_test
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "testing"
- "time"
-
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-func TestHasTable(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- tableName string
- expected bool
- }{
- {"", false},
- {"test", false},
- {"_admins", true},
- {"demo3", true},
- {"DEMO3", true}, // table names are case insensitives by default
- {"view1", true}, // view
- }
-
- for i, scenario := range scenarios {
- result := app.Dao().HasTable(scenario.tableName)
- if result != scenario.expected {
- t.Errorf("[%d] Expected %v, got %v", i, scenario.expected, result)
- }
- }
-}
-
-func TestTableColumns(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- tableName string
- expected []string
- }{
- {"", nil},
- {"_params", []string{"id", "key", "value", "created", "updated"}},
- }
-
- for i, s := range scenarios {
- columns, _ := app.Dao().TableColumns(s.tableName)
-
- if len(columns) != len(s.expected) {
- t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
- continue
- }
-
- for _, c := range columns {
- if !list.ExistInSlice(c, s.expected) {
- t.Errorf("[%d] Didn't expect column %s", i, c)
- }
- }
- }
-}
-
-func TestTableInfo(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- tableName string
- expected string
- }{
- {"", "null"},
- {"missing", "null"},
- {
- "_admins",
- `[{"PK":1,"Index":0,"Name":"id","Type":"TEXT","NotNull":false,"DefaultValue":null},{"PK":0,"Index":1,"Name":"avatar","Type":"INTEGER","NotNull":true,"DefaultValue":0},{"PK":0,"Index":2,"Name":"email","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":3,"Name":"tokenKey","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":4,"Name":"passwordHash","Type":"TEXT","NotNull":true,"DefaultValue":null},{"PK":0,"Index":5,"Name":"lastResetSentAt","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":6,"Name":"created","Type":"TEXT","NotNull":true,"DefaultValue":""},{"PK":0,"Index":7,"Name":"updated","Type":"TEXT","NotNull":true,"DefaultValue":""}]`,
- },
- }
-
- for i, s := range scenarios {
- rows, _ := app.Dao().TableInfo(s.tableName)
-
- raw, _ := json.Marshal(rows)
- rawStr := string(raw)
-
- if rawStr != s.expected {
- t.Errorf("[%d] Expected \n%v, \ngot \n%v", i, s.expected, rawStr)
- }
- }
-}
-
-func TestDeleteTable(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- tableName string
- expectError bool
- }{
- {"", true},
- {"test", false}, // missing tables are ignored
- {"_admins", false},
- {"demo3", false},
- }
-
- for i, s := range scenarios {
- err := app.Dao().DeleteTable(s.tableName)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%d] Expected hasErr %v, got %v", i, s.expectError, hasErr)
- }
- }
-}
-
-func TestVacuum(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- calledQueries := []string{}
- app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
- calledQueries = append(calledQueries, sql)
- }
- app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
- calledQueries = append(calledQueries, sql)
- }
-
- if err := app.Dao().Vacuum(); err != nil {
- t.Fatal(err)
- }
-
- if total := len(calledQueries); total != 1 {
- t.Fatalf("Expected 1 query, got %d", total)
- }
-
- if calledQueries[0] != "VACUUM" {
- t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
- }
-}
-
-func TestTableIndexes(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- table string
- expectError bool
- expectIndexes []string
- }{
- {
- "missing",
- false,
- nil,
- },
- {
- "demo2",
- false,
- []string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
- },
- }
-
- for _, s := range scenarios {
- result, err := app.Dao().TableIndexes(s.table)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
- }
-
- if len(s.expectIndexes) != len(result) {
- t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
- continue
- }
-
- for _, name := range s.expectIndexes {
- if result[name] == "" {
- t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
- }
- }
- }
-}
diff --git a/daos/view.go b/daos/view.go
deleted file mode 100644
index 2e38d3bc13..0000000000
--- a/daos/view.go
+++ /dev/null
@@ -1,617 +0,0 @@
-package daos
-
-import (
- "errors"
- "fmt"
- "io"
- "regexp"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/inflector"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/tokenizer"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// DeleteView drops the specified view name.
-//
-// This method is a no-op if a view with the provided name doesn't exist.
-//
-// Be aware that this method is vulnerable to SQL injection and the
-// "name" argument must come only from trusted input!
-func (dao *Dao) DeleteView(name string) error {
- _, err := dao.DB().NewQuery(fmt.Sprintf(
- "DROP VIEW IF EXISTS {{%s}}",
- name,
- )).Execute()
-
- return err
-}
-
-// SaveView creates (or updates already existing) persistent SQL view.
-//
-// Be aware that this method is vulnerable to SQL injection and the
-// "selectQuery" argument must come only from trusted input!
-func (dao *Dao) SaveView(name string, selectQuery string) error {
- return dao.RunInTransaction(func(txDao *Dao) error {
- // delete old view (if exists)
- if err := txDao.DeleteView(name); err != nil {
- return err
- }
-
- trimmed := strings.Trim(selectQuery, ";")
-
- // try to eagerly detect multiple inline statements
- tk := tokenizer.NewFromString(trimmed)
- tk.Separators(';')
- if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
- return errors.New("multiple statements are not supported")
- }
-
- // (re)create the view
- //
- // note: the query is wrapped in a secondary SELECT as a rudimentary
- // measure to discourage multiple inline sql statements execution.
- viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
- if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
- return err
- }
-
- // fetch the view table info to ensure that the view was created
- // because missing tables or columns won't return an error
- if _, err := txDao.TableInfo(name); err != nil {
- // manually cleanup previously created view in case the func
- // is called in a nested transaction and the error is discarded
- txDao.DeleteView(name)
-
- return err
- }
-
- return nil
- })
-}
-
-// CreateViewSchema creates a new view schema from the provided select query.
-//
-// There are some caveats:
-// - The select query must have an "id" column.
-// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
-func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
- result := schema.NewSchema()
-
- suggestedFields, err := dao.parseQueryToFields(selectQuery)
- if err != nil {
- return result, err
- }
-
- // note wrap in a transaction in case the selectQuery contains
- // multiple statements allowing us to rollback on any error
- txErr := dao.RunInTransaction(func(txDao *Dao) error {
- tempView := "_temp_" + security.PseudorandomString(5)
- // create a temp view with the provided query
- if err := txDao.SaveView(tempView, selectQuery); err != nil {
- return err
- }
- defer txDao.DeleteView(tempView)
-
- // extract the generated view table info
- info, err := txDao.TableInfo(tempView)
- if err != nil {
- return err
- }
-
- var hasId bool
-
- for _, row := range info {
- if row.Name == schema.FieldNameId {
- hasId = true
- }
-
- if list.ExistInSlice(row.Name, schema.BaseModelFieldNames()) {
- continue // skip base model fields since they are not part of the schema
- }
-
- var field *schema.SchemaField
-
- if f, ok := suggestedFields[row.Name]; ok {
- field = f.field
- } else {
- field = defaultViewField(row.Name)
- }
-
- result.AddField(field)
- }
-
- if !hasId {
- return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
- }
-
- return nil
- })
-
- return result, txErr
-}
-
-// FindRecordByViewFile returns the original models.Record of the
-// provided view collection file.
-func (dao *Dao) FindRecordByViewFile(
- viewCollectionNameOrId string,
- fileFieldName string,
- filename string,
-) (*models.Record, error) {
- view, err := dao.FindCollectionByNameOrId(viewCollectionNameOrId)
- if err != nil {
- return nil, err
- }
-
- if !view.IsView() {
- return nil, errors.New("not a view collection")
- }
-
- var findFirstNonViewQueryFileField func(int) (*queryField, error)
- findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
- // check the level depth to prevent infinite circular recursion
- // (the limit is arbitrary and may change in the future)
- if level > 5 {
- return nil, errors.New("reached the max recursion level of view collection file field queries")
- }
-
- queryFields, err := dao.parseQueryToFields(view.ViewOptions().Query)
- if err != nil {
- return nil, err
- }
-
- for _, item := range queryFields {
- if item.collection == nil ||
- item.original == nil ||
- item.field.Name != fileFieldName {
- continue
- }
-
- if item.collection.IsView() {
- view = item.collection
- fileFieldName = item.original.Name
- return findFirstNonViewQueryFileField(level + 1)
- }
-
- return item, nil
- }
-
- return nil, errors.New("no query file field found")
- }
-
- qf, err := findFirstNonViewQueryFileField(1)
- if err != nil {
- return nil, err
- }
-
- cleanFieldName := inflector.Columnify(qf.original.Name)
-
- record := &models.Record{}
-
- query := dao.RecordQuery(qf.collection).Limit(1)
-
- if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
- query.AndWhere(dbx.HashExp{cleanFieldName: filename})
- } else {
- query.InnerJoin(fmt.Sprintf(
- `json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
- cleanFieldName, cleanFieldName, cleanFieldName,
- ), dbx.HashExp{"_je_file.value": filename})
- }
-
- if err := query.One(record); err != nil {
- return nil, err
- }
-
- return record, nil
-}
-
-// -------------------------------------------------------------------
-// Raw query to schema helpers
-// -------------------------------------------------------------------
-
-type queryField struct {
- // field is the final resolved field.
- field *schema.SchemaField
-
- // collection refers to the original field's collection model.
- // It could be nil if the found query field is not from a collection schema.
- collection *models.Collection
-
- // original is the original found collection field.
- // It could be nil if the found query field is not from a collection schema.
- original *schema.SchemaField
-}
-
-func defaultViewField(name string) *schema.SchemaField {
- return &schema.SchemaField{
- Name: name,
- Type: schema.FieldTypeJson,
- }
-}
-
-var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
-
-func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
- p := new(identifiersParser)
- if err := p.parse(selectQuery); err != nil {
- return nil, err
- }
-
- collections, err := dao.findCollectionsByIdentifiers(p.tables)
- if err != nil {
- return nil, err
- }
-
- result := make(map[string]*queryField, len(p.columns))
-
- var mainTable identifier
-
- if len(p.tables) > 0 {
- mainTable = p.tables[0]
- }
-
- for _, col := range p.columns {
- colLower := strings.ToLower(col.original)
-
- // numeric aggregations
- if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeNumber,
- },
- }
- continue
- }
-
- castMatch := castRegex.FindStringSubmatch(colLower)
-
- // numeric casts
- if len(castMatch) == 2 {
- switch castMatch[1] {
- case "real", "integer", "int", "decimal", "numeric":
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeNumber,
- },
- }
- continue
- case "text":
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeText,
- },
- }
- continue
- case "boolean", "bool":
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeBool,
- },
- }
- continue
- }
- }
-
- parts := strings.Split(col.original, ".")
-
- var fieldName string
- var collection *models.Collection
-
- if len(parts) == 2 {
- fieldName = parts[1]
- collection = collections[parts[0]]
- } else {
- fieldName = parts[0]
- collection = collections[mainTable.alias]
- }
-
- // fallback to the default field if the found column is not from a collection schema
- if collection == nil {
- result[col.alias] = &queryField{
- field: defaultViewField(col.alias),
- }
- continue
- }
-
- if fieldName == "*" {
- return nil, errors.New("dynamic column names are not supported")
- }
-
- // find the first field by name (case insensitive)
- var field *schema.SchemaField
- for _, f := range collection.Schema.Fields() {
- if strings.EqualFold(f.Name, fieldName) {
- field = f
- break
- }
- }
-
- if field != nil {
- clone := *field
- clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
- clone.Name = col.alias
- result[col.alias] = &queryField{
- field: &clone,
- collection: collection,
- original: field,
- }
- continue
- }
-
- if fieldName == schema.FieldNameId {
- // convert to relation since it is a direct id reference
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(1),
- CollectionId: collection.Id,
- },
- },
- collection: collection,
- }
- } else if fieldName == schema.FieldNameCreated || fieldName == schema.FieldNameUpdated {
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeDate,
- },
- collection: collection,
- }
- } else if fieldName == schema.FieldNameUsername && collection.IsAuth() {
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeText,
- },
- collection: collection,
- }
- } else if fieldName == schema.FieldNameEmail && collection.IsAuth() {
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeEmail,
- },
- collection: collection,
- }
- } else if (fieldName == schema.FieldNameVerified || fieldName == schema.FieldNameEmailVisibility) && collection.IsAuth() {
- result[col.alias] = &queryField{
- field: &schema.SchemaField{
- Name: col.alias,
- Type: schema.FieldTypeBool,
- },
- collection: collection,
- }
- } else {
- result[col.alias] = &queryField{
- field: defaultViewField(col.alias),
- collection: collection,
- }
- }
- }
-
- return result, nil
-}
-
-func (dao *Dao) findCollectionsByIdentifiers(tables []identifier) (map[string]*models.Collection, error) {
- names := make([]any, 0, len(tables))
-
- for _, table := range tables {
- if strings.Contains(table.alias, "(") {
- continue // skip expressions
- }
- names = append(names, table.original)
- }
-
- if len(names) == 0 {
- return nil, nil
- }
-
- result := make(map[string]*models.Collection, len(names))
- collections := make([]*models.Collection, 0, len(names))
-
- err := dao.CollectionQuery().
- AndWhere(dbx.In("name", names...)).
- All(&collections)
- if err != nil {
- return nil, err
- }
-
- for _, table := range tables {
- for _, collection := range collections {
- if collection.Name == table.original {
- result[table.alias] = collection
- }
- }
- }
-
- return result, nil
-}
-
-// -------------------------------------------------------------------
-// Raw query identifiers parser
-// -------------------------------------------------------------------
-
-var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(inner join|outer join|left join|right join|join)\s+?`)
-var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group by|having|order|limit|with)\s+?`)
-var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
-
-type identifier struct {
- original string
- alias string
-}
-
-type identifiersParser struct {
- columns []identifier
- tables []identifier
-}
-
-func (p *identifiersParser) parse(selectQuery string) error {
- str := strings.Trim(selectQuery, ";")
- str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
- str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
- str = commentsReplaceRegex.ReplaceAllString(str, "")
-
- tk := tokenizer.NewFromString(str)
- tk.Separators(',', ' ', '\n', '\t')
- tk.KeepSeparator(true)
-
- var skip bool
- var partType string
- var activeBuilder *strings.Builder
- var selectParts strings.Builder
- var fromParts strings.Builder
- var joinParts strings.Builder
-
- for {
- token, err := tk.Scan()
- if err != nil {
- if err != io.EOF {
- return err
- }
- break
- }
-
- trimmed := strings.ToLower(strings.TrimSpace(token))
-
- switch trimmed {
- case "select":
- skip = false
- partType = "select"
- activeBuilder = &selectParts
- case "distinct":
- continue // ignore as it is not important for the identifiers parsing
- case "from":
- skip = false
- partType = "from"
- activeBuilder = &fromParts
- case "_join_":
- skip = false
-
- // the previous part was also a join
- if partType == "join" {
- joinParts.WriteString(",")
- }
-
- partType = "join"
- activeBuilder = &joinParts
- case "_discard_":
- // skip following tokens
- skip = true
- default:
- isJoin := partType == "join"
-
- if isJoin && trimmed == "on" {
- skip = true
- }
-
- if !skip && activeBuilder != nil {
- activeBuilder.WriteString(" ")
- activeBuilder.WriteString(token)
- }
- }
- }
-
- selects, err := extractIdentifiers(selectParts.String())
- if err != nil {
- return err
- }
-
- froms, err := extractIdentifiers(fromParts.String())
- if err != nil {
- return err
- }
-
- joins, err := extractIdentifiers(joinParts.String())
- if err != nil {
- return err
- }
-
- p.columns = selects
- p.tables = froms
- p.tables = append(p.tables, joins...)
-
- return nil
-}
-
-func extractIdentifiers(rawExpression string) ([]identifier, error) {
- rawTk := tokenizer.NewFromString(rawExpression)
- rawTk.Separators(',')
-
- rawIdentifiers, err := rawTk.ScanAll()
- if err != nil {
- return nil, err
- }
-
- result := make([]identifier, 0, len(rawIdentifiers))
-
- for _, rawIdentifier := range rawIdentifiers {
- tk := tokenizer.NewFromString(rawIdentifier)
- tk.Separators(' ', '\n', '\t')
-
- parts, err := tk.ScanAll()
- if err != nil {
- return nil, err
- }
-
- resolved, err := identifierFromParts(parts)
- if err != nil {
- return nil, err
- }
-
- result = append(result, resolved)
- }
-
- return result, nil
-}
-
-func identifierFromParts(parts []string) (identifier, error) {
- var result identifier
-
- switch len(parts) {
- case 3:
- if !strings.EqualFold(parts[1], "as") {
- return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
- }
-
- result.original = parts[0]
- result.alias = parts[2]
- case 2:
- result.original = parts[0]
- result.alias = parts[1]
- case 1:
- subParts := strings.Split(parts[0], ".")
- result.original = parts[0]
- result.alias = subParts[len(subParts)-1]
- default:
- return result, fmt.Errorf(`invalid identifier parts %v`, parts)
- }
-
- result.original = trimRawIdentifier(result.original)
- result.alias = trimRawIdentifier(result.alias)
-
- return result, nil
-}
-
-func trimRawIdentifier(rawIdentifier string) string {
- const trimChars = "`\"[];"
-
- parts := strings.Split(rawIdentifier, ".")
-
- for i := range parts {
- parts[i] = strings.Trim(parts[i], trimChars)
- }
-
- return strings.Join(parts, ".")
-}
diff --git a/daos/view_test.go b/daos/view_test.go
deleted file mode 100644
index f5997a946c..0000000000
--- a/daos/view_test.go
+++ /dev/null
@@ -1,618 +0,0 @@
-package daos_test
-
-import (
- "encoding/json"
- "fmt"
- "testing"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-func ensureNoTempViews(app core.App, t *testing.T) {
- var total int
-
- err := app.Dao().DB().Select("count(*)").
- From("sqlite_schema").
- AndWhere(dbx.HashExp{"type": "view"}).
- AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
- Limit(1).
- Row(&total)
- if err != nil {
- t.Fatalf("Failed to check for temp views: %v", err)
- }
-
- if total > 0 {
- t.Fatalf("Expected all temp views to be deleted, got %d", total)
- }
-}
-
-func TestDeleteView(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- viewName string
- expectError bool
- }{
- {"", true},
- {"demo1", true}, // not a view table
- {"missing", false}, // missing or already deleted
- {"view1", false}, // existing
- {"VieW1", false}, // view names are case insensitives
- }
-
- for i, s := range scenarios {
- err := app.Dao().DeleteView(s.viewName)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
- }
- }
-
- ensureNoTempViews(app, t)
-}
-
-func TestSaveView(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- scenarioName string
- viewName string
- query string
- expectError bool
- expectColumns []string
- }{
- {
- "empty name and query",
- "",
- "",
- true,
- nil,
- },
- {
- "empty name",
- "",
- "select * from _admins",
- true,
- nil,
- },
- {
- "empty query",
- "123Test",
- "",
- true,
- nil,
- },
- {
- "invalid query",
- "123Test",
- "123 456",
- true,
- nil,
- },
- {
- "missing table",
- "123Test",
- "select id from missing",
- true,
- nil,
- },
- {
- "non select query",
- "123Test",
- "drop table _admins",
- true,
- nil,
- },
- {
- "multiple select queries",
- "123Test",
- "select *, count(id) as c from _admins; select * from demo1;",
- true,
- nil,
- },
- {
- "try to break the parent parenthesis",
- "123Test",
- "select *, count(id) as c from `_admins`)",
- true,
- nil,
- },
- {
- "simple select query (+ trimmed semicolon)",
- "123Test",
- ";select *, count(id) as c from _admins;",
- false,
- []string{
- "id", "created", "updated",
- "passwordHash", "tokenKey", "email",
- "lastResetSentAt", "avatar", "c",
- },
- },
- {
- "update old view with new query",
- "123Test",
- "select 1 as test from _admins",
- false,
- []string{"test"},
- },
- }
-
- for _, s := range scenarios {
- err := app.Dao().SaveView(s.viewName, s.query)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- infoRows, err := app.Dao().TableInfo(s.viewName)
- if err != nil {
- t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
- continue
- }
-
- if len(s.expectColumns) != len(infoRows) {
- t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
- continue
- }
-
- for _, row := range infoRows {
- if !list.ExistInSlice(row.Name, s.expectColumns) {
- t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
- }
- }
- }
-
- ensureNoTempViews(app, t)
-}
-
-func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
- _, err := txDao.CreateViewSchema("select id from missing")
- if err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- return nil
- })
-
- ensureNoTempViews(app, t)
-}
-
-func TestCreateViewSchema(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- query string
- expectError bool
- expectFields map[string]string // name-type pairs
- }{
- {
- "empty query",
- "",
- true,
- nil,
- },
- {
- "invalid query",
- "test 123456",
- true,
- nil,
- },
- {
- "missing table",
- "select id from missing",
- true,
- nil,
- },
- {
- "query with wildcard column",
- "select a.id, a.* from demo1 a",
- true,
- nil,
- },
- {
- "query without id",
- "select text, url, created, updated from demo1",
- true,
- nil,
- },
- {
- "query with comments",
- `
- select
- -- test single line
- id,
- text,
- /* multi
- line comment */
- url, created, updated from demo1
- `,
- false,
- map[string]string{
- "text": schema.FieldTypeText,
- "url": schema.FieldTypeUrl,
- },
- },
- {
- "query with all fields and quoted identifiers",
- `
- select
- "id",
- "created",
- "updated",
- [text],
- ` + "`bool`" + `,
- "url",
- "select_one",
- "select_many",
- "file_one",
- "demo1"."file_many",
- ` + "`demo1`." + "`number`" + ` number_alias,
- "email",
- "datetime",
- "json",
- "rel_one",
- "rel_many"
- from demo1
- `,
- false,
- map[string]string{
- "text": schema.FieldTypeText,
- "bool": schema.FieldTypeBool,
- "url": schema.FieldTypeUrl,
- "select_one": schema.FieldTypeSelect,
- "select_many": schema.FieldTypeSelect,
- "file_one": schema.FieldTypeFile,
- "file_many": schema.FieldTypeFile,
- "number_alias": schema.FieldTypeNumber,
- "email": schema.FieldTypeEmail,
- "datetime": schema.FieldTypeDate,
- "json": schema.FieldTypeJson,
- "rel_one": schema.FieldTypeRelation,
- "rel_many": schema.FieldTypeRelation,
- },
- },
- {
- "query with indirect relations fields",
- "select a.id, b.id as bid, b.created from demo1 as a left join demo2 b",
- false,
- map[string]string{
- "bid": schema.FieldTypeRelation,
- },
- },
- {
- "query with multiple froms, joins and style of aliasses",
- `
- select
- a.id as id,
- b.id as bid,
- lj.id cid,
- ij.id as did,
- a.bool,
- _admins.id as eid,
- _admins.email
- from demo1 a, demo2 as b
- left join demo3 lj on lj.id = 123
- inner join demo4 as ij on ij.id = 123
- join _admins
- where 1=1
- group by a.id
- limit 10
- `,
- false,
- map[string]string{
- "bid": schema.FieldTypeRelation,
- "cid": schema.FieldTypeRelation,
- "did": schema.FieldTypeRelation,
- "bool": schema.FieldTypeBool,
- "eid": schema.FieldTypeJson, // not from collection
- "email": schema.FieldTypeJson, // not from collection
- },
- },
- {
- "query with casts",
- `select
- a.id,
- count(a.id) count,
- cast(a.id as int) cast_int,
- cast(a.id as integer) cast_integer,
- cast(a.id as real) cast_real,
- cast(a.id as decimal) cast_decimal,
- cast(a.id as numeric) cast_numeric,
- cast(a.id as text) cast_text,
- cast(a.id as bool) cast_bool,
- cast(a.id as boolean) cast_boolean,
- avg(a.id) avg,
- sum(a.id) sum,
- total(a.id) total,
- min(a.id) min,
- max(a.id) max
- from demo1 a`,
- false,
- map[string]string{
- "count": schema.FieldTypeNumber,
- "total": schema.FieldTypeNumber,
- "cast_int": schema.FieldTypeNumber,
- "cast_integer": schema.FieldTypeNumber,
- "cast_real": schema.FieldTypeNumber,
- "cast_decimal": schema.FieldTypeNumber,
- "cast_numeric": schema.FieldTypeNumber,
- "cast_text": schema.FieldTypeText,
- "cast_bool": schema.FieldTypeBool,
- "cast_boolean": schema.FieldTypeBool,
- // json because they are nullable
- "sum": schema.FieldTypeJson,
- "avg": schema.FieldTypeJson,
- "min": schema.FieldTypeJson,
- "max": schema.FieldTypeJson,
- },
- },
- {
- "query with reserved auth collection fields",
- `
- select
- a.id,
- a.username,
- a.email,
- a.emailVisibility,
- a.verified,
- demo1.id relid
- from users a
- left join demo1
- `,
- false,
- map[string]string{
- "username": schema.FieldTypeText,
- "email": schema.FieldTypeEmail,
- "emailVisibility": schema.FieldTypeBool,
- "verified": schema.FieldTypeBool,
- "relid": schema.FieldTypeRelation,
- },
- },
- {
- "query with unknown fields and aliases",
- `select
- id,
- id as id2,
- text as text_alias,
- url as url_alias,
- "demo1"."bool" as bool_alias,
- number as number_alias,
- created created_alias,
- updated updated_alias,
- 123 as custom
- from demo1`,
- false,
- map[string]string{
- "id2": schema.FieldTypeRelation,
- "text_alias": schema.FieldTypeText,
- "url_alias": schema.FieldTypeUrl,
- "bool_alias": schema.FieldTypeBool,
- "number_alias": schema.FieldTypeNumber,
- "created_alias": schema.FieldTypeDate,
- "updated_alias": schema.FieldTypeDate,
- "custom": schema.FieldTypeJson,
- },
- },
- {
- "query with distinct and reordered id column",
- `select distinct
- id as id2,
- id,
- 123 as custom
- from demo1`,
- false,
- map[string]string{
- "id2": schema.FieldTypeRelation,
- "custom": schema.FieldTypeJson,
- },
- },
- {
- "query with aliasing the same field multiple times",
- `select
- a.id as id,
- a.text as alias1,
- a.text as alias2,
- b.text as alias3,
- b.text as alias4
- from demo1 a
- left join demo1 as b`,
- false,
- map[string]string{
- "alias1": schema.FieldTypeText,
- "alias2": schema.FieldTypeText,
- "alias3": schema.FieldTypeText,
- "alias4": schema.FieldTypeText,
- },
- },
- }
-
- for _, s := range scenarios {
- result, err := app.Dao().CreateViewSchema(s.query)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- if len(s.expectFields) != len(result.Fields()) {
- serialized, _ := json.Marshal(result)
- t.Errorf("[%s] Expected %d fields, got %d: \n%s", s.name, len(s.expectFields), len(result.Fields()), serialized)
- continue
- }
-
- for name, typ := range s.expectFields {
- field := result.GetFieldByName(name)
-
- if field == nil {
- t.Errorf("[%s] Expected to find field %s, got nil", s.name, name)
- continue
- }
-
- if field.Type != typ {
- t.Errorf("[%s] Expected field %s to be %q, got %s", s.name, name, typ, field.Type)
- continue
- }
- }
- }
-
- ensureNoTempViews(app, t)
-}
-
-func TestFindRecordByViewFile(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- prevCollection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
-
- totalLevels := 6
-
- // create collection view mocks
- fileOneAlias := "file_one one0"
- fileManyAlias := "file_many many0"
- mockCollections := make([]*models.Collection, 0, totalLevels)
- for i := 0; i <= totalLevels; i++ {
- view := new(models.Collection)
- view.Type = models.CollectionTypeView
- view.Name = fmt.Sprintf("_test_view%d", i)
- view.SetOptions(&models.CollectionViewOptions{
- Query: fmt.Sprintf(
- "select id, %s, %s from %s",
- fileOneAlias,
- fileManyAlias,
- prevCollection.Name,
- ),
- })
-
- // save view
- if err := app.Dao().SaveCollection(view); err != nil {
- t.Fatalf("Failed to save view%d: %v", i, err)
- }
-
- mockCollections = append(mockCollections, view)
- prevCollection = view
- fileOneAlias = fmt.Sprintf("one%d one%d", i, i+1)
- fileManyAlias = fmt.Sprintf("many%d many%d", i, i+1)
- }
-
- fileOneName := "test_d61b33QdDU.txt"
- fileManyName := "test_QZFjKjXchk.txt"
- expectedRecordId := "84nmscqy84lsi1t"
-
- scenarios := []struct {
- name string
- collectionNameOrId string
- fileFieldName string
- filename string
- expectError bool
- expectRecordId string
- }{
- {
- "missing collection",
- "missing",
- "a",
- fileOneName,
- true,
- "",
- },
- {
- "non-view collection",
- "demo1",
- "file_one",
- fileOneName,
- true,
- "",
- },
- {
- "view collection after the max recursion limit",
- mockCollections[totalLevels-1].Name,
- fmt.Sprintf("one%d", totalLevels-1),
- fileOneName,
- true,
- "",
- },
- {
- "first view collection (single file)",
- mockCollections[0].Name,
- "one0",
- fileOneName,
- false,
- expectedRecordId,
- },
- {
- "first view collection (many files)",
- mockCollections[0].Name,
- "many0",
- fileManyName,
- false,
- expectedRecordId,
- },
-
- {
- "last view collection before the recursion limit (single file)",
- mockCollections[totalLevels-2].Name,
- fmt.Sprintf("one%d", totalLevels-2),
- fileOneName,
- false,
- expectedRecordId,
- },
- {
- "last view collection before the recursion limit (many files)",
- mockCollections[totalLevels-2].Name,
- fmt.Sprintf("many%d", totalLevels-2),
- fileManyName,
- false,
- expectedRecordId,
- },
- }
-
- for _, s := range scenarios {
- record, err := app.Dao().FindRecordByViewFile(
- s.collectionNameOrId,
- s.fileFieldName,
- s.filename,
- )
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- if record.Id != s.expectRecordId {
- t.Errorf("[%s] Expected recordId %q, got %q", s.name, s.expectRecordId, record.Id)
- }
- }
-}
diff --git a/examples/base/main.go b/examples/base/main.go
index d5586067a9..1ea529b460 100644
--- a/examples/base/main.go
+++ b/examples/base/main.go
@@ -2,10 +2,9 @@ package main
import (
"log"
+ "net/http"
"os"
"path/filepath"
- "strings"
- "time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
@@ -13,6 +12,8 @@ import (
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
+ "github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/osutils"
)
func main() {
@@ -35,14 +36,14 @@ func main() {
&hooksWatch,
"hooksWatch",
true,
- "auto restart the app on pb_hooks file change",
+ "auto restart the app on pb_hooks file change; it has no effect on Windows",
)
var hooksPool int
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
- 25,
+ 15,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)
@@ -75,15 +76,7 @@ func main() {
&indexFallback,
"indexFallback",
true,
- "fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
- )
-
- var queryTimeout int
- app.RootCmd.PersistentFlags().IntVar(
- &queryTimeout,
- "queryTimeout",
- 30,
- "the default SELECT queries timeout in seconds",
+ "fallback the request to index.html on missing static path, e.g. when pretty urls are used with SPA",
)
app.RootCmd.ParseFlags(os.Args[1:])
@@ -92,7 +85,7 @@ func main() {
// Plugins and hooks:
// ---------------------------------------------------------------
- // load jsvm (hooks and migrations)
+ // load jsvm (pb_hooks and pb_migrations)
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
@@ -110,15 +103,17 @@ func main() {
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
- app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
- app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
- return nil
- })
-
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- // serves static files from the provided public dir (if exists)
- e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
- return nil
+ // static route to serves files from the provided public dir
+ // (if publicDir exists and the route path is not already defined)
+ app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
+ Func: func(e *core.ServeEvent) error {
+ if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
+ e.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
+ }
+
+ return e.Next()
+ },
+ Priority: 999, // execute as latest as possible to allow users to provide their own route
})
if err := app.Start(); err != nil {
@@ -128,8 +123,7 @@ func main() {
// the default pb_public dir location is relative to the executable
func defaultPublicDir() string {
- if strings.HasPrefix(os.Args[0], os.TempDir()) {
- // most likely ran with go run
+ if osutils.IsProbablyGoRun() {
return "./pb_public"
}
diff --git a/forms/admin_login.go b/forms/admin_login.go
deleted file mode 100644
index da4631a77d..0000000000
--- a/forms/admin_login.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package forms
-
-import (
- "database/sql"
- "errors"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// AdminLogin is an admin email/pass login form.
-type AdminLogin struct {
- app core.App
- dao *daos.Dao
-
- Identity string `form:"identity" json:"identity"`
- Password string `form:"password" json:"password"`
-}
-
-// NewAdminLogin creates a new [AdminLogin] form initialized with
-// the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewAdminLogin(app core.App) *AdminLogin {
- return &AdminLogin{
- app: app,
- dao: app.Dao(),
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *AdminLogin) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *AdminLogin) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
- validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
- )
-}
-
-// Submit validates and submits the admin form.
-// On success returns the authorized admin model.
-//
-// You can optionally provide a list of InterceptorFunc to
-// further modify the form behavior before persisting it.
-func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
-
- // ignore not found errors to allow custom fetch implementations
- if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
- return nil, fetchErr
- }
-
- interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
- admin = m
-
- if admin == nil || !admin.ValidatePassword(form.Password) {
- return errors.New("Invalid login credentials.")
- }
-
- return nil
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return admin, nil
-}
diff --git a/forms/admin_login_test.go b/forms/admin_login_test.go
deleted file mode 100644
index 6ff591378f..0000000000
--- a/forms/admin_login_test.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package forms_test
-
-import (
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestAdminLoginValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewAdminLogin(app)
-
- scenarios := []struct {
- email string
- password string
- expectError bool
- }{
- {"", "", true},
- {"", "1234567890", true},
- {"test@example.com", "", true},
- {"test", "test", true},
- {"missing@example.com", "1234567890", true},
- {"test@example.com", "123456789", true},
- {"test@example.com", "1234567890", false},
- }
-
- for i, s := range scenarios {
- form.Identity = s.email
- form.Password = s.password
-
- admin, err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if !s.expectError && admin == nil {
- t.Errorf("(%d) Expected admin model to be returned, got nil", i)
- }
-
- if admin != nil && admin.Email != s.email {
- t.Errorf("(%d) Expected admin with email %s to be returned, got %v", i, s.email, admin)
- }
- }
-}
-
-func TestAdminLoginInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- form := forms.NewAdminLogin(testApp)
- form.Identity = "test@example.com"
- form.Password = "123456"
- var interceptorAdmin *models.Admin
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptor1Called = true
- return next(admin)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptorAdmin = admin
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
- t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
- }
-}
diff --git a/forms/admin_password_reset_confirm.go b/forms/admin_password_reset_confirm.go
deleted file mode 100644
index 0c5ee11fe1..0000000000
--- a/forms/admin_password_reset_confirm.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// AdminPasswordResetConfirm is an admin password reset confirmation form.
-type AdminPasswordResetConfirm struct {
- app core.App
- dao *daos.Dao
-
- Token string `form:"token" json:"token"`
- Password string `form:"password" json:"password"`
- PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
-}
-
-// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
- return &AdminPasswordResetConfirm{
- app: app,
- dao: app.Dao(),
- }
-}
-
-// SetDao replaces the form Dao instance with the provided one.
-//
-// This is useful if you want to use a specific transaction Dao instance
-// instead of the default app.Dao().
-func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *AdminPasswordResetConfirm) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- validation.Field(&form.Password, validation.Required, validation.Length(10, 72)),
- validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
- )
-}
-
-func (form *AdminPasswordResetConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
- if err != nil || admin == nil {
- return validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- return nil
-}
-
-// Submit validates and submits the admin password reset confirmation form.
-// On success returns the updated admin model associated to `form.Token`.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- admin, err := form.dao.FindAdminByToken(
- form.Token,
- form.app.Settings().AdminPasswordResetToken.Secret,
- )
- if err != nil {
- return nil, err
- }
-
- if err := admin.SetPassword(form.Password); err != nil {
- return nil, err
- }
-
- interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
- admin = m
- return form.dao.SaveAdmin(m)
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return admin, nil
-}
diff --git a/forms/admin_password_reset_confirm_test.go b/forms/admin_password_reset_confirm_test.go
deleted file mode 100644
index 546b8e7cdd..0000000000
--- a/forms/admin_password_reset_confirm_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package forms_test
-
-import (
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewAdminPasswordResetConfirm(app)
-
- scenarios := []struct {
- token string
- password string
- passwordConfirm string
- expectError bool
- }{
- {"", "", "", true},
- {"", "123", "", true},
- {"", "", "123", true},
- {"test", "", "", true},
- {"test", "123", "", true},
- {"test", "123", "123", true},
- {
- // expired
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
- "1234567890",
- "1234567890",
- true,
- },
- {
- // valid with mismatched passwords
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
- "1234567890",
- "1234567891",
- true,
- },
- {
- // valid with matching passwords
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
- "1234567891",
- "1234567891",
- false,
- },
- }
-
- for i, s := range scenarios {
- form.Token = s.token
- form.Password = s.password
- form.PasswordConfirm = s.passwordConfirm
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- interceptorCalls++
- return next(m)
- }
- }
-
- admin, err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if s.expectError {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- continue
- }
-
- if s.expectError {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(s.token)
- tokenAdminId := claims["id"]
-
- if admin.Id != tokenAdminId {
- t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin)
- }
-
- if !admin.ValidatePassword(form.Password) {
- t.Errorf("(%d) Expected the admin password to have been updated to %q", i, form.Password)
- }
- }
-}
-
-func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewAdminPasswordResetConfirm(testApp)
- form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
- form.Password = "1234567891"
- form.PasswordConfirm = "1234567891"
- interceptorTokenKey := admin.TokenKey
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptor1Called = true
- return next(admin)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptorTokenKey = admin.TokenKey
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorTokenKey == admin.TokenKey {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
diff --git a/forms/admin_password_reset_request.go b/forms/admin_password_reset_request.go
deleted file mode 100644
index cecdfb6985..0000000000
--- a/forms/admin_password_reset_request.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package forms
-
-import (
- "errors"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// AdminPasswordResetRequest is an admin password reset request form.
-type AdminPasswordResetRequest struct {
- app core.App
- dao *daos.Dao
- resendThreshold float64 // in seconds
-
- Email string `form:"email" json:"email"`
-}
-
-// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
- return &AdminPasswordResetRequest{
- app: app,
- dao: app.Dao(),
- resendThreshold: 120, // 2min
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-//
-// This method doesn't verify that admin with `form.Email` exists (this is done on Submit).
-func (form *AdminPasswordResetRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- ),
- )
-}
-
-// Submit validates and submits the form.
-// On success sends a password reset email to the `form.Email` admin.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- admin, err := form.dao.FindAdminByEmail(form.Email)
- if err != nil {
- return err
- }
-
- now := time.Now().UTC()
- lastResetSentAt := admin.LastResetSentAt.Time()
- if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
- return errors.New("You have already requested a password reset.")
- }
-
- return runInterceptors(admin, func(m *models.Admin) error {
- if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
- return err
- }
-
- // update last sent timestamp
- m.LastResetSentAt = types.NowDateTime()
-
- return form.dao.SaveAdmin(m)
- }, interceptors...)
-}
diff --git a/forms/admin_password_reset_request_test.go b/forms/admin_password_reset_request_test.go
deleted file mode 100644
index 79f304b5b9..0000000000
--- a/forms/admin_password_reset_request_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package forms_test
-
-import (
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- form := forms.NewAdminPasswordResetRequest(testApp)
-
- scenarios := []struct {
- email string
- expectError bool
- }{
- {"", true},
- {"", true},
- {"invalid", true},
- {"missing@example.com", true},
- {"test@example.com", false},
- {"test@example.com", true}, // already requested
- }
-
- for i, s := range scenarios {
- testApp.TestMailer.TotalSend = 0 // reset
- form.Email = s.email
-
- adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- interceptorCalls++
- return next(m)
- }
- }
-
- err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if s.expectError {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- adminAfter, _ := testApp.Dao().FindAdminByEmail(s.email)
-
- if !s.expectError && (adminBefore.LastResetSentAt == adminAfter.LastResetSentAt || adminAfter.LastResetSentAt.IsZero()) {
- t.Errorf("(%d) Expected admin.LastResetSentAt to change, got %q", i, adminAfter.LastResetSentAt)
- }
-
- expectedMails := 1
- if s.expectError {
- expectedMails = 0
- }
- if testApp.TestMailer.TotalSend != expectedMails {
- t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
- }
- }
-}
-
-func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewAdminPasswordResetRequest(testApp)
- form.Email = admin.Email
- interceptorLastResetSentAt := admin.LastResetSentAt
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptor1Called = true
- return next(admin)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(admin *models.Admin) error {
- interceptorLastResetSentAt = admin.LastResetSentAt
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorLastResetSentAt.String() != admin.LastResetSentAt.String() {
- t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
- }
-}
diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go
deleted file mode 100644
index 4afcb67ecb..0000000000
--- a/forms/admin_upsert.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// AdminUpsert is a [models.Admin] upsert (create/update) form.
-type AdminUpsert struct {
- app core.App
- dao *daos.Dao
- admin *models.Admin
-
- Id string `form:"id" json:"id"`
- Avatar int `form:"avatar" json:"avatar"`
- Email string `form:"email" json:"email"`
- Password string `form:"password" json:"password"`
- PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
-}
-
-// NewAdminUpsert creates a new [AdminUpsert] form with initializer
-// config created from the provided [core.App] and [models.Admin] instances
-// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
- form := &AdminUpsert{
- app: app,
- dao: app.Dao(),
- admin: admin,
- }
-
- // load defaults
- form.Id = admin.Id
- form.Avatar = admin.Avatar
- form.Email = admin.Email
-
- return form
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *AdminUpsert) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *AdminUpsert) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Id,
- validation.When(
- form.admin.IsNew(),
- validation.Length(models.DefaultIdLength, models.DefaultIdLength),
- validation.Match(idRegex),
- validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
- ).Else(validation.In(form.admin.Id)),
- ),
- validation.Field(
- &form.Avatar,
- validation.Min(0),
- validation.Max(9),
- ),
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- validation.By(form.checkUniqueEmail),
- ),
- validation.Field(
- &form.Password,
- validation.When(form.admin.IsNew(), validation.Required),
- validation.Length(10, 72),
- ),
- validation.Field(
- &form.PasswordConfirm,
- validation.When(form.Password != "", validation.Required),
- validation.By(validators.Compare(form.Password)),
- ),
- )
-}
-
-func (form *AdminUpsert) checkUniqueEmail(value any) error {
- v, _ := value.(string)
-
- if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
- return nil
- }
-
- return validation.NewError("validation_admin_email_exists", "Admin email already exists.")
-}
-
-// Submit validates the form and upserts the form admin model.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- // custom insertion id can be set only on create
- if form.admin.IsNew() && form.Id != "" {
- form.admin.MarkAsNew()
- form.admin.SetId(form.Id)
- }
-
- form.admin.Avatar = form.Avatar
- form.admin.Email = form.Email
-
- if form.Password != "" {
- form.admin.SetPassword(form.Password)
- }
-
- return runInterceptors(form.admin, func(admin *models.Admin) error {
- return form.dao.SaveAdmin(admin)
- }, interceptors...)
-}
diff --git a/forms/admin_upsert_test.go b/forms/admin_upsert_test.go
deleted file mode 100644
index 1465736088..0000000000
--- a/forms/admin_upsert_test.go
+++ /dev/null
@@ -1,333 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestNewAdminUpsert(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- admin := &models.Admin{}
- admin.Avatar = 3
- admin.Email = "new@example.com"
-
- form := forms.NewAdminUpsert(app, admin)
-
- // test defaults
- if form.Avatar != admin.Avatar {
- t.Errorf("Expected Avatar %d, got %d", admin.Avatar, form.Avatar)
- }
- if form.Email != admin.Email {
- t.Errorf("Expected Email %q, got %q", admin.Email, form.Email)
- }
-}
-
-func TestAdminUpsertValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- jsonData string
- expectError bool
- }{
- {
- // create empty
- "",
- `{}`,
- true,
- },
- {
- // update empty
- "sywbhecnh46rhm0",
- `{}`,
- false,
- },
- {
- // create failure - existing email
- "",
- `{
- "email": "test@example.com",
- "password": "1234567890",
- "passwordConfirm": "1234567890"
- }`,
- true,
- },
- {
- // create failure - passwords mismatch
- "",
- `{
- "email": "test_new@example.com",
- "password": "1234567890",
- "passwordConfirm": "1234567891"
- }`,
- true,
- },
- {
- // create success
- "",
- `{
- "email": "test_new@example.com",
- "password": "1234567890",
- "passwordConfirm": "1234567890"
- }`,
- false,
- },
- {
- // update failure - existing email
- "sywbhecnh46rhm0",
- `{
- "email": "test2@example.com"
- }`,
- true,
- },
- {
- // update failure - mismatching passwords
- "sywbhecnh46rhm0",
- `{
- "password": "1234567890",
- "passwordConfirm": "1234567891"
- }`,
- true,
- },
- {
- // update success - new email
- "sywbhecnh46rhm0",
- `{
- "email": "test_update@example.com"
- }`,
- false,
- },
- {
- // update success - new password
- "sywbhecnh46rhm0",
- `{
- "password": "1234567890",
- "passwordConfirm": "1234567890"
- }`,
- false,
- },
- }
-
- for i, s := range scenarios {
- isCreate := true
- admin := &models.Admin{}
- if s.id != "" {
- isCreate = false
- admin, _ = app.Dao().FindAdminById(s.id)
- }
- initialTokenKey := admin.TokenKey
-
- form := forms.NewAdminUpsert(app, admin)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
-
- err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- interceptorCalls++
- return next(m)
- }
- })
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- foundAdmin, _ := app.Dao().FindAdminByEmail(form.Email)
-
- if !s.expectError && isCreate && foundAdmin == nil {
- t.Errorf("(%d) Expected admin to be created, got nil", i)
- continue
- }
-
- expectInterceptorCall := 1
- if s.expectError {
- expectInterceptorCall = 0
- }
- if interceptorCalls != expectInterceptorCall {
- t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
- }
-
- if s.expectError {
- continue // skip persistence check
- }
-
- if foundAdmin.Email != form.Email {
- t.Errorf("(%d) Expected email %s, got %s", i, form.Email, foundAdmin.Email)
- }
-
- if foundAdmin.Avatar != form.Avatar {
- t.Errorf("(%d) Expected avatar %d, got %d", i, form.Avatar, foundAdmin.Avatar)
- }
-
- if form.Password != "" && initialTokenKey == foundAdmin.TokenKey {
- t.Errorf("(%d) Expected token key to be renewed when setting a new password", i)
- }
- }
-}
-
-func TestAdminUpsertSubmitInterceptors(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- admin := &models.Admin{}
- form := forms.NewAdminUpsert(app, admin)
- form.Email = "test_new@example.com"
- form.Password = "1234567890"
- form.PasswordConfirm = form.Password
-
- testErr := errors.New("test_error")
- interceptorAdminEmail := ""
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- interceptor1Called = true
- return next(m)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
- return func(m *models.Admin) error {
- interceptorAdminEmail = admin.Email // to check if the record was filled
- interceptor2Called = true
- return testErr
- }
- }
-
- err := form.Submit(interceptor1, interceptor2)
- if err != testErr {
- t.Fatalf("Expected error %v, got %v", testErr, err)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorAdminEmail != form.Email {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
-
-func TestAdminUpsertWithCustomId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- name string
- jsonData string
- collection *models.Admin
- expectError bool
- }{
- {
- "empty data",
- "{}",
- &models.Admin{},
- false,
- },
- {
- "empty id",
- `{"id":""}`,
- &models.Admin{},
- false,
- },
- {
- "id < 15 chars",
- `{"id":"a23"}`,
- &models.Admin{},
- true,
- },
- {
- "id > 15 chars",
- `{"id":"a234567890123456"}`,
- &models.Admin{},
- true,
- },
- {
- "id = 15 chars (invalid chars)",
- `{"id":"a@3456789012345"}`,
- &models.Admin{},
- true,
- },
- {
- "id = 15 chars (valid chars)",
- `{"id":"a23456789012345"}`,
- &models.Admin{},
- false,
- },
- {
- "changing the id of an existing item",
- `{"id":"b23456789012345"}`,
- existingAdmin,
- true,
- },
- {
- "using the same existing item id",
- `{"id":"` + existingAdmin.Id + `"}`,
- existingAdmin,
- false,
- },
- {
- "skipping the id for existing item",
- `{}`,
- existingAdmin,
- false,
- },
- }
-
- for i, scenario := range scenarios {
- form := forms.NewAdminUpsert(app, scenario.collection)
- if form.Email == "" {
- form.Email = fmt.Sprintf("test_id_%d@example.com", i)
- }
- form.Password = "1234567890"
- form.PasswordConfirm = form.Password
-
- // load data
- loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
- continue
- }
-
- submitErr := form.Submit()
- hasErr := submitErr != nil
-
- if hasErr != scenario.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
- }
-
- if !hasErr && form.Id != "" {
- _, err := app.Dao().FindAdminById(form.Id)
- if err != nil {
- t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
- }
- }
- }
-}
diff --git a/forms/apple_client_secret_create.go b/forms/apple_client_secret_create.go
index fe60412684..5a22a9d13c 100644
--- a/forms/apple_client_secret_create.go
+++ b/forms/apple_client_secret_create.go
@@ -5,14 +5,14 @@ import (
"strings"
"time"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/golang-jwt/jwt/v4"
+ "github.com/golang-jwt/jwt/v5"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
)
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
-// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
+// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`
- // Duration specifies how long the generated JWT token should be considered valid.
+ // Duration specifies how long the generated JWT should be considered valid.
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}
diff --git a/forms/apple_client_secret_create_test.go b/forms/apple_client_secret_create_test.go
index b20ce636fc..66092973c5 100644
--- a/forms/apple_client_secret_create_test.go
+++ b/forms/apple_client_secret_create_test.go
@@ -5,16 +5,18 @@ import (
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
- "encoding/json"
+ "encoding/json/v2"
"encoding/pem"
"testing"
- "github.com/golang-jwt/jwt/v4"
+ "github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
+ t.Parallel()
+
app, _ := tests.NewTestApp()
defer app.Cleanup()
diff --git a/forms/backup_create.go b/forms/backup_create.go
deleted file mode 100644
index d773702750..0000000000
--- a/forms/backup_create.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package forms
-
-import (
- "context"
- "regexp"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
-)
-
-var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
-
-// BackupCreate is a request form for creating a new app backup.
-type BackupCreate struct {
- app core.App
- ctx context.Context
-
- Name string `form:"name" json:"name"`
-}
-
-// NewBackupCreate creates new BackupCreate request form.
-func NewBackupCreate(app core.App) *BackupCreate {
- return &BackupCreate{
- app: app,
- ctx: context.Background(),
- }
-}
-
-// SetContext replaces the default form context with the provided one.
-func (form *BackupCreate) SetContext(ctx context.Context) {
- form.ctx = ctx
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *BackupCreate) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Name,
- validation.Length(1, 100),
- validation.Match(backupNameRegex),
- validation.By(form.checkUniqueName),
- ),
- )
-}
-
-func (form *BackupCreate) checkUniqueName(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- fsys, err := form.app.NewBackupsFilesystem()
- if err != nil {
- return err
- }
- defer fsys.Close()
-
- fsys.SetContext(form.ctx)
-
- if exists, err := fsys.Exists(v); err != nil || exists {
- return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
- }
-
- return nil
-}
-
-// Submit validates the form and creates the app backup.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before creating the backup.
-func (form *BackupCreate) Submit(interceptors ...InterceptorFunc[string]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return runInterceptors(form.Name, func(name string) error {
- return form.app.CreateBackup(form.ctx, name)
- }, interceptors...)
-}
diff --git a/forms/backup_create_test.go b/forms/backup_create_test.go
deleted file mode 100644
index 4aafbde918..0000000000
--- a/forms/backup_create_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package forms_test
-
-import (
- "strings"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestBackupCreateValidateAndSubmit(t *testing.T) {
- scenarios := []struct {
- name string
- backupName string
- expectedErrors []string
- }{
- {
- "invalid length",
- strings.Repeat("a", 97) + ".zip",
- []string{"name"},
- },
- {
- "valid length + invalid format",
- strings.Repeat("a", 96),
- []string{"name"},
- },
- {
- "valid length + valid format",
- strings.Repeat("a", 96) + ".zip",
- []string{},
- },
- {
- "auto generated name",
- "",
- []string{},
- },
- }
-
- for _, s := range scenarios {
- t.Run(s.name, func(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- fsys, err := app.NewBackupsFilesystem()
- if err != nil {
- t.Fatal(err)
- }
- defer fsys.Close()
-
- form := forms.NewBackupCreate(app)
- form.Name = s.backupName
-
- result := form.Submit()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Fatalf("Failed to parse errors %v", result)
- return
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Fatalf("Missing expected error key %q in %v", k, errs)
- }
- }
-
- // retrieve all created backup files
- files, err := fsys.List("")
- if err != nil {
- t.Fatal("Failed to retrieve backup files")
- return
- }
-
- if result != nil {
- if total := len(files); total != 0 {
- t.Fatalf("Didn't expected backup files, found %d", total)
- }
- return
- }
-
- if total := len(files); total != 1 {
- t.Fatalf("Expected 1 backup file, got %d", total)
- return
- }
-
- if s.backupName == "" {
- prefix := "pb_backup_"
- if !strings.HasPrefix(files[0].Key, prefix) {
- t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
- }
- } else if s.backupName != files[0].Key {
- t.Fatalf("Expected backup file %q, got %q", s.backupName, files[0].Key)
- }
- })
- }
-}
diff --git a/forms/backup_upload.go b/forms/backup_upload.go
deleted file mode 100644
index 8056e69184..0000000000
--- a/forms/backup_upload.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package forms
-
-import (
- "context"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/tools/filesystem"
-)
-
-// BackupUpload is a request form for uploading a new app backup.
-type BackupUpload struct {
- app core.App
- ctx context.Context
-
- File *filesystem.File `json:"file"`
-}
-
-// NewBackupUpload creates new BackupUpload request form.
-func NewBackupUpload(app core.App) *BackupUpload {
- return &BackupUpload{
- app: app,
- ctx: context.Background(),
- }
-}
-
-// SetContext replaces the default form upload context with the provided one.
-func (form *BackupUpload) SetContext(ctx context.Context) {
- form.ctx = ctx
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *BackupUpload) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.File,
- validation.Required,
- validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
- validation.By(form.checkUniqueName),
- ),
- )
-}
-
-func (form *BackupUpload) checkUniqueName(value any) error {
- v, _ := value.(*filesystem.File)
- if v == nil {
- return nil // nothing to check
- }
-
- fsys, err := form.app.NewBackupsFilesystem()
- if err != nil {
- return err
- }
- defer fsys.Close()
-
- fsys.SetContext(form.ctx)
-
- if exists, err := fsys.Exists(v.OriginalName); err != nil || exists {
- return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
- }
-
- return nil
-}
-
-// Submit validates the form and upload the backup file.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before uploading the backup.
-func (form *BackupUpload) Submit(interceptors ...InterceptorFunc[*filesystem.File]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return runInterceptors(form.File, func(file *filesystem.File) error {
- fsys, err := form.app.NewBackupsFilesystem()
- if err != nil {
- return err
- }
-
- fsys.SetContext(form.ctx)
-
- return fsys.UploadFile(file, file.OriginalName)
- }, interceptors...)
-}
diff --git a/forms/backup_upload_test.go b/forms/backup_upload_test.go
deleted file mode 100644
index 8284c6689e..0000000000
--- a/forms/backup_upload_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package forms_test
-
-import (
- "archive/zip"
- "bytes"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/filesystem"
-)
-
-func TestBackupUploadValidateAndSubmit(t *testing.T) {
- var zb bytes.Buffer
- zw := zip.NewWriter(&zb)
- if err := zw.Close(); err != nil {
- t.Fatal(err)
- }
-
- f0, _ := filesystem.NewFileFromBytes([]byte("test"), "existing")
- f1, _ := filesystem.NewFileFromBytes([]byte("456"), "nozip")
- f2, _ := filesystem.NewFileFromBytes(zb.Bytes(), "existing")
- f3, _ := filesystem.NewFileFromBytes(zb.Bytes(), "zip")
-
- scenarios := []struct {
- name string
- file *filesystem.File
- expectedErrors []string
- }{
- {
- "missing file",
- nil,
- []string{"file"},
- },
- {
- "non-zip file",
- f1,
- []string{"file"},
- },
- {
- "zip file with non-unique name",
- f2,
- []string{"file"},
- },
- {
- "zip file with unique name",
- f3,
- []string{},
- },
- }
-
- for _, s := range scenarios {
- t.Run(s.name, func(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- fsys, err := app.NewBackupsFilesystem()
- if err != nil {
- t.Fatal(err)
- }
- defer fsys.Close()
- // create a dummy backup file to simulate existing backups
- if err := fsys.UploadFile(f0, f0.OriginalName); err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewBackupUpload(app)
- form.File = s.file
-
- result := form.Submit()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Fatalf("Failed to parse errors %v", result)
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Fatalf("Missing expected error key %q in %v", k, errs)
- }
- }
-
- expectedFiles := []*filesystem.File{f0}
- if result == nil {
- expectedFiles = append(expectedFiles, s.file)
- }
-
- // retrieve all uploaded backup files
- files, err := fsys.List("")
- if err != nil {
- t.Fatal("Failed to retrieve backup files")
- }
-
- if len(files) != len(expectedFiles) {
- t.Fatalf("Expected %d files, got %d", len(expectedFiles), len(files))
- }
-
- for _, ef := range expectedFiles {
- exists := false
- for _, f := range files {
- if f.Key == ef.OriginalName {
- exists = true
- break
- }
- }
- if !exists {
- t.Fatalf("Missing expected backup file %v", ef.OriginalName)
- }
- }
- })
- }
-}
diff --git a/forms/base.go b/forms/base.go
deleted file mode 100644
index 64c1888306..0000000000
--- a/forms/base.go
+++ /dev/null
@@ -1,31 +0,0 @@
-// Package models implements various services used for request data
-// validation and applying changes to existing DB models through the app Dao.
-package forms
-
-import (
- "regexp"
-)
-
-// base ID value regex pattern
-var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
-
-// InterceptorNextFunc is a interceptor handler function.
-// Usually used in combination with InterceptorFunc.
-type InterceptorNextFunc[T any] func(t T) error
-
-// InterceptorFunc defines a single interceptor function that
-// will execute the provided next func handler.
-type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
-
-// runInterceptors executes the provided list of interceptors.
-func runInterceptors[T any](
- data T,
- next InterceptorNextFunc[T],
- interceptors ...InterceptorFunc[T],
-) error {
- for i := len(interceptors) - 1; i >= 0; i-- {
- next = interceptors[i](next)
- }
-
- return next(data)
-}
diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go
deleted file mode 100644
index 27bc8bfad0..0000000000
--- a/forms/collection_upsert.go
+++ /dev/null
@@ -1,525 +0,0 @@
-package forms
-
-import (
- "encoding/json"
- "fmt"
- "regexp"
- "strconv"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/resolvers"
- "github.com/pocketbase/pocketbase/tools/dbutils"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/search"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
-
-// CollectionUpsert is a [models.Collection] upsert (create/update) form.
-type CollectionUpsert struct {
- app core.App
- dao *daos.Dao
- collection *models.Collection
-
- Id string `form:"id" json:"id"`
- Type string `form:"type" json:"type"`
- Name string `form:"name" json:"name"`
- System bool `form:"system" json:"system"`
- Schema schema.Schema `form:"schema" json:"schema"`
- Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
- ListRule *string `form:"listRule" json:"listRule"`
- ViewRule *string `form:"viewRule" json:"viewRule"`
- CreateRule *string `form:"createRule" json:"createRule"`
- UpdateRule *string `form:"updateRule" json:"updateRule"`
- DeleteRule *string `form:"deleteRule" json:"deleteRule"`
- Options types.JsonMap `form:"options" json:"options"`
-}
-
-// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
-// config created from the provided [core.App] and [models.Collection] instances
-// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
- form := &CollectionUpsert{
- app: app,
- dao: app.Dao(),
- collection: collection,
- }
-
- // load defaults
- form.Id = form.collection.Id
- form.Type = form.collection.Type
- form.Name = form.collection.Name
- form.System = form.collection.System
- form.Indexes = form.collection.Indexes
- form.ListRule = form.collection.ListRule
- form.ViewRule = form.collection.ViewRule
- form.CreateRule = form.collection.CreateRule
- form.UpdateRule = form.collection.UpdateRule
- form.DeleteRule = form.collection.DeleteRule
- form.Options = form.collection.Options
-
- if form.Type == "" {
- form.Type = models.CollectionTypeBase
- }
-
- clone, _ := form.collection.Schema.Clone()
- if clone != nil && form.Type != models.CollectionTypeView {
- form.Schema = *clone
- } else {
- form.Schema = schema.Schema{}
- }
-
- return form
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *CollectionUpsert) Validate() error {
- isAuth := form.Type == models.CollectionTypeAuth
- isView := form.Type == models.CollectionTypeView
-
- // generate schema from the query (overwriting any explicit user defined schema)
- if isView {
- options := models.CollectionViewOptions{}
- if err := decodeOptions(form.Options, &options); err != nil {
- return err
- }
- form.Schema, _ = form.dao.CreateViewSchema(options.Query)
- }
-
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Id,
- validation.When(
- form.collection.IsNew(),
- validation.Length(models.DefaultIdLength, models.DefaultIdLength),
- validation.Match(idRegex),
- validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
- ).Else(validation.In(form.collection.Id)),
- ),
- validation.Field(
- &form.System,
- validation.By(form.ensureNoSystemFlagChange),
- ),
- validation.Field(
- &form.Type,
- validation.Required,
- validation.In(
- models.CollectionTypeBase,
- models.CollectionTypeAuth,
- models.CollectionTypeView,
- ),
- validation.By(form.ensureNoTypeChange),
- ),
- validation.Field(
- &form.Name,
- validation.Required,
- validation.Length(1, 255),
- validation.Match(collectionNameRegex),
- validation.By(form.ensureNoSystemNameChange),
- validation.By(form.checkUniqueName),
- ),
- // validates using the type's own validation rules + some collection's specifics
- validation.Field(
- &form.Schema,
- validation.By(form.checkMinSchemaFields),
- validation.By(form.ensureNoSystemFieldsChange),
- validation.By(form.ensureNoFieldsTypeChange),
- validation.By(form.checkRelationFields),
- validation.When(isAuth, validation.By(form.ensureNoAuthFieldName)),
- ),
- validation.Field(&form.ListRule, validation.By(form.checkRule)),
- validation.Field(&form.ViewRule, validation.By(form.checkRule)),
- validation.Field(
- &form.CreateRule,
- validation.When(isView, validation.Nil),
- validation.By(form.checkRule),
- ),
- validation.Field(
- &form.UpdateRule,
- validation.When(isView, validation.Nil),
- validation.By(form.checkRule),
- ),
- validation.Field(
- &form.DeleteRule,
- validation.When(isView, validation.Nil),
- validation.By(form.checkRule),
- ),
- validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
- validation.Field(&form.Options, validation.By(form.checkOptions)),
- )
-}
-
-func (form *CollectionUpsert) checkUniqueName(value any) error {
- v, _ := value.(string)
-
- // ensure unique collection name
- if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
- return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
- }
-
- // ensure that the collection name doesn't collide with the id of any collection
- if form.dao.FindById(&models.Collection{}, v) == nil {
- return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
- v, _ := value.(string)
-
- if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
- return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
- v, _ := value.(bool)
-
- if !form.collection.IsNew() && v != form.collection.System {
- return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
- v, _ := value.(string)
-
- if !form.collection.IsNew() && v != form.collection.Type {
- return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
- v, _ := value.(schema.Schema)
-
- for i, field := range v.Fields() {
- oldField := form.collection.Schema.GetFieldById(field.Id)
-
- if oldField != nil && oldField.Type != field.Type {
- return validation.Errors{fmt.Sprint(i): validation.NewError(
- "validation_field_type_change",
- "Field type cannot be changed.",
- )}
- }
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) checkRelationFields(value any) error {
- v, _ := value.(schema.Schema)
-
- for i, field := range v.Fields() {
- if field.Type != schema.FieldTypeRelation {
- continue
- }
-
- options, _ := field.Options.(*schema.RelationOptions)
- if options == nil {
- return validation.Errors{fmt.Sprint(i): validation.Errors{
- "options": validation.NewError(
- "validation_schema_invalid_relation_field_options",
- "The relation field has invalid field options.",
- )},
- }
- }
-
- // prevent collectionId change
- oldField := form.collection.Schema.GetFieldById(field.Id)
- if oldField != nil {
- oldOptions, _ := oldField.Options.(*schema.RelationOptions)
- if oldOptions != nil && oldOptions.CollectionId != options.CollectionId {
- return validation.Errors{fmt.Sprint(i): validation.Errors{
- "options": validation.Errors{
- "collectionId": validation.NewError(
- "validation_field_relation_change",
- "The relation collection cannot be changed.",
- ),
- }},
- }
- }
- }
-
- relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
-
- // validate collectionId
- if relCollection == nil || relCollection.Id != options.CollectionId {
- return validation.Errors{fmt.Sprint(i): validation.Errors{
- "options": validation.Errors{
- "collectionId": validation.NewError(
- "validation_field_invalid_relation",
- "The relation collection doesn't exist.",
- ),
- }},
- }
- }
-
- // allow only views to have relations to other views
- // (see https://github.com/pocketbase/pocketbase/issues/3000)
- if form.Type != models.CollectionTypeView && relCollection.IsView() {
- return validation.Errors{fmt.Sprint(i): validation.Errors{
- "options": validation.Errors{
- "collectionId": validation.NewError(
- "validation_field_non_view_base_relation_collection",
- "Non view collections are not allowed to have a view relation.",
- ),
- }},
- }
- }
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
- v, _ := value.(schema.Schema)
-
- if form.Type != models.CollectionTypeAuth {
- return nil // not an auth collection
- }
-
- authFieldNames := schema.AuthFieldNames()
- // exclude the meta RecordUpsert form fields
- authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
-
- errs := validation.Errors{}
- for i, field := range v.Fields() {
- if list.ExistInSlice(field.Name, authFieldNames) {
- errs[fmt.Sprint(i)] = validation.Errors{
- "name": validation.NewError(
- "validation_reserved_auth_field_name",
- "The field name is reserved and cannot be used.",
- ),
- }
- }
- }
-
- if len(errs) > 0 {
- return errs
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
- v, _ := value.(schema.Schema)
-
- switch form.Type {
- case models.CollectionTypeAuth, models.CollectionTypeView:
- return nil // no schema fields constraint
- default:
- if len(v.Fields()) == 0 {
- return validation.ErrRequired
- }
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
- v, _ := value.(schema.Schema)
-
- for _, oldField := range form.collection.Schema.Fields() {
- if !oldField.System {
- continue
- }
-
- newField := v.GetFieldById(oldField.Id)
-
- if newField == nil || oldField.String() != newField.String() {
- return validation.NewError("validation_system_field_change", "System fields cannot be deleted or changed.")
- }
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) checkRule(value any) error {
- v, _ := value.(*string)
- if v == nil || *v == "" {
- return nil // nothing to check
- }
-
- dummy := *form.collection
- dummy.Type = form.Type
- dummy.Schema = form.Schema
- dummy.System = form.System
- dummy.Options = form.Options
-
- r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
-
- _, err := search.FilterData(*v).BuildExpr(r)
- if err != nil {
- return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) checkIndexes(value any) error {
- v, _ := value.(types.JsonArray[string])
-
- if form.Type == models.CollectionTypeView && len(v) > 0 {
- return validation.NewError(
- "validation_indexes_not_supported",
- "The collection doesn't support indexes.",
- )
- }
-
- for i, rawIndex := range v {
- parsed := dbutils.ParseIndex(rawIndex)
-
- if !parsed.IsValid() {
- return validation.Errors{
- strconv.Itoa(i): validation.NewError(
- "validation_invalid_index_expression",
- "Invalid CREATE INDEX expression.",
- ),
- }
- }
-
- // note: we don't check the index table because it is always
- // overwritten by the daos.SyncRecordTableSchema to allow
- // easier partial modifications (eg. changing only the collection name).
- // if !strings.EqualFold(parsed.TableName, form.Name) {
- // return validation.Errors{
- // strconv.Itoa(i): validation.NewError(
- // "validation_invalid_index_table",
- // fmt.Sprintf("The index table must be the same as the collection name."),
- // ),
- // }
- // }
- }
-
- return nil
-}
-
-func (form *CollectionUpsert) checkOptions(value any) error {
- v, _ := value.(types.JsonMap)
-
- switch form.Type {
- case models.CollectionTypeAuth:
- options := models.CollectionAuthOptions{}
- if err := decodeOptions(v, &options); err != nil {
- return err
- }
-
- // check the generic validations
- if err := options.Validate(); err != nil {
- return err
- }
-
- // additional form specific validations
- if err := form.checkRule(options.ManageRule); err != nil {
- return validation.Errors{"manageRule": err}
- }
- case models.CollectionTypeView:
- options := models.CollectionViewOptions{}
- if err := decodeOptions(v, &options); err != nil {
- return err
- }
-
- // check the generic validations
- if err := options.Validate(); err != nil {
- return err
- }
-
- // check the query option
- if _, err := form.dao.CreateViewSchema(options.Query); err != nil {
- return validation.Errors{
- "query": validation.NewError(
- "validation_invalid_view_query",
- fmt.Sprintf("Invalid query - %s", err.Error()),
- ),
- }
- }
- }
-
- return nil
-}
-
-func decodeOptions(options types.JsonMap, result any) error {
- raw, err := options.MarshalJSON()
- if err != nil {
- return validation.NewError("validation_invalid_options", "Invalid options.")
- }
-
- if err := json.Unmarshal(raw, result); err != nil {
- return validation.NewError("validation_invalid_options", "Invalid options.")
- }
-
- return nil
-}
-
-// Submit validates the form and upserts the form's Collection model.
-//
-// On success the related record table schema will be auto updated.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- if form.collection.IsNew() {
- // type can be set only on create
- form.collection.Type = form.Type
-
- // system flag can be set only on create
- form.collection.System = form.System
-
- // custom insertion id can be set only on create
- if form.Id != "" {
- form.collection.MarkAsNew()
- form.collection.SetId(form.Id)
- }
- }
-
- // system collections cannot be renamed
- if form.collection.IsNew() || !form.collection.System {
- form.collection.Name = form.Name
- }
-
- // view schema is autogenerated on save and cannot have indexes
- if !form.collection.IsView() {
- form.collection.Schema = form.Schema
-
- // normalize indexes format
- form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
- for i, rawIdx := range form.Indexes {
- form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
- }
- }
-
- form.collection.ListRule = form.ListRule
- form.collection.ViewRule = form.ViewRule
- form.collection.CreateRule = form.CreateRule
- form.collection.UpdateRule = form.UpdateRule
- form.collection.DeleteRule = form.DeleteRule
- form.collection.SetOptions(form.Options)
-
- return runInterceptors(form.collection, func(collection *models.Collection) error {
- return form.dao.SaveCollection(collection)
- }, interceptors...)
-}
diff --git a/forms/collection_upsert_test.go b/forms/collection_upsert_test.go
deleted file mode 100644
index e9f6a9ae5f..0000000000
--- a/forms/collection_upsert_test.go
+++ /dev/null
@@ -1,808 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/dbutils"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/spf13/cast"
-)
-
-func TestNewCollectionUpsert(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection := &models.Collection{}
- collection.Name = "test_name"
- collection.Type = "test_type"
- collection.System = true
- listRule := "test_list"
- collection.ListRule = &listRule
- viewRule := "test_view"
- collection.ViewRule = &viewRule
- createRule := "test_create"
- collection.CreateRule = &createRule
- updateRule := "test_update"
- collection.UpdateRule = &updateRule
- deleteRule := "test_delete"
- collection.DeleteRule = &deleteRule
- collection.Schema = schema.NewSchema(&schema.SchemaField{
- Name: "test",
- Type: schema.FieldTypeText,
- })
-
- form := forms.NewCollectionUpsert(app, collection)
-
- if form.Name != collection.Name {
- t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
- }
-
- if form.Type != collection.Type {
- t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
- }
-
- if form.System != collection.System {
- t.Errorf("Expected System %v, got %v", collection.System, form.System)
- }
-
- if form.ListRule != collection.ListRule {
- t.Errorf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
- }
-
- if form.ViewRule != collection.ViewRule {
- t.Errorf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
- }
-
- if form.CreateRule != collection.CreateRule {
- t.Errorf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
- }
-
- if form.UpdateRule != collection.UpdateRule {
- t.Errorf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
- }
-
- if form.DeleteRule != collection.DeleteRule {
- t.Errorf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
- }
-
- // store previous state and modify the collection schema to verify
- // that the form.Schema is a deep clone
- loadedSchema, _ := collection.Schema.MarshalJSON()
- collection.Schema.AddField(&schema.SchemaField{
- Name: "new_field",
- Type: schema.FieldTypeBool,
- })
-
- formSchema, _ := form.Schema.MarshalJSON()
-
- if string(formSchema) != string(loadedSchema) {
- t.Errorf("Expected Schema %v, got %v", string(loadedSchema), string(formSchema))
- }
-}
-
-func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- testName string
- existingName string
- jsonData string
- expectedErrors []string
- }{
- {"empty create (base)", "", "{}", []string{"name", "schema"}},
- {"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
- {"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
- {"empty update", "demo2", "{}", []string{}},
- {
- "create failure",
- "",
- `{
- "name": "test ?!@#$",
- "type": "invalid",
- "system": true,
- "schema": [
- {"name":"","type":"text"}
- ],
- "listRule": "missing = '123'",
- "viewRule": "missing = '123'",
- "createRule": "missing = '123'",
- "updateRule": "missing = '123'",
- "deleteRule": "missing = '123'",
- "indexes": ["create index '' on '' ()"]
- }`,
- []string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
- },
- {
- "create failure - existing name",
- "",
- `{
- "name": "demo1",
- "system": true,
- "schema": [
- {"name":"test","type":"text"}
- ],
- "listRule": "test='123'",
- "viewRule": "test='123'",
- "createRule": "test='123'",
- "updateRule": "test='123'",
- "deleteRule": "test='123'"
- }`,
- []string{"name"},
- },
- {
- "create failure - existing internal table",
- "",
- `{
- "name": "_admins",
- "schema": [
- {"name":"test","type":"text"}
- ]
- }`,
- []string{"name"},
- },
- {
- "create failure - name starting with underscore",
- "",
- `{
- "name": "_test_new",
- "schema": [
- {"name":"test","type":"text"}
- ]
- }`,
- []string{"name"},
- },
- {
- "create failure - duplicated field names (case insensitive)",
- "",
- `{
- "name": "test_new",
- "schema": [
- {"name":"test","type":"text"},
- {"name":"tESt","type":"text"}
- ]
- }`,
- []string{"schema"},
- },
- {
- "create failure - check auth options validators",
- "",
- `{
- "name": "test_new",
- "type": "auth",
- "schema": [
- {"name":"test","type":"text"}
- ],
- "options": { "minPasswordLength": 3 }
- }`,
- []string{"options"},
- },
- {
- "create failure - check view options validators",
- "",
- `{
- "name": "test_new",
- "type": "view",
- "options": { "query": "invalid query" }
- }`,
- []string{"options"},
- },
- {
- "create success",
- "",
- `{
- "name": "test_new",
- "type": "auth",
- "system": true,
- "schema": [
- {"id":"a123456","name":"test1","type":"text"},
- {"id":"b123456","name":"test2","type":"email"},
- {
- "name":"test3",
- "type":"relation",
- "options":{
- "collectionId":"v851q4r790rhknl",
- "displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
- }
- }
- ],
- "listRule": "test1='123' && verified = true",
- "viewRule": "test1='123' && emailVisibility = true",
- "createRule": "test1='123' && email != ''",
- "updateRule": "test1='123' && username != ''",
- "deleteRule": "test1='123' && id != ''",
- "indexes": ["create index idx_test_new on anything (test1)"]
- }`,
- []string{},
- },
- {
- "update failure - changing field type",
- "test_new",
- `{
- "schema": [
- {"id":"a123456","name":"test1","type":"url"},
- {"id":"b123456","name":"test2","type":"bool"}
- ],
- "indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
- }`,
- []string{"schema", "indexes"},
- },
- {
- "update success - rename fields to existing field names (aka. reusing field names)",
- "test_new",
- `{
- "schema": [
- {"id":"a123456","name":"test2","type":"text"},
- {"id":"b123456","name":"test1","type":"email"}
- ]
- }`,
- []string{},
- },
- {
- "update failure - existing name",
- "demo2",
- `{"name": "demo3"}`,
- []string{"name"},
- },
- {
- "update failure - changing system collection",
- "nologin",
- `{
- "name": "update",
- "system": false,
- "schema": [
- {"id":"koih1lqx","name":"abc","type":"text"}
- ],
- "listRule": "abc = '123'",
- "viewRule": "abc = '123'",
- "createRule": "abc = '123'",
- "updateRule": "abc = '123'",
- "deleteRule": "abc = '123'"
- }`,
- []string{"name", "system"},
- },
- {
- "update failure - changing collection type",
- "demo3",
- `{
- "type": "auth"
- }`,
- []string{"type"},
- },
- {
- "update failure - changing relation collection",
- "users",
- `{
- "schema": [
- {
- "id": "lkeigvv3",
- "name": "rel",
- "type": "relation",
- "options": {
- "collectionId": "wzlqyes4orhoygb",
- "cascadeDelete": false,
- "maxSelect": 1,
- "displayFields": null
- }
- }
- ]
- }`,
- []string{"schema"},
- },
- {
- "update failure - all fields",
- "demo2",
- `{
- "name": "test ?!@#$",
- "type": "invalid",
- "system": true,
- "schema": [
- {"name":"","type":"text"}
- ],
- "listRule": "missing = '123'",
- "viewRule": "missing = '123'",
- "createRule": "missing = '123'",
- "updateRule": "missing = '123'",
- "deleteRule": "missing = '123'",
- "options": {"test": 123},
- "indexes": ["create index '' from demo2 on (id)"]
- }`,
- []string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
- },
- {
- "update success - update all fields",
- "clients",
- `{
- "name": "demo_update",
- "type": "auth",
- "schema": [
- {"id":"_2hlxbmp","name":"test","type":"text"}
- ],
- "listRule": "test='123' && verified = true",
- "viewRule": "test='123' && emailVisibility = true",
- "createRule": "test='123' && email != ''",
- "updateRule": "test='123' && username != ''",
- "deleteRule": "test='123' && id != ''",
- "options": {"minPasswordLength": 10},
- "indexes": [
- "create index idx_clients_test1 on anything (id, email, test)",
- "create unique index idx_clients_test2 on clients (id, username, email)"
- ]
- }`,
- []string{},
- },
- // (fail due to filters old field references)
- {
- "update failure - rename the schema field of the last updated collection",
- "demo_update",
- `{
- "schema": [
- {"id":"_2hlxbmp","name":"test_renamed","type":"text"}
- ]
- }`,
- []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
- },
- // (cleared filter references)
- {
- "update success - rename the schema field of the last updated collection",
- "demo_update",
- `{
- "schema": [
- {"id":"_2hlxbmp","name":"test_renamed","type":"text"}
- ],
- "listRule": null,
- "viewRule": null,
- "createRule": null,
- "updateRule": null,
- "deleteRule": null,
- "indexes": []
- }`,
- []string{},
- },
- {
- "update success - system collection",
- "nologin",
- `{
- "listRule": "name='123'",
- "viewRule": "name='123'",
- "createRule": "name='123'",
- "updateRule": "name='123'",
- "deleteRule": "name='123'"
- }`,
- []string{},
- },
-
- // view tests
- // -----------------------------------------------------------
- {
- "base->view relation",
- "",
- `{
- "name": "test_view_relation",
- "type": "base",
- "schema": [
- {
- "name": "test",
- "type": "relation",
- "options":{
- "collectionId": "v9gwnfh02gjq1q0"
- }
- }
- ]
- }`,
- []string{"schema"}, // not allowed
- },
- {
- "auth->view relation",
- "",
- `{
- "name": "test_view_relation",
- "type": "auth",
- "schema": [
- {
- "name": "test",
- "type": "relation",
- "options": {
- "collectionId": "v9gwnfh02gjq1q0"
- }
- }
- ]
- }`,
- []string{"schema"}, // not allowed
- },
- {
- "view->view relation",
- "",
- `{
- "name": "test_view_relation",
- "type": "view",
- "options": {
- "query": "select view1.id, view1.id as rel from view1"
- }
- }`,
- []string{}, // allowed
- },
- {
- "view create failure",
- "",
- `{
- "name": "upsert_view",
- "type": "view",
- "listRule": "id='123' && verified = true",
- "viewRule": "id='123' && emailVisibility = true",
- "schema": [
- {"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
- ],
- "options": {
- "query": "select id, email from users; drop table _admins;"
- },
- "indexes": ["create index idx_test_view on upsert_view (id)"]
- }`,
- []string{
- "listRule",
- "viewRule",
- "options",
- "indexes", // views don't have indexes
- },
- },
- {
- "view create success",
- "",
- `{
- "name": "upsert_view",
- "type": "view",
- "listRule": "id='123' && verified = true",
- "viewRule": "id='123' && emailVisibility = true",
- "schema": [
- {"id":"abc123","name":"some invalid field name that will be overwritten !@#$","type":"bool"}
- ],
- "options": {
- "query": "select id, emailVisibility, verified from users"
- }
- }`,
- []string{
- // "schema", should be overwritten by an autogenerated from the query
- },
- },
- {
- "view update failure (schema autogeneration and rule fields check)",
- "upsert_view",
- `{
- "name": "upsert_view_2",
- "listRule": "id='456' && verified = true",
- "viewRule": "id='456'",
- "createRule": "id='123'",
- "updateRule": "id='123'",
- "deleteRule": "id='123'",
- "schema": [
- {"id":"abc123","name":"verified","type":"bool"}
- ],
- "options": {
- "query": "select 1 as id"
- }
- }`,
- []string{
- "listRule", // missing field (ignoring the old or explicit schema)
- "createRule", // not allowed
- "updateRule", // not allowed
- "deleteRule", // not allowed
- },
- },
- {
- "view update failure (check query identifiers format)",
- "upsert_view",
- `{
- "listRule": null,
- "viewRule": null,
- "options": {
- "query": "select 1 as id, 2 as [invalid!@#]"
- }
- }`,
- []string{
- "schema", // should fail due to invalid field name
- },
- },
- {
- "view update success",
- "upsert_view",
- `{
- "listRule": null,
- "viewRule": null,
- "options": {
- "query": "select 1 as id, 2 as valid"
- }
- }`,
- []string{},
- },
- }
-
- for _, s := range scenarios {
- t.Run(s.testName, func(t *testing.T) {
- collection := &models.Collection{}
- if s.existingName != "" {
- var err error
- collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
- if err != nil {
- t.Fatal(err)
- }
- }
-
- form := forms.NewCollectionUpsert(app, collection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Fatalf("Failed to load form data: %v", loadErr)
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
- return func(c *models.Collection) error {
- interceptorCalls++
- return next(c)
- }
- }
-
- // parse errors
- result := form.Submit(interceptor)
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Fatalf("Failed to parse errors %v", result)
- }
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if len(s.expectedErrors) > 0 {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Fatalf("Missing expected error key %q in %v", k, errs)
- }
- }
-
- if len(s.expectedErrors) > 0 {
- return
- }
-
- collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
- if collection == nil {
- t.Fatalf("Expected to find collection %q, got nil", form.Name)
- }
-
- if form.Name != collection.Name {
- t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
- }
-
- if form.Type != collection.Type {
- t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
- }
-
- if form.System != collection.System {
- t.Fatalf("Expected System %v, got %v", collection.System, form.System)
- }
-
- if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
- t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
- }
-
- if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
- t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
- }
-
- if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
- t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
- }
-
- if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
- t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
- }
-
- if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
- t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
- }
-
- rawFormSchema, _ := form.Schema.MarshalJSON()
- rawCollectionSchema, _ := collection.Schema.MarshalJSON()
-
- if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
- t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
- }
-
- for _, f := range form.Schema.Fields() {
- if collection.Schema.GetFieldByName(f.Name) == nil {
- t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
- }
- }
-
- // check indexes (if any)
- allIndexes, _ := app.Dao().TableIndexes(form.Name)
- for _, formIdx := range form.Indexes {
- parsed := dbutils.ParseIndex(formIdx)
- parsed.TableName = form.Name
- normalizedIdx := parsed.Build()
-
- var exists bool
- for _, idx := range allIndexes {
- if dbutils.ParseIndex(idx).Build() == normalizedIdx {
- exists = true
- continue
- }
- }
-
- if !exists {
- t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
- }
- }
- })
- }
-}
-
-func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo2")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewCollectionUpsert(app, collection)
- form.Name = "test_new"
-
- testErr := errors.New("test_error")
- interceptorCollectionName := ""
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
- return func(c *models.Collection) error {
- interceptor1Called = true
- return next(c)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
- return func(c *models.Collection) error {
- interceptorCollectionName = collection.Name // to check if the record was filled
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorCollectionName != form.Name {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
-
-func TestCollectionUpsertWithCustomId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
- if err != nil {
- t.Fatal(err)
- }
-
- newCollection := func() *models.Collection {
- return &models.Collection{
- Name: "c_" + security.PseudorandomString(4),
- Schema: existingCollection.Schema,
- }
- }
-
- scenarios := []struct {
- name string
- jsonData string
- collection *models.Collection
- expectError bool
- }{
- {
- "empty data",
- "{}",
- newCollection(),
- false,
- },
- {
- "empty id",
- `{"id":""}`,
- newCollection(),
- false,
- },
- {
- "id < 15 chars",
- `{"id":"a23"}`,
- newCollection(),
- true,
- },
- {
- "id > 15 chars",
- `{"id":"a234567890123456"}`,
- newCollection(),
- true,
- },
- {
- "id = 15 chars (invalid chars)",
- `{"id":"a@3456789012345"}`,
- newCollection(),
- true,
- },
- {
- "id = 15 chars (valid chars)",
- `{"id":"a23456789012345"}`,
- newCollection(),
- false,
- },
- {
- "changing the id of an existing item",
- `{"id":"b23456789012345"}`,
- existingCollection,
- true,
- },
- {
- "using the same existing item id",
- `{"id":"` + existingCollection.Id + `"}`,
- existingCollection,
- false,
- },
- {
- "skipping the id for existing item",
- `{}`,
- existingCollection,
- false,
- },
- }
-
- for _, s := range scenarios {
- form := forms.NewCollectionUpsert(app, s.collection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
- continue
- }
-
- submitErr := form.Submit()
- hasErr := submitErr != nil
-
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
- }
-
- if !hasErr && form.Id != "" {
- _, err := app.Dao().FindCollectionByNameOrId(form.Id)
- if err != nil {
- t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
- }
- }
- }
-}
diff --git a/forms/collections_import.go b/forms/collections_import.go
deleted file mode 100644
index 70d8be822d..0000000000
--- a/forms/collections_import.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package forms
-
-import (
- "encoding/json"
- "fmt"
- "log"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// CollectionsImport is a form model to bulk import
-// (create, replace and delete) collections from a user provided list.
-type CollectionsImport struct {
- app core.App
- dao *daos.Dao
-
- Collections []*models.Collection `form:"collections" json:"collections"`
- DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
-}
-
-// NewCollectionsImport creates a new [CollectionsImport] form with
-// initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewCollectionsImport(app core.App) *CollectionsImport {
- return &CollectionsImport{
- app: app,
- dao: app.Dao(),
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *CollectionsImport) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *CollectionsImport) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Collections, validation.Required),
- )
-}
-
-// Submit applies the import, aka.:
-// - imports the form collections (create or replace)
-// - sync the collection changes with their related records table
-// - ensures the integrity of the imported structure (aka. run validations for each collection)
-// - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list
-//
-// All operations are wrapped in a single transaction that are
-// rollbacked on the first encountered error.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return runInterceptors(form.Collections, func(collections []*models.Collection) error {
- return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
- importErr := txDao.ImportCollections(
- collections,
- form.DeleteMissing,
- form.afterSync,
- )
- if importErr == nil {
- return nil
- }
-
- // validation failure
- if err, ok := importErr.(validation.Errors); ok {
- return err
- }
-
- // generic/db failure
- if form.app.IsDebug() {
- log.Println("Internal import failure:", importErr)
- }
- return validation.Errors{"collections": validation.NewError(
- "collections_import_failure",
- "Failed to import the collections configuration.",
- )}
- })
- }, interceptors...)
-}
-
-func (form *CollectionsImport) afterSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
- // refresh the actual persisted collections list
- refreshedCollections := []*models.Collection{}
- if err := txDao.CollectionQuery().OrderBy("updated ASC").All(&refreshedCollections); err != nil {
- return err
- }
-
- // trigger the validator for each existing collection to
- // ensure that the app is not left in a broken state
- for _, collection := range refreshedCollections {
- upsertModel := mappedOld[collection.GetId()]
- if upsertModel == nil {
- upsertModel = collection
- }
- upsertModel.MarkAsNotNew()
-
- upsertForm := NewCollectionUpsert(form.app, upsertModel)
- upsertForm.SetDao(txDao)
-
- // load form fields with the refreshed collection state
- upsertForm.Id = collection.Id
- upsertForm.Type = collection.Type
- upsertForm.Name = collection.Name
- upsertForm.System = collection.System
- upsertForm.ListRule = collection.ListRule
- upsertForm.ViewRule = collection.ViewRule
- upsertForm.CreateRule = collection.CreateRule
- upsertForm.UpdateRule = collection.UpdateRule
- upsertForm.DeleteRule = collection.DeleteRule
- upsertForm.Schema = collection.Schema
- upsertForm.Options = collection.Options
-
- if err := upsertForm.Validate(); err != nil {
- // serialize the validation error(s)
- serializedErr, _ := json.MarshalIndent(err, "", " ")
-
- return validation.Errors{"collections": validation.NewError(
- "collections_import_validate_failure",
- fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr),
- )}
- }
- }
-
- return nil
-}
diff --git a/forms/collections_import_test.go b/forms/collections_import_test.go
deleted file mode 100644
index 0d740e70d5..0000000000
--- a/forms/collections_import_test.go
+++ /dev/null
@@ -1,504 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestCollectionsImportValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewCollectionsImport(app)
-
- scenarios := []struct {
- collections []*models.Collection
- expectError bool
- }{
- {nil, true},
- {[]*models.Collection{}, true},
- {[]*models.Collection{{}}, false},
- }
-
- for i, s := range scenarios {
- form.Collections = s.collections
-
- err := form.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestCollectionsImportSubmit(t *testing.T) {
- totalCollections := 11
-
- scenarios := []struct {
- name string
- jsonData string
- expectError bool
- expectCollectionsCount int
- expectEvents map[string]int
- }{
- {
- name: "empty collections",
- jsonData: `{
- "deleteMissing": true,
- "collections": []
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: nil,
- },
- {
- name: "one of the collections has invalid data",
- jsonData: `{
- "collections": [
- {
- "name": "import1",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "name": "import 2",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: map[string]int{
- "OnModelBeforeCreate": 2,
- },
- },
- {
- name: "test empty base collection schema",
- jsonData: `{
- "collections": [
- {
- "name": "import1"
- },
- {
- "name": "import2",
- "type": "auth"
- }
- ]
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: map[string]int{
- "OnModelBeforeCreate": 2,
- },
- },
- {
- name: "all imported collections has valid data",
- jsonData: `{
- "collections": [
- {
- "name": "import1",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "name": "import2",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "name": "import3",
- "type": "auth"
- }
- ]
- }`,
- expectError: false,
- expectCollectionsCount: totalCollections + 3,
- expectEvents: map[string]int{
- "OnModelBeforeCreate": 3,
- "OnModelAfterCreate": 3,
- },
- },
- {
- name: "new collection with existing name",
- jsonData: `{
- "collections": [
- {
- "name": "demo2",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- },
- },
- {
- name: "delete system + modified + new collection",
- jsonData: `{
- "deleteMissing": true,
- "collections": [
- {
- "id":"sz5l5z67tg7gku0",
- "name":"demo2",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- }
- ]
- },
- {
- "name": "import1",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: map[string]int{
- "OnModelBeforeDelete": 4,
- },
- },
- {
- name: "modified + new collection",
- jsonData: `{
- "collections": [
- {
- "id":"sz5l5z67tg7gku0",
- "name":"demo2_rename",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title_new",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- }
- ]
- },
- {
- "name": "import1",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "name": "import2",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: false,
- expectCollectionsCount: totalCollections + 2,
- expectEvents: map[string]int{
- "OnModelBeforeUpdate": 1,
- "OnModelAfterUpdate": 1,
- "OnModelBeforeCreate": 2,
- "OnModelAfterCreate": 2,
- },
- },
- {
- name: "delete non-system + modified + new collection",
- jsonData: `{
- "deleteMissing": true,
- "collections": [
- {
- "id": "kpv709sk2lqbqk8",
- "system": true,
- "name": "nologin",
- "type": "auth",
- "options": {
- "allowEmailAuth": false,
- "allowOAuth2Auth": false,
- "allowUsernameAuth": false,
- "exceptEmailDomains": [],
- "manageRule": "@request.auth.collectionName = 'users'",
- "minPasswordLength": 8,
- "onlyEmailDomains": [],
- "requireEmail": true
- },
- "listRule": "",
- "viewRule": "",
- "createRule": "",
- "updateRule": "",
- "deleteRule": "",
- "schema": [
- {
- "id": "x8zzktwe",
- "name": "name",
- "type": "text",
- "system": false,
- "required": false,
- "unique": false,
- "options": {
- "min": null,
- "max": null,
- "pattern": ""
- }
- }
- ]
- },
- {
- "id":"sz5l5z67tg7gku0",
- "name":"demo2",
- "schema":[
- {
- "id":"_2hlxbmp",
- "name":"title",
- "type":"text",
- "system":false,
- "required":true,
- "unique":false,
- "options":{
- "min":3,
- "max":null,
- "pattern":""
- }
- }
- ]
- },
- {
- "id": "test_deleted_collection_name_reuse",
- "name": "demo1",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: false,
- expectCollectionsCount: 3,
- expectEvents: map[string]int{
- "OnModelBeforeUpdate": 2,
- "OnModelAfterUpdate": 2,
- "OnModelBeforeCreate": 1,
- "OnModelAfterCreate": 1,
- "OnModelBeforeDelete": totalCollections - 2,
- "OnModelAfterDelete": totalCollections - 2,
- },
- },
- {
- name: "lazy system table name error",
- jsonData: `{
- "collections": [
- {
- "name": "_admins",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- }
- ]
- }`,
- expectError: true,
- expectCollectionsCount: totalCollections,
- expectEvents: map[string]int{
- "OnModelBeforeCreate": 1,
- },
- },
- {
- name: "lazy view evaluation",
- jsonData: `{
- "collections": [
- {
- "name": "view_before",
- "type": "view",
- "options": {
- "query": "select id, active from base_test"
- }
- },
- {
- "name": "base_test",
- "schema": [
- {
- "id":"fz6iql2m",
- "name":"active",
- "type":"bool"
- }
- ]
- },
- {
- "name": "view_after_new",
- "type": "view",
- "options": {
- "query": "select id, active from base_test"
- }
- },
- {
- "name": "view_after_old",
- "type": "view",
- "options": {
- "query": "select id from demo1"
- }
- }
- ]
- }`,
- expectError: false,
- expectCollectionsCount: totalCollections + 4,
- expectEvents: map[string]int{
- "OnModelBeforeUpdate": 3,
- "OnModelAfterUpdate": 3,
- "OnModelBeforeCreate": 4,
- "OnModelAfterCreate": 4,
- },
- },
- }
-
- for _, s := range scenarios {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- form := forms.NewCollectionsImport(testApp)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
- continue
- }
-
- err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
- }
-
- // check collections count
- collections := []*models.Collection{}
- if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
- t.Fatal(err)
- }
- if len(collections) != s.expectCollectionsCount {
- t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
- }
-
- // check events
- if len(testApp.EventCalls) > len(s.expectEvents) {
- t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
- }
- for event, expectedCalls := range s.expectEvents {
- actualCalls := testApp.EventCalls[event]
- if actualCalls != expectedCalls {
- t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
- }
- }
- }
-}
-
-func TestCollectionsImportSubmitInterceptors(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collections := []*models.Collection{}
- if err := app.Dao().CollectionQuery().All(&collections); err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewCollectionsImport(app)
- form.Collections = collections
-
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
- return func(imports []*models.Collection) error {
- interceptor1Called = true
- return next(imports)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
- return func(imports []*models.Collection) error {
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-}
diff --git a/forms/realtime_subscribe.go b/forms/realtime_subscribe.go
deleted file mode 100644
index fc852fc807..0000000000
--- a/forms/realtime_subscribe.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
-)
-
-// RealtimeSubscribe is a realtime subscriptions request form.
-type RealtimeSubscribe struct {
- ClientId string `form:"clientId" json:"clientId"`
- Subscriptions []string `form:"subscriptions" json:"subscriptions"`
-}
-
-// NewRealtimeSubscribe creates new RealtimeSubscribe request form.
-func NewRealtimeSubscribe() *RealtimeSubscribe {
- return &RealtimeSubscribe{}
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RealtimeSubscribe) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
- )
-}
diff --git a/forms/realtime_subscribe_test.go b/forms/realtime_subscribe_test.go
deleted file mode 100644
index d1df830b52..0000000000
--- a/forms/realtime_subscribe_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package forms_test
-
-import (
- "strings"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
-)
-
-func TestRealtimeSubscribeValidate(t *testing.T) {
- scenarios := []struct {
- clientId string
- expectError bool
- }{
- {"", true},
- {strings.Repeat("a", 256), true},
- {"test", false},
- }
-
- for i, s := range scenarios {
- form := forms.NewRealtimeSubscribe()
- form.ClientId = s.clientId
-
- err := form.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
diff --git a/forms/record_email_change_confirm.go b/forms/record_email_change_confirm.go
deleted file mode 100644
index 37a7419bdb..0000000000
--- a/forms/record_email_change_confirm.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// RecordEmailChangeConfirm is an auth record email change confirmation form.
-type RecordEmailChangeConfirm struct {
- app core.App
- dao *daos.Dao
- collection *models.Collection
-
- Token string `form:"token" json:"token"`
- Password string `form:"password" json:"password"`
-}
-
-// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
-// initialized with from the provided [core.App] and [models.Collection] instances.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
- return &RecordEmailChangeConfirm{
- app: app,
- dao: app.Dao(),
- collection: collection,
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordEmailChangeConfirm) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Token,
- validation.Required,
- validation.By(form.checkToken),
- ),
- validation.Field(
- &form.Password,
- validation.Required,
- validation.Length(1, 100),
- validation.By(form.checkPassword),
- ),
- )
-}
-
-func (form *RecordEmailChangeConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- authRecord, _, err := form.parseToken(v)
- if err != nil {
- return err
- }
-
- if authRecord.Collection().Id != form.collection.Id {
- return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
- }
-
- return nil
-}
-
-func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- authRecord, _, _ := form.parseToken(form.Token)
- if authRecord == nil || !authRecord.ValidatePassword(v) {
- return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
- }
-
- return nil
-}
-
-func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) {
- // check token payload
- claims, _ := security.ParseUnverifiedJWT(token)
- newEmail, _ := claims["newEmail"].(string)
- if newEmail == "" {
- return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
- }
-
- // ensure that there aren't other users with the new email
- if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) {
- return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
- }
-
- // verify that the token is not expired and its signature is valid
- authRecord, err := form.dao.FindAuthRecordByToken(
- token,
- form.app.Settings().RecordEmailChangeToken.Secret,
- )
- if err != nil || authRecord == nil {
- return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- return authRecord, newEmail, nil
-}
-
-// Submit validates and submits the auth record email change confirmation form.
-// On success returns the updated auth record associated to `form.Token`.
-//
-// You can optionally provide a list of InterceptorFunc to
-// further modify the form behavior before persisting it.
-func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- authRecord, newEmail, err := form.parseToken(form.Token)
- if err != nil {
- return nil, err
- }
-
- authRecord.SetEmail(newEmail)
- authRecord.SetVerified(true)
- authRecord.RefreshTokenKey() // invalidate old tokens
-
- interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
- authRecord = m
- return form.dao.SaveRecord(m)
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return authRecord, nil
-}
diff --git a/forms/record_email_change_confirm_test.go b/forms/record_email_change_confirm_test.go
deleted file mode 100644
index e64b89c055..0000000000
--- a/forms/record_email_change_confirm_test.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty payload
- {"{}", []string{"token", "password"}},
- // empty data
- {
- `{"token": "", "password": ""}`,
- []string{"token", "password"},
- },
- // invalid token payload
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
- "password": "123456"
- }`,
- []string{"token", "password"},
- },
- // expired token
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
- "password": "123456"
- }`,
- []string{"token", "password"},
- },
- // existing new email
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
- "password": "1234567890"
- }`,
- []string{"token", "password"},
- },
- // wrong confirmation password
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
- "password": "123456"
- }`,
- []string{"password"},
- },
- // valid data
- {
- `{
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
- "password": "1234567890"
- }`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- record, err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if len(s.expectedErrors) > 0 {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- // parse errors
- errs, ok := err.(validation.Errors)
- if !ok && err != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, err)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
-
- if len(errs) > 0 {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- newEmail, _ := claims["newEmail"].(string)
-
- // check whether the user was updated
- // ---
- if record.Email() != newEmail {
- t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
- }
-
- if !record.Verified() {
- t.Errorf("(%d) Expected record to be verified, got false", i)
- }
-
- // shouldn't validate second time due to refreshed record token
- if err := form.Validate(); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
- form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
- form.Password = "1234567890"
- interceptorEmail := authRecord.Email()
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorEmail = record.Email()
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorEmail == authRecord.Email() {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
diff --git a/forms/record_email_change_request.go b/forms/record_email_change_request.go
deleted file mode 100644
index 9f29a74eed..0000000000
--- a/forms/record_email_change_request.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// RecordEmailChangeRequest is an auth record email change request form.
-type RecordEmailChangeRequest struct {
- app core.App
- dao *daos.Dao
- record *models.Record
-
- NewEmail string `form:"newEmail" json:"newEmail"`
-}
-
-// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
-// initialized with from the provided [core.App] and [models.Record] instances.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
- return &RecordEmailChangeRequest{
- app: app,
- dao: app.Dao(),
- record: record,
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordEmailChangeRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.NewEmail,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- validation.By(form.checkUniqueEmail),
- ),
- )
-}
-
-func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
- v, _ := value.(string)
-
- if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
- return validation.NewError("validation_record_email_exists", "User email already exists.")
- }
-
- return nil
-}
-
-// Submit validates and sends the change email request.
-//
-// You can optionally provide a list of InterceptorFunc to
-// further modify the form behavior before persisting it.
-func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return runInterceptors(form.record, func(m *models.Record) error {
- return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
- }, interceptors...)
-}
diff --git a/forms/record_email_change_request_test.go b/forms/record_email_change_request_test.go
deleted file mode 100644
index 4b19b503b9..0000000000
--- a/forms/record_email_change_request_test.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty payload
- {"{}", []string{"newEmail"}},
- // empty data
- {
- `{"newEmail": ""}`,
- []string{"newEmail"},
- },
- // invalid email
- {
- `{"newEmail": "invalid"}`,
- []string{"newEmail"},
- },
- // existing email token
- {
- `{"newEmail": "test2@example.com"}`,
- []string{"newEmail"},
- },
- // valid new email
- {
- `{"newEmail": "test_new@example.com"}`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewRecordEmailChangeRequest(testApp, user)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if len(s.expectedErrors) > 0 {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- // parse errors
- errs, ok := err.(validation.Errors)
- if !ok && err != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, err)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
-
- expectedMails := 1
- if len(s.expectedErrors) > 0 {
- expectedMails = 0
- }
- if testApp.TestMailer.TotalSend != expectedMails {
- t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
- }
- }
-}
-
-func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
- form.NewEmail = "test_new@example.com"
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-}
diff --git a/forms/record_oauth2_login.go b/forms/record_oauth2_login.go
deleted file mode 100644
index 38a42db304..0000000000
--- a/forms/record_oauth2_login.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package forms
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/security"
- "golang.org/x/oauth2"
-)
-
-// RecordOAuth2LoginData defines the OA
-type RecordOAuth2LoginData struct {
- ExternalAuth *models.ExternalAuth
- Record *models.Record
- OAuth2User *auth.AuthUser
- ProviderClient auth.Provider
-}
-
-// BeforeOAuth2RecordCreateFunc defines a callback function that will
-// be called before OAuth2 new Record creation.
-type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
-
-// RecordOAuth2Login is an auth record OAuth2 login form.
-type RecordOAuth2Login struct {
- app core.App
- dao *daos.Dao
- collection *models.Collection
-
- beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
-
- // Optional auth record that will be used if no external
- // auth relation is found (if it is from the same collection)
- loggedAuthRecord *models.Record
-
- // The name of the OAuth2 client provider (eg. "google")
- Provider string `form:"provider" json:"provider"`
-
- // The authorization code returned from the initial request.
- Code string `form:"code" json:"code"`
-
- // The code verifier sent with the initial request as part of the code_challenge.
- CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
-
- // The redirect url sent with the initial request.
- RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
-
- // Additional data that will be used for creating a new auth record
- // if an existing OAuth2 account doesn't exist.
- CreateData map[string]any `form:"createData" json:"createData"`
-}
-
-// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
-// initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
- form := &RecordOAuth2Login{
- app: app,
- dao: app.Dao(),
- collection: collection,
- loggedAuthRecord: optAuthRecord,
- }
-
- return form
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
-func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
- form.beforeOAuth2RecordCreateFunc = f
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordOAuth2Login) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
- validation.Field(&form.Code, validation.Required),
- validation.Field(&form.CodeVerifier, validation.Required),
- validation.Field(&form.RedirectUrl, validation.Required, is.URL),
- )
-}
-
-func (form *RecordOAuth2Login) checkProviderName(value any) error {
- name, _ := value.(string)
-
- config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
- if !ok || !config.Enabled {
- return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-//
-// If an auth record doesn't exist, it will make an attempt to create it
-// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
-// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
-//
-// You can also optionally provide a list of InterceptorFunc to
-// further modify the form behavior before persisting it.
-//
-// On success returns the authorized record model and the fetched provider's data.
-func (form *RecordOAuth2Login) Submit(
- interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
-) (*models.Record, *auth.AuthUser, error) {
- if err := form.Validate(); err != nil {
- return nil, nil, err
- }
-
- if !form.collection.AuthOptions().AllowOAuth2Auth {
- return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
- }
-
- provider, err := auth.NewProviderByName(form.Provider)
- if err != nil {
- return nil, nil, err
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- provider.SetContext(ctx)
-
- // load provider configuration
- providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
- if err := providerConfig.SetupProvider(provider); err != nil {
- return nil, nil, err
- }
-
- provider.SetRedirectUrl(form.RedirectUrl)
-
- // fetch token
- token, err := provider.FetchToken(
- form.Code,
- oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
- )
- if err != nil {
- return nil, nil, err
- }
-
- // fetch external auth user
- authUser, err := provider.FetchAuthUser(token)
- if err != nil {
- return nil, nil, err
- }
-
- var authRecord *models.Record
-
- // check for existing relation with the auth record
- rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
- switch {
- case rel != nil:
- authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
- if err != nil {
- return nil, authUser, err
- }
- case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
- // fallback to the logged auth record (if any)
- authRecord = form.loggedAuthRecord
- case authUser.Email != "":
- // look for an existing auth record by the external auth record's email
- authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
- }
-
- interceptorData := &RecordOAuth2LoginData{
- ExternalAuth: rel,
- Record: authRecord,
- OAuth2User: authUser,
- ProviderClient: provider,
- }
-
- interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
- return form.submit(newData)
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorData.OAuth2User, interceptorsErr
- }
-
- return interceptorData.Record, interceptorData.OAuth2User, nil
-}
-
-func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
- return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
- if data.Record == nil {
- data.Record = models.NewRecord(form.collection)
- data.Record.RefreshId()
- data.Record.MarkAsNew()
- createForm := NewRecordUpsert(form.app, data.Record)
- createForm.SetFullManageAccess(true)
- createForm.SetDao(txDao)
- if data.OAuth2User.Username != "" &&
- len(data.OAuth2User.Username) >= 3 &&
- len(data.OAuth2User.Username) <= 150 &&
- usernameRegex.MatchString(data.OAuth2User.Username) {
- createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
- form.collection.Id,
- data.OAuth2User.Username,
- )
- }
-
- // load custom data
- createForm.LoadData(form.CreateData)
-
- // load the OAuth2 profile data as fallback
- if createForm.Email == "" {
- createForm.Email = data.OAuth2User.Email
- }
- createForm.Verified = false
- if createForm.Email == data.OAuth2User.Email {
- // mark as verified as long as it matches the OAuth2 data (even if the email is empty)
- createForm.Verified = true
- }
- if createForm.Password == "" {
- createForm.Password = security.RandomString(30)
- createForm.PasswordConfirm = createForm.Password
- }
-
- if form.beforeOAuth2RecordCreateFunc != nil {
- if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
- return err
- }
- }
-
- // create the new auth record
- if err := createForm.Submit(); err != nil {
- return err
- }
- } else {
- // update the existing auth record empty email if the data.OAuth2User has one
- // (this is in case previously the auth record was created
- // with an OAuth2 provider that didn't return an email address)
- if data.Record.Email() == "" && data.OAuth2User.Email != "" {
- data.Record.SetEmail(data.OAuth2User.Email)
- if err := txDao.SaveRecord(data.Record); err != nil {
- return err
- }
- }
-
- // update the existing auth record verified state
- // (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
- if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
- data.Record.SetVerified(true)
- if err := txDao.SaveRecord(data.Record); err != nil {
- return err
- }
- }
- }
-
- // create ExternalAuth relation if missing
- if data.ExternalAuth == nil {
- data.ExternalAuth = &models.ExternalAuth{
- CollectionId: data.Record.Collection().Id,
- RecordId: data.Record.Id,
- Provider: form.Provider,
- ProviderId: data.OAuth2User.Id,
- }
- if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
diff --git a/forms/record_oauth2_login_test.go b/forms/record_oauth2_login_test.go
deleted file mode 100644
index 637ed08377..0000000000
--- a/forms/record_oauth2_login_test.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestUserOauth2LoginValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- testName string
- collectionName string
- jsonData string
- expectedErrors []string
- }{
- {
- "empty payload",
- "users",
- "{}",
- []string{"provider", "code", "codeVerifier", "redirectUrl"},
- },
- {
- "empty data",
- "users",
- `{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
- []string{"provider", "code", "codeVerifier", "redirectUrl"},
- },
- {
- "missing provider",
- "users",
- `{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
- []string{"provider"},
- },
- {
- "disabled provider",
- "users",
- `{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
- []string{"provider"},
- },
- {
- "enabled provider",
- "users",
- `{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
- []string{},
- },
- }
-
- for _, s := range scenarios {
- authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
- if authCollection == nil {
- t.Errorf("[%s] Failed to fetch auth collection", s.testName)
- }
-
- form := forms.NewRecordOAuth2Login(app, authCollection, nil)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
- continue
- }
-
- err := form.Validate()
-
- // parse errors
- errs, ok := err.(validation.Errors)
- if !ok && err != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
- }
- }
- }
-}
-
-// @todo consider mocking a Oauth2 provider to test Submit
diff --git a/forms/record_password_login.go b/forms/record_password_login.go
deleted file mode 100644
index 85f8caaeb4..0000000000
--- a/forms/record_password_login.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package forms
-
-import (
- "database/sql"
- "errors"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// RecordPasswordLogin is record username/email + password login form.
-type RecordPasswordLogin struct {
- app core.App
- dao *daos.Dao
- collection *models.Collection
-
- Identity string `form:"identity" json:"identity"`
- Password string `form:"password" json:"password"`
-}
-
-// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
-// with from the provided [core.App] and [models.Collection] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
- return &RecordPasswordLogin{
- app: app,
- dao: app.Dao(),
- collection: collection,
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordPasswordLogin) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
- validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
- )
-}
-
-// Submit validates and submits the form.
-// On success returns the authorized record model.
-//
-// You can optionally provide a list of InterceptorFunc to
-// further modify the form behavior before persisting it.
-func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- authOptions := form.collection.AuthOptions()
-
- var authRecord *models.Record
- var fetchErr error
-
- isEmail := is.EmailFormat.Validate(form.Identity) == nil
-
- if isEmail {
- if authOptions.AllowEmailAuth {
- authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
- }
- } else if authOptions.AllowUsernameAuth {
- authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
- }
-
- // ignore not found errors to allow custom fetch implementations
- if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
- return nil, fetchErr
- }
-
- interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
- authRecord = m
-
- if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
- return errors.New("Invalid login credentials.")
- }
-
- return nil
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return authRecord, nil
-}
diff --git a/forms/record_password_login_test.go b/forms/record_password_login_test.go
deleted file mode 100644
index a7d5a1737e..0000000000
--- a/forms/record_password_login_test.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package forms_test
-
-import (
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- scenarios := []struct {
- testName string
- collectionName string
- identity string
- password string
- expectError bool
- }{
- {
- "empty data",
- "users",
- "",
- "",
- true,
- },
-
- // username
- {
- "existing username + wrong password",
- "users",
- "users75657",
- "invalid",
- true,
- },
- {
- "missing username + valid password",
- "users",
- "clients57772", // not in the "users" collection
- "1234567890",
- true,
- },
- {
- "existing username + valid password but in restricted username auth collection",
- "clients",
- "clients57772",
- "1234567890",
- true,
- },
- {
- "existing username + valid password but in restricted username and email auth collection",
- "nologin",
- "test_username",
- "1234567890",
- true,
- },
- {
- "existing username + valid password",
- "users",
- "users75657",
- "1234567890",
- false,
- },
-
- // email
- {
- "existing email + wrong password",
- "users",
- "test@example.com",
- "invalid",
- true,
- },
- {
- "missing email + valid password",
- "users",
- "test_missing@example.com",
- "1234567890",
- true,
- },
- {
- "existing username + valid password but in restricted username auth collection",
- "clients",
- "test@example.com",
- "1234567890",
- false,
- },
- {
- "existing username + valid password but in restricted username and email auth collection",
- "nologin",
- "test@example.com",
- "1234567890",
- true,
- },
- {
- "existing email + valid password",
- "users",
- "test@example.com",
- "1234567890",
- false,
- },
- }
-
- for _, s := range scenarios {
- authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
- if err != nil {
- t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
- }
-
- form := forms.NewRecordPasswordLogin(testApp, authCollection)
- form.Identity = s.identity
- form.Password = s.password
-
- record, err := form.Submit()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
- continue
- }
-
- if hasErr {
- continue
- }
-
- if record.Email() != s.identity && record.Username() != s.identity {
- t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
- }
- }
-}
-
-func TestRecordPasswordLoginInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordPasswordLogin(testApp, authCollection)
- form.Identity = "test@example.com"
- form.Password = "123456"
- var interceptorRecord *models.Record
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorRecord = record
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
- t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
- }
-}
diff --git a/forms/record_password_reset_confirm.go b/forms/record_password_reset_confirm.go
deleted file mode 100644
index e79a21f0e9..0000000000
--- a/forms/record_password_reset_confirm.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// RecordPasswordResetConfirm is an auth record password reset confirmation form.
-type RecordPasswordResetConfirm struct {
- app core.App
- collection *models.Collection
- dao *daos.Dao
-
- Token string `form:"token" json:"token"`
- Password string `form:"password" json:"password"`
- PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
-}
-
-// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
- return &RecordPasswordResetConfirm{
- app: app,
- dao: app.Dao(),
- collection: collection,
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordPasswordResetConfirm) Validate() error {
- minPasswordLength := form.collection.AuthOptions().MinPasswordLength
-
- return validation.ValidateStruct(form,
- validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
- validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
- )
-}
-
-func (form *RecordPasswordResetConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- record, err := form.dao.FindAuthRecordByToken(
- v,
- form.app.Settings().RecordPasswordResetToken.Secret,
- )
- if err != nil || record == nil {
- return validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- if record.Collection().Id != form.collection.Id {
- return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-// On success returns the updated auth record associated to `form.Token`.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- authRecord, err := form.dao.FindAuthRecordByToken(
- form.Token,
- form.app.Settings().RecordPasswordResetToken.Secret,
- )
- if err != nil {
- return nil, err
- }
-
- if err := authRecord.SetPassword(form.Password); err != nil {
- return nil, err
- }
-
- interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
- authRecord = m
- return form.dao.SaveRecord(m)
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return authRecord, nil
-}
diff --git a/forms/record_password_reset_confirm_test.go b/forms/record_password_reset_confirm_test.go
deleted file mode 100644
index 2012e346c1..0000000000
--- a/forms/record_password_reset_confirm_test.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectedErrors []string
- }{
- // empty data (Validate call check)
- {
- `{}`,
- []string{"token", "password", "passwordConfirm"},
- },
- // expired token
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- []string{"token"},
- },
- // valid token but invalid passwords lengths
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"1234567",
- "passwordConfirm":"1234567"
- }`,
- []string{"password"},
- },
- // valid token but mismatched passwordConfirm
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345679"
- }`,
- []string{"passwordConfirm"},
- },
- // valid token and password
- {
- `{
- "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
- "password":"12345678",
- "passwordConfirm":"12345678"
- }`,
- []string{},
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- record, submitErr := form.Submit(interceptor)
-
- // parse errors
- errs, ok := submitErr.(validation.Errors)
- if !ok && submitErr != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
- continue
- }
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if len(s.expectedErrors) > 0 {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
-
- if len(errs) > 0 || len(s.expectedErrors) > 0 {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- tokenRecordId := claims["id"]
-
- if record.Id != tokenRecordId {
- t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
- }
-
- if !record.LastResetSentAt().IsZero() {
- t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
- }
-
- if !record.ValidatePassword(form.Password) {
- t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
- }
- }
-}
-
-func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
- form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
- form.Password = "1234567890"
- form.PasswordConfirm = "1234567890"
- interceptorTokenKey := authRecord.TokenKey()
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorTokenKey = record.TokenKey()
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorTokenKey == authRecord.TokenKey() {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
diff --git a/forms/record_password_reset_request.go b/forms/record_password_reset_request.go
deleted file mode 100644
index 286878edc6..0000000000
--- a/forms/record_password_reset_request.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package forms
-
-import (
- "errors"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// RecordPasswordResetRequest is an auth record reset password request form.
-type RecordPasswordResetRequest struct {
- app core.App
- dao *daos.Dao
- collection *models.Collection
- resendThreshold float64 // in seconds
-
- Email string `form:"email" json:"email"`
-}
-
-// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest {
- return &RecordPasswordResetRequest{
- app: app,
- dao: app.Dao(),
- collection: collection,
- resendThreshold: 120, // 2 min
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-//
-// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
-func (form *RecordPasswordResetRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- ),
- )
-}
-
-// Submit validates and submits the form.
-// On success, sends a password reset email to the `form.Email` auth record.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
- if err != nil {
- return err
- }
-
- now := time.Now().UTC()
- lastResetSentAt := authRecord.LastResetSentAt().Time()
- if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
- return errors.New("You've already requested a password reset.")
- }
-
- return runInterceptors(authRecord, func(m *models.Record) error {
- if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
- return err
- }
-
- // update last sent timestamp
- m.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
-
- return form.dao.SaveRecord(m)
- }, interceptors...)
-}
diff --git a/forms/record_password_reset_request_test.go b/forms/record_password_reset_request_test.go
deleted file mode 100644
index 1755282ef6..0000000000
--- a/forms/record_password_reset_request_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
- "time"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestRecordPasswordResetRequestSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectError bool
- }{
- // empty field (Validate call check)
- {
- `{"email":""}`,
- true,
- },
- // invalid email field (Validate call check)
- {
- `{"email":"invalid"}`,
- true,
- },
- // nonexisting user
- {
- `{"email":"missing@example.com"}`,
- true,
- },
- // existing user
- {
- `{"email":"test@example.com"}`,
- false,
- },
- // existing user - reached send threshod
- {
- `{"email":"test@example.com"}`,
- true,
- },
- }
-
- now := types.NowDateTime()
- time.Sleep(1 * time.Millisecond)
-
- for i, s := range scenarios {
- testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if s.expectError {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- expectedMails := 1
- if s.expectError {
- expectedMails = 0
- }
- if testApp.TestMailer.TotalSend != expectedMails {
- t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
- }
-
- if s.expectError {
- continue
- }
-
- // check whether LastResetSentAt was updated
- user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
- if err != nil {
- t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
- continue
- }
-
- if user.LastResetSentAt().Time().Sub(now.Time()) < 0 {
- t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt())
- }
- }
-}
-
-func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
- form.Email = authRecord.Email()
- interceptorLastResetSentAt := authRecord.LastResetSentAt()
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorLastResetSentAt = record.LastResetSentAt()
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorLastResetSentAt.String() != authRecord.LastResetSentAt().String() {
- t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
- }
-}
diff --git a/forms/record_upsert.go b/forms/record_upsert.go
index 43d660c968..16df0ecea6 100644
--- a/forms/record_upsert.go
+++ b/forms/record_upsert.go
@@ -1,918 +1,316 @@
package forms
import (
- "encoding/json"
+ "context"
"errors"
"fmt"
- "log"
- "net/http"
- "regexp"
- "strings"
+ "slices"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
-// username value regex pattern
-var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
+const (
+ accessLevelDefault = iota
+ accessLevelManager
+ accessLevelSuperuser
+)
-// RecordUpsert is a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
- app core.App
- dao *daos.Dao
- manageAccess bool
- record *models.Record
-
- filesToUpload map[string][]*filesystem.File
- filesToDelete []string // names list
-
- // base model fields
- Id string `json:"id"`
-
- // auth collection fields
- // ---
- Username string `json:"username"`
- Email string `json:"email"`
- EmailVisibility bool `json:"emailVisibility"`
- Verified bool `json:"verified"`
- Password string `json:"password"`
- PasswordConfirm string `json:"passwordConfirm"`
- OldPassword string `json:"oldPassword"`
- // ---
-
- data map[string]any
+ ctx context.Context
+ app core.App
+ record *core.Record
+ accessLevel int
+
+ // extra password fields
+ disablePasswordValidations bool
+ password string
+ passwordConfirm string
+ oldPassword string
}
-// NewRecordUpsert creates a new [RecordUpsert] form with initializer
-// config created from the provided [core.App] and [models.Record] instances
-// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
+// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
+// (for create you could pass a pointer to an empty Record - core.NewRecord(collection)).
+func NewRecordUpsert(app core.App, record *core.Record) *RecordUpsert {
form := &RecordUpsert{
- app: app,
- dao: app.Dao(),
- record: record,
- filesToDelete: []string{},
- filesToUpload: map[string][]*filesystem.File{},
+ ctx: context.Background(),
+ app: app,
+ record: record,
}
- form.loadFormDefaults()
-
return form
}
-// Data returns the loaded form's data.
-func (form *RecordUpsert) Data() map[string]any {
- return form.data
+// SetContext assigns ctx as context of the current form.
+func (form *RecordUpsert) SetContext(ctx context.Context) {
+ form.ctx = ctx
}
-// SetFullManageAccess sets the manageAccess bool flag of the current
-// form to enable/disable directly changing some system record fields
-// (often used with auth collection records).
-func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) {
- form.manageAccess = fullManageAccess
+// SetApp replaces the current form app instance.
+//
+// This could be used for example if you want to change at later stage
+// before submission to change from regular -> transactional app instance.
+func (form *RecordUpsert) SetApp(app core.App) {
+ form.app = app
}
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordUpsert) SetDao(dao *daos.Dao) {
- form.dao = dao
+// SetRecord replaces the current form record instance.
+func (form *RecordUpsert) SetRecord(record *core.Record) {
+ form.record = record
}
-func (form *RecordUpsert) loadFormDefaults() {
- form.Id = form.record.Id
-
- if form.record.Collection().IsAuth() {
- form.Username = form.record.Username()
- form.Email = form.record.Email()
- form.EmailVisibility = form.record.EmailVisibility()
- form.Verified = form.record.Verified()
- }
-
- form.data = map[string]any{}
- for _, field := range form.record.Collection().Schema.Fields() {
- form.data[field.Name] = form.record.Get(field.Name)
- }
+// ResetAccess resets the form access level to the accessLevelDefault.
+func (form *RecordUpsert) ResetAccess() {
+ form.accessLevel = accessLevelDefault
}
-func (form *RecordUpsert) getContentType(r *http.Request) string {
- t := r.Header.Get("Content-Type")
- for i, c := range t {
- if c == ' ' || c == ';' {
- return t[:i]
- }
- }
- return t
+// GrantManagerAccess updates the form access level to "manager" allowing
+// directly changing some system record fields (often used with auth collection records).
+func (form *RecordUpsert) GrantManagerAccess() {
+ form.accessLevel = accessLevelManager
}
-func (form *RecordUpsert) extractRequestInfo(
- r *http.Request,
- keyPrefix string,
-) (map[string]any, map[string][]*filesystem.File, error) {
- switch form.getContentType(r) {
- case "application/json":
- return form.extractJsonData(r, keyPrefix)
- case "multipart/form-data":
- return form.extractMultipartFormData(r, keyPrefix)
- default:
- return nil, nil, errors.New("unsupported request content-type")
- }
+// GrantSuperuserAccess updates the form access level to "superuser" allowing
+// directly changing all system record fields, including those marked as "Hidden".
+func (form *RecordUpsert) GrantSuperuserAccess() {
+ form.accessLevel = accessLevelSuperuser
}
-func (form *RecordUpsert) extractJsonData(
- r *http.Request,
- keyPrefix string,
-) (map[string]any, map[string][]*filesystem.File, error) {
- data := map[string]any{}
-
- err := rest.CopyJsonBody(r, &data)
-
- if keyPrefix != "" {
- parts := strings.Split(keyPrefix, ".")
- for _, part := range parts {
- if data[part] == nil {
- break
- }
- if v, ok := data[part].(map[string]any); ok {
- data = v
- }
- }
- }
-
- return data, nil, err
+// HasManageAccess reports whether the form has "manager" or "superuser" level access.
+func (form *RecordUpsert) HasManageAccess() bool {
+ return form.accessLevel == accessLevelManager || form.accessLevel == accessLevelSuperuser
}
-func (form *RecordUpsert) extractMultipartFormData(
- r *http.Request,
- keyPrefix string,
-) (map[string]any, map[string][]*filesystem.File, error) {
- // parse form data (if not already)
- if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
- return nil, nil, err
- }
-
- data := map[string]any{}
- filesToUpload := map[string][]*filesystem.File{}
- arrayValueSupportTypes := schema.ArraybleFieldTypes()
-
- for fullKey, values := range r.PostForm {
- key := fullKey
- if keyPrefix != "" {
- key = strings.TrimPrefix(key, keyPrefix+".")
- }
-
- if len(values) == 0 {
- data[key] = nil
- continue
- }
+// Load loads the provided data into the form and the related record.
+func (form *RecordUpsert) Load(data map[string]any) {
+ excludeFields := []string{core.FieldNameExpand}
- field := form.record.Collection().Schema.GetFieldByName(key)
- if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
- data[key] = values
- } else {
- data[key] = values[0]
- }
- }
+ isAuth := form.record.Collection().IsAuth()
- // load uploaded files (if any)
- for _, field := range form.record.Collection().Schema.Fields() {
- if field.Type != schema.FieldTypeFile {
- continue // not a file field
+ // load the special auth form fields
+ if isAuth {
+ if v, ok := data["password"]; ok {
+ form.password = cast.ToString(v)
}
-
- key := field.Name
- fullKey := key
- if keyPrefix != "" {
- fullKey = keyPrefix + "." + key
+ if v, ok := data["passwordConfirm"]; ok {
+ form.passwordConfirm = cast.ToString(v)
}
-
- files, err := rest.FindUploadedFiles(r, fullKey)
- if err != nil || len(files) == 0 {
- if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
- log.Printf("%q uploaded file error: %v\n", fullKey, err)
- }
-
- // skip invalid or missing file(s)
- continue
+ if v, ok := data["oldPassword"]; ok {
+ form.oldPassword = cast.ToString(v)
}
- filesToUpload[key] = append(filesToUpload[key], files...)
+ excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
}
- return data, filesToUpload, nil
-}
-
-// LoadRequest extracts the json or multipart/form-data request data
-// and lods it into the form.
-//
-// File upload is supported only via multipart/form-data.
-func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
- requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix)
- if err != nil {
- return err
- }
-
- if err := form.LoadData(requestInfo); err != nil {
- return err
- }
-
- for key, files := range uploadedFiles {
- form.AddFiles(key, files...)
- }
-
- return nil
-}
-
-// FilesToUpload returns the parsed request files ready for upload.
-func (form *RecordUpsert) FilesToUpload() map[string][]*filesystem.File {
- return form.filesToUpload
-}
-
-// FilesToUpload returns the parsed request filenames ready to be deleted.
-func (form *RecordUpsert) FilesToDelete() []string {
- return form.filesToDelete
-}
-
-// AddFiles adds the provided file(s) to the specified file field.
-//
-// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
-// then the newly added file will REPLACE the existing one.
-// In this case if you pass more than 1 files only the first one will be assigned.
-//
-// If the file field is a MULTI-value file field (aka. "Max Select > 1"),
-// then the newly added file(s) will be APPENDED to the existing one(s).
-//
-// Example
-//
-// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
-// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
-// form.AddFiles("documents", f1, f2)
-func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
- field := form.record.Collection().Schema.GetFieldByName(key)
- if field == nil || field.Type != schema.FieldTypeFile {
- return errors.New("invalid field key")
- }
-
- options, ok := field.Options.(*schema.FileOptions)
- if !ok {
- return errors.New("failed to initilize field options")
- }
-
- if len(files) == 0 {
- return nil // nothing to upload
- }
-
- if form.filesToUpload == nil {
- form.filesToUpload = map[string][]*filesystem.File{}
- }
-
- oldNames := list.ToUniqueStringSlice(form.data[key])
-
- if options.MaxSelect == 1 {
- // mark previous file(s) for deletion before replacing
- if len(oldNames) > 0 {
- form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
+ for k, v := range data {
+ if slices.Contains(excludeFields, k) {
+ continue
}
- // replace
- form.filesToUpload[key] = []*filesystem.File{files[0]}
- form.data[key] = field.PrepareValue(files[0].Name)
- } else {
- // append
- form.filesToUpload[key] = append(form.filesToUpload[key], files...)
- for _, f := range files {
- oldNames = append(oldNames, f.Name)
- }
- form.data[key] = field.PrepareValue(oldNames)
- }
+ // set only known collection fields
+ field := form.record.SetIfFieldExists(k, v)
- return nil
-}
-
-// RemoveFiles removes a single or multiple file from the specified file field.
-//
-// NB! If filesToDelete is not set it will remove all existing files
-// assigned to the file field (including those assigned with AddFiles)!
-//
-// Example
-//
-// // mark only only 2 files for removal
-// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
-//
-// // mark all "documents" files for removal
-// form.AddFiles("documents")
-func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
- field := form.record.Collection().Schema.GetFieldByName(key)
- if field == nil || field.Type != schema.FieldTypeFile {
- return errors.New("invalid field key")
- }
-
- existing := list.ToUniqueStringSlice(form.data[key])
-
- // mark all files for deletion
- if len(toDelete) == 0 {
- toDelete = make([]string, len(existing))
- copy(toDelete, existing)
- }
-
- // check for existing files
- for i := len(existing) - 1; i >= 0; i-- {
- if list.ExistInSlice(existing[i], toDelete) {
- form.filesToDelete = append(form.filesToDelete, existing[i])
- existing = append(existing[:i], existing[i+1:]...)
- }
- }
-
- // check for newly uploaded files
- for i := len(form.filesToUpload[key]) - 1; i >= 0; i-- {
- f := form.filesToUpload[key][i]
- if list.ExistInSlice(f.Name, toDelete) {
- form.filesToUpload[key] = append(form.filesToUpload[key][:i], form.filesToUpload[key][i+1:]...)
+ // restore original value if hidden field (with exception of the auth "password")
+ //
+ // note: this is an extra measure against loading hidden fields
+ // but usually is not used by the default route handlers since
+ // they filter additionally the data before calling Load
+ if form.accessLevel != accessLevelSuperuser && field != nil && field.GetHidden() && (!isAuth || field.GetName() != core.FieldNamePassword) {
+ form.record.SetRaw(field.GetName(), form.record.Original().GetRaw(field.GetName()))
}
}
-
- form.data[key] = field.PrepareValue(existing)
-
- return nil
}
-// LoadData loads and normalizes the provided regular record data fields into the form.
-func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
- // load base system fields
- if v, ok := requestInfo[schema.FieldNameId]; ok {
- form.Id = cast.ToString(v)
- }
-
- // load auth system fields
- if form.record.Collection().IsAuth() {
- if v, ok := requestInfo[schema.FieldNameUsername]; ok {
- form.Username = cast.ToString(v)
- }
- if v, ok := requestInfo[schema.FieldNameEmail]; ok {
- form.Email = cast.ToString(v)
- }
- if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok {
- form.EmailVisibility = cast.ToBool(v)
- }
- if v, ok := requestInfo[schema.FieldNameVerified]; ok {
- form.Verified = cast.ToBool(v)
- }
- if v, ok := requestInfo["password"]; ok {
- form.Password = cast.ToString(v)
- }
- if v, ok := requestInfo["passwordConfirm"]; ok {
- form.PasswordConfirm = cast.ToString(v)
- }
- if v, ok := requestInfo["oldPassword"]; ok {
- form.OldPassword = cast.ToString(v)
- }
- }
-
- // replace modifiers (if any)
- requestInfo = form.record.ReplaceModifers(requestInfo)
-
- // create a shallow copy of form.data
- var extendedData = make(map[string]any, len(form.data))
- for k, v := range form.data {
- extendedData[k] = v
- }
-
- // extend form.data with the request data
- rawData, err := json.Marshal(requestInfo)
- if err != nil {
- return err
- }
- if err := json.Unmarshal(rawData, &extendedData); err != nil {
- return err
+func (form *RecordUpsert) validateFormFields() error {
+ isAuth := form.record.Collection().IsAuth()
+ if !isAuth {
+ return nil
}
- for _, field := range form.record.Collection().Schema.Fields() {
- key := field.Name
- value := field.PrepareValue(extendedData[key])
-
- if field.Type != schema.FieldTypeFile {
- form.data[key] = value
- continue
- }
-
- // -----------------------------------------------------------
- // Delete previously uploaded file(s)
- // -----------------------------------------------------------
-
- oldNames := form.record.GetStringSlice(key)
- submittedNames := list.ToUniqueStringSlice(value)
-
- // ensure that all submitted names are existing to prevent accidental files deletions
- if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
- return validation.Errors{
- key: validation.NewError(
- "validation_unknown_filenames",
- "The field contains unknown filenames.",
- ),
- }
- }
-
- // if empty value was set, mark all previously uploaded files for deletion
- // otherwise check for "deleted" (aka. unsubmitted) file names
- if len(submittedNames) == 0 && len(oldNames) > 0 {
- form.RemoveFiles(key)
- } else if len(oldNames) > 0 {
- toDelete := []string{}
-
- for _, name := range oldNames {
- // submitted as a modifier or a new array
- if !list.ExistInSlice(name, submittedNames) {
- toDelete = append(toDelete, name)
- continue
- }
- }
-
- if len(toDelete) > 0 {
- form.RemoveFiles(key, toDelete...)
- }
- }
+ form.syncPasswordFields()
- // allow file key reasignments for file names sorting
- // (only if all submitted values already exists)
- if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
- form.data[key] = submittedNames
- }
- }
+ isNew := form.record.IsNew()
- return nil
-}
+ original := form.record.Original()
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordUpsert) Validate() error {
- // base form fields validator
- baseFieldsRules := []*validation.FieldRules{
- validation.Field(
- &form.Id,
- validation.When(
- form.record.IsNew(),
- validation.Length(models.DefaultIdLength, models.DefaultIdLength),
- validation.Match(idRegex),
- validation.By(validators.UniqueId(form.dao, form.record.TableName())),
- ).Else(validation.In(form.record.Id)),
- ),
+ validateData := map[string]any{
+ "email": form.record.Email(),
+ "verified": form.record.Verified(),
+ "password": form.password,
+ "passwordConfirm": form.passwordConfirm,
+ "oldPassword": form.oldPassword,
}
- // auth fields validators
- if form.record.Collection().IsAuth() {
- baseFieldsRules = append(baseFieldsRules,
- validation.Field(
- &form.Username,
- // require only on update, because on create we fallback to auto generated username
- validation.When(!form.record.IsNew(), validation.Required),
- validation.Length(3, 150),
- validation.Match(usernameRegex),
- validation.By(form.checkUniqueUsername),
- ),
- validation.Field(
- &form.Email,
- validation.When(
- form.record.Collection().AuthOptions().RequireEmail,
- validation.Required,
- ),
- // don't allow direct email change (or unset) if the form doesn't have manage access permissions
+ return validation.Validate(validateData,
+ validation.Map(
+ validation.Key(
+ "email",
+ // don't allow direct email updates if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly update the field)
validation.When(
- !form.record.IsNew() && !form.manageAccess,
- validation.In(form.record.Email()),
+ !isNew && !form.HasManageAccess(),
+ validation.By(validators.Equal(original.Email())),
),
- validation.Length(1, 255),
- is.EmailFormat,
- validation.By(form.checkEmailDomain),
- validation.By(form.checkUniqueEmail),
),
- validation.Field(
- &form.Verified,
+ validation.Key(
+ "verified",
// don't allow changing verified if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly change the field)
validation.When(
- !form.manageAccess,
- validation.In(form.record.Verified()),
+ !form.HasManageAccess(),
+ validation.By(validators.Equal(original.Verified())),
),
),
- validation.Field(
- &form.Password,
+ validation.Key(
+ "password",
validation.When(
- (form.record.IsNew() || form.PasswordConfirm != "" || form.OldPassword != ""),
+ !form.disablePasswordValidations && (isNew || form.passwordConfirm != "" || form.oldPassword != ""),
validation.Required,
),
- validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
),
- validation.Field(
- &form.PasswordConfirm,
+ validation.Key(
+ "passwordConfirm",
validation.When(
- (form.record.IsNew() || form.Password != "" || form.OldPassword != ""),
+ !form.disablePasswordValidations && (isNew || form.password != "" || form.oldPassword != ""),
validation.Required,
),
- validation.By(validators.Compare(form.Password)),
+ validation.When(!form.disablePasswordValidations, validation.By(validators.Equal(form.password))),
),
- validation.Field(
- &form.OldPassword,
+ validation.Key(
+ "oldPassword",
// require old password only on update when:
- // - form.manageAccess is not set
+ // - form.HasManageAccess() is not satisfied
// - changing the existing password
validation.When(
- !form.record.IsNew() && !form.manageAccess && (form.Password != "" || form.PasswordConfirm != ""),
+ !form.disablePasswordValidations && !isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
validation.Required,
validation.By(form.checkOldPassword),
),
),
- )
- }
-
- if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil {
- return err
- }
-
- // record data validator
- return validators.NewRecordDataValidator(
- form.dao,
- form.record,
- form.filesToUpload,
- ).Validate(form.data)
-}
-
-func (form *RecordUpsert) checkUniqueUsername(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil
- }
-
- isUnique := form.dao.IsRecordValueUnique(
- form.record.Collection().Id,
- schema.FieldNameUsername,
- v,
- form.record.Id,
- )
- if !isUnique {
- return validation.NewError("validation_invalid_username", "The username is invalid or already in use.")
- }
-
- return nil
-}
-
-func (form *RecordUpsert) checkUniqueEmail(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil
- }
-
- isUnique := form.dao.IsRecordValueUnique(
- form.record.Collection().Id,
- schema.FieldNameEmail,
- v,
- form.record.Id,
+ ),
)
- if !isUnique {
- return validation.NewError("validation_invalid_email", "The email is invalid or already in use.")
- }
-
- return nil
-}
-
-func (form *RecordUpsert) checkEmailDomain(value any) error {
- val, _ := value.(string)
- if val == "" {
- return nil // nothing to check
- }
-
- domain := val[strings.LastIndex(val, "@")+1:]
- only := form.record.Collection().AuthOptions().OnlyEmailDomains
- except := form.record.Collection().AuthOptions().ExceptEmailDomains
-
- // only domains check
- if len(only) > 0 && !list.ExistInSlice(domain, only) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
- }
-
- // except domains check
- if len(except) > 0 && list.ExistInSlice(domain, except) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
- }
-
- return nil
}
func (form *RecordUpsert) checkOldPassword(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
+ v, ok := value.(string)
+ if !ok {
+ return validators.ErrUnsupportedValueType
}
- if !form.record.ValidatePassword(v) {
+ if !form.record.Original().ValidatePassword(v) {
return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
}
return nil
}
-func (form *RecordUpsert) ValidateAndFill() error {
- if err := form.Validate(); err != nil {
- return err
- }
-
+// Deprecated: It was previously used as part of the record create action but it is not needed anymore and will be removed in the future.
+//
+// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
+// For actual record persistence, check the [RecordUpsert.Submit()] method.
+//
+// This method doesn't perform validations, handle file uploads/deletes or trigger app save events!
+func (form *RecordUpsert) DrySubmit(callback func(txApp core.App, drySavedRecord *core.Record) error) error {
isNew := form.record.IsNew()
- // custom insertion id can be set only on create
- if isNew && form.Id != "" {
- form.record.SetId(form.Id)
- form.record.MarkAsNew()
- }
+ clone := form.record.Clone()
- // set auth fields
- if form.record.Collection().IsAuth() {
- // generate a default username during create (if missing)
- if form.record.IsNew() && form.Username == "" {
- baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789")
- form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername)
- }
+ // set an id if it doesn't have already
+ // (the value doesn't matter; it is used only during the manual delete/update rollback)
+ if clone.IsNew() && clone.Id == "" {
+ clone.Id = "_temp_" + security.PseudorandomString(15)
+ }
- if form.Username != "" {
- if err := form.record.SetUsername(form.Username); err != nil {
- return err
- }
- }
+ app := form.app.UnsafeWithoutHooks()
- if isNew || form.manageAccess {
- if err := form.record.SetEmail(form.Email); err != nil {
- return err
+ isTransactional := app.IsTransactional()
+ if !isTransactional {
+ return app.RunInTransaction(func(txApp core.App) error {
+ tx, ok := txApp.DB().(*dbx.Tx)
+ if !ok {
+ return errors.New("failed to get transaction db")
}
- }
+ defer tx.Rollback()
- if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil {
- return err
- }
-
- if form.manageAccess {
- if err := form.record.SetVerified(form.Verified); err != nil {
- return err
+ if err := txApp.SaveNoValidateWithContext(form.ctx, clone); err != nil {
+ return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
}
- }
- if form.Password != "" && form.Password == form.PasswordConfirm {
- if err := form.record.SetPassword(form.Password); err != nil {
- return err
+ if callback != nil {
+ return callback(txApp, clone)
}
- }
- }
-
- // bulk load the remaining form data
- form.record.Load(form.data)
-
- return nil
-}
-
-// DrySubmit performs a form submit within a transaction and reverts it.
-// For actual record persistence, check the `form.Submit()` method.
-//
-// This method doesn't handle file uploads/deletes or trigger any app events!
-func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
- isNew := form.record.IsNew()
- if err := form.ValidateAndFill(); err != nil {
- return err
+ return nil
+ })
}
- var dryDao *daos.Dao
- if form.dao.ConcurrentDB() == form.dao.NonconcurrentDB() {
- // it is already in a transaction and therefore use the app concurrent db pool
- // to prevent "transaction has already been committed or rolled back" error
- dryDao = daos.New(form.app.Dao().ConcurrentDB())
- } else {
- // otherwise use the form noncurrent dao db pool
- dryDao = daos.New(form.dao.NonconcurrentDB())
+ // already in a transaction
+ // (manual rollback to avoid starting another transaction)
+ // ---------------------------------------------------------------
+ err := app.SaveNoValidateWithContext(form.ctx, clone)
+ if err != nil {
+ return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
}
- return dryDao.RunInTransaction(func(txDao *daos.Dao) error {
- tx, ok := txDao.DB().(*dbx.Tx)
- if !ok {
- return errors.New("failed to get transaction db")
- }
- defer tx.Rollback()
-
- if err := txDao.SaveRecord(form.record); err != nil {
- return form.prepareError(err)
- }
-
- // restore record isNew state
+ manualRollback := func() error {
if isNew {
- form.record.MarkAsNew()
- }
-
- if callback != nil {
- return callback(txDao)
- }
-
- return nil
- })
-}
-
-// Submit validates the form and upserts the form Record model.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
- if err := form.ValidateAndFill(); err != nil {
- return err
- }
-
- return runInterceptors(form.record, func(record *models.Record) error {
- form.record = record
-
- if !form.record.HasId() {
- form.record.RefreshId()
- form.record.MarkAsNew()
- }
-
- dao := form.dao.Clone()
-
- // upload new files (if any)
- //
- // note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
- // to allow uploading AFTER the before app model hooks (eg. in case of an id change)
- // but BEFORE the actual record db persistence
- // ---
- dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- newAction := func() error {
- if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
- if err := form.processFilesToUpload(); err != nil {
- return err
- }
- }
-
- return action()
- }
-
- if form.dao.BeforeCreateFunc != nil {
- return form.dao.BeforeCreateFunc(eventDao, m, newAction)
+ err = app.DeleteWithContext(form.ctx, clone)
+ if err != nil {
+ return fmt.Errorf("failed to rollback dry submit created record: %w", err)
}
-
- return newAction()
- }
-
- dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
- newAction := func() error {
- if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
- if err := form.processFilesToUpload(); err != nil {
- return err
- }
- }
-
- return action()
- }
-
- if form.dao.BeforeUpdateFunc != nil {
- return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
+ } else {
+ clone.Load(clone.Original().FieldsData())
+ err = app.SaveNoValidateWithContext(form.ctx, clone)
+ if err != nil {
+ return fmt.Errorf("failed to rollback dry submit updated record: %w", err)
}
-
- return newAction()
- }
- // ---
-
- // persist the record model
- if err := dao.SaveRecord(form.record); err != nil {
- return form.prepareError(err)
- }
-
- // delete old files (if any)
- //
- // for now fail silently to avoid reupload when `form.Submit()`
- // is called manually (aka. not from an api request)...
- if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
- log.Println(err)
}
return nil
- }, interceptors...)
-}
-
-func (form *RecordUpsert) processFilesToUpload() error {
- if len(form.filesToUpload) == 0 {
- return nil // no parsed file fields
}
- if !form.record.HasId() {
- return errors.New("the record doesn't have an id")
+ if callback != nil {
+ return errors.Join(callback(app, clone), manualRollback())
}
- fs, err := form.app.NewFilesystem()
- if err != nil {
- return err
- }
- defer fs.Close()
-
- var uploadErrors []error // list of upload errors
- var uploaded []string // list of uploaded file paths
-
- for fieldKey := range form.filesToUpload {
- for i, file := range form.filesToUpload[fieldKey] {
- path := form.record.BaseFilesPath() + "/" + file.Name
- if err := fs.UploadFile(file, path); err == nil {
- // keep track of the already uploaded file
- uploaded = append(uploaded, path)
- } else {
- // store the upload error
- uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
- }
- }
- }
-
- if len(uploadErrors) > 0 {
- // cleanup - try to delete the successfully uploaded files (if any)
- form.deleteFilesByNamesList(uploaded)
-
- return fmt.Errorf("failed to upload all files: %v", uploadErrors)
- }
-
- return nil
-}
-
-func (form *RecordUpsert) processFilesToDelete() (err error) {
- form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete)
- return
+ return manualRollback()
}
-// deleteFiles deletes a list of record files by their names.
-// Returns the failed/remaining files.
-func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) {
- if len(filenames) == 0 {
- return filenames, nil // nothing to delete
- }
-
- if !form.record.HasId() {
- return filenames, errors.New("the record doesn't have an id")
- }
-
- fs, err := form.app.NewFilesystem()
+// Submit validates the form specific validations and attempts to save the form record.
+func (form *RecordUpsert) Submit() error {
+ err := form.validateFormFields()
if err != nil {
- return filenames, err
- }
- defer fs.Close()
-
- var deleteErrors []error
-
- for i := len(filenames) - 1; i >= 0; i-- {
- filename := filenames[i]
- path := form.record.BaseFilesPath() + "/" + filename
-
- if err := fs.Delete(path); err == nil {
- // remove the deleted file from the list
- filenames = append(filenames[:i], filenames[i+1:]...)
-
- // try to delete the related file thumbs (if any)
- fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
- } else {
- // store the delete error
- deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
- }
- }
-
- if len(deleteErrors) > 0 {
- return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
+ return err
}
- return filenames, nil
+ // run record validations and persist in db
+ return form.app.SaveWithContext(form.ctx, form.record)
}
-// prepareError parses the provided error and tries to return
-// user-friendly validation error(s).
-func (form *RecordUpsert) prepareError(err error) error {
- msg := strings.ToLower(err.Error())
-
- validationErrs := validation.Errors{}
+// syncPasswordFields syncs the form's auth password fields with their
+// corresponding record field values.
+//
+// This could be useful in case the password fields were programmatically set
+// directly by modifying the related record model.
+func (form *RecordUpsert) syncPasswordFields() {
+ if !form.record.Collection().IsAuth() {
+ return // not an auth collection
+ }
- // check for unique constraint failure
- if strings.Contains(msg, "unique constraint failed") {
- msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
+ form.disablePasswordValidations = false
- c := form.record.Collection()
- for _, f := range c.Schema.Fields() {
- // blank space to unify multi-columns lookup
- if strings.Contains(msg+" ", strings.ToLower(c.Name+"."+f.Name)) {
- validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
- }
+ rawPassword := form.record.GetRaw(core.FieldNamePassword)
+ if v, ok := rawPassword.(*core.PasswordFieldValue); ok && v != nil {
+ if
+ // programmatically set custom plain password value
+ (v.Plain != "" && v.Plain != form.password) ||
+ // random generated password for new record
+ (v.Plain == "" && v.Hash != "" && form.record.IsNew()) {
+ form.disablePasswordValidations = true
}
}
-
- if len(validationErrs) > 0 {
- return validationErrs
- }
-
- return err
}
diff --git a/forms/record_upsert_test.go b/forms/record_upsert_test.go
index cf7aabe942..bd985a43c1 100644
--- a/forms/record_upsert_test.go
+++ b/forms/record_upsert_test.go
@@ -2,1211 +2,1011 @@ package forms_test
import (
"bytes"
- "encoding/json"
+ "encoding/json/v2"
"errors"
- "fmt"
- "net/http"
- "net/http/httptest"
+ "maps"
"os"
"path/filepath"
"strings"
"testing"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
)
-func hasRecordFile(app core.App, record *models.Record, filename string) bool {
- fs, _ := app.NewFilesystem()
- defer fs.Close()
+func TestRecordUpsertLoad(t *testing.T) {
+ t.Parallel()
- fileKey := filepath.Join(
- record.Collection().Id,
- record.Id,
- filename,
- )
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- exists, _ := fs.Exists(fileKey)
-
- return exists
-}
-
-func TestNewRecordUpsert(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
- record := models.NewRecord(collection)
- record.Set("title", "test_value")
-
- form := forms.NewRecordUpsert(app, record)
-
- val := form.Data()["title"]
- if val != "test_value" {
- t.Errorf("Expected record data to be loaded, got %v", form.Data())
- }
-}
-
-func TestRecordUpsertLoadRequestUnsupported(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- record, err := app.Dao().FindRecordById("demo2", "0yxhwia2amd8gec")
+ demo1Col, err := testApp.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
- testData := "title=test123"
-
- form := forms.NewRecordUpsert(app, record)
- req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData))
- req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
-
- if err := form.LoadRequest(req, ""); err == nil {
- t.Fatal("Expected LoadRequest to fail, got nil")
+ usersCol, err := testApp.FindCollectionByNameOrId("users")
+ if err != nil {
+ t.Fatal(err)
}
-}
-
-func TestRecordUpsertLoadRequestJson(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
- record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
if err != nil {
t.Fatal(err)
}
- testData := map[string]any{
- "a": map[string]any{
- "b": map[string]any{
- "id": "test_id",
- "text": "test123",
- "unknown": "test456",
- // file fields unset/delete
- "file_one": nil,
- "file_many.0": "", // delete by index
- "file_many-": []string{"test_MaWC6mWyrP.txt", "test_tC1Yc87DfC.txt"}, // multiple delete with modifier
- "file_many.300_WlbFWSGmW9.png": nil, // delete by filename
- "file_many.2": "test.png", // should be ignored
+ scenarios := []struct {
+ name string
+ data map[string]any
+ record *core.Record
+ managerAccessLevel bool
+ superuserAccessLevel bool
+ expected []string
+ notExpected []string
+ }{
+ {
+ name: "base collection record",
+ data: map[string]any{
+ "text": "test_text",
+ "custom": "123", // should be ignored
+ "number": "456", // should be normalized by the setter
+ "select_many+": []string{"optionB", "optionC"}, // test modifier fields
+ "created": "2022-01:01 10:00:00.000Z", // should be ignored
+ // ignore special auth fields
+ "oldPassword": "123",
+ "password": "456",
+ "passwordConfirm": "789",
+ },
+ record: core.NewRecord(demo1Col),
+ expected: []string{
+ `"text":"test_text"`,
+ `"number":456`,
+ `"select_many":["optionB","optionC"]`,
+ `"created":""`,
+ `"updated":""`,
+ `"json":null`,
+ },
+ notExpected: []string{
+ `"custom"`,
+ `"password"`,
+ `"oldPassword"`,
+ `"passwordConfirm"`,
+ `"select_many-"`,
+ `"select_many+"`,
+ },
+ },
+ {
+ name: "auth collection record",
+ data: map[string]any{
+ "email": "test@example.com",
+ // special auth fields
+ "oldPassword": "123",
+ "password": "456",
+ "passwordConfirm": "789",
+ },
+ record: core.NewRecord(usersCol),
+ expected: []string{
+ `"email":"test@example.com"`,
+ `"password":"456"`,
+ },
+ notExpected: []string{
+ `"oldPassword"`,
+ `"passwordConfirm"`,
+ },
+ },
+ {
+ name: "hidden fields (manager)",
+ data: map[string]any{
+ "email": "test@example.com",
+ "tokenKey": "abc", // should be ignored
+ // special auth fields
+ "password": "456",
+ "oldPassword": "123",
+ "passwordConfirm": "789",
+ },
+ managerAccessLevel: true,
+ record: core.NewRecord(usersCol),
+ expected: []string{
+ `"email":"test@example.com"`,
+ `"tokenKey":""`,
+ `"password":"456"`,
+ },
+ notExpected: []string{
+ `"oldPassword"`,
+ `"passwordConfirm"`,
+ },
+ },
+ {
+ name: "hidden fields (superuser)",
+ data: map[string]any{
+ "email": "test@example.com",
+ "tokenKey": "abc",
+ // special auth fields
+ "password": "456",
+ "oldPassword": "123",
+ "passwordConfirm": "789",
+ },
+ superuserAccessLevel: true,
+ record: core.NewRecord(usersCol),
+ expected: []string{
+ `"email":"test@example.com"`,
+ `"tokenKey":"abc"`,
+ `"password":"456"`,
+ },
+ notExpected: []string{
+ `"oldPassword"`,
+ `"passwordConfirm"`,
+ },
+ },
+ {
+ name: "with file field",
+ data: map[string]any{
+ "file_one": file,
+ "url": file, // should be ignored for non-file fields
+ },
+ record: core.NewRecord(demo1Col),
+ expected: []string{
+ `"file_one":{`,
+ `"originalName":"test.txt"`,
+ `"url":""`,
},
},
}
- form := forms.NewRecordUpsert(app, record)
- jsonBody, _ := json.Marshal(testData)
- req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody))
- req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
- loadErr := form.LoadRequest(req, "a.b")
- if loadErr != nil {
- t.Fatal(loadErr)
- }
+ for _, s := range scenarios {
+ t.Run(s.name, func(t *testing.T) {
+ form := forms.NewRecordUpsert(testApp, s.record)
- if form.Id != "test_id" {
- t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
- }
+ if s.managerAccessLevel {
+ form.GrantManagerAccess()
+ }
- if v, ok := form.Data()["text"]; !ok || v != "test123" {
- t.Fatalf("Expect title field to be %q, got %q", "test123", v)
- }
+ if s.superuserAccessLevel {
+ form.GrantSuperuserAccess()
+ }
- if v, ok := form.Data()["unknown"]; ok {
- t.Fatalf("Didn't expect unknown field to be set, got %v", v)
- }
+ // ensure that the form access level was updated
+ if !form.HasManageAccess() && (s.superuserAccessLevel || s.managerAccessLevel) {
+ t.Fatalf("Expected the form to have manage access level (manager or superuser)")
+ }
- fileOne, ok := form.Data()["file_one"]
- if !ok {
- t.Fatal("Expect file_one field to be set")
- }
- if fileOne != "" {
- t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
- }
+ form.Load(s.data)
- fileMany, ok := form.Data()["file_many"]
- if !ok || fileMany == nil {
- t.Fatal("Expect file_many field to be set")
- }
- manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
- if manyfilesRemains != 1 {
- t.Fatalf("Expect only 1 file_many to remain, got \n%v", fileMany)
- }
-}
+ loaded := map[string]any{}
+ maps.Copy(loaded, s.record.FieldsData())
+ maps.Copy(loaded, s.record.CustomData())
-func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ raw, err := json.Marshal(loaded)
+ if err != nil {
+ t.Fatalf("Failed to serialize data: %v", err)
+ }
- record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
- if err != nil {
- t.Fatal(err)
- }
+ rawStr := string(raw)
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "a.b.id": "test_id",
- "a.b.text": "test123",
- "a.b.unknown": "test456",
- // file fields unset/delete
- "a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
- "a.b.file_many.0": "", // delete by index
- "a.b.file_many-": "test_tC1Yc87DfC.txt", // delete with modifier
- "a.b.file_many.300_WlbFWSGmW9.png": "", // delete by filename
- "a.b.file_many.2": "test.png", // should be ignored
- }, "a.b.file_many")
- if err != nil {
- t.Fatal(err)
- }
+ for _, str := range s.expected {
+ if !strings.Contains(rawStr, str) {
+ t.Fatalf("Couldn't find %q in \n%v", str, rawStr)
+ }
+ }
- form := forms.NewRecordUpsert(app, record)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- loadErr := form.LoadRequest(req, "a.b")
- if loadErr != nil {
- t.Fatal(loadErr)
+ for _, str := range s.notExpected {
+ if strings.Contains(rawStr, str) {
+ t.Fatalf("Didn't expect %q in \n%v", str, rawStr)
+ }
+ }
+ })
}
+}
- if form.Id != "test_id" {
- t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
- }
+func TestRecordUpsertDrySubmitFailure(t *testing.T) {
+ runTest := func(t *testing.T, testApp core.App) {
+ col, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
- if v, ok := form.Data()["text"]; !ok || v != "test123" {
- t.Fatalf("Expect text field to be %q, got %q", "test123", v)
- }
+ originalId := "imy661ixudk5izi"
- if v, ok := form.Data()["unknown"]; ok {
- t.Fatalf("Didn't expect unknown field to be set, got %v", v)
- }
+ record, err := testApp.FindRecordById(col, originalId)
+ if err != nil {
+ t.Fatal(err)
+ }
- fileOne, ok := form.Data()["file_one"]
- if !ok {
- t.Fatal("Expect file_one field to be set")
- }
- if fileOne != "" {
- t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
- }
+ oldRaw, err := json.Marshal(record)
+ if err != nil {
+ t.Fatal(err)
+ }
- fileMany, ok := form.Data()["file_many"]
- if !ok || fileMany == nil {
- t.Fatal("Expect file_many field to be set")
- }
- manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
- expectedRemains := 3 // 5 old; 3 deleted and 1 new uploaded
- if manyfilesRemains != expectedRemains {
- t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
- }
-}
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
-func TestRecordUpsertLoadData(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ form := forms.NewRecordUpsert(testApp, record)
+ form.Load(map[string]any{
+ "text": "test_update",
+ "file_one": file,
+ "select_one": "!invalid", // should be allowed even if invalid since validations are not executed
+ })
- record, err := app.Dao().FindRecordById("demo2", "llvuca81nly1qls")
- if err != nil {
- t.Fatal(err)
- }
+ calls := ""
+ testApp.OnRecordValidate(col.Name).BindFunc(func(e *core.RecordEvent) error {
+ calls += "a" // shouldn't be called
+ return e.Next()
+ })
- form := forms.NewRecordUpsert(app, record)
+ result := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
+ calls += "b"
+ return errors.New("error...")
+ })
- loadErr := form.LoadData(map[string]any{
- "title": "test_new",
- "active": true,
- })
- if loadErr != nil {
- t.Fatal(loadErr)
- }
+ if result == nil {
+ t.Fatal("Expected DrySubmit error, got nil")
+ }
- if v, ok := form.Data()["title"]; !ok || v != "test_new" {
- t.Fatalf("Expect title field to be %v, got %v", "test_new", v)
- }
+ if calls != "b" {
+ t.Fatalf("Expected calls %q, got %q", "ab", calls)
+ }
- if v, ok := form.Data()["active"]; !ok || v != true {
- t.Fatalf("Expect active field to be %v, got %v", true, v)
- }
-}
+ // refresh the record to ensure that the changes weren't persisted
+ record, err = testApp.FindRecordById(col, originalId)
+ if err != nil {
+ t.Fatalf("Expected record with the original id %q to exist, got\n%v", originalId, record.PublicExport())
+ }
-func TestRecordUpsertDrySubmitFailure(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ newRaw, err := json.Marshal(record)
+ if err != nil {
+ t.Fatal(err)
+ }
- collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
- recordBefore, err := app.Dao().FindRecordById(collection.Id, "al1h9ijdeojtsjy")
- if err != nil {
- t.Fatal(err)
- }
+ if !bytes.Equal(oldRaw, newRaw) {
+ t.Fatalf("Expected record\n%s\ngot\n%s", oldRaw, newRaw)
+ }
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "abc",
- "rel_one": "missing",
- })
- if err != nil {
- t.Fatal(err)
+ testFilesCount(t, testApp, record, 0)
}
- form := forms.NewRecordUpsert(app, recordBefore)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
-
- callbackCalls := 0
+ t.Run("without parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- // ensure that validate is triggered
- // ---
- result := form.DrySubmit(func(txDao *daos.Dao) error {
- callbackCalls++
- return nil
+ runTest(t, testApp)
})
- if result == nil {
- t.Fatal("Expected error, got nil")
- }
- if callbackCalls != 0 {
- t.Fatalf("Expected callbackCalls to be 0, got %d", callbackCalls)
- }
-
- // ensure that the record changes weren't persisted
- // ---
- recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
- if err != nil {
- t.Fatal(err)
- }
- if recordAfter.GetString("title") == "abc" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "abc")
- }
+ t.Run("with parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- if recordAfter.GetString("rel_one") == "missing" {
- t.Fatalf("Expected record.rel_one to be %s, got %s", recordBefore.GetString("rel_one"), "missing")
- }
+ testApp.RunInTransaction(func(txApp core.App) error {
+ runTest(t, txApp)
+ return nil
+ })
+ })
}
-func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
- recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
- if err != nil {
- t.Fatal(err)
- }
+func TestRecordUpsertDrySubmitCreateSuccess(t *testing.T) {
+ runTest := func(t *testing.T, testApp core.App) {
+ col, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
+ }
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "dry_test",
- "file_one": "",
- }, "file_many")
- if err != nil {
- t.Fatal(err)
- }
+ record := core.NewRecord(col)
- form := forms.NewRecordUpsert(app, recordBefore)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
+ }
- callbackCalls := 0
+ form := forms.NewRecordUpsert(testApp, record)
+ form.Load(map[string]any{
+ "id": "test",
+ "text": "test_update",
+ "file_one": file,
+ "select_one": "!invalid", // should be allowed even if invalid since validations are not executed
+ })
- result := form.DrySubmit(func(txDao *daos.Dao) error {
- callbackCalls++
- return nil
- })
- if result != nil {
- t.Fatalf("Expected nil, got error %v", result)
- }
+ calls := ""
+ testApp.OnRecordValidate(col.Name).BindFunc(func(e *core.RecordEvent) error {
+ calls += "a" // shouldn't be called
+ return e.Next()
+ })
- // ensure callback was called
- if callbackCalls != 1 {
- t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
- }
+ result := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
+ calls += "b"
+ return nil
+ })
- // ensure that the record changes weren't persisted
- recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
- if err != nil {
- t.Fatal(err)
- }
- if recordAfter.GetString("title") == "dry_test" {
- t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
- }
- if recordAfter.GetString("file_one") == "" {
- t.Fatal("Expected record.file_one to not be changed, got empty string")
- }
+ if result != nil {
+ t.Fatalf("Expected DrySubmit success, got error: %v", result)
+ }
- // file wasn't removed
- if !hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
- t.Fatal("file_one file should not have been deleted")
- }
-}
+ if calls != "b" {
+ t.Fatalf("Expected calls %q, got %q", "ab", calls)
+ }
-func TestRecordUpsertDrySubmitWithNestedTx(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ // refresh the record to ensure that the changes weren't persisted
+ _, err = testApp.FindRecordById(col, record.Id)
+ if err == nil {
+ t.Fatal("Expected the created record to be deleted")
+ }
- collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
- recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
- if err != nil {
- t.Fatal(err)
+ testFilesCount(t, testApp, record, 0)
}
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "title": "dry_test",
- })
- if err != nil {
- t.Fatal(err)
- }
+ t.Run("without parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- txErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
- form := forms.NewRecordUpsert(app, recordBefore)
- form.SetDao(txDao)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
+ runTest(t, testApp)
+ })
- callbackCalls := 0
+ t.Run("with parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- result := form.DrySubmit(func(innerTxDao *daos.Dao) error {
- callbackCalls++
+ testApp.RunInTransaction(func(txApp core.App) error {
+ runTest(t, txApp)
return nil
})
- if result != nil {
- t.Fatalf("Expected nil, got error %v", result)
- }
+ })
+}
- // ensure callback was called
- if callbackCalls != 1 {
- t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
+func TestRecordUpsertDrySubmitUpdateSuccess(t *testing.T) {
+ runTest := func(t *testing.T, testApp core.App) {
+ col, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
+ t.Fatal(err)
}
- // ensure that the original txDao can still be used after the DrySubmit rollback
- if _, err := txDao.FindRecordById(collection.Id, recordBefore.Id); err != nil {
- t.Fatalf("Expected the dry submit rollback to not affect the outer tx context, got %v", err)
+ record, err := testApp.FindRecordById(col, "imy661ixudk5izi")
+ if err != nil {
+ t.Fatal(err)
}
- // ensure that the record changes weren't persisted
- recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
+ oldRaw, err := json.Marshal(record)
if err != nil {
t.Fatal(err)
}
- if recordAfter.GetString("title") == "dry_test" {
- t.Fatalf("Expected record.title to be %v, got %v", recordBefore.GetString("title"), "dry_test")
- }
-
- return nil
- })
- if txErr != nil {
- t.Fatalf("Nested transactions failure: %v", txErr)
- }
-}
-
-func TestRecordUpsertSubmitFailure(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, err := app.Dao().FindCollectionByNameOrId("demo1")
- if err != nil {
- t.Fatal(err)
- }
- recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
- if err != nil {
- t.Fatal(err)
- }
-
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "text": "abc",
- "bool": "false",
- "select_one": "invalid",
- "file_many": "invalid",
- "email": "invalid",
- }, "file_one")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordUpsert(app, recordBefore)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
}
- }
- // ensure that validate is triggered
- // ---
- result := form.Submit(interceptor)
- if result == nil {
- t.Fatal("Expected error, got nil")
- }
-
- // check interceptor calls
- // ---
- if interceptorCalls != 0 {
- t.Fatalf("Expected interceptor to be called 0 times, got %d", interceptorCalls)
- }
+ form := forms.NewRecordUpsert(testApp, record)
+ form.Load(map[string]any{
+ "text": "test_update",
+ "file_one": file,
+ })
- // ensure that the record changes weren't persisted
- // ---
- recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
- if err != nil {
- t.Fatal(err)
- }
+ calls := ""
+ testApp.OnRecordValidate(col.Name).BindFunc(func(e *core.RecordEvent) error {
+ calls += "a" // shouldn't be called
+ return e.Next()
+ })
- if v := recordAfter.Get("text"); v == "abc" {
- t.Fatalf("Expected record.text not to change, got %v", v)
- }
- if v := recordAfter.Get("bool"); v == false {
- t.Fatalf("Expected record.bool not to change, got %v", v)
- }
- if v := recordAfter.Get("select_one"); v == "invalid" {
- t.Fatalf("Expected record.select_one not to change, got %v", v)
- }
- if v := recordAfter.Get("email"); v == "invalid" {
- t.Fatalf("Expected record.email not to change, got %v", v)
- }
- if v := recordAfter.GetStringSlice("file_many"); len(v) != 5 {
- t.Fatalf("Expected record.file_many not to change, got %v", v)
- }
+ result := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
+ calls += "b"
+ return nil
+ })
- // ensure the files weren't removed
- for _, f := range recordAfter.GetStringSlice("file_many") {
- if !hasRecordFile(app, recordAfter, f) {
- t.Fatal("file_many file should not have been deleted")
+ if result != nil {
+ t.Fatalf("Expected DrySubmit success, got error: %v", result)
}
- }
-}
-func TestRecordUpsertSubmitSuccess(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
- recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
- if err != nil {
- t.Fatal(err)
- }
-
- formData, mp, err := tests.MockMultipartData(map[string]string{
- "text": "test_save",
- "bool": "true",
- "select_one": "optionA",
- "file_one": "",
- }, "file_many.1", "file_many") // replace + new file
- if err != nil {
- t.Fatal(err)
- }
+ if calls != "b" {
+ t.Fatalf("Expected calls %q, got %q", "ab", calls)
+ }
- form := forms.NewRecordUpsert(app, recordBefore)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
+ // refresh the record to ensure that the changes weren't persisted
+ record, err = testApp.FindRecordById(col, record.Id)
+ if err != nil {
+ t.Fatal(err)
+ }
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
+ newRaw, err := json.Marshal(record)
+ if err != nil {
+ t.Fatal(err)
}
- }
- result := form.Submit(interceptor)
- if result != nil {
- t.Fatalf("Expected nil, got error %v", result)
- }
+ if !bytes.Equal(oldRaw, newRaw) {
+ t.Fatalf("Expected record\n%s\ngot\n%s", oldRaw, newRaw)
+ }
- // check interceptor calls
- // ---
- if interceptorCalls != 1 {
- t.Fatalf("Expected interceptor to be called 1 time, got %d", interceptorCalls)
+ testFilesCount(t, testApp, record, 0)
}
- // ensure that the record changes were persisted
- // ---
- recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
- if err != nil {
- t.Fatal(err)
- }
+ t.Run("without parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- if v := recordAfter.GetString("text"); v != "test_save" {
- t.Fatalf("Expected record.text to be %v, got %v", v, "test_save")
- }
+ runTest(t, testApp)
+ })
- if hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
- t.Fatal("Expected record.file_one to be deleted")
- }
+ t.Run("with parent transaction", func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- fileMany := (recordAfter.GetStringSlice("file_many"))
- if len(fileMany) != 6 { // 1 replace + 1 new
- t.Fatalf("Expected 6 record.file_many, got %d (%v)", len(fileMany), fileMany)
- }
- for _, f := range fileMany {
- if !hasRecordFile(app, recordAfter, f) {
- t.Fatalf("Expected file %q to exist", f)
- }
- }
+ testApp.RunInTransaction(func(txApp core.App) error {
+ runTest(t, txApp)
+ return nil
+ })
+ })
}
-func TestRecordUpsertSubmitInterceptors(t *testing.T) {
+func TestRecordUpsertSubmitValidations(t *testing.T) {
+ t.Parallel()
+
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
- record, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
+ demo2Col, err := app.FindCollectionByNameOrId("demo2")
if err != nil {
t.Fatal(err)
}
- form := forms.NewRecordUpsert(app, record)
- form.Data()["title"] = "test_new"
-
- testErr := errors.New("test_error")
- interceptorRecordTitle := ""
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptor1Called = true
- return next(r)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorRecordTitle = record.GetString("title") // to check if the record was filled
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorRecordTitle != form.Data()["title"].(string) {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
+ demo2Rec, err := app.FindRecordById(demo2Col, "llvuca81nly1qls")
+ if err != nil {
+ t.Fatal(err)
}
-}
-
-func TestRecordUpsertWithCustomId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("demo3")
+ usersCol, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
- existingRecord, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
+ userRec, err := app.FindRecordById(usersCol, "4q1xlclmfloku33")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
- name string
- data map[string]string
- record *models.Record
- expectError bool
+ name string
+ record *core.Record
+ data map[string]any
+ managerAccess bool
+ expectedErrors []string
}{
- {
- "empty data",
- map[string]string{},
- models.NewRecord(collection),
- false,
- },
- {
- "empty id",
- map[string]string{"id": ""},
- models.NewRecord(collection),
- false,
- },
- {
- "id < 15 chars",
- map[string]string{"id": "a23"},
- models.NewRecord(collection),
- true,
- },
- {
- "id > 15 chars",
- map[string]string{"id": "a234567890123456"},
- models.NewRecord(collection),
- true,
- },
- {
- "id = 15 chars (invalid chars)",
- map[string]string{"id": "a@3456789012345"},
- models.NewRecord(collection),
- true,
- },
- {
- "id = 15 chars (valid chars)",
- map[string]string{"id": "a23456789012345"},
- models.NewRecord(collection),
- false,
+ // base
+ {
+ name: "new base collection record with empty data",
+ record: core.NewRecord(demo2Col),
+ data: map[string]any{},
+ expectedErrors: []string{"title"},
+ },
+ {
+ name: "new base collection record with invalid data",
+ record: core.NewRecord(demo2Col),
+ data: map[string]any{
+ "title": "",
+ // should be ignored
+ "custom": "abc",
+ "oldPassword": "123",
+ "password": "456",
+ "passwordConfirm": "789",
+ },
+ expectedErrors: []string{"title"},
+ },
+ {
+ name: "new base collection record with valid data",
+ record: core.NewRecord(demo2Col),
+ data: map[string]any{
+ "title": "abc",
+ // should be ignored
+ "custom": "abc",
+ "oldPassword": "123",
+ "password": "456",
+ "passwordConfirm": "789",
+ },
+ expectedErrors: []string{},
},
{
- "changing the id of an existing record",
- map[string]string{"id": "b23456789012345"},
- existingRecord,
- true,
+ name: "existing base collection record with empty data",
+ record: demo2Rec,
+ data: map[string]any{},
+ expectedErrors: []string{},
},
{
- "using the same existing record id",
- map[string]string{"id": existingRecord.Id},
- existingRecord,
- false,
+ name: "existing base collection record with invalid data",
+ record: demo2Rec,
+ data: map[string]any{
+ "title": "",
+ },
+ expectedErrors: []string{"title"},
},
{
- "skipping the id for existing record",
- map[string]string{},
- existingRecord,
- false,
+ name: "existing base collection record with valid data",
+ record: demo2Rec,
+ data: map[string]any{
+ "title": "abc",
+ },
+ expectedErrors: []string{},
},
- }
-
- for _, scenario := range scenarios {
- formData, mp, err := tests.MockMultipartData(scenario.data)
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordUpsert(app, scenario.record)
- req := httptest.NewRequest(http.MethodGet, "/", formData)
- req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
- form.LoadRequest(req, "")
-
- dryErr := form.DrySubmit(nil)
- hasDryErr := dryErr != nil
-
- submitErr := form.Submit()
- hasSubmitErr := submitErr != nil
- if hasDryErr != hasSubmitErr {
- t.Errorf("[%s] Expected hasDryErr and hasSubmitErr to have the same value, got %v vs %v", scenario.name, hasDryErr, hasSubmitErr)
- }
-
- if hasSubmitErr != scenario.expectError {
- t.Errorf("[%s] Expected hasSubmitErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasSubmitErr, submitErr)
- }
-
- if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr {
- _, err := app.Dao().FindRecordById(collection.Id, id)
- if err != nil {
- t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err)
- }
- }
- }
-}
-
-func TestRecordUpsertAuthRecord(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- name string
- existingId string
- data map[string]any
- manageAccess bool
- expectError bool
- }{
+ // auth
{
- "empty create data",
- "",
- map[string]any{},
- false,
- true,
+ name: "new auth collection record with empty data",
+ record: core.NewRecord(usersCol),
+ data: map[string]any{},
+ expectedErrors: []string{"password", "passwordConfirm"},
},
{
- "empty update data",
- "4q1xlclmfloku33",
- map[string]any{},
- false,
- false,
- },
- {
- "minimum valid create data",
- "",
- map[string]any{
- "password": "12345678",
- "passwordConfirm": "12345678",
+ name: "new auth collection record with invalid record and invalid form data (without manager access)",
+ record: core.NewRecord(usersCol),
+ data: map[string]any{
+ "verified": true,
+ "emailVisibility": true,
+ "email": "test@example.com",
+ "password": "456",
+ "passwordConfirm": "789",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- false,
+ // fail the form validator
+ expectedErrors: []string{"verified", "passwordConfirm"},
},
{
- "create with all allowed auth fields",
- "",
- map[string]any{
- "username": "test_new",
- "email": "test_new@example.com",
+ name: "new auth collection record with invalid record and valid form data (without manager access)",
+ record: core.NewRecord(usersCol),
+ data: map[string]any{
+ "verified": false,
"emailVisibility": true,
- "password": "12345678",
- "passwordConfirm": "12345678",
+ "email": "test@example.com",
+ "password": "456",
+ "passwordConfirm": "456",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- false,
+ // fail the record fields validator
+ expectedErrors: []string{"password", "username"},
},
-
- // username
{
- "invalid username characters",
- "",
- map[string]any{
- "username": "test abc!@#",
- "password": "12345678",
- "passwordConfirm": "12345678",
+ name: "new auth collection record with invalid record and invalid form data (with manager access)",
+ record: core.NewRecord(usersCol),
+ managerAccess: true,
+ data: map[string]any{
+ "verified": true,
+ "emailVisibility": true,
+ "email": "test@example.com",
+ "password": "456",
+ "passwordConfirm": "789",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- true,
+ // fail the form validator
+ expectedErrors: []string{"passwordConfirm"},
},
{
- "invalid username length (less than 3)",
- "",
- map[string]any{
- "username": "ab",
- "password": "12345678",
- "passwordConfirm": "12345678",
+ name: "new auth collection record with invalid record and valid form data (with manager access)",
+ record: core.NewRecord(usersCol),
+ managerAccess: true,
+ data: map[string]any{
+ "verified": true,
+ "emailVisibility": true,
+ "email": "test@example.com",
+ "password": "456",
+ "passwordConfirm": "456",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- true,
+ // fail the record fields validator
+ expectedErrors: []string{"password", "username"},
},
{
- "invalid username length (more than 150)",
- "",
- map[string]any{
- "username": strings.Repeat("a", 151),
- "password": "12345678",
- "passwordConfirm": "12345678",
+ name: "new auth collection record with valid data",
+ record: core.NewRecord(usersCol),
+ data: map[string]any{
+ "emailVisibility": true,
+ "email": "test_new@example.com",
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- true,
+ expectedErrors: []string{},
},
-
- // verified
{
- "try to set verified without managed access",
- "",
- map[string]any{
- "verified": true,
- "password": "12345678",
- "passwordConfirm": "12345678",
+ name: "new auth collection record with valid data and duplicated email",
+ record: core.NewRecord(usersCol),
+ data: map[string]any{
+ "email": "test@example.com",
+ "password": "1234567890",
+ "passwordConfirm": "1234567890",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
+ "oldPassword": "123",
},
- false,
- true,
+ // fail the unique db validator
+ expectedErrors: []string{"email"},
},
{
- "try to update verified without managed access",
- "4q1xlclmfloku33",
- map[string]any{
- "verified": true,
- },
- false,
- true,
+ name: "existing auth collection record with empty data",
+ record: userRec,
+ data: map[string]any{},
+ expectedErrors: []string{},
},
{
- "set verified with managed access",
- "",
- map[string]any{
+ name: "existing auth collection record with invalid record data and invalid form data (without manager access)",
+ record: userRec,
+ data: map[string]any{
"verified": true,
- "password": "12345678",
- "passwordConfirm": "12345678",
- },
- true,
- false,
- },
- {
- "update verified with managed access",
- "4q1xlclmfloku33",
- map[string]any{
- "verified": true,
+ "email": "test_new@example.com", // not allowed to change
+ "oldPassword": "123",
+ "password": "456",
+ "passwordConfirm": "789",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
},
- true,
- false,
+ // fail form validator
+ expectedErrors: []string{"verified", "email", "oldPassword", "passwordConfirm"},
},
-
- // email
{
- "try to update email without managed access",
- "4q1xlclmfloku33",
- map[string]any{
- "email": "test_update@example.com",
+ name: "existing auth collection record with invalid record data and valid form data (without manager access)",
+ record: userRec,
+ data: map[string]any{
+ "oldPassword": "1234567890",
+ "password": "12345678901",
+ "passwordConfirm": "12345678901",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
},
- false,
- true,
+ // fail record fields validator
+ expectedErrors: []string{"username"},
},
{
- "update email with managed access",
- "4q1xlclmfloku33",
- map[string]any{
- "email": "test_update@example.com",
+ name: "existing auth collection record with invalid record data and invalid form data (with manager access)",
+ record: userRec,
+ managerAccess: true,
+ data: map[string]any{
+ "verified": true,
+ "email": "test_new@example.com",
+ "oldPassword": "123", // should be ignored
+ "password": "456",
+ "passwordConfirm": "789",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
},
- true,
- false,
+ // fail form validator
+ expectedErrors: []string{"passwordConfirm"},
},
-
- // password
{
- "trigger the password validations if only oldPassword is set",
- "4q1xlclmfloku33",
- map[string]any{
- "oldPassword": "1234567890",
+ name: "existing auth collection record with invalid record data and valid form data (with manager access)",
+ record: userRec,
+ managerAccess: true,
+ data: map[string]any{
+ "verified": true,
+ "email": "test_new@example.com",
+ "oldPassword": "1234567890",
+ "password": "12345678901",
+ "passwordConfirm": "12345678901",
+ "username": "!invalid",
+ // should be ignored (custom or hidden fields)
+ "tokenKey": strings.Repeat("a", 2),
+ "custom": "abc",
},
- false,
- true,
+ // fail record fields validator
+ expectedErrors: []string{"username"},
},
{
- "trigger the password validations if only passwordConfirm is set",
- "4q1xlclmfloku33",
- map[string]any{
- "passwordConfirm": "1234567890",
+ name: "existing auth collection record with base valid data",
+ record: userRec,
+ data: map[string]any{
+ "name": "test",
},
- false,
- true,
+ expectedErrors: []string{},
},
{
- "try to update password without managed access",
- "4q1xlclmfloku33",
- map[string]any{
+ name: "existing auth collection record with valid password and invalid oldPassword data",
+ record: userRec,
+ data: map[string]any{
+ "name": "test",
+ "oldPassword": "invalid",
"password": "1234567890",
"passwordConfirm": "1234567890",
},
- false,
- true,
+ expectedErrors: []string{"oldPassword"},
},
{
- "update password without managed access but with oldPassword",
- "4q1xlclmfloku33",
- map[string]any{
+ name: "existing auth collection record with valid password data",
+ record: userRec,
+ data: map[string]any{
+ "name": "test",
"oldPassword": "1234567890",
- "password": "1234567890",
- "passwordConfirm": "1234567890",
- },
- false,
- false,
- },
- {
- "update email with managed access (without oldPassword)",
- "4q1xlclmfloku33",
- map[string]any{
- "password": "1234567890",
- "passwordConfirm": "1234567890",
+ "password": "0987654321",
+ "passwordConfirm": "0987654321",
},
- true,
- false,
+ expectedErrors: []string{},
},
}
for _, s := range scenarios {
- collection, err := app.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
+ t.Run(s.name, func(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- record := models.NewRecord(collection)
- if s.existingId != "" {
- var err error
- record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
- if err != nil {
- t.Errorf("[%s] Failed to fetch auth record with id %s", s.name, s.existingId)
- continue
+ form := forms.NewRecordUpsert(testApp, s.record.Original())
+ if s.managerAccess {
+ form.GrantManagerAccess()
}
- }
+ form.Load(s.data)
- form := forms.NewRecordUpsert(app, record)
- form.SetFullManageAccess(s.manageAccess)
- if err := form.LoadData(s.data); err != nil {
- t.Errorf("[%s] Failed to load form data", s.name)
- continue
- }
-
- submitErr := form.Submit()
-
- hasErr := submitErr != nil
- if hasErr != s.expectError {
- t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
- }
+ result := form.Submit()
- if !hasErr && record.Username() == "" {
- t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.name, record)
- }
+ tests.TestValidationErrors(t, result, s.expectedErrors)
+ })
}
}
-func TestRecordUpsertUniqueValidator(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+func TestRecordUpsertSubmitFailure(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- // create a dummy collection
- collection := &models.Collection{
- Name: "test",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Type: "text",
- Name: "fieldA",
- },
- &schema.SchemaField{
- Type: "text",
- Name: "fieldB",
- },
- &schema.SchemaField{
- Type: "text",
- Name: "fieldC",
- },
- ),
- Indexes: types.JsonArray[string]{
- // the field case shouldn't matter
- "create unique index unique_single_idx on test (fielda)",
- "create unique index unique_combined_idx on test (fieldb, FIELDC)",
- },
- }
- if err := app.Dao().SaveCollection(collection); err != nil {
+ col, err := testApp.FindCollectionByNameOrId("demo1")
+ if err != nil {
t.Fatal(err)
}
- dummyRecord := models.NewRecord(collection)
- dummyRecord.Set("fieldA", "a")
- dummyRecord.Set("fieldB", "b")
- dummyRecord.Set("fieldC", "c")
- if err := app.Dao().SaveRecord(dummyRecord); err != nil {
+ record, err := testApp.FindRecordById(col, "imy661ixudk5izi")
+ if err != nil {
t.Fatal(err)
}
- scenarios := []struct {
- name string
- data map[string]any
- expectedErrors []string
- }{
- {
- "duplicated unique value",
- map[string]any{
- "fieldA": "a",
- },
- []string{"fieldA"},
- },
- {
- "duplicated combined unique value",
- map[string]any{
- "fieldB": "b",
- "fieldC": "c",
- },
- []string{"fieldB", "fieldC"},
- },
- {
- "non-duplicated unique value",
- map[string]any{
- "fieldA": "a2",
- },
- nil,
- },
- {
- "non-duplicated combined unique value",
- map[string]any{
- "fieldB": "b",
- "fieldC": "d",
- },
- nil,
- },
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
+ if err != nil {
+ t.Fatal(err)
}
- for _, s := range scenarios {
- record := models.NewRecord(collection)
-
- form := forms.NewRecordUpsert(app, record)
- if err := form.LoadData(s.data); err != nil {
- t.Errorf("[%s] Failed to load form data", s.name)
- continue
- }
+ form := forms.NewRecordUpsert(testApp, record)
+ form.Load(map[string]any{
+ "text": "test_update",
+ "file_one": file,
+ "select_one": "invalid",
+ })
- result := form.Submit()
+ validateCalls := 0
+ testApp.OnRecordValidate(col.Name).BindFunc(func(e *core.RecordEvent) error {
+ validateCalls++
+ return e.Next()
+ })
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
+ result := form.Submit()
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
- continue
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
- continue
- }
- }
+ if result == nil {
+ t.Fatal("Expected Submit error, got nil")
}
-}
-func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ if validateCalls != 1 {
+ t.Fatalf("Expected validateCalls %d, got %d", 1, validateCalls)
+ }
- recordBefore, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
+ // refresh the record to ensure that the changes weren't persisted
+ record, err = testApp.FindRecordById(col, record.Id)
if err != nil {
t.Fatal(err)
}
- // create test temp files
- tempDir := filepath.Join(app.DataDir(), "temp")
- if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
- t.Fatal(err)
+ if v := record.GetString("text"); v == "test_update" {
+ t.Fatalf("Expected record.text to remain the same, got %q", v)
}
- defer os.RemoveAll(tempDir)
- tmpFile, _ := os.CreateTemp(os.TempDir(), "tmpfile1-*.txt")
- tmpFile.Close()
- form := forms.NewRecordUpsert(app, recordBefore)
+ if v := record.GetString("select_one"); v != "" {
+ t.Fatalf("Expected record.select_one to remain the same, got %q", v)
+ }
- f1, err := filesystem.NewFileFromPath(tmpFile.Name())
+ if v := record.GetString("file_one"); v != "" {
+ t.Fatalf("Expected record.file_one to remain the same, got %q", v)
+ }
+
+ testFilesCount(t, testApp, record, 0)
+}
+
+func TestRecordUpsertSubmitSuccess(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ col, err := testApp.FindCollectionByNameOrId("demo1")
if err != nil {
t.Fatal(err)
}
- f2, err := filesystem.NewFileFromPath(tmpFile.Name())
+ record, err := testApp.FindRecordById(col, "imy661ixudk5izi")
if err != nil {
t.Fatal(err)
}
- f3, err := filesystem.NewFileFromPath(tmpFile.Name())
+ file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
if err != nil {
t.Fatal(err)
}
- removed0 := "test_d61b33QdDU.txt" // replaced
- removed1 := "300_WlbFWSGmW9.png"
- removed2 := "logo_vcfJJG5TAh.svg"
-
- form.AddFiles("file_one", f1) // should replace the existin file
-
- form.AddFiles("file_many", f2, f3) // should append
+ form := forms.NewRecordUpsert(testApp, record)
+ form.Load(map[string]any{
+ "text": "test_update",
+ "file_one": file,
+ "select_one": "optionC",
+ })
- form.RemoveFiles("file_many", removed1, removed2) // should remove
+ validateCalls := 0
+ testApp.OnRecordValidate(col.Name).BindFunc(func(e *core.RecordEvent) error {
+ validateCalls++
+ return e.Next()
+ })
- filesToUpload := form.FilesToUpload()
- if v, ok := filesToUpload["file_one"]; !ok || len(v) != 1 {
- t.Fatalf("Expected filesToUpload[file_one] to have exactly 1 file, got %v", v)
- }
- if v, ok := filesToUpload["file_many"]; !ok || len(v) != 2 {
- t.Fatalf("Expected filesToUpload[file_many] to have exactly 2 file, got %v", v)
- }
+ result := form.Submit()
- filesToDelete := form.FilesToDelete()
- if len(filesToDelete) != 3 {
- t.Fatalf("Expected exactly 2 file to delete, got %v", filesToDelete)
- }
- for _, f := range []string{removed0, removed1, removed2} {
- if !list.ExistInSlice(f, filesToDelete) {
- t.Fatalf("Missing file %q from filesToDelete %v", f, filesToDelete)
- }
+ if result != nil {
+ t.Fatalf("Expected Submit success, got error: %v", result)
}
- if err := form.Submit(); err != nil {
- t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
+ if validateCalls != 1 {
+ t.Fatalf("Expected validateCalls %d, got %d", 1, validateCalls)
}
- recordAfter, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
+ // refresh the record to ensure that the changes were persisted
+ record, err = testApp.FindRecordById(col, record.Id)
if err != nil {
t.Fatal(err)
}
- // ensure files deletion
- if hasRecordFile(app, recordAfter, removed0) {
- t.Fatalf("Expected the old file_one file to be deleted")
- }
- if hasRecordFile(app, recordAfter, removed1) {
- t.Fatalf("Expected %s to be deleted", removed1)
- }
- if hasRecordFile(app, recordAfter, removed2) {
- t.Fatalf("Expected %s to be deleted", removed2)
+ if v := record.GetString("text"); v != "test_update" {
+ t.Fatalf("Expected record.text %q, got %q", "test_update", v)
}
- fileOne := recordAfter.GetStringSlice("file_one")
- if len(fileOne) == 0 {
- t.Fatalf("Expected new file_one file to be uploaded")
+ if v := record.GetString("select_one"); v != "optionC" {
+ t.Fatalf("Expected record.select_one %q, got %q", "optionC", v)
}
- fileMany := recordAfter.GetStringSlice("file_many")
- if len(fileMany) != 5 {
- t.Fatalf("Expected file_many to be 5, got %v", fileMany)
+ if v := record.GetString("file_one"); v != file.Name {
+ t.Fatalf("Expected record.file_one %q, got %q", file.Name, v)
}
+
+ testFilesCount(t, testApp, record, 2) // the file + attrs
}
-func TestRecordUpsertUploadFailure(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+func TestRecordUpsertPasswordsSync(t *testing.T) {
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("demo3")
+ users, err := testApp.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
- testDaos := []*daos.Dao{
- app.Dao(), // with hooks
- daos.New(app.Dao().DB()), // without hooks
- }
+ t.Run("new user without password", func(t *testing.T) {
+ record := core.NewRecord(users)
- for i, dao := range testDaos {
- // create with invalid file
- {
- prefix := fmt.Sprintf("%d-create", i)
+ form := forms.NewRecordUpsert(testApp, record)
+
+ err := form.Submit()
- new := models.NewRecord(collection)
- new.Id = "123456789012341"
+ tests.TestValidationErrors(t, err, []string{"password", "passwordConfirm"})
+ })
- form := forms.NewRecordUpsert(app, new)
- form.SetDao(dao)
- form.LoadData(map[string]any{"title": "new_test"})
- form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
+ t.Run("new user with manual password", func(t *testing.T) {
+ record := core.NewRecord(users)
- if err := form.Submit(); err == nil {
- t.Fatalf("[%s] Expected error, got nil", prefix)
- }
+ form := forms.NewRecordUpsert(testApp, record)
- if r, err := app.Dao().FindRecordById(collection.Id, new.Id); err == nil {
- t.Fatalf("[%s] Expected the inserted record to be deleted, found \n%v", prefix, r.PublicExport())
- }
+ record.SetPassword("1234567890")
+
+ err := form.Submit()
+ if err != nil {
+ t.Fatalf("Expected no errors, got %v", err)
}
+ })
- // update with invalid file
- {
- prefix := fmt.Sprintf("%d-update", i)
+ t.Run("new user with random password", func(t *testing.T) {
+ record := core.NewRecord(users)
- record, err := app.Dao().FindRecordById(collection.Id, "1tmknxy2868d869")
- if err != nil {
- t.Fatal(err)
- }
+ form := forms.NewRecordUpsert(testApp, record)
- form := forms.NewRecordUpsert(app, record)
- form.SetDao(dao)
- form.LoadData(map[string]any{"title": "update_test"})
- form.AddFiles("files", &filesystem.File{Reader: &filesystem.PathReader{Path: "/tmp/__missing__"}})
+ record.SetRandomPassword()
- if err := form.Submit(); err == nil {
- t.Fatalf("[%s] Expected error, got nil", prefix)
- }
+ err := form.Submit()
+ if err != nil {
+ t.Fatalf("Expected no errors, got %v", err)
+ }
+ })
- if r, _ := app.Dao().FindRecordById(collection.Id, record.Id); r == nil || r.GetString("title") == "update_test" {
- t.Fatalf("[%s] Expected the record changes to be reverted, got \n%v", prefix, r.PublicExport())
- }
+ t.Run("update user with no password change", func(t *testing.T) {
+ record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldHash := record.GetString("password:hash")
+
+ form := forms.NewRecordUpsert(testApp, record)
+
+ err = form.Submit()
+ if err != nil {
+ t.Fatalf("Expected no errors, got %v", err)
+ }
+
+ newHash := record.GetString("password:hash")
+ if newHash == "" || newHash != oldHash {
+ t.Fatal("Expected no password change")
}
+ })
+
+ t.Run("update user with manual password change", func(t *testing.T) {
+ record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldHash := record.GetString("password:hash")
+
+ form := forms.NewRecordUpsert(testApp, record)
+
+ record.SetPassword("1234567890")
+
+ err = form.Submit()
+ if err != nil {
+ t.Fatalf("Expected no errors, got %v", err)
+ }
+
+ newHash := record.GetString("password:hash")
+ if newHash == "" || newHash == oldHash {
+ t.Fatal("Expected password change")
+ }
+ })
+
+ t.Run("update user with random password change", func(t *testing.T) {
+ record, err := testApp.FindAuthRecordByEmail(users, "test@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ oldHash := record.GetString("password:hash")
+
+ form := forms.NewRecordUpsert(testApp, record)
+
+ record.SetRandomPassword()
+
+ err = form.Submit()
+ if err != nil {
+ t.Fatalf("Expected no errors, got %v", err)
+ }
+
+ newHash := record.GetString("password:hash")
+ if newHash == "" || newHash == oldHash {
+ t.Fatal("Expected password change")
+ }
+ })
+}
+
+// -------------------------------------------------------------------
+
+func testFilesCount(t *testing.T, app core.App, record *core.Record, count int) {
+ storageDir := filepath.Join(app.DataDir(), "storage", record.Collection().Id, record.Id)
+
+ entries, _ := os.ReadDir(storageDir)
+ if len(entries) != count {
+ t.Errorf("Expected %d entries, got %d\n%v", count, len(entries), entries)
}
}
diff --git a/forms/record_verification_confirm.go b/forms/record_verification_confirm.go
deleted file mode 100644
index 2d0f7ad552..0000000000
--- a/forms/record_verification_confirm.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package forms
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/spf13/cast"
-)
-
-// RecordVerificationConfirm is an auth record email verification confirmation form.
-type RecordVerificationConfirm struct {
- app core.App
- collection *models.Collection
- dao *daos.Dao
-
- Token string `form:"token" json:"token"`
-}
-
-// NewRecordVerificationConfirm creates a new [RecordVerificationConfirm]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordVerificationConfirm(app core.App, collection *models.Collection) *RecordVerificationConfirm {
- return &RecordVerificationConfirm{
- app: app,
- dao: app.Dao(),
- collection: collection,
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordVerificationConfirm) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *RecordVerificationConfirm) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
- )
-}
-
-func (form *RecordVerificationConfirm) checkToken(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- claims, _ := security.ParseUnverifiedJWT(v)
- email := cast.ToString(claims["email"])
- if email == "" {
- return validation.NewError("validation_invalid_token_claims", "Missing email token claim.")
- }
-
- record, err := form.dao.FindAuthRecordByToken(
- v,
- form.app.Settings().RecordVerificationToken.Secret,
- )
- if err != nil || record == nil {
- return validation.NewError("validation_invalid_token", "Invalid or expired token.")
- }
-
- if record.Collection().Id != form.collection.Id {
- return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
- }
-
- if record.Email() != email {
- return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.")
- }
-
- return nil
-}
-
-// Submit validates and submits the form.
-// On success returns the verified auth record associated to `form.Token`.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
- if err := form.Validate(); err != nil {
- return nil, err
- }
-
- record, err := form.dao.FindAuthRecordByToken(
- form.Token,
- form.app.Settings().RecordVerificationToken.Secret,
- )
- if err != nil {
- return nil, err
- }
-
- wasVerified := record.Verified()
-
- if !wasVerified {
- record.SetVerified(true)
- }
-
- interceptorsErr := runInterceptors(record, func(m *models.Record) error {
- record = m
-
- if wasVerified {
- return nil // already verified
- }
-
- return form.dao.SaveRecord(m)
- }, interceptors...)
-
- if interceptorsErr != nil {
- return nil, interceptorsErr
- }
-
- return record, nil
-}
diff --git a/forms/record_verification_confirm_test.go b/forms/record_verification_confirm_test.go
deleted file mode 100644
index 487b9b35f8..0000000000
--- a/forms/record_verification_confirm_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectError bool
- }{
- // empty data (Validate call check)
- {
- `{}`,
- true,
- },
- // expired token (Validate call check)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"}`,
- true,
- },
- // valid token (already verified record)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"}`,
- false,
- },
- // valid token (unverified record)
- {
- `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"}`,
- false,
- },
- }
-
- for i, s := range scenarios {
- form := forms.NewRecordVerificationConfirm(testApp, authCollection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- record, err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if s.expectError {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if hasErr {
- continue
- }
-
- claims, _ := security.ParseUnverifiedJWT(form.Token)
- tokenRecordId := claims["id"]
-
- if record.Id != tokenRecordId {
- t.Errorf("(%d) Expected record.Id %q, got %q", i, tokenRecordId, record.Id)
- }
-
- if !record.Verified() {
- t.Errorf("(%d) Expected record.Verified() to be true, got false", i)
- }
- }
-}
-
-func TestRecordVerificationConfirmInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordVerificationConfirm(testApp, authCollection)
- form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
- interceptorVerified := authRecord.Verified()
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorVerified = record.Verified()
- interceptor2Called = true
- return testErr
- }
- }
-
- _, submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorVerified == authRecord.Verified() {
- t.Fatalf("Expected the form model to be filled before calling the interceptors")
- }
-}
diff --git a/forms/record_verification_request.go b/forms/record_verification_request.go
deleted file mode 100644
index 09b46799ad..0000000000
--- a/forms/record_verification_request.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package forms
-
-import (
- "errors"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-// RecordVerificationRequest is an auth record email verification request form.
-type RecordVerificationRequest struct {
- app core.App
- collection *models.Collection
- dao *daos.Dao
- resendThreshold float64 // in seconds
-
- Email string `form:"email" json:"email"`
-}
-
-// NewRecordVerificationRequest creates a new [RecordVerificationRequest]
-// form initialized with from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewRecordVerificationRequest(app core.App, collection *models.Collection) *RecordVerificationRequest {
- return &RecordVerificationRequest{
- app: app,
- dao: app.Dao(),
- collection: collection,
- resendThreshold: 120, // 2 min
- }
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *RecordVerificationRequest) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-//
-// // This method doesn't verify that auth record with `form.Email` exists (this is done on Submit).
-func (form *RecordVerificationRequest) Validate() error {
- return validation.ValidateStruct(form,
- validation.Field(
- &form.Email,
- validation.Required,
- validation.Length(1, 255),
- is.EmailFormat,
- ),
- )
-}
-
-// Submit validates and sends a verification request email
-// to the `form.Email` auth record.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- record, err := form.dao.FindFirstRecordByData(
- form.collection.Id,
- schema.FieldNameEmail,
- form.Email,
- )
- if err != nil {
- return err
- }
-
- if !record.Verified() {
- now := time.Now().UTC()
- lastVerificationSentAt := record.LastVerificationSentAt().Time()
- if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
- return errors.New("A verification email was already sent.")
- }
- }
-
- return runInterceptors(record, func(m *models.Record) error {
- if m.Verified() {
- return nil // already verified
- }
-
- if err := mails.SendRecordVerification(form.app, m); err != nil {
- return err
- }
-
- // update last sent timestamp
- m.SetLastVerificationSentAt(types.NowDateTime())
-
- return form.dao.SaveRecord(m)
- }, interceptors...)
-}
diff --git a/forms/record_verification_request_test.go b/forms/record_verification_request_test.go
deleted file mode 100644
index 598ebbd6e6..0000000000
--- a/forms/record_verification_request_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "testing"
- "time"
-
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestRecordVerificationRequestSubmit(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("clients")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []struct {
- jsonData string
- expectError bool
- expectMail bool
- }{
- // empty field (Validate call check)
- {
- `{"email":""}`,
- true,
- false,
- },
- // invalid email field (Validate call check)
- {
- `{"email":"invalid"}`,
- true,
- false,
- },
- // nonexisting user
- {
- `{"email":"missing@example.com"}`,
- true,
- false,
- },
- // existing user (already verified)
- {
- `{"email":"test@example.com"}`,
- false,
- false,
- },
- // existing user (already verified) - repeating request to test threshod skip
- {
- `{"email":"test@example.com"}`,
- false,
- false,
- },
- // existing user (unverified)
- {
- `{"email":"test2@example.com"}`,
- false,
- true,
- },
- // existing user (inverified) - reached send threshod
- {
- `{"email":"test2@example.com"}`,
- true,
- false,
- },
- }
-
- now := types.NowDateTime()
- time.Sleep(1 * time.Millisecond)
-
- for i, s := range scenarios {
- testApp.TestMailer.TotalSend = 0 // reset
- form := forms.NewRecordVerificationRequest(testApp, authCollection)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("[%d] Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(r *models.Record) error {
- interceptorCalls++
- return next(r)
- }
- }
-
- err := form.Submit(interceptor)
-
- // check interceptor calls
- expectInterceptorCalls := 1
- if s.expectError {
- expectInterceptorCalls = 0
- }
- if interceptorCalls != expectInterceptorCalls {
- t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
- }
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- expectedMails := 0
- if s.expectMail {
- expectedMails = 1
- }
- if testApp.TestMailer.TotalSend != expectedMails {
- t.Errorf("[%d] Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
- }
-
- if s.expectError {
- continue
- }
-
- user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
- if err != nil {
- t.Errorf("[%d] Expected user with email %q to exist, got nil", i, form.Email)
- continue
- }
-
- // check whether LastVerificationSentAt was updated
- if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 {
- t.Errorf("[%d] Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
- }
- }
-}
-
-func TestRecordVerificationRequestInterceptors(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
- if err != nil {
- t.Fatal(err)
- }
-
- authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- form := forms.NewRecordVerificationRequest(testApp, authCollection)
- form.Email = authRecord.Email()
- interceptorLastVerificationSentAt := authRecord.LastVerificationSentAt()
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptor1Called = true
- return next(record)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
- return func(record *models.Record) error {
- interceptorLastVerificationSentAt = record.LastVerificationSentAt()
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-
- if interceptorLastVerificationSentAt.String() != authRecord.LastVerificationSentAt().String() {
- t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
- }
-}
diff --git a/forms/settings_upsert.go b/forms/settings_upsert.go
deleted file mode 100644
index f3ee47f4c5..0000000000
--- a/forms/settings_upsert.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package forms
-
-import (
- "os"
- "time"
-
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models/settings"
-)
-
-// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
-type SettingsUpsert struct {
- *settings.Settings
-
- app core.App
- dao *daos.Dao
-}
-
-// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
-// config created from the provided [core.App] instance.
-//
-// If you want to submit the form as part of a transaction,
-// you can change the default Dao via [SetDao()].
-func NewSettingsUpsert(app core.App) *SettingsUpsert {
- form := &SettingsUpsert{
- app: app,
- dao: app.Dao(),
- }
-
- // load the application settings into the form
- form.Settings, _ = app.Settings().Clone()
-
- return form
-}
-
-// SetDao replaces the default form Dao instance with the provided one.
-func (form *SettingsUpsert) SetDao(dao *daos.Dao) {
- form.dao = dao
-}
-
-// Validate makes the form validatable by implementing [validation.Validatable] interface.
-func (form *SettingsUpsert) Validate() error {
- return form.Settings.Validate()
-}
-
-// Submit validates the form and upserts the loaded settings.
-//
-// On success the app settings will be refreshed with the form ones.
-//
-// You can optionally provide a list of InterceptorFunc to further
-// modify the form behavior before persisting it.
-func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Settings]) error {
- if err := form.Validate(); err != nil {
- return err
- }
-
- return runInterceptors(form.Settings, func(s *settings.Settings) error {
- form.Settings = s
-
- oldSettings, err := form.app.Settings().Clone()
- if err != nil {
- return err
- }
-
- // eagerly merge the application settings with the form ones
- if err := form.app.Settings().Merge(form.Settings); err != nil {
- return err
- }
-
- // persists settings change
- encryptionKey := os.Getenv(form.app.EncryptionEnv())
- if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
- // try to revert app settings
- form.app.Settings().Merge(oldSettings)
-
- return err
- }
-
- // explicitly trigger old logs deletion
- form.app.LogsDao().DeleteOldRequests(
- time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
- )
-
- if form.Settings.Logs.MaxDays == 0 {
- // no logs are allowed -> reclaim preserved disk space after the previous delete operation
- form.app.LogsDao().Vacuum()
- }
-
- return nil
- }, interceptors...)
-}
diff --git a/forms/settings_upsert_test.go b/forms/settings_upsert_test.go
deleted file mode 100644
index 53041ffa7b..0000000000
--- a/forms/settings_upsert_test.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package forms_test
-
-import (
- "encoding/json"
- "errors"
- "os"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/forms"
- "github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-func TestNewSettingsUpsert(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- app.Settings().Meta.AppName = "name_update"
-
- form := forms.NewSettingsUpsert(app)
-
- formSettings, _ := json.Marshal(form.Settings)
- appSettings, _ := json.Marshal(app.Settings())
-
- if string(formSettings) != string(appSettings) {
- t.Errorf("Expected settings \n%s, got \n%s", string(appSettings), string(formSettings))
- }
-}
-
-func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- jsonData string
- encryption bool
- expectedErrors []string
- }{
- // empty (plain)
- {"{}", false, nil},
- // empty (encrypt)
- {"{}", true, nil},
- // failure - invalid data
- {
- `{"meta": {"appName": ""}, "logs": {"maxDays": -1}}`,
- false,
- []string{"meta", "logs"},
- },
- // success - valid data (plain)
- {
- `{"meta": {"appName": "test"}, "logs": {"maxDays": 0}}`,
- false,
- nil,
- },
- // success - valid data (encrypt)
- {
- `{"meta": {"appName": "test"}, "logs": {"maxDays": 7}}`,
- true,
- nil,
- },
- }
-
- for i, s := range scenarios {
- if s.encryption {
- os.Setenv(app.EncryptionEnv(), security.RandomString(32))
- } else {
- os.Unsetenv(app.EncryptionEnv())
- }
-
- form := forms.NewSettingsUpsert(app)
-
- // load data
- loadErr := json.Unmarshal([]byte(s.jsonData), form)
- if loadErr != nil {
- t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
- continue
- }
-
- interceptorCalls := 0
- interceptor := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
- return func(s *settings.Settings) error {
- interceptorCalls++
- return next(s)
- }
- }
-
- // parse errors
- result := form.Submit(interceptor)
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check interceptor calls
- expectInterceptorCall := 1
- if len(s.expectedErrors) > 0 {
- expectInterceptorCall = 0
- }
- if interceptorCalls != expectInterceptorCall {
- t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
-
- if len(s.expectedErrors) > 0 {
- continue
- }
-
- formSettings, _ := json.Marshal(form.Settings)
- appSettings, _ := json.Marshal(app.Settings())
-
- if string(formSettings) != string(appSettings) {
- t.Errorf("Expected app settings \n%s, got \n%s", string(appSettings), string(formSettings))
- }
- }
-}
-
-func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- form := forms.NewSettingsUpsert(app)
- form.Meta.AppName = "test_new"
-
- testErr := errors.New("test_error")
-
- interceptor1Called := false
- interceptor1 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
- return func(s *settings.Settings) error {
- interceptor1Called = true
- return next(s)
- }
- }
-
- interceptor2Called := false
- interceptor2 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
- return func(s *settings.Settings) error {
- interceptor2Called = true
- return testErr
- }
- }
-
- submitErr := form.Submit(interceptor1, interceptor2)
- if submitErr != testErr {
- t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
- }
-
- if !interceptor1Called {
- t.Fatalf("Expected interceptor1 to be called")
- }
-
- if !interceptor2Called {
- t.Fatalf("Expected interceptor2 to be called")
- }
-}
diff --git a/forms/test_email_send.go b/forms/test_email_send.go
index 5dd902e41a..f2cda52ecb 100644
--- a/forms/test_email_send.go
+++ b/forms/test_email_send.go
@@ -1,26 +1,30 @@
package forms
import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
+ "errors"
+
+ validation "github.com/pocketbase/ozzo-validation/v4"
+ "github.com/pocketbase/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
+ "github.com/pocketbase/pocketbase/tools/types"
)
const (
- templateVerification = "verification"
- templatePasswordReset = "password-reset"
- templateEmailChange = "email-change"
+ TestTemplateVerification = "verification"
+ TestTemplatePasswordReset = "password-reset"
+ TestTemplateEmailChange = "email-change"
+ TestTemplateOTP = "otp"
+ TestTemplateAuthAlert = "login-alert"
)
// TestEmailSend is a email template test request form.
type TestEmailSend struct {
app core.App
- Template string `form:"template" json:"template"`
- Email string `form:"email" json:"email"`
+ Email string `form:"email" json:"email"`
+ Template string `form:"template" json:"template"`
+ Collection string `form:"collection" json:"collection"` // optional, fallbacks to _superusers
}
// NewTestEmailSend creates and initializes new TestEmailSend form.
@@ -31,6 +35,11 @@ func NewTestEmailSend(app core.App) *TestEmailSend {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *TestEmailSend) Validate() error {
return validation.ValidateStruct(form,
+ validation.Field(
+ &form.Collection,
+ validation.Length(1, 255),
+ validation.By(form.checkAuthCollection),
+ ),
validation.Field(
&form.Email,
validation.Required,
@@ -40,38 +49,70 @@ func (form *TestEmailSend) Validate() error {
validation.Field(
&form.Template,
validation.Required,
- validation.In(templateVerification, templatePasswordReset, templateEmailChange),
+ validation.In(
+ TestTemplateVerification,
+ TestTemplatePasswordReset,
+ TestTemplateEmailChange,
+ TestTemplateOTP,
+ TestTemplateAuthAlert,
+ ),
),
)
}
+func (form *TestEmailSend) checkAuthCollection(value any) error {
+ v, _ := value.(string)
+ if v == "" {
+ return nil // nothing to check
+ }
+
+ c, _ := form.app.FindCollectionByNameOrId(v)
+ if c == nil || !c.IsAuth() {
+ return validation.NewError("validation_invalid_auth_collection", "Must be a valid auth collection id or name.")
+ }
+
+ return nil
+}
+
// Submit validates and sends a test email to the form.Email address.
func (form *TestEmailSend) Submit() error {
if err := form.Validate(); err != nil {
return err
}
- // create a test auth record
- collection := &models.Collection{
- BaseModel: models.BaseModel{Id: "__pb_test_collection_id__"},
- Name: "__pb_test_collection_name__",
- Type: models.CollectionTypeAuth,
+ collectionIdOrName := form.Collection
+ if collectionIdOrName == "" {
+ collectionIdOrName = core.CollectionNameSuperusers
+ }
+
+ collection, err := form.app.FindCollectionByNameOrId(collectionIdOrName)
+ if err != nil {
+ return err
}
- record := models.NewRecord(collection)
- record.Id = "__pb_test_id__"
- record.Set(schema.FieldNameUsername, "pb_test")
- record.Set(schema.FieldNameEmail, form.Email)
+ record := core.NewRecord(collection)
+ for _, field := range collection.Fields {
+ if field.GetHidden() {
+ continue
+ }
+ record.Set(field.GetName(), "__pb_test_"+field.GetName()+"__")
+ }
record.RefreshTokenKey()
+ record.SetEmail(form.Email)
switch form.Template {
- case templateVerification:
+ case TestTemplateVerification:
return mails.SendRecordVerification(form.app, record)
- case templatePasswordReset:
+ case TestTemplatePasswordReset:
return mails.SendRecordPasswordReset(form.app, record)
- case templateEmailChange:
+ case TestTemplateEmailChange:
return mails.SendRecordChangeEmail(form.app, record, form.Email)
+ case TestTemplateOTP:
+ return mails.SendRecordOTP(form.app, record, "_PB_TEST_OTP_ID_", "123456")
+ case TestTemplateAuthAlert:
+ testEvent := types.NowDateTime().String() + " - TEST_IP TEST_USER_AGENT"
+ return mails.SendRecordAuthAlert(form.app, record, testEvent)
+ default:
+ return errors.New("unknown template " + form.Template)
}
-
- return nil
}
diff --git a/forms/test_email_send_test.go b/forms/test_email_send_test.go
index 5d0b7bc2b0..85fb1038aa 100644
--- a/forms/test_email_send_test.go
+++ b/forms/test_email_send_test.go
@@ -1,55 +1,61 @@
package forms_test
import (
+ "fmt"
"strings"
"testing"
- validation "github.com/go-ozzo/ozzo-validation/v4"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestEmailSendValidateAndSubmit(t *testing.T) {
+ t.Parallel()
+
scenarios := []struct {
template string
email string
+ collection string
expectedErrors []string
}{
- {"", "", []string{"template", "email"}},
- {"invalid", "test@example.com", []string{"template"}},
- {"verification", "invalid", []string{"email"}},
- {"verification", "test@example.com", nil},
- {"password-reset", "test@example.com", nil},
- {"email-change", "test@example.com", nil},
+ {"", "", "", []string{"template", "email"}},
+ {"invalid", "test@example.com", "", []string{"template"}},
+ {forms.TestTemplateVerification, "invalid", "", []string{"email"}},
+ {forms.TestTemplateVerification, "test@example.com", "invalid", []string{"collection"}},
+ {forms.TestTemplateVerification, "test@example.com", "demo1", []string{"collection"}},
+ {forms.TestTemplateVerification, "test@example.com", "users", nil},
+ {forms.TestTemplatePasswordReset, "test@example.com", "", nil},
+ {forms.TestTemplateEmailChange, "test@example.com", "", nil},
+ {forms.TestTemplateOTP, "test@example.com", "", nil},
+ {forms.TestTemplateAuthAlert, "test@example.com", "", nil},
}
for i, s := range scenarios {
- func() {
+ t.Run(fmt.Sprintf("%d_%s", i, s.template), func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewTestEmailSend(app)
form.Email = s.email
form.Template = s.template
+ form.Collection = s.collection
result := form.Submit()
// parse errors
errs, ok := result.(validation.Errors)
if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- return
+ t.Fatalf("Failed to parse errors %v", result)
}
// check errors
if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- return
+ t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
}
for _, k := range s.expectedErrors {
if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- return
+ t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
@@ -58,24 +64,33 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
expectedEmails = 0
}
- if app.TestMailer.TotalSend != expectedEmails {
- t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
+ if app.TestMailer.TotalSend() != expectedEmails {
+ t.Fatalf("Expected %d email(s) to be sent, got %d", expectedEmails, app.TestMailer.TotalSend())
}
if len(s.expectedErrors) > 0 {
return
}
- expectedContent := "Verify"
- if s.template == "password-reset" {
+ var expectedContent string
+ switch s.template {
+ case forms.TestTemplatePasswordReset:
expectedContent = "Reset password"
- } else if s.template == "email-change" {
+ case forms.TestTemplateEmailChange:
expectedContent = "Confirm new email"
+ case forms.TestTemplateVerification:
+ expectedContent = "Verify"
+ case forms.TestTemplateOTP:
+ expectedContent = "one-time password"
+ case forms.TestTemplateAuthAlert:
+ expectedContent = "from a new location"
+ default:
+ expectedContent = "__UNKNOWN_TEMPLATE__"
}
- if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
- t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
+ if !strings.Contains(app.TestMailer.LastMessage().HTML, expectedContent) {
+ t.Errorf("Expected the email to contains %q, got\n%v", expectedContent, app.TestMailer.LastMessage().HTML)
}
- }()
+ })
}
}
diff --git a/forms/test_s3_filesystem.go b/forms/test_s3_filesystem.go
index c2e26e59b4..54a2763717 100644
--- a/forms/test_s3_filesystem.go
+++ b/forms/test_s3_filesystem.go
@@ -4,9 +4,8 @@ import (
"errors"
"fmt"
- validation "github.com/go-ozzo/ozzo-validation/v4"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -46,7 +45,7 @@ func (form *TestS3Filesystem) Submit() error {
return err
}
- var s3Config settings.S3Config
+ var s3Config core.S3Config
if form.Filesystem == s3FilesystemBackups {
s3Config = form.app.Settings().Backups.S3
diff --git a/forms/test_s3_filesystem_test.go b/forms/test_s3_filesystem_test.go
index 79091db43c..bd81b77e63 100644
--- a/forms/test_s3_filesystem_test.go
+++ b/forms/test_s3_filesystem_test.go
@@ -3,14 +3,13 @@ package forms_test
import (
"testing"
- validation "github.com/go-ozzo/ozzo-validation/v4"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)
func TestS3FilesystemValidate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+ t.Parallel()
scenarios := []struct {
name string
@@ -40,32 +39,37 @@ func TestS3FilesystemValidate(t *testing.T) {
}
for _, s := range scenarios {
- form := forms.NewTestS3Filesystem(app)
- form.Filesystem = s.filesystem
+ t.Run(s.name, func(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
- result := form.Validate()
+ form := forms.NewTestS3Filesystem(app)
+ form.Filesystem = s.filesystem
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
+ result := form.Validate()
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
- continue
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
+ // parse errors
+ errs, ok := result.(validation.Errors)
+ if !ok && result != nil {
+ t.Fatalf("Failed to parse errors %v", result)
}
- }
+
+ // check errors
+ if len(errs) > len(s.expectedErrors) {
+ t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
+ }
+ for _, k := range s.expectedErrors {
+ if _, ok := errs[k]; !ok {
+ t.Fatalf("Missing expected error key %q in %v", k, errs)
+ }
+ }
+ })
}
}
func TestS3FilesystemSubmitFailure(t *testing.T) {
+ t.Parallel()
+
app, _ := tests.NewTestApp()
defer app.Cleanup()
diff --git a/forms/validators/file.go b/forms/validators/file.go
deleted file mode 100644
index 2177078e3b..0000000000
--- a/forms/validators/file.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package validators
-
-import (
- "fmt"
- "strings"
-
- "github.com/gabriel-vasile/mimetype"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/tools/filesystem"
-)
-
-// UploadedFileSize checks whether the validated `rest.UploadedFile`
-// size is no more than the provided maxBytes.
-//
-// Example:
-//
-// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
-func UploadedFileSize(maxBytes int) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(*filesystem.File)
- if v == nil {
- return nil // nothing to validate
- }
-
- if int(v.Size) > maxBytes {
- return validation.NewError(
- "validation_file_size_limit",
- fmt.Sprintf("Failed to upload %q - the maximum allowed file size is %v bytes.", v.OriginalName, maxBytes),
- )
- }
-
- return nil
- }
-}
-
-// UploadedFileMimeType checks whether the validated `rest.UploadedFile`
-// mimetype is within the provided allowed mime types.
-//
-// Example:
-//
-// validMimeTypes := []string{"test/plain","image/jpeg"}
-// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
-func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(*filesystem.File)
- if v == nil {
- return nil // nothing to validate
- }
-
- baseErr := validation.NewError(
- "validation_invalid_mime_type",
- fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
- )
-
- if len(validTypes) == 0 {
- return baseErr
- }
-
- f, err := v.Reader.Open()
- if err != nil {
- return baseErr
- }
- defer f.Close()
-
- filetype, err := mimetype.DetectReader(f)
- if err != nil {
- return baseErr
- }
-
- for _, t := range validTypes {
- if filetype.Is(t) {
- return nil // valid
- }
- }
-
- return validation.NewError(
- "validation_invalid_mime_type",
- fmt.Sprintf(
- "%q mime type must be one of: %s.",
- v.Name,
- strings.Join(validTypes, ", "),
- ),
- )
- }
-}
diff --git a/forms/validators/file_test.go b/forms/validators/file_test.go
deleted file mode 100644
index 6dd440a49a..0000000000
--- a/forms/validators/file_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package validators_test
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/rest"
-)
-
-func TestUploadedFileSize(t *testing.T) {
- data, mp, err := tests.MockMultipartData(nil, "test")
- if err != nil {
- t.Fatal(err)
- }
-
- req := httptest.NewRequest(http.MethodPost, "/", data)
- req.Header.Add("Content-Type", mp.FormDataContentType())
-
- files, err := rest.FindUploadedFiles(req, "test")
- if err != nil {
- t.Fatal(err)
- }
-
- if len(files) != 1 {
- t.Fatalf("Expected one test file, got %d", len(files))
- }
-
- scenarios := []struct {
- maxBytes int
- file *filesystem.File
- expectError bool
- }{
- {0, nil, false},
- {4, nil, false},
- {3, files[0], true}, // all test files have "test" as content
- {4, files[0], false},
- {5, files[0], false},
- }
-
- for i, s := range scenarios {
- err := validators.UploadedFileSize(s.maxBytes)(s.file)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
-
-func TestUploadedFileMimeType(t *testing.T) {
- data, mp, err := tests.MockMultipartData(nil, "test")
- if err != nil {
- t.Fatal(err)
- }
-
- req := httptest.NewRequest(http.MethodPost, "/", data)
- req.Header.Add("Content-Type", mp.FormDataContentType())
-
- files, err := rest.FindUploadedFiles(req, "test")
- if err != nil {
- t.Fatal(err)
- }
-
- if len(files) != 1 {
- t.Fatalf("Expected one test file, got %d", len(files))
- }
-
- scenarios := []struct {
- types []string
- file *filesystem.File
- expectError bool
- }{
- {nil, nil, false},
- {[]string{"image/jpeg"}, nil, false},
- {[]string{}, files[0], true},
- {[]string{"image/jpeg"}, files[0], true},
- // test files are detected as "text/plain; charset=utf-8" content type
- {[]string{"image/jpeg", "text/plain; charset=utf-8"}, files[0], false},
- }
-
- for i, s := range scenarios {
- err := validators.UploadedFileMimeType(s.types)(s.file)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
diff --git a/forms/validators/model.go b/forms/validators/model.go
deleted file mode 100644
index a4a126fbc0..0000000000
--- a/forms/validators/model.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package validators
-
-import (
- "database/sql"
- "errors"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
-)
-
-// Compare checks whether the provided model id exists.
-//
-// Example:
-//
-// validation.Field(&form.Id, validation.By(validators.UniqueId(form.dao, tableName)))
-func UniqueId(dao *daos.Dao, tableName string) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- var foundId string
-
- err := dao.DB().
- Select("id").
- From(tableName).
- Where(dbx.HashExp{"id": v}).
- Limit(1).
- Row(&foundId)
-
- if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
- return validation.NewError("validation_invalid_id", "The model id is invalid or already exists.")
- }
-
- return nil
- }
-}
diff --git a/forms/validators/model_test.go b/forms/validators/model_test.go
deleted file mode 100644
index 45b486d58a..0000000000
--- a/forms/validators/model_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package validators_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestUniqueId(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- scenarios := []struct {
- id string
- tableName string
- expectError bool
- }{
- {"", "", false},
- {"test", "", true},
- {"wsmn24bux7wo113", "_collections", true},
- {"test_unique_id", "unknown_table", true},
- {"test_unique_id", "_collections", false},
- }
-
- for i, s := range scenarios {
- err := validators.UniqueId(app.Dao(), s.tableName)(s.id)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
diff --git a/forms/validators/record_data.go b/forms/validators/record_data.go
deleted file mode 100644
index cf2d3cc022..0000000000
--- a/forms/validators/record_data.go
+++ /dev/null
@@ -1,384 +0,0 @@
-package validators
-
-import (
- "fmt"
- "net/url"
- "regexp"
- "strings"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-var requiredErr = validation.NewError("validation_required", "Missing required value")
-
-// NewRecordDataValidator creates new [models.Record] data validator
-// using the provided record constraints and schema.
-//
-// Example:
-//
-// validator := NewRecordDataValidator(app.Dao(), record, nil)
-// err := validator.Validate(map[string]any{"test":123})
-func NewRecordDataValidator(
- dao *daos.Dao,
- record *models.Record,
- uploadedFiles map[string][]*filesystem.File,
-) *RecordDataValidator {
- return &RecordDataValidator{
- dao: dao,
- record: record,
- uploadedFiles: uploadedFiles,
- }
-}
-
-// RecordDataValidator defines a model.Record data validator
-// using the provided record constraints and schema.
-type RecordDataValidator struct {
- dao *daos.Dao
- record *models.Record
- uploadedFiles map[string][]*filesystem.File
-}
-
-// Validate validates the provided `data` by checking it against
-// the validator record constraints and schema.
-func (validator *RecordDataValidator) Validate(data map[string]any) error {
- keyedSchema := validator.record.Collection().Schema.AsMap()
- if len(keyedSchema) == 0 {
- return nil // no fields to check
- }
-
- if len(data) == 0 {
- return validation.NewError("validation_empty_data", "No data to validate")
- }
-
- errs := validation.Errors{}
-
- // check for unknown fields
- for key := range data {
- if _, ok := keyedSchema[key]; !ok {
- errs[key] = validation.NewError("validation_unknown_field", "Unknown field")
- }
- }
- if len(errs) > 0 {
- return errs
- }
-
- for key, field := range keyedSchema {
- // normalize value to emulate the same behavior
- // when fetching or persisting the record model
- value := field.PrepareValue(data[key])
-
- // check required constraint
- if field.Required && validation.Required.Validate(value) != nil {
- errs[key] = requiredErr
- continue
- }
-
- // validate field value by its field type
- if err := validator.checkFieldValue(field, value); err != nil {
- errs[key] = err
- continue
- }
- }
-
- if len(errs) == 0 {
- return nil
- }
-
- return errs
-}
-
-func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField, value any) error {
- switch field.Type {
- case schema.FieldTypeText:
- return validator.checkTextValue(field, value)
- case schema.FieldTypeNumber:
- return validator.checkNumberValue(field, value)
- case schema.FieldTypeBool:
- return validator.checkBoolValue(field, value)
- case schema.FieldTypeEmail:
- return validator.checkEmailValue(field, value)
- case schema.FieldTypeUrl:
- return validator.checkUrlValue(field, value)
- case schema.FieldTypeEditor:
- return validator.checkEditorValue(field, value)
- case schema.FieldTypeDate:
- return validator.checkDateValue(field, value)
- case schema.FieldTypeSelect:
- return validator.checkSelectValue(field, value)
- case schema.FieldTypeJson:
- return validator.checkJsonValue(field, value)
- case schema.FieldTypeFile:
- return validator.checkFileValue(field, value)
- case schema.FieldTypeRelation:
- return validator.checkRelationValue(field, value)
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField, value any) error {
- val, _ := value.(string)
- if val == "" {
- return nil // nothing to check (skip zero-defaults)
- }
-
- options, _ := field.Options.(*schema.TextOptions)
-
- if options.Min != nil && len(val) < *options.Min {
- return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
- }
-
- if options.Max != nil && len(val) > *options.Max {
- return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
- }
-
- if options.Pattern != "" {
- match, _ := regexp.MatchString(options.Pattern, val)
- if !match {
- return validation.NewError("validation_invalid_format", "Invalid value format")
- }
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField, value any) error {
- val, _ := value.(float64)
- if val == 0 {
- return nil // nothing to check (skip zero-defaults)
- }
-
- options, _ := field.Options.(*schema.NumberOptions)
-
- if options.NoDecimal && val != float64(int64(val)) {
- return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed")
- }
-
- if options.Min != nil && val < *options.Min {
- return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min))
- }
-
- if options.Max != nil && val > *options.Max {
- return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *options.Max))
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkBoolValue(field *schema.SchemaField, value any) error {
- return nil
-}
-
-func (validator *RecordDataValidator) checkEmailValue(field *schema.SchemaField, value any) error {
- val, _ := value.(string)
- if val == "" {
- return nil // nothing to check
- }
-
- if is.EmailFormat.Validate(val) != nil {
- return validation.NewError("validation_invalid_email", "Must be a valid email")
- }
-
- options, _ := field.Options.(*schema.EmailOptions)
- domain := val[strings.LastIndex(val, "@")+1:]
-
- // only domains check
- if len(options.OnlyDomains) > 0 && !list.ExistInSlice(domain, options.OnlyDomains) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
- }
-
- // except domains check
- if len(options.ExceptDomains) > 0 && list.ExistInSlice(domain, options.ExceptDomains) {
- return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed")
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, value any) error {
- val, _ := value.(string)
- if val == "" {
- return nil // nothing to check
- }
-
- if is.URL.Validate(val) != nil {
- return validation.NewError("validation_invalid_url", "Must be a valid url")
- }
-
- options, _ := field.Options.(*schema.UrlOptions)
-
- // extract host/domain
- u, _ := url.Parse(val)
- host := u.Host
-
- // only domains check
- if len(options.OnlyDomains) > 0 && !list.ExistInSlice(host, options.OnlyDomains) {
- return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
- }
-
- // except domains check
- if len(options.ExceptDomains) > 0 && list.ExistInSlice(host, options.ExceptDomains) {
- return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed")
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkEditorValue(field *schema.SchemaField, value any) error {
- return nil
-}
-
-func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
- val, _ := value.(types.DateTime)
- if val.IsZero() {
- if field.Required {
- return requiredErr
- }
- return nil // nothing to check
- }
-
- options, _ := field.Options.(*schema.DateOptions)
-
- if !options.Min.IsZero() {
- if err := validation.Min(options.Min.Time()).Validate(val.Time()); err != nil {
- return err
- }
- }
-
- if !options.Max.IsZero() {
- if err := validation.Max(options.Max.Time()).Validate(val.Time()); err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error {
- normalizedVal := list.ToUniqueStringSlice(value)
- if len(normalizedVal) == 0 {
- if field.Required {
- return requiredErr
- }
- return nil // nothing to check
- }
-
- options, _ := field.Options.(*schema.SelectOptions)
-
- // check max selected items
- if len(normalizedVal) > options.MaxSelect {
- return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
- }
-
- // check against the allowed values
- for _, val := range normalizedVal {
- if !list.ExistInSlice(val, options.Values) {
- return validation.NewError("validation_invalid_value", "Invalid value "+val)
- }
- }
-
- return nil
-}
-
-var emptyJsonValues = []string{
- "null", `""`, "[]", "{}",
-}
-
-func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
- if is.JSON.Validate(value) != nil {
- return validation.NewError("validation_invalid_json", "Must be a valid json value")
- }
-
- raw, _ := types.ParseJsonRaw(value)
- rawStr := strings.TrimSpace(raw.String())
-
- if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
- return requiredErr
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error {
- names := list.ToUniqueStringSlice(value)
- if len(names) == 0 && field.Required {
- return requiredErr
- }
-
- options, _ := field.Options.(*schema.FileOptions)
-
- if len(names) > options.MaxSelect {
- return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
- }
-
- // extract the uploaded files
- files := make([]*filesystem.File, 0, len(validator.uploadedFiles[field.Name]))
- for _, file := range validator.uploadedFiles[field.Name] {
- if list.ExistInSlice(file.Name, names) {
- files = append(files, file)
- }
- }
-
- for _, file := range files {
- // check size
- if err := UploadedFileSize(options.MaxSize)(file); err != nil {
- return err
- }
-
- // check type
- if len(options.MimeTypes) > 0 {
- if err := UploadedFileMimeType(options.MimeTypes)(file); err != nil {
- return err
- }
- }
- }
-
- return nil
-}
-
-func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error {
- ids := list.ToUniqueStringSlice(value)
- if len(ids) == 0 {
- if field.Required {
- return requiredErr
- }
- return nil // nothing to check
- }
-
- options, _ := field.Options.(*schema.RelationOptions)
-
- if options.MinSelect != nil && len(ids) < *options.MinSelect {
- return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", *options.MinSelect))
- }
-
- if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
- return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
- }
-
- // check if the related records exist
- // ---
- relCollection, err := validator.dao.FindCollectionByNameOrId(options.CollectionId)
- if err != nil {
- return validation.NewError("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed")
- }
-
- var total int
- validator.dao.RecordQuery(relCollection).
- Select("count(*)").
- AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
- Row(&total)
- if total != len(ids) {
- return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids")
- }
- // ---
-
- return nil
-}
diff --git a/forms/validators/record_data_test.go b/forms/validators/record_data_test.go
deleted file mode 100644
index 2de37d566d..0000000000
--- a/forms/validators/record_data_test.go
+++ /dev/null
@@ -1,1279 +0,0 @@
-package validators_test
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/forms/validators"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tests"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/rest"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-type testDataFieldScenario struct {
- name string
- data map[string]any
- files map[string][]*filesystem.File
- expectedErrors []string
-}
-
-func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
- record := models.NewRecord(collection)
- validator := validators.NewRecordDataValidator(app.Dao(), record, nil)
-
- emptyErr := validator.Validate(map[string]any{})
- if emptyErr == nil {
- t.Fatal("Expected error for empty data, got nil")
- }
-
- unknownErr := validator.Validate(map[string]any{"unknown": 123})
- if unknownErr == nil {
- t.Fatal("Expected error for unknown data, got nil")
- }
-}
-
-func TestRecordDataValidatorValidateText(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- min := 3
- max := 10
- pattern := `^\w+$`
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeText,
- Options: &schema.TextOptions{
- Min: &min,
- Max: &max,
- Pattern: pattern,
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", "test")
- dummy.Set("field2", "test")
- dummy.Set("field3", "test")
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(text) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(text) check min constraint",
- map[string]any{
- "field1": "test",
- "field2": "test",
- "field3": strings.Repeat("a", min-1),
- },
- nil,
- []string{"field3"},
- },
- {
- "(text) check max constraint",
- map[string]any{
- "field1": "test",
- "field2": "test",
- "field3": strings.Repeat("a", max+1),
- },
- nil,
- []string{"field3"},
- },
- {
- "(text) check pattern constraint",
- map[string]any{
- "field1": nil,
- "field2": "test",
- "field3": "test!",
- },
- nil,
- []string{"field3"},
- },
- {
- "(text) valid data (only required)",
- map[string]any{
- "field2": "test",
- },
- nil,
- []string{},
- },
- {
- "(text) valid data (all)",
- map[string]any{
- "field1": "test",
- "field2": 12345, // test value cast
- "field3": "test2",
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateNumber(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- min := 2.0
- max := 150.0
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeNumber,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeNumber,
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeNumber,
- Options: &schema.NumberOptions{
- Min: &min,
- Max: &max,
- },
- },
- &schema.SchemaField{
- Name: "field4",
- Type: schema.FieldTypeNumber,
- Options: &schema.NumberOptions{
- NoDecimal: true,
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", 123)
- dummy.Set("field2", 123)
- dummy.Set("field3", 123)
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(number) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- "field4": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(number) check required constraint + casting",
- map[string]any{
- "field1": "invalid",
- "field2": "invalid",
- "field3": "invalid",
- "field4": "invalid",
- },
- nil,
- []string{"field2"},
- },
- {
- "(number) check min constraint",
- map[string]any{
- "field1": 0.5,
- "field2": 1,
- "field3": min - 0.5,
- },
- nil,
- []string{"field3"},
- },
- {
- "(number) check min with zero-default",
- map[string]any{
- "field2": 1,
- "field3": 0,
- },
- nil,
- []string{},
- },
- {
- "(number) check max constraint",
- map[string]any{
- "field1": nil,
- "field2": max,
- "field3": max + 0.5,
- },
- nil,
- []string{"field3"},
- },
- {
- "(number) check NoDecimal",
- map[string]any{
- "field2": 1,
- "field4": 456.789,
- },
- nil,
- []string{"field4"},
- },
- {
- "(number) valid data (only required)",
- map[string]any{
- "field2": 1,
- },
- nil,
- []string{},
- },
- {
- "(number) valid data (all)",
- map[string]any{
- "field1": nil,
- "field2": 123, // test value cast
- "field3": max,
- "field4": 456,
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateBool(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeBool,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeBool,
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeBool,
- Options: &schema.BoolOptions{},
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", false)
- dummy.Set("field2", true)
- dummy.Set("field3", true)
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(bool) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(bool) check required constraint + casting",
- map[string]any{
- "field1": "invalid",
- "field2": "invalid",
- "field3": "invalid",
- },
- nil,
- []string{"field2"},
- },
- {
- "(bool) valid data (only required)",
- map[string]any{
- "field2": 1,
- },
- nil,
- []string{},
- },
- {
- "(bool) valid data (all)",
- map[string]any{
- "field1": false,
- "field2": true,
- "field3": false,
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateEmail(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeEmail,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeEmail,
- Options: &schema.EmailOptions{
- ExceptDomains: []string{"example.com"},
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeEmail,
- Options: &schema.EmailOptions{
- OnlyDomains: []string{"example.com"},
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", "test@demo.com")
- dummy.Set("field2", "test@test.com")
- dummy.Set("field3", "test@example.com")
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(email) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(email) check email format validator",
- map[string]any{
- "field1": "test",
- "field2": "test.com",
- "field3": 123,
- },
- nil,
- []string{"field1", "field2", "field3"},
- },
- {
- "(email) check ExceptDomains constraint",
- map[string]any{
- "field1": "test@example.com",
- "field2": "test@example.com",
- "field3": "test2@example.com",
- },
- nil,
- []string{"field2"},
- },
- {
- "(email) check OnlyDomains constraint",
- map[string]any{
- "field1": "test@test.com",
- "field2": "test@test.com",
- "field3": "test@test.com",
- },
- nil,
- []string{"field3"},
- },
- {
- "(email) valid data (only required)",
- map[string]any{
- "field2": "test@test.com",
- },
- nil,
- []string{},
- },
- {
- "(email) valid data (all)",
- map[string]any{
- "field1": "123@example.com",
- "field2": "test@test.com",
- "field3": "test2@example.com",
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateUrl(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeUrl,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeUrl,
- Options: &schema.UrlOptions{
- ExceptDomains: []string{"example.com"},
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeUrl,
- Options: &schema.UrlOptions{
- OnlyDomains: []string{"example.com"},
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", "http://demo.com")
- dummy.Set("field2", "http://test.com")
- dummy.Set("field3", "http://example.com")
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(url) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(url) check url format validator",
- map[string]any{
- "field1": "/abc",
- "field2": "test.com", // valid
- "field3": "test@example.com",
- },
- nil,
- []string{"field1", "field3"},
- },
- {
- "(url) check ExceptDomains constraint",
- map[string]any{
- "field1": "http://example.com",
- "field2": "http://example.com",
- "field3": "https://example.com",
- },
- nil,
- []string{"field2"},
- },
- {
- "(url) check OnlyDomains constraint",
- map[string]any{
- "field1": "http://test.com/abc",
- "field2": "http://test.com/abc",
- "field3": "http://test.com/abc",
- },
- nil,
- []string{"field3"},
- },
- {
- "(url) check subdomains constraint",
- map[string]any{
- "field1": "http://test.test.com",
- "field2": "http://test.example.com",
- "field3": "http://test.example.com",
- },
- nil,
- []string{"field3"},
- },
- {
- "(url) valid data (only required)",
- map[string]any{
- "field2": "http://sub.test.com/abc",
- },
- nil,
- []string{},
- },
- {
- "(url) valid data (all)",
- map[string]any{
- "field1": "http://example.com/123",
- "field2": "http://test.com/",
- "field3": "http://example.com/test2",
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateDate(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- min, _ := types.ParseDateTime("2022-01-01 01:01:01.123")
- max, _ := types.ParseDateTime("2030-01-01 01:01:01")
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeDate,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeDate,
- Options: &schema.DateOptions{
- Min: min,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeDate,
- Options: &schema.DateOptions{
- Max: max,
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", "2022-01-01 01:01:01")
- dummy.Set("field2", "2029-01-01 01:01:01.123")
- dummy.Set("field3", "2029-01-01 01:01:01.123")
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(date) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(date) check required constraint + cast",
- map[string]any{
- "field1": "invalid",
- "field2": "invalid",
- "field3": "invalid",
- },
- nil,
- []string{"field2"},
- },
- {
- "(date) check required constraint + zero datetime",
- map[string]any{
- "field1": "January 1, year 1, 00:00:00 UTC",
- "field2": "0001-01-01 00:00:00",
- "field3": "0001-01-01 00:00:00 +0000 UTC",
- },
- nil,
- []string{"field2"},
- },
- {
- "(date) check min date constraint",
- map[string]any{
- "field1": "2021-01-01 01:01:01",
- "field2": "2021-01-01 01:01:01",
- "field3": "2021-01-01 01:01:01",
- },
- nil,
- []string{"field2"},
- },
- {
- "(date) check max date constraint",
- map[string]any{
- "field1": "2030-02-01 01:01:01",
- "field2": "2030-02-01 01:01:01",
- "field3": "2030-02-01 01:01:01",
- },
- nil,
- []string{"field3"},
- },
- {
- "(date) valid data (only required)",
- map[string]any{
- "field2": "2029-01-01 01:01:01",
- },
- nil,
- []string{},
- },
- {
- "(date) valid data (all)",
- map[string]any{
- "field1": "2029-01-01 01:01:01.000",
- "field2": "2029-01-01 01:01:01",
- "field3": "2029-01-01 01:01:01.456",
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateSelect(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"1", "a", "b", "c"},
- MaxSelect: 1,
- },
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"a", "b", "c"},
- MaxSelect: 2,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"a", "b", "c"},
- MaxSelect: 99,
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", "a")
- dummy.Set("field2", []string{"a", "b"})
- dummy.Set("field3", []string{"a", "b", "c"})
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(select) check required constraint",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(select) check required constraint - empty values",
- map[string]any{
- "field1": "",
- "field2": "",
- "field3": "",
- },
- nil,
- []string{"field2"},
- },
- {
- "(select) check required constraint - multiple select cast",
- map[string]any{
- "field1": "a",
- "field2": "a",
- "field3": "a",
- },
- nil,
- []string{},
- },
- {
- "(select) check Values constraint",
- map[string]any{
- "field1": 1,
- "field2": "d",
- "field3": 123,
- },
- nil,
- []string{"field2", "field3"},
- },
- {
- "(select) check MaxSelect constraint",
- map[string]any{
- "field1": []string{"a", "b"}, // this will be normalized to a single string value
- "field2": []string{"a", "b", "c"},
- "field3": []string{"a", "b", "b", "b"}, // repeating values will be merged
- },
- nil,
- []string{"field2"},
- },
- {
- "(select) valid data - only required fields",
- map[string]any{
- "field2": []string{"a", "b"},
- },
- nil,
- []string{},
- },
- {
- "(select) valid data - all fields with normalizations",
- map[string]any{
- "field1": "a",
- "field2": []string{"a", "b", "b"}, // will be collapsed
- "field3": "b", // will be normalzied to slice
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateJson(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeJson,
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeJson,
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeJson,
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", `{"test":123}`)
- dummy.Set("field2", `{"test":123}`)
- dummy.Set("field3", `{"test":123}`)
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "(json) check required constraint - nil",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "(json) check required constraint - zero string",
- map[string]any{
- "field1": "",
- "field2": "",
- "field3": "",
- },
- nil,
- []string{"field2"},
- },
- {
- "(json) check required constraint - zero number",
- map[string]any{
- "field1": 0,
- "field2": 0,
- "field3": 0,
- },
- nil,
- []string{},
- },
- {
- "(json) check required constraint - zero slice",
- map[string]any{
- "field1": []string{},
- "field2": []string{},
- "field3": []string{},
- },
- nil,
- []string{"field2"},
- },
- {
- "(json) check required constraint - zero map",
- map[string]any{
- "field1": map[string]string{},
- "field2": map[string]string{},
- "field3": map[string]string{},
- },
- nil,
- []string{"field2"},
- },
- {
- "(json) check json text invalid obj, array and number normalizations",
- map[string]any{
- "field1": `[1 2 3]`,
- "field2": `{a: 123}`,
- "field3": `123.456 abc`,
- },
- nil,
- []string{},
- },
- {
- "(json) check json text reserved literals normalizations",
- map[string]any{
- "field1": `true`,
- "field2": `false`,
- "field3": `null`,
- },
- nil,
- []string{},
- },
- {
- "(json) valid data - only required fields",
- map[string]any{
- "field2": `{"test":123}`,
- },
- nil,
- []string{},
- },
- {
- "(json) valid data - all fields with normalizations",
- map[string]any{
- "field1": []string{"a", "b", "c"},
- "field2": 123,
- "field3": `"test"`,
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateFile(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 3,
- },
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 2,
- MaxSize: 10,
- MimeTypes: []string{"image/jpeg", "text/plain; charset=utf-8"},
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 3,
- MaxSize: 10,
- MimeTypes: []string{"image/jpeg"},
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // stub uploaded files
- data, mp, err := tests.MockMultipartData(nil, "test", "test", "test", "test", "test")
- if err != nil {
- t.Fatal(err)
- }
- req := httptest.NewRequest(http.MethodPost, "/", data)
- req.Header.Add("Content-Type", mp.FormDataContentType())
- testFiles, err := rest.FindUploadedFiles(req, "test")
- if err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "check required constraint - nil",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "check MaxSelect constraint",
- map[string]any{
- "field1": "test1",
- "field2": []string{"test1", testFiles[0].Name, testFiles[3].Name},
- "field3": []string{"test1", "test2", "test3", "test4"},
- },
- map[string][]*filesystem.File{
- "field2": {testFiles[0], testFiles[3]},
- },
- []string{"field2", "field3"},
- },
- {
- "check MaxSize constraint",
- map[string]any{
- "field1": testFiles[0].Name,
- "field2": []string{"test1", testFiles[0].Name},
- "field3": []string{"test1", "test2", "test3"},
- },
- map[string][]*filesystem.File{
- "field1": {testFiles[0]},
- "field2": {testFiles[0]},
- },
- []string{"field1"},
- },
- {
- "check MimeTypes constraint",
- map[string]any{
- "field1": "test1",
- "field2": []string{"test1", testFiles[0].Name},
- "field3": []string{testFiles[1].Name, testFiles[2].Name},
- },
- map[string][]*filesystem.File{
- "field2": {testFiles[0], testFiles[1], testFiles[2]},
- "field3": {testFiles[1], testFiles[2]},
- },
- []string{"field3"},
- },
- {
- "valid data - no new files (just file ids)",
- map[string]any{
- "field1": "test1",
- "field2": []string{"test1", "test2"},
- "field3": []string{"test1", "test2", "test3"},
- },
- nil,
- []string{},
- },
- {
- "valid data - just new files",
- map[string]any{
- "field1": nil,
- "field2": []string{testFiles[0].Name, testFiles[1].Name},
- "field3": nil,
- },
- map[string][]*filesystem.File{
- "field2": {testFiles[0], testFiles[1]},
- },
- []string{},
- },
- {
- "valid data - mixed existing and new files",
- map[string]any{
- "field1": "test1",
- "field2": []string{"test1", testFiles[0].Name},
- "field3": "test1", // will be casted
- },
- map[string][]*filesystem.File{
- "field2": {testFiles[0], testFiles[1], testFiles[2]},
- },
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func TestRecordDataValidatorValidateRelation(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- demo, _ := app.Dao().FindCollectionByNameOrId("demo3")
-
- // demo3 rel ids
- relId1 := "mk5fmymtx4wsprk"
- relId2 := "7nwo8tuiatetxdm"
- relId3 := "lcl9d87w22ml6jy"
- relId4 := "1tmknxy2868d869"
-
- // record rel ids from different collections
- diffRelId1 := "0yxhwia2amd8gec"
- diffRelId2 := "llvuca81nly1qls"
-
- // create new test collection
- collection := &models.Collection{}
- collection.Name = "validate_test"
- collection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(1),
- CollectionId: demo.Id,
- },
- },
- &schema.SchemaField{
- Name: "field2",
- Required: true,
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(2),
- CollectionId: demo.Id,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Unique: true,
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MinSelect: types.Pointer(2),
- CollectionId: demo.Id,
- },
- },
- &schema.SchemaField{
- Name: "field4",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(3),
- CollectionId: "", // missing or non-existing collection id
- },
- },
- )
- if err := app.Dao().SaveCollection(collection); err != nil {
- t.Fatal(err)
- }
-
- // create dummy record (used for the unique check)
- dummy := models.NewRecord(collection)
- dummy.Set("field1", relId1)
- dummy.Set("field2", []string{relId1, relId2})
- dummy.Set("field3", []string{relId1, relId2, relId3})
- if err := app.Dao().SaveRecord(dummy); err != nil {
- t.Fatal(err)
- }
-
- scenarios := []testDataFieldScenario{
- {
- "check required constraint - nil",
- map[string]any{
- "field1": nil,
- "field2": nil,
- "field3": nil,
- },
- nil,
- []string{"field2"},
- },
- {
- "check required constraint - zero id",
- map[string]any{
- "field1": "",
- "field2": "",
- "field3": "",
- },
- nil,
- []string{"field2"},
- },
- {
- "check min constraint",
- map[string]any{
- "field2": relId2,
- "field3": []string{relId1},
- },
- nil,
- []string{"field3"},
- },
- {
- "check nonexisting collection id",
- map[string]any{
- "field2": relId1,
- "field4": relId1,
- },
- nil,
- []string{"field4"},
- },
- {
- "check MaxSelect constraint",
- map[string]any{
- "field1": []string{relId1, relId2}, // will be normalized to relId1 only
- "field2": []string{relId1, relId2, relId3},
- "field3": []string{relId1, relId2, relId3, relId4},
- },
- nil,
- []string{"field2"},
- },
- {
- "check with ids from different collections",
- map[string]any{
- "field1": diffRelId1,
- "field2": []string{relId2, diffRelId1},
- "field3": []string{diffRelId1, diffRelId2},
- },
- nil,
- []string{"field1", "field2", "field3"},
- },
- {
- "valid data - only required fields",
- map[string]any{
- "field2": []string{relId1, relId2},
- },
- nil,
- []string{},
- },
- {
- "valid data - all fields with normalization",
- map[string]any{
- "field1": []string{relId1, relId2},
- "field2": relId2,
- "field3": []string{relId3, relId2, relId1}, // unique is not triggered because the order is different
- },
- nil,
- []string{},
- },
- }
-
- checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
-}
-
-func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
- for i, s := range scenarios {
- validator := validators.NewRecordDataValidator(dao, record, s.files)
- result := validator.Validate(s.data)
-
- prefix := fmt.Sprintf("%d", i)
- if s.name != "" {
- prefix = s.name
- }
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", prefix, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
- }
- }
- }
-}
diff --git a/forms/validators/string.go b/forms/validators/string.go
deleted file mode 100644
index 00eb997d78..0000000000
--- a/forms/validators/string.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package validators
-
-import (
- validation "github.com/go-ozzo/ozzo-validation/v4"
-)
-
-// Compare checks whether the validated value matches another string.
-//
-// Example:
-//
-// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password)))
-func Compare(valueToCompare string) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(string)
-
- if v != valueToCompare {
- return validation.NewError("validation_values_mismatch", "Values don't match.")
- }
-
- return nil
- }
-}
diff --git a/forms/validators/string_test.go b/forms/validators/string_test.go
deleted file mode 100644
index e9a0c6a002..0000000000
--- a/forms/validators/string_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package validators_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/forms/validators"
-)
-
-func TestCompare(t *testing.T) {
- scenarios := []struct {
- valA string
- valB string
- expectError bool
- }{
- {"", "", false},
- {"", "456", true},
- {"123", "", true},
- {"123", "456", true},
- {"123", "123", false},
- }
-
- for i, s := range scenarios {
- err := validators.Compare(s.valA)(s.valB)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
- }
-}
diff --git a/forms/validators/validators.go b/forms/validators/validators.go
deleted file mode 100644
index ec8c217778..0000000000
--- a/forms/validators/validators.go
+++ /dev/null
@@ -1,2 +0,0 @@
-// Package validators implements custom shared PocketBase validators.
-package validators
diff --git a/go.mod b/go.mod
index 904cb514f1..4ca8c8169f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,95 +1,50 @@
module github.com/pocketbase/pocketbase
-go 1.18
+go 1.27
require (
- github.com/AlecAivazis/survey/v2 v2.3.7
- github.com/aws/aws-sdk-go v1.45.3
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2
- github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1
- github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562
- github.com/fatih/color v1.15.0
- github.com/fsnotify/fsnotify v1.6.0
- github.com/gabriel-vasile/mimetype v1.4.2
- github.com/ganigeorgiev/fexpr v0.3.0
- github.com/go-ozzo/ozzo-validation/v4 v4.3.0
- github.com/golang-jwt/jwt/v4 v4.5.0
- github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
- github.com/mattn/go-sqlite3 v1.14.17
- github.com/pocketbase/dbx v1.10.1
- github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1
- github.com/spf13/cast v1.5.1
- github.com/spf13/cobra v1.7.0
- gocloud.dev v0.34.0
- golang.org/x/crypto v0.13.0
- golang.org/x/net v0.15.0
- golang.org/x/oauth2 v0.12.0
- golang.org/x/sync v0.3.0
- modernc.org/sqlite v1.25.0
+ github.com/dop251/goja v0.0.0-20260901132549-43234fa61381
+ github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
+ github.com/fatih/color v1.19.0
+ github.com/fsnotify/fsnotify v1.10.1
+ github.com/gabriel-vasile/mimetype v1.4.15
+ github.com/ganigeorgiev/fexpr v0.6.0
+ github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/pocketbase/dbx v1.12.0
+ github.com/pocketbase/ozzo-validation/v4 v4.3.0
+ github.com/pocketbase/tygoja v0.1.0
+ github.com/spf13/cast v1.10.0
+ github.com/spf13/cobra v1.10.2
+ golang.org/x/crypto v0.57.0
+ golang.org/x/image v0.46.0
+ golang.org/x/net v0.59.0
+ golang.org/x/oauth2 v0.37.0
+ golang.org/x/sync v0.23.0
+ modernc.org/sqlite v1.60.1
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
- github.com/aws/aws-sdk-go-v2 v1.21.0 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.18.39 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.13.37 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect
- github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.83 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.3.42 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 // indirect
- github.com/aws/aws-sdk-go-v2/service/s3 v1.38.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.13.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.6 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.21.5 // indirect
- github.com/aws/smithy-go v1.14.2 // indirect
- github.com/dlclark/regexp2 v1.10.0 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/golang/protobuf v1.5.3 // indirect
- github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 // indirect
- github.com/google/uuid v1.3.1 // indirect
- github.com/google/wire v0.5.0 // indirect
- github.com/googleapis/gax-go/v2 v2.12.0 // indirect
+ github.com/dlclark/regexp2/v2 v2.7.1 // indirect
+ github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
+ github.com/dustin/go-humanize v1.1.0 // indirect
+ github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
+ github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/jmespath/go-jmespath v0.4.0 // indirect
- github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.19 // indirect
- github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
+ github.com/mattn/go-colorable v0.1.16 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasttemplate v1.2.2 // indirect
- go.opencensus.io v0.24.0 // indirect
- golang.org/x/image v0.12.0 // indirect
- golang.org/x/mod v0.12.0 // indirect
- golang.org/x/sys v0.12.0 // indirect
- golang.org/x/term v0.12.0 // indirect
- golang.org/x/text v0.13.0 // indirect
- golang.org/x/time v0.3.0 // indirect
- golang.org/x/tools v0.13.0 // indirect
- golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
- google.golang.org/api v0.138.0 // indirect
- google.golang.org/appengine v1.6.8 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
- google.golang.org/grpc v1.57.0 // indirect
- google.golang.org/protobuf v1.31.0 // indirect
- lukechampine.com/uint128 v1.3.0 // indirect
- modernc.org/cc/v3 v3.41.0 // indirect
- modernc.org/ccgo/v3 v3.16.15 // indirect
- modernc.org/libc v1.24.1 // indirect
- modernc.org/mathutil v1.6.0 // indirect
- modernc.org/memory v1.7.1 // indirect
- modernc.org/opt v0.1.3 // indirect
- modernc.org/strutil v1.2.0 // indirect
- modernc.org/token v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/stretchr/testify v1.8.0 // indirect
+ golang.org/x/mod v0.41.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/text v0.42.0 // indirect
+ golang.org/x/tools v0.50.0 // indirect
+ modernc.org/libc v1.77.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
)
diff --git a/go.sum b/go.sum
index 62b6de9a7b..dc74834f55 100644
--- a/go.sum
+++ b/go.sum
@@ -1,385 +1,149 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o=
-cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
-cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
-cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
-cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
-github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
-github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
-github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
+github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
+github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
-github.com/aws/aws-sdk-go v1.45.3 h1:Q8BksXg2ZUu2dCbA62+UCEtfvqsW8EO4tzt2IVeYAws=
-github.com/aws/aws-sdk-go v1.45.3/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
-github.com/aws/aws-sdk-go-v2 v1.21.0 h1:gMT0IW+03wtYJhRqTVYn0wLzwdnK9sRMcxmtfGzRdJc=
-github.com/aws/aws-sdk-go-v2 v1.21.0/go.mod h1:/RfNgGmRxI+iFOB1OeJUyxiU+9s88k3pfHvDagGEp0M=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 h1:OPLEkmhXf6xFPiz0bLeDArZIDx1NNS4oJyG4nv3Gct0=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13/go.mod h1:gpAbvyDGQFozTEmlTFO8XcQKHzubdq0LzRyJpG6MiXM=
-github.com/aws/aws-sdk-go-v2/config v1.18.39 h1:oPVyh6fuu/u4OiW4qcuQyEtk7U7uuNBmHmJSLg1AJsQ=
-github.com/aws/aws-sdk-go-v2/config v1.18.39/go.mod h1:+NH/ZigdPckFpgB1TRcRuWCB/Kbbvkxc/iNAKTq5RhE=
-github.com/aws/aws-sdk-go-v2/credentials v1.13.37 h1:BvEdm09+ZEh2XtN+PVHPcYwKY3wIeB6pw7vPRM4M9/U=
-github.com/aws/aws-sdk-go-v2/credentials v1.13.37/go.mod h1:ACLrdkd4CLZyXOghZ8IYumQbcooAcp2jo/s2xsFH8IM=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 h1:uDZJF1hu0EVT/4bogChk8DyjSF6fof6uL/0Y26Ma7Fg=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11/go.mod h1:TEPP4tENqBGO99KwVpV9MlOX4NSrSLP8u3KRy2CDwA8=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.83 h1:wcluDLIQ0uYaxv0fCWQRimbXkPdTgWHUD21j1CzXEwc=
-github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.83/go.mod h1:nGCBuon134gW67yAtxHKV73x+tAcY/xG4ZPNPDB1h/I=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 h1:22dGT7PneFMx4+b3pz7lMTRyN8ZKH7M2cW4GP9yUS2g=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41/go.mod h1:CrObHAuPneJBlfEJ5T3szXOUkLEThaGfvnhTf33buas=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 h1:SijA0mgjV8E+8G45ltVHs0fvKpTj8xmZJ3VwhGKtUSI=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35/go.mod h1:SJC1nEVVva1g3pHAIdCp7QsRIkMmLAgoDquQ9Rr8kYw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.42 h1:GPUcE/Yq7Ur8YSUk6lVkoIMWnJNO0HT18GUzCWCgCI0=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.3.42/go.mod h1:rzfdUlfA+jdgLDmPKjd3Chq9V7LVLYo1Nz++Wb91aRo=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 h1:6lJvvkQ9HmbHZ4h/IEwclwv2mrTW8Uq1SOB/kXy0mfw=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4/go.mod h1:1PrKYwxTM+zjpw9Y41KFtoJCQrJ34Z47Y4VgVbfndjo=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 h1:m0QTSI6pZYJTk5WSKx3fm5cNW/DCicVzULBgU/6IyD0=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14/go.mod h1:dDilntgHy9WnHXsh7dDtUPgHKEfTJIBUTHM8OWm0f/0=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 h1:eev2yZX7esGRjqRbnVk1UxMLw4CyVZDpZXRCcy75oQk=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36/go.mod h1:lGnOkH9NJATw0XEPcAknFBj3zzNTEGRHtSw+CwC1YTg=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 h1:CdzPW9kKitgIiLV1+MHobfR5Xg25iYnyzWZhyQuSlDI=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35/go.mod h1:QGF2Rs33W5MaN9gYdEQOBBFPLwTZkEhRwI33f7KIG0o=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 h1:v0jkRigbSD6uOdwcaUQmgEwG1BkPfAPDqaeNt/29ghg=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4/go.mod h1:LhTyt8J04LL+9cIt7pYJ5lbS/U98ZmXovLOR/4LUsk8=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.38.5 h1:A42xdtStObqy7NGvzZKpnyNXvoOmm+FENobZ0/ssHWk=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.38.5/go.mod h1:rDGMZA7f4pbmTtPOk5v5UM2lmX6UAbRnMDJeDvnH7AM=
-github.com/aws/aws-sdk-go-v2/service/sso v1.13.6 h1:2PylFCfKCEDv6PeSN09pC/VUiRd10wi1VfHG5FrW0/g=
-github.com/aws/aws-sdk-go-v2/service/sso v1.13.6/go.mod h1:fIAwKQKBFu90pBxx07BFOMJLpRUGu8VOzLJakeY+0K4=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.6 h1:pSB560BbVj9ZlJZF4WYj5zsytWHWKxg+NgyGV4B2L58=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.6/go.mod h1:yygr8ACQRY2PrEcy3xsUI357stq2AxnFM6DIsR9lij4=
-github.com/aws/aws-sdk-go-v2/service/sts v1.21.5 h1:CQBFElb0LS8RojMJlxRSo/HXipvTZW2S44Lt9Mk2aYQ=
-github.com/aws/aws-sdk-go-v2/service/sts v1.21.5/go.mod h1:VC7JDqsqiwXukYEDjoHh9U0fOJtNWh04FPQz4ct4GGU=
-github.com/aws/smithy-go v1.14.2 h1:MJU9hqBGbvWZdApzpvoF2WAIJDbtjK2NDJSiJP7HblQ=
-github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
-github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
-github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
-github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
-github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
-github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
+github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
-github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
-github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
-github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1 h1:sC/DYk3eEi5cKkpJX1vl+CpAM138dmuW7rutje9Eo4E=
-github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
-github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
-github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
-github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562 h1:ObbB2tzHWWAxzsG5futqeq2Ual2zYlo/+eMkSc5sn8w=
-github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562/go.mod h1:X2TOTJ+Uamd454RFp7ig2tmP3hQg0Z2Qk8gbVQmU0mk=
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
-github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
-github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
-github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
-github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
-github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
-github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
-github.com/ganigeorgiev/fexpr v0.3.0 h1:RwSyJBME+g/XdzlUW0paH/4VXhLHPg+rErtLeC7K8Ew=
-github.com/ganigeorgiev/fexpr v0.3.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
-github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
-github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
-github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
-github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
+github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
+github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
+github.com/dop251/goja v0.0.0-20260901132549-43234fa61381 h1:vzmJ+R9qxuaksCAB+Kn6wtGgQ01cIfKzUGwWWcX5zXI=
+github.com/dop251/goja v0.0.0-20260901132549-43234fa61381/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
+github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
+github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
+github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
+github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
+github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
+github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
+github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
+github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
-github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
-github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE=
-github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
-github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
-github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
-github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 h1:n6vlPhxsA+BW/XsS5+uqi7GyzaLa5MH7qlSLBZtRdiA=
-github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA=
-github.com/google/s2a-go v0.1.5 h1:8IYp3w9nysqv3JH+NJgXJzGbDHzLOTj43BmSkp+O7qg=
-github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
-github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
-github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
-github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM=
-github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
-github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
-github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
-github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
-github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg=
+github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
-github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
-github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
-github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
-github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
-github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 h1:FwuzbVh87iLiUQj1+uQUsuw9x5t9m5n5g7rG7o4svW4=
-github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61/go.mod h1:paQfF1YtHe+GrGg5fOgjsjoCX/UKDr9bc1DoWpZfns8=
-github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
-github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
-github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
+github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
-github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
-github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1 h1:0FQXnhUhBeF/z6UDj9Zm8GQHFHJSUtqLr1h4/YXf3vc=
-github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
+github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
+github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
+github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
+github.com/pocketbase/tygoja v0.1.0 h1:NJtUNHr5OlWdCyblKr8zPA9awmTv41t7LBro4EGcZ/Y=
+github.com/pocketbase/tygoja v0.1.0/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
-github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
-github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
-github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
-github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-gocloud.dev v0.34.0 h1:LzlQY+4l2cMtuNfwT2ht4+fiXwWf/NmPTnXUlLmGif4=
-gocloud.dev v0.34.0/go.mod h1:psKOachbnvY3DAOPbsFVmLIErwsbWPUG2H5i65D38vE=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
-golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
+golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
-golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
+golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
-golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
-golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.138.0 h1:K/tVp05MxNVbHShRw9m7e9VJGdagNeTdMzqPH7AUqr0=
-google.golang.org/api v0.138.0/go.mod h1:4xyob8CxC+0GChNBvEUAk8VBKNvYOTWM9T3v3UfRxuY=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
-google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g=
-google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 h1:nIgk/EEq3/YlnmVVXVnm14rC2oxgs1o0ong4sD/rd44=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
-google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
-lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
-modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
-modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
-modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
-modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
-modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
-modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
-modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
-modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
-modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
-modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
-modernc.org/memory v1.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M=
-modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
-modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
-modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
-modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=
-modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
-modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
-modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
-modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
+modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
+modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
+modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
diff --git a/golangci.yml b/golangci.yml
index 9f3a9f4fbb..24ca0cbe88 100644
--- a/golangci.yml
+++ b/golangci.yml
@@ -1,27 +1,31 @@
+version: "2"
run:
- go: 1.18
concurrency: 4
timeout: 10m
-
linters:
- disable-all: true
+ default: none
enable:
+ - asasalint
- asciicheck
- - depguard
- - exportloopref
- - gofmt
- - goimports
- - gomodguard
+ - gomodguard_v2
- goprintffuncname
- - gosimple
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
+ - reassign
- staticcheck
- - typecheck
- unconvert
- unused
- whitespace
+ exclusions:
+ rules:
+ - path: _test\.go
+ linters:
+ - prealloc
+formatters:
+ enable:
+ - gofmt
+ - goimports
diff --git a/mails/admin.go b/mails/admin.go
deleted file mode 100644
index e4f484a2b2..0000000000
--- a/mails/admin.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package mails
-
-import (
- "fmt"
- "net/mail"
-
- "github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/mails/templates"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/mailer"
- "github.com/pocketbase/pocketbase/tools/rest"
-)
-
-// SendAdminPasswordReset sends a password reset request email to the specified admin.
-func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
- token, tokenErr := tokens.NewAdminResetPasswordToken(app, admin)
- if tokenErr != nil {
- return tokenErr
- }
-
- actionUrl, urlErr := rest.NormalizeUrl(fmt.Sprintf(
- "%s/_/#/confirm-password-reset/%s",
- app.Settings().Meta.AppUrl,
- token,
- ))
- if urlErr != nil {
- return urlErr
- }
-
- params := struct {
- AppName string
- AppUrl string
- Admin *models.Admin
- Token string
- ActionUrl string
- }{
- AppName: app.Settings().Meta.AppName,
- AppUrl: app.Settings().Meta.AppUrl,
- Admin: admin,
- Token: token,
- ActionUrl: actionUrl,
- }
-
- mailClient := app.NewMailClient()
-
- // resolve body template
- body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
- if renderErr != nil {
- return renderErr
- }
-
- message := &mailer.Message{
- From: mail.Address{
- Name: app.Settings().Meta.SenderName,
- Address: app.Settings().Meta.SenderAddress,
- },
- To: []mail.Address{{Address: admin.Email}},
- Subject: "Reset admin password",
- HTML: body,
- }
-
- event := new(core.MailerAdminEvent)
- event.MailClient = mailClient
- event.Message = message
- event.Admin = admin
- event.Meta = map[string]any{"token": token}
-
- return app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
- if err := e.MailClient.Send(e.Message); err != nil {
- return err
- }
-
- return app.OnMailerAfterAdminResetPasswordSend().Trigger(e)
- })
-}
diff --git a/mails/admin_test.go b/mails/admin_test.go
deleted file mode 100644
index 32bb6fe7b5..0000000000
--- a/mails/admin_test.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package mails_test
-
-import (
- "strings"
- "testing"
-
- "github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/tests"
-)
-
-func TestSendAdminPasswordReset(t *testing.T) {
- testApp, _ := tests.NewTestApp()
- defer testApp.Cleanup()
-
- // ensure that action url normalization will be applied
- testApp.Settings().Meta.AppUrl = "http://localhost:8090////"
-
- admin, _ := testApp.Dao().FindAdminByEmail("test@example.com")
-
- err := mails.SendAdminPasswordReset(testApp, admin)
- if err != nil {
- t.Fatal(err)
- }
-
- if testApp.TestMailer.TotalSend != 1 {
- t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend)
- }
-
- expectedParts := []string{
- "http://localhost:8090/_/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
- }
- for _, part := range expectedParts {
- if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
- t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
- }
- }
-}
diff --git a/mails/base.go b/mails/base.go
index 91a0ab7a39..df34c94700 100644
--- a/mails/base.go
+++ b/mails/base.go
@@ -1,4 +1,4 @@
-// Package mails implements various helper methods for sending user and admin
+// Package mails implements various helper methods for sending common
// emails like forgotten password, verification, etc.
package mails
diff --git a/mails/record.go b/mails/record.go
index fc2a93da9b..50916911a8 100644
--- a/mails/record.go
+++ b/mails/record.go
@@ -1,27 +1,63 @@
package mails
import (
+ "html"
"html/template"
"net/mail"
+ "slices"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails/templates"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/mailer"
)
-// SendRecordPasswordReset sends a password reset request email to the specified user.
-func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
- token, tokenErr := tokens.NewRecordResetPasswordToken(app, authRecord)
- if tokenErr != nil {
- return tokenErr
+// SendRecordAuthAlert sends a new device login alert to the specified auth record.
+func SendRecordAuthAlert(app core.App, authRecord *core.Record, info string) error {
+ mailClient := app.NewMailClient()
+
+ info = html.EscapeString(info)
+
+ subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().AuthAlert.EmailTemplate, map[string]any{
+ core.EmailPlaceholderAlertInfo: info,
+ })
+ if err != nil {
+ return err
}
+ message := &mailer.Message{
+ From: mail.Address{
+ Name: app.Settings().Meta.SenderName,
+ Address: app.Settings().Meta.SenderAddress,
+ },
+ To: []mail.Address{{Address: authRecord.Email()}},
+ Subject: subject,
+ HTML: body,
+ }
+
+ event := new(core.MailerRecordEvent)
+ event.App = app
+ event.Mailer = mailClient
+ event.Message = message
+ event.Record = authRecord
+ event.Meta = map[string]any{
+ "info": info,
+ }
+
+ return app.OnMailerRecordAuthAlertSend().Trigger(event, func(e *core.MailerRecordEvent) error {
+ return e.Mailer.Send(e.Message)
+ })
+}
+
+// SendRecordOTP sends OTP email to the specified auth record.
+//
+// This method will also update the "sentTo" field of the related OTP record to the mail sent To address (if the OTP exists and not already assigned).
+func SendRecordOTP(app core.App, authRecord *core.Record, otpId string, pass string) error {
mailClient := app.NewMailClient()
- subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ResetPasswordTemplate)
+ subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().OTP.EmailTemplate, map[string]any{
+ core.EmailPlaceholderOTPId: otpId,
+ core.EmailPlaceholderOTP: pass,
+ })
if err != nil {
return err
}
@@ -37,31 +73,69 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
}
event := new(core.MailerRecordEvent)
- event.MailClient = mailClient
+ event.App = app
+ event.Mailer = mailClient
event.Message = message
- event.Collection = authRecord.Collection()
event.Record = authRecord
- event.Meta = map[string]any{"token": token}
+ event.Meta = map[string]any{
+ "otpId": otpId,
+ "password": pass,
+ }
- return app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
- if err := e.MailClient.Send(e.Message); err != nil {
+ return app.OnMailerRecordOTPSend().Trigger(event, func(e *core.MailerRecordEvent) error {
+ err := e.Mailer.Send(e.Message)
+ if err != nil {
return err
}
- return app.OnMailerAfterRecordResetPasswordSend().Trigger(e)
+ var toAddress string
+ if len(e.Message.To) > 0 {
+ toAddress = e.Message.To[0].Address
+ }
+ if toAddress == "" {
+ return nil
+ }
+
+ otp, err := e.App.FindOTPById(otpId)
+ if err != nil {
+ e.App.Logger().Warn(
+ "Unable to find OTP to update its sentTo field (either it was already deleted or the id is nonexisting)",
+ "error", err,
+ "otpId", otpId,
+ )
+ return nil
+ }
+
+ if otp.SentTo() != "" {
+ return nil // was already sent to another target
+ }
+
+ otp.SetSentTo(toAddress)
+ if err = e.App.Save(otp); err != nil {
+ e.App.Logger().Error(
+ "Failed to update OTP sentTo field",
+ "error", err,
+ "otpId", otpId,
+ "to", toAddress,
+ )
+ }
+
+ return nil
})
}
-// SendRecordVerification sends a verification request email to the specified user.
-func SendRecordVerification(app core.App, authRecord *models.Record) error {
- token, tokenErr := tokens.NewRecordVerifyToken(app, authRecord)
+// SendRecordPasswordReset sends a password reset request email to the specified auth record.
+func SendRecordPasswordReset(app core.App, authRecord *core.Record) error {
+ token, tokenErr := authRecord.NewPasswordResetToken()
if tokenErr != nil {
return tokenErr
}
mailClient := app.NewMailClient()
- subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.VerificationTemplate)
+ subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().ResetPasswordTemplate, map[string]any{
+ core.EmailPlaceholderToken: token,
+ })
if err != nil {
return err
}
@@ -77,31 +151,67 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
}
event := new(core.MailerRecordEvent)
- event.MailClient = mailClient
+ event.App = app
+ event.Mailer = mailClient
event.Message = message
- event.Collection = authRecord.Collection()
event.Record = authRecord
event.Meta = map[string]any{"token": token}
- return app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
- if err := e.MailClient.Send(e.Message); err != nil {
- return err
- }
+ return app.OnMailerRecordPasswordResetSend().Trigger(event, func(e *core.MailerRecordEvent) error {
+ return e.Mailer.Send(e.Message)
+ })
+}
- return app.OnMailerAfterRecordVerificationSend().Trigger(e)
+// SendRecordVerification sends a verification request email to the specified auth record.
+func SendRecordVerification(app core.App, authRecord *core.Record) error {
+ token, tokenErr := authRecord.NewVerificationToken()
+ if tokenErr != nil {
+ return tokenErr
+ }
+
+ mailClient := app.NewMailClient()
+
+ subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().VerificationTemplate, map[string]any{
+ core.EmailPlaceholderToken: token,
+ })
+ if err != nil {
+ return err
+ }
+
+ message := &mailer.Message{
+ From: mail.Address{
+ Name: app.Settings().Meta.SenderName,
+ Address: app.Settings().Meta.SenderAddress,
+ },
+ To: []mail.Address{{Address: authRecord.Email()}},
+ Subject: subject,
+ HTML: body,
+ }
+
+ event := new(core.MailerRecordEvent)
+ event.App = app
+ event.Mailer = mailClient
+ event.Message = message
+ event.Record = authRecord
+ event.Meta = map[string]any{"token": token}
+
+ return app.OnMailerRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
+ return e.Mailer.Send(e.Message)
})
}
-// SendUserChangeEmail sends a change email confirmation email to the specified user.
-func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string) error {
- token, tokenErr := tokens.NewRecordChangeEmailToken(app, record, newEmail)
+// SendRecordChangeEmail sends a change email confirmation email to the specified auth record.
+func SendRecordChangeEmail(app core.App, authRecord *core.Record, newEmail string) error {
+ token, tokenErr := authRecord.NewEmailChangeToken(newEmail)
if tokenErr != nil {
return tokenErr
}
mailClient := app.NewMailClient()
- subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ConfirmEmailChangeTemplate)
+ subject, body, err := resolveEmailTemplate(app, authRecord, authRecord.Collection().ConfirmEmailChangeTemplate, map[string]any{
+ core.EmailPlaceholderToken: token,
+ })
if err != nil {
return err
}
@@ -117,42 +227,74 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
}
event := new(core.MailerRecordEvent)
- event.MailClient = mailClient
+ event.App = app
+ event.Mailer = mailClient
event.Message = message
- event.Collection = record.Collection()
- event.Record = record
+ event.Record = authRecord
event.Meta = map[string]any{
"token": token,
"newEmail": newEmail,
}
- return app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
- if err := e.MailClient.Send(e.Message); err != nil {
- return err
- }
-
- return app.OnMailerAfterRecordChangeEmailSend().Trigger(e)
+ return app.OnMailerRecordEmailChangeSend().Trigger(event, func(e *core.MailerRecordEvent) error {
+ return e.Mailer.Send(e.Message)
})
}
+var nonescapeTypes = []string{
+ core.FieldTypeAutodate,
+ core.FieldTypeDate,
+ core.FieldTypeBool,
+ core.FieldTypeNumber,
+}
+
func resolveEmailTemplate(
app core.App,
- token string,
- emailTemplate settings.EmailTemplate,
+ authRecord *core.Record,
+ emailTemplate core.EmailTemplate,
+ placeholders map[string]any,
) (subject string, body string, err error) {
- subject, rawBody, _ := emailTemplate.Resolve(
- app.Settings().Meta.AppName,
- app.Settings().Meta.AppUrl,
- token,
- )
+ if placeholders == nil {
+ placeholders = map[string]any{}
+ }
+
+ // register default system placeholders
+ if _, ok := placeholders[core.EmailPlaceholderAppName]; !ok {
+ placeholders[core.EmailPlaceholderAppName] = app.Settings().Meta.AppName
+ }
+ if _, ok := placeholders[core.EmailPlaceholderAppURL]; !ok {
+ placeholders[core.EmailPlaceholderAppURL] = app.Settings().Meta.AppURL
+ }
+
+ // register default auth record placeholders
+ for _, field := range authRecord.Collection().Fields {
+ if field.GetHidden() {
+ continue
+ }
+
+ fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
+ if _, ok := placeholders[fieldPlacehodler]; !ok {
+ val := authRecord.GetString(field.GetName())
+
+ // note: the escaping is not strictly necessary but for just in case
+ // the user decide to store and render the email as plain html
+ if !slices.Contains(nonescapeTypes, field.Type()) {
+ val = html.EscapeString(val)
+ }
+
+ placeholders[fieldPlacehodler] = val
+ }
+ }
+
+ subject, rawBody := emailTemplate.Resolve(placeholders)
params := struct {
- HtmlContent template.HTML
+ HTMLContent template.HTML
}{
- HtmlContent: template.HTML(rawBody),
+ HTMLContent: template.HTML(rawBody),
}
- body, err = resolveTemplateContent(params, templates.Layout, templates.HtmlBody)
+ body, err = resolveTemplateContent(params, templates.Layout, templates.HTMLBody)
if err != nil {
return "", "", err
}
diff --git a/mails/record_test.go b/mails/record_test.go
index f885d1f236..24f86d3cb1 100644
--- a/mails/record_test.go
+++ b/mails/record_test.go
@@ -1,6 +1,7 @@
package mails_test
import (
+ "html"
"strings"
"testing"
@@ -8,80 +9,163 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
+func TestSendRecordAuthAlert(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ info := "test_info
"
+
+ user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
+
+ // to test that it is escaped
+ user.Set("name", ""+user.GetString("name")+"
")
+
+ err := mails.SendRecordAuthAlert(testApp, user, info)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if testApp.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend())
+ }
+
+ expectedParts := []string{
+ html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // public and private record placeholder checks
+ "login to your " + testApp.Settings().Meta.AppName + " account from a new location",
+ "If this was you",
+ "If this wasn't you",
+ html.EscapeString(info),
+ }
+ for _, part := range expectedParts {
+ if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
+ t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage().HTML)
+ }
+ }
+}
+
func TestSendRecordPasswordReset(t *testing.T) {
+ t.Parallel()
+
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- // ensure that action url normalization will be applied
- testApp.Settings().Meta.AppUrl = "http://localhost:8090////"
+ user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
- user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ // to test that it is escaped
+ user.Set("name", ""+user.GetString("name")+"
")
err := mails.SendRecordPasswordReset(testApp, user)
if err != nil {
t.Fatal(err)
}
- if testApp.TestMailer.TotalSend != 1 {
- t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend)
+ if testApp.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend())
}
expectedParts := []string{
+ html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
- if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
- t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
+ if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
+ t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage().HTML)
}
}
}
func TestSendRecordVerification(t *testing.T) {
+ t.Parallel()
+
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
+
+ // to test that it is escaped
+ user.Set("name", ""+user.GetString("name")+"
")
err := mails.SendRecordVerification(testApp, user)
if err != nil {
t.Fatal(err)
}
- if testApp.TestMailer.TotalSend != 1 {
- t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend)
+ if testApp.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend())
}
expectedParts := []string{
+ html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
- if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
- t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
+ if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
+ t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage().HTML)
}
}
}
func TestSendRecordChangeEmail(t *testing.T) {
+ t.Parallel()
+
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
- user, _ := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
+ user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
+
+ // to test that it is escaped
+ user.Set("name", ""+user.GetString("name")+"
")
err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
if err != nil {
t.Fatal(err)
}
- if testApp.TestMailer.TotalSend != 1 {
- t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend)
+ if testApp.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend())
}
expectedParts := []string{
+ html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
- if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
- t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
+ if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
+ t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage().HTML)
+ }
+ }
+}
+
+func TestSendRecordOTP(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
+
+ // to test that it is escaped
+ user.Set("name", ""+user.GetString("name")+"
")
+
+ err := mails.SendRecordOTP(testApp, user, "test_otp_id", "test_otp_code")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if testApp.TestMailer.TotalSend() != 1 {
+ t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend())
+ }
+
+ expectedParts := []string{
+ html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
+ "one-time password",
+ "test_otp_code",
+ }
+ for _, part := range expectedParts {
+ if !strings.Contains(testApp.TestMailer.LastMessage().HTML, part) {
+ t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage().HTML)
}
}
}
diff --git a/mails/templates/admin_password_reset.go b/mails/templates/admin_password_reset.go
deleted file mode 100644
index f6207c3fe1..0000000000
--- a/mails/templates/admin_password_reset.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package templates
-
-// Available variables:
-//
-// ```
-// Admin *models.Admin
-// AppName string
-// AppUrl string
-// Token string
-// ActionUrl string
-// ```
-const AdminPasswordResetBody = `
-{{define "content"}}
- Hello,
- Follow this link to reset your admin password for {{.AppName}}.
-
- Reset password
-
- If you did not request to reset your password, please ignore this email and the link will expire on its own.
-{{end}}
-`
diff --git a/mails/templates/html_content.go b/mails/templates/html_content.go
index cb4127514c..34d5845723 100644
--- a/mails/templates/html_content.go
+++ b/mails/templates/html_content.go
@@ -3,6 +3,6 @@ package templates
// Available variables:
//
// ```
-// HtmlContent template.HTML
+// HTMLContent template.HTML
// ```
-const HtmlBody = `{{define "content"}}{{.HtmlContent}}{{end}}`
+const HTMLBody = `{{define "content"}}{{.HTMLContent}}{{end}}`
diff --git a/mails/templates/layout.go b/mails/templates/layout.go
index 7a43ad9205..21e2fc7f48 100644
--- a/mails/templates/layout.go
+++ b/mails/templates/layout.go
@@ -64,7 +64,7 @@ const Layout = `
text-align: center;
padding: 0 20px;
margin: 5px 0;
- font-family: Source Sans Pro, sans-serif, emoji;;
+ font-family: Source Sans Pro, sans-serif, emoji;
font-size: 14px;
font-weight: bold;
border-radius: 6px;
diff --git a/migrations/1640988000_aux_init.go b/migrations/1640988000_aux_init.go
new file mode 100644
index 0000000000..db1b841f59
--- /dev/null
+++ b/migrations/1640988000_aux_init.go
@@ -0,0 +1,36 @@
+package migrations
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+)
+
+func init() {
+ core.SystemMigrations.Add(&core.Migration{
+ Up: func(txApp core.App) error {
+ _, execErr := txApp.AuxDB().NewQuery(`
+ CREATE TABLE IF NOT EXISTS {{_logs}} (
+ [[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
+ [[level]] INTEGER DEFAULT 0 NOT NULL,
+ [[message]] TEXT DEFAULT "" NOT NULL,
+ [[data]] JSON DEFAULT "{}" NOT NULL,
+ [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_logs_level on {{_logs}} ([[level]]);
+ CREATE INDEX IF NOT EXISTS idx_logs_message on {{_logs}} ([[message]]);
+ CREATE INDEX IF NOT EXISTS idx_logs_created_hour on {{_logs}} (strftime('%Y-%m-%d %H:00:00', [[created]]));
+ `).Execute()
+
+ return execErr
+ },
+ Down: func(txApp core.App) error {
+ _, err := txApp.AuxDB().DropTable("_logs").Execute()
+ return err
+ },
+ ReapplyCondition: func(txApp core.App, runner *core.MigrationsRunner, fileName string) (bool, error) {
+ // reapply only if the _logs table doesn't exist
+ exists := txApp.AuxHasTable("_logs")
+ return !exists, nil
+ },
+ })
+}
diff --git a/migrations/1640988000_init.go b/migrations/1640988000_init.go
index d7413b21f8..541f045d1c 100644
--- a/migrations/1640988000_init.go
+++ b/migrations/1640988000_init.go
@@ -1,26 +1,19 @@
-// Package migrations contains the system PocketBase DB migrations.
package migrations
import (
+ "fmt"
"path/filepath"
"runtime"
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tools/migrate"
+ "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
-var AppMigrations migrate.MigrationsList
-
// Register is a short alias for `AppMigrations.Register()`
// that is usually used in external/user defined migrations.
func Register(
- up func(db dbx.Builder) error,
- down func(db dbx.Builder) error,
+ up func(app core.App) error,
+ down func(app core.App) error,
optFilename ...string,
) {
var optFiles []string
@@ -30,29 +23,24 @@ func Register(
_, path, _, _ := runtime.Caller(1)
optFiles = append(optFiles, filepath.Base(path))
}
- AppMigrations.Register(up, down, optFiles...)
+ core.AppMigrations.Register(up, down, optFiles...)
}
func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- _, tablesErr := db.NewQuery(`
- CREATE TABLE {{_admins}} (
- [[id]] TEXT PRIMARY KEY NOT NULL,
- [[avatar]] INTEGER DEFAULT 0 NOT NULL,
- [[email]] TEXT UNIQUE NOT NULL,
- [[tokenKey]] TEXT UNIQUE NOT NULL,
- [[passwordHash]] TEXT NOT NULL,
- [[lastResetSentAt]] TEXT DEFAULT "" NOT NULL,
- [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
- [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
- );
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ if err := createParamsTable(txApp); err != nil {
+ return fmt.Errorf("_params exec error: %w", err)
+ }
+
+ // -----------------------------------------------------------
+ _, execerr := txApp.DB().NewQuery(`
CREATE TABLE {{_collections}} (
- [[id]] TEXT PRIMARY KEY NOT NULL,
+ [[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
[[type]] TEXT DEFAULT "base" NOT NULL,
[[name]] TEXT UNIQUE NOT NULL,
- [[schema]] JSON DEFAULT "[]" NOT NULL,
+ [[fields]] JSON DEFAULT "[]" NOT NULL,
[[indexes]] JSON DEFAULT "[]" NOT NULL,
[[listRule]] TEXT DEFAULT NULL,
[[viewRule]] TEXT DEFAULT NULL,
@@ -64,103 +52,50 @@ func init() {
[[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
);
- CREATE TABLE {{_params}} (
- [[id]] TEXT PRIMARY KEY NOT NULL,
- [[key]] TEXT UNIQUE NOT NULL,
- [[value]] JSON DEFAULT NULL,
- [[created]] TEXT DEFAULT "" NOT NULL,
- [[updated]] TEXT DEFAULT "" NOT NULL
- );
+ CREATE INDEX IF NOT EXISTS idx__collections_type on {{_collections}} ([[type]]);
+ `).Execute()
+ if execerr != nil {
+ return fmt.Errorf("_collections exec error: %w", execerr)
+ }
- CREATE TABLE {{_externalAuths}} (
- [[id]] TEXT PRIMARY KEY NOT NULL,
- [[collectionId]] TEXT NOT NULL,
- [[recordId]] TEXT NOT NULL,
- [[provider]] TEXT NOT NULL,
- [[providerId]] TEXT NOT NULL,
- [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
- [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
- ---
- FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
- );
+ if err := createMFAsCollection(txApp); err != nil {
+ return fmt.Errorf("_mfas error: %w", err)
+ }
- CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
- CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
- `).Execute()
- if tablesErr != nil {
- return tablesErr
+ if err := createOTPsCollection(txApp); err != nil {
+ return fmt.Errorf("_otps error: %w", err)
}
- dao := daos.New(db)
+ if err := createExternalAuthsCollection(txApp); err != nil {
+ return fmt.Errorf("_externalAuths error: %w", err)
+ }
- // inserts default settings
- // -----------------------------------------------------------
- defaultSettings := settings.New()
- if err := dao.SaveSettings(defaultSettings); err != nil {
- return err
+ if err := createAuthOriginsCollection(txApp); err != nil {
+ return fmt.Errorf("_authOrigins error: %w", err)
}
- // inserts the system users collection
- // -----------------------------------------------------------
- usersCollection := &models.Collection{}
- usersCollection.MarkAsNew()
- usersCollection.Id = "_pb_users_auth_"
- usersCollection.Name = "users"
- usersCollection.Type = models.CollectionTypeAuth
- usersCollection.ListRule = types.Pointer("id = @request.auth.id")
- usersCollection.ViewRule = types.Pointer("id = @request.auth.id")
- usersCollection.CreateRule = types.Pointer("")
- usersCollection.UpdateRule = types.Pointer("id = @request.auth.id")
- usersCollection.DeleteRule = types.Pointer("id = @request.auth.id")
-
- // set auth options
- usersCollection.SetOptions(models.CollectionAuthOptions{
- ManageRule: nil,
- AllowOAuth2Auth: true,
- AllowUsernameAuth: true,
- AllowEmailAuth: true,
- MinPasswordLength: 8,
- RequireEmail: false,
- })
-
- // set optional default fields
- usersCollection.Schema = schema.NewSchema(
- &schema.SchemaField{
- Id: "users_name",
- Type: schema.FieldTypeText,
- Name: "name",
- Options: &schema.TextOptions{},
- },
- &schema.SchemaField{
- Id: "users_avatar",
- Type: schema.FieldTypeFile,
- Name: "avatar",
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 5242880,
- MimeTypes: []string{
- "image/jpeg",
- "image/png",
- "image/svg+xml",
- "image/gif",
- "image/webp",
- },
- },
- },
- )
-
- return dao.SaveCollection(usersCollection)
- }, func(db dbx.Builder) error {
+ if err := createSuperusersCollection(txApp); err != nil {
+ return fmt.Errorf("_superusers error: %w", err)
+ }
+
+ if err := createUsersCollection(txApp); err != nil {
+ return fmt.Errorf("users error: %w", err)
+ }
+
+ return nil
+ }, func(txApp core.App) error {
tables := []string{
"users",
- "_externalAuths",
+ core.CollectionNameSuperusers,
+ core.CollectionNameMFAs,
+ core.CollectionNameOTPs,
+ core.CollectionNameAuthOrigins,
"_params",
"_collections",
- "_admins",
}
for _, name := range tables {
- if _, err := db.DropTable(name).Execute(); err != nil {
+ if _, err := txApp.DB().DropTable(name).Execute(); err != nil {
return err
}
}
@@ -168,3 +103,245 @@ func init() {
return nil
})
}
+
+func createParamsTable(txApp core.App) error {
+ _, execErr := txApp.DB().NewQuery(`
+ CREATE TABLE {{_params}} (
+ [[id]] TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
+ [[value]] JSON DEFAULT NULL,
+ [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
+ [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
+ );
+ `).Execute()
+
+ return execErr
+}
+
+func createMFAsCollection(txApp core.App) error {
+ col := core.NewBaseCollection(core.CollectionNameMFAs)
+ col.System = true
+
+ ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
+ col.ListRule = types.Pointer(ownerRule)
+ col.ViewRule = types.Pointer(ownerRule)
+
+ col.Fields.Add(&core.TextField{
+ Name: "collectionRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "recordRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "method",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "created",
+ System: true,
+ OnCreate: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ System: true,
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ col.AddIndex("idx_mfas_collectionRef_recordRef", false, "collectionRef,recordRef", "")
+
+ return txApp.Save(col)
+}
+
+func createOTPsCollection(txApp core.App) error {
+ col := core.NewBaseCollection(core.CollectionNameOTPs)
+ col.System = true
+
+ ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
+ col.ListRule = types.Pointer(ownerRule)
+ col.ViewRule = types.Pointer(ownerRule)
+
+ col.Fields.Add(&core.TextField{
+ Name: "collectionRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "recordRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.PasswordField{
+ Name: "password",
+ System: true,
+ Hidden: true,
+ Required: true,
+ Cost: 8, // low cost for better performance and because it is not critical
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "sentTo",
+ System: true,
+ Hidden: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "created",
+ System: true,
+ OnCreate: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ System: true,
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ col.AddIndex("idx_otps_collectionRef_recordRef", false, "collectionRef, recordRef", "")
+
+ return txApp.Save(col)
+}
+
+func createAuthOriginsCollection(txApp core.App) error {
+ col := core.NewBaseCollection(core.CollectionNameAuthOrigins)
+ col.System = true
+
+ ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
+ col.ListRule = types.Pointer(ownerRule)
+ col.ViewRule = types.Pointer(ownerRule)
+ col.DeleteRule = types.Pointer(ownerRule)
+
+ col.Fields.Add(&core.TextField{
+ Name: "collectionRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "recordRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "fingerprint",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "created",
+ System: true,
+ OnCreate: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ System: true,
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ col.AddIndex("idx_authOrigins_unique_pairs", true, "collectionRef, recordRef, fingerprint", "")
+
+ return txApp.Save(col)
+}
+
+func createExternalAuthsCollection(txApp core.App) error {
+ col := core.NewBaseCollection(core.CollectionNameExternalAuths)
+ col.System = true
+
+ ownerRule := "@request.auth.id != '' && recordRef = @request.auth.id && collectionRef = @request.auth.collectionId"
+ col.ListRule = types.Pointer(ownerRule)
+ col.ViewRule = types.Pointer(ownerRule)
+ col.DeleteRule = types.Pointer(ownerRule)
+
+ col.Fields.Add(&core.TextField{
+ Name: "collectionRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "recordRef",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "provider",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.TextField{
+ Name: "providerId",
+ System: true,
+ Required: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "created",
+ System: true,
+ OnCreate: true,
+ })
+ col.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ System: true,
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ col.AddIndex("idx_externalAuths_record_provider", true, "collectionRef, recordRef, provider", "")
+ col.AddIndex("idx_externalAuths_collection_provider", true, "collectionRef, provider, providerId", "")
+
+ return txApp.Save(col)
+}
+
+func createSuperusersCollection(txApp core.App) error {
+ superusers := core.NewAuthCollection(core.CollectionNameSuperusers)
+ superusers.System = true
+ superusers.Fields.Add(&core.EmailField{
+ Name: "email",
+ System: true,
+ Required: true,
+ })
+ superusers.Fields.Add(&core.AutodateField{
+ Name: "created",
+ System: true,
+ OnCreate: true,
+ })
+ superusers.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ System: true,
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ superusers.AuthToken.Duration = 86400 // 1 day
+
+ return txApp.Save(superusers)
+}
+
+func createUsersCollection(txApp core.App) error {
+ users := core.NewAuthCollection("users", "_pb_users_auth_")
+
+ ownerRule := "id = @request.auth.id"
+ users.ListRule = types.Pointer(ownerRule)
+ users.ViewRule = types.Pointer(ownerRule)
+ users.CreateRule = types.Pointer("")
+ users.UpdateRule = types.Pointer(ownerRule)
+ users.DeleteRule = types.Pointer(ownerRule)
+
+ users.Fields.Add(&core.TextField{
+ Name: "name",
+ Max: 255,
+ })
+ users.Fields.Add(&core.FileField{
+ Name: "avatar",
+ MaxSelect: 1,
+ MimeTypes: []string{"image/jpeg", "image/png", "image/svg+xml", "image/gif", "image/webp"},
+ })
+ users.Fields.Add(&core.AutodateField{
+ Name: "created",
+ OnCreate: true,
+ })
+ users.Fields.Add(&core.AutodateField{
+ Name: "updated",
+ OnCreate: true,
+ OnUpdate: true,
+ })
+ users.OAuth2.MappedFields.Name = "name"
+ users.OAuth2.MappedFields.AvatarURL = "avatar"
+
+ return txApp.Save(users)
+}
diff --git a/migrations/1673167670_multi_match_migrate.go b/migrations/1673167670_multi_match_migrate.go
deleted file mode 100644
index 936df7c00b..0000000000
--- a/migrations/1673167670_multi_match_migrate.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package migrations
-
-import (
- "regexp"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// This migration replaces for backward compatibility the default operators
-// (=, !=, >, etc.) with their any/opt equivalent (?=, ?=, ?>, etc.)
-// in any muli-rel expression collection rule.
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- exprRegex := regexp.MustCompile(`([\@\'\"\w\.]+)\s*(=|!=|~|!~|>|>=|<|<=)\s*([\@\'\"\w\.]+)`)
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().All(&collections); err != nil {
- return err
- }
-
- findCollection := func(nameOrId string) *models.Collection {
- for _, c := range collections {
- if c.Id == nameOrId || c.Name == nameOrId {
- return c
- }
- }
-
- return nil
- }
-
- var isMultiRelLiteral func(mainCollection *models.Collection, literal string) bool
- isMultiRelLiteral = func(mainCollection *models.Collection, literal string) bool {
- if strings.HasPrefix(literal, "@collection.") {
- return true
- }
-
- if strings.HasPrefix(literal, `"`) ||
- strings.HasPrefix(literal, `'`) ||
- strings.HasPrefix(literal, "@request.method") ||
- strings.HasPrefix(literal, "@request.data") ||
- strings.HasPrefix(literal, "@request.query") {
- return false
- }
-
- parts := strings.Split(literal, ".")
- if len(parts) <= 1 {
- return false
- }
-
- if strings.HasPrefix(literal, "@request.auth") && len(parts) >= 4 {
- // check each auth collection
- for _, c := range collections {
- if c.IsAuth() && isMultiRelLiteral(c, strings.Join(parts[2:], ".")) {
- return true
- }
- }
-
- return false
- }
-
- activeCollection := mainCollection
-
- for i, p := range parts {
- f := activeCollection.Schema.GetFieldByName(p)
- if f == nil || f.Type != schema.FieldTypeRelation {
- return false // not a relation field
- }
-
- // is multi-relation and not the last prop
- opt, ok := f.Options.(*schema.RelationOptions)
- if ok && (opt.MaxSelect == nil || *opt.MaxSelect != 1) && i != len(parts)-1 {
- return true
- }
-
- activeCollection = findCollection(opt.CollectionId)
- if activeCollection == nil {
- return false
- }
- }
-
- return false
- }
-
- // replace all multi-match operators to their any/opt equivalent, eg. "=" => "?="
- migrateRule := func(collection *models.Collection, rule *string) (*string, error) {
- if rule == nil || *rule == "" {
- return rule, nil
- }
-
- newRule := *rule
- parts := exprRegex.FindAllStringSubmatch(newRule, -1)
-
- for _, p := range parts {
- if isMultiRelLiteral(collection, p[1]) || isMultiRelLiteral(collection, p[3]) {
- newRule = strings.ReplaceAll(newRule, p[0], p[1]+" ?"+p[2]+" "+p[3])
- }
- }
-
- return &newRule, nil
- }
-
- var ruleErr error
- for _, c := range collections {
- c.ListRule, ruleErr = migrateRule(c, c.ListRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.ViewRule, ruleErr = migrateRule(c, c.ViewRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.CreateRule, ruleErr = migrateRule(c, c.CreateRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.UpdateRule, ruleErr = migrateRule(c, c.UpdateRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.DeleteRule, ruleErr = migrateRule(c, c.DeleteRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- if c.IsAuth() {
- opt := c.AuthOptions()
- opt.ManageRule, ruleErr = migrateRule(c, opt.ManageRule)
- if ruleErr != nil {
- return ruleErr
- }
- c.SetOptions(opt)
- }
-
- if err := dao.Save(c); err != nil {
- return err
- }
- }
-
- return nil
- }, func(db dbx.Builder) error {
- dao := daos.New(db)
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().All(&collections); err != nil {
- return err
- }
-
- anyOpRegex := regexp.MustCompile(`\?(=|!=|~|!~|>|>=|<|<=)`)
-
- // replace any/opt operators to their old versions, eg. "?=" => "="
- revertRule := func(rule *string) (*string, error) {
- if rule == nil || *rule == "" {
- return rule, nil
- }
-
- newRule := *rule
- newRule = anyOpRegex.ReplaceAllString(newRule, "${1}")
-
- return &newRule, nil
- }
-
- var ruleErr error
- for _, c := range collections {
- c.ListRule, ruleErr = revertRule(c.ListRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.ViewRule, ruleErr = revertRule(c.ViewRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.CreateRule, ruleErr = revertRule(c.CreateRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.UpdateRule, ruleErr = revertRule(c.UpdateRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- c.DeleteRule, ruleErr = revertRule(c.DeleteRule)
- if ruleErr != nil {
- return ruleErr
- }
-
- if c.IsAuth() {
- opt := c.AuthOptions()
- opt.ManageRule, ruleErr = revertRule(opt.ManageRule)
- if ruleErr != nil {
- return ruleErr
- }
- c.SetOptions(opt)
- }
-
- if err := dao.Save(c); err != nil {
- return err
- }
- }
-
- return nil
- })
-}
diff --git a/migrations/1677152688_rename_authentik_to_oidc.go b/migrations/1677152688_rename_authentik_to_oidc.go
deleted file mode 100644
index b0b0d7483f..0000000000
--- a/migrations/1677152688_rename_authentik_to_oidc.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-// This migration replaces the "authentikAuth" setting with "oidc".
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- _, err := db.NewQuery(`
- UPDATE {{_params}}
- SET [[value]] = replace([[value]], '"authentikAuth":', '"oidcAuth":')
- WHERE [[key]] = 'settings'
- `).Execute()
-
- return err
- }, func(db dbx.Builder) error {
- _, err := db.NewQuery(`
- UPDATE {{_params}}
- SET [[value]] = replace([[value]], '"oidcAuth":', '"authentikAuth":')
- WHERE [[key]] = 'settings'
- `).Execute()
-
- return err
- })
-}
diff --git a/migrations/1679943780_normalize_single_multiple_values.go b/migrations/1679943780_normalize_single_multiple_values.go
deleted file mode 100644
index bc82bc4ff9..0000000000
--- a/migrations/1679943780_normalize_single_multiple_values.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package migrations
-
-import (
- "fmt"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- return normalizeMultivaluerFields(db)
- }, func(db dbx.Builder) error {
- return nil
- })
-}
-
-func normalizeMultivaluerFields(db dbx.Builder) error {
- dao := daos.New(db)
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().All(&collections); err != nil {
- return err
- }
-
- for _, c := range collections {
- if c.IsView() {
- // skip view collections
- continue
- }
-
- for _, f := range c.Schema.Fields() {
- opt, ok := f.Options.(schema.MultiValuer)
- if !ok {
- continue
- }
-
- var updateQuery *dbx.Query
-
- if opt.IsMultiple() {
- updateQuery = dao.DB().NewQuery(fmt.Sprintf(
- `UPDATE {{%s}} set [[%s]] = (
- CASE
- WHEN COALESCE([[%s]], '') = ''
- THEN '[]'
- ELSE (
- CASE
- WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
- THEN [[%s]]
- ELSE json_array([[%s]])
- END
- )
- END
- )`,
- c.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- ))
- } else {
- updateQuery = dao.DB().NewQuery(fmt.Sprintf(
- `UPDATE {{%s}} set [[%s]] = (
- CASE
- WHEN COALESCE([[%s]], '[]') = '[]'
- THEN ''
- ELSE (
- CASE
- WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
- THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
- ELSE [[%s]]
- END
- )
- END
- )`,
- c.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- f.Name,
- ))
- }
-
- if _, err := updateQuery.Execute(); err != nil {
- return err
- }
- }
- }
-
- // trigger view query update after the records normalization
- // (ignore save error in case of invalid query to allow users to change it from the UI)
- for _, c := range collections {
- if !c.IsView() {
- continue
- }
-
- dao.SaveCollection(c)
- }
-
- return nil
-}
diff --git a/migrations/1679943781_add_indexes_column.go b/migrations/1679943781_add_indexes_column.go
deleted file mode 100644
index 96b1039bee..0000000000
--- a/migrations/1679943781_add_indexes_column.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package migrations
-
-import (
- "fmt"
- "strings"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/dbutils"
- "github.com/pocketbase/pocketbase/tools/list"
-)
-
-// Adds _collections indexes column (if not already).
-//
-// Note: This migration will be deleted once schema.SchemaField.Unuique is removed.
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- // cleanup failed remaining/"dangling" temp views to prevent
- // errors during the indexes upsert
- // ---
- tempViews := []string{}
- viewsErr := db.Select("name").
- From("sqlite_schema").
- AndWhere(dbx.HashExp{"type": "view"}).
- AndWhere(dbx.NewExp(`[[name]] LIKE '\_temp\_%' ESCAPE '\'`)).
- Column(&tempViews)
- if viewsErr != nil {
- return viewsErr
- }
- for _, name := range tempViews {
- if err := dao.DeleteView(name); err != nil {
- return err
- }
- }
- // ---
-
- cols, err := dao.TableColumns("_collections")
- if err != nil {
- return err
- }
-
- var hasIndexesColumn bool
- for _, col := range cols {
- if col == "indexes" {
- // already existing (probably via the init migration)
- hasIndexesColumn = true
- break
- }
- }
-
- if !hasIndexesColumn {
- if _, err := db.AddColumn("_collections", "indexes", `JSON DEFAULT "[]" NOT NULL`).Execute(); err != nil {
- return err
- }
- }
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().AndWhere(dbx.NewExp("type != 'view'")).All(&collections); err != nil {
- return err
- }
-
- type indexInfo struct {
- Sql string `db:"sql"`
- IndexName string `db:"name"`
- TableName string `db:"tbl_name"`
- }
-
- indexesQuery := db.NewQuery(`SELECT * FROM sqlite_master WHERE type = "index" and sql is not null`)
- rawIndexes := []indexInfo{}
- if err := indexesQuery.All(&rawIndexes); err != nil {
- return err
- }
-
- indexesByTableName := map[string][]indexInfo{}
- for _, idx := range rawIndexes {
- indexesByTableName[idx.TableName] = append(indexesByTableName[idx.TableName], idx)
- }
-
- for _, c := range collections {
- c.Indexes = nil // reset
-
- excludeIndexes := []string{
- "_" + c.Id + "_email_idx",
- "_" + c.Id + "_username_idx",
- "_" + c.Id + "_tokenKey_idx",
- }
-
- // convert custom indexes into the related collections
- for _, idx := range indexesByTableName[c.Name] {
- if strings.Contains(idx.IndexName, "sqlite_autoindex_") ||
- list.ExistInSlice(idx.IndexName, excludeIndexes) {
- continue
- }
-
- // drop old index (it will be recreated with the collection)
- if _, err := db.DropIndex(idx.TableName, idx.IndexName).Execute(); err != nil {
- return err
- }
-
- c.Indexes = append(c.Indexes, idx.Sql)
- }
-
- // convert unique fields to indexes
- FieldsLoop:
- for _, f := range c.Schema.Fields() {
- if !f.Unique {
- continue
- }
-
- for _, idx := range indexesByTableName[c.Name] {
- parsed := dbutils.ParseIndex(idx.Sql)
- if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, f.Name) {
- continue FieldsLoop // already added
- }
- }
-
- c.Indexes = append(c.Indexes, fmt.Sprintf(
- `CREATE UNIQUE INDEX "idx_unique_%s" on "%s" ("%s")`,
- f.Id,
- c.Name,
- f.Name,
- ))
- }
-
- if len(c.Indexes) > 0 {
- if err := dao.SaveCollection(c); err != nil {
- return err
- }
- }
- }
-
- return nil
- }, func(db dbx.Builder) error {
- _, err := db.DropColumn("_collections", "indexes").Execute()
-
- return err
- })
-}
diff --git a/migrations/1685164450_check_fk.go b/migrations/1685164450_check_fk.go
deleted file mode 100644
index ecc0ff7d6d..0000000000
--- a/migrations/1685164450_check_fk.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-// Cleanup dangling deleted collections references
-// (see https://github.com/pocketbase/pocketbase/discussions/2570).
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- _, err := db.NewQuery(`
- DELETE FROM {{_externalAuths}}
- WHERE [[collectionId]] NOT IN (SELECT [[id]] FROM {{_collections}})
- `).Execute()
-
- return err
- }, func(db dbx.Builder) error {
- return nil
- })
-}
diff --git a/migrations/1689579878_renormalize_single_multiple_values.go b/migrations/1689579878_renormalize_single_multiple_values.go
deleted file mode 100644
index 51b62b9425..0000000000
--- a/migrations/1689579878_renormalize_single_multiple_values.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-// Renormalizes old single and multiple values of MultiValuer fields (file, select, relation)
-// (see https://github.com/pocketbase/pocketbase/issues/2930).
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- return normalizeMultivaluerFields(db)
- }, func(db dbx.Builder) error {
- return nil
- })
-}
diff --git a/migrations/1690319366_reset_null_values.go b/migrations/1690319366_reset_null_values.go
deleted file mode 100644
index 306ab3a4b7..0000000000
--- a/migrations/1690319366_reset_null_values.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package migrations
-
-import (
- "fmt"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// Reset all previously inserted NULL values to the fields zero-default.
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().All(&collections); err != nil {
- return err
- }
-
- for _, collection := range collections {
- if collection.IsView() {
- continue
- }
-
- for _, f := range collection.Schema.Fields() {
- defaultVal := "''"
-
- switch f.Type {
- case schema.FieldTypeJson:
- continue
- case schema.FieldTypeBool:
- defaultVal = "FALSE"
- case schema.FieldTypeNumber:
- defaultVal = "0"
- default:
- if opt, ok := f.Options.(schema.MultiValuer); ok && opt.IsMultiple() {
- defaultVal = "'[]'"
- }
- }
-
- _, err := db.NewQuery(fmt.Sprintf(
- "UPDATE {{%s}} SET [[%s]] = %s WHERE [[%s]] IS NULL",
- collection.Name,
- f.Name,
- defaultVal,
- f.Name,
- )).Execute()
- if err != nil {
- return err
- }
- }
- }
-
- return nil
- }, nil)
-}
diff --git a/migrations/1690454337_transform_relations_to_views.go b/migrations/1690454337_transform_relations_to_views.go
deleted file mode 100644
index e158dd3f09..0000000000
--- a/migrations/1690454337_transform_relations_to_views.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// Transform the relation fields to views from non-view collections to json or text fields
-// (see https://github.com/pocketbase/pocketbase/issues/3000).
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- views, err := dao.FindCollectionsByType(models.CollectionTypeView)
- if err != nil {
- return err
- }
-
- for _, view := range views {
- refs, err := dao.FindCollectionReferences(view)
- if err != nil {
- return nil
- }
-
- for collection, fields := range refs {
- if collection.IsView() {
- continue // view-view relations are allowed
- }
-
- for _, f := range fields {
- opt, ok := f.Options.(schema.MultiValuer)
- if !ok {
- continue
- }
-
- if opt.IsMultiple() {
- f.Type = schema.FieldTypeJson
- f.Options = &schema.JsonOptions{}
- } else {
- f.Type = schema.FieldTypeText
- f.Options = &schema.TextOptions{}
- }
-
- // replace the existing field
- // (this usually is not necessary since it is a pointer,
- // but it is better to be explicit in case FindCollectionReferences changes)
- collection.Schema.AddField(f)
- }
-
- // "raw" save without records table sync
- if err := dao.Save(collection); err != nil {
- return err
- }
- }
- }
-
- return nil
- }, nil)
-}
diff --git a/migrations/1691747913_resave_views.go b/migrations/1691747913_resave_views.go
deleted file mode 100644
index 3d684609a2..0000000000
--- a/migrations/1691747913_resave_views.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
-)
-
-// Resave all view collections to ensure that the proper id normalization is applied.
-// (see https://github.com/pocketbase/pocketbase/issues/3110)
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
- if err != nil {
- return nil
- }
-
- for _, collection := range collections {
- // ignore errors to allow users to adjust
- // the view queries after app start
- dao.SaveCollection(collection)
- }
-
- return nil
- }, nil)
-}
diff --git a/migrations/1692609521_copy_display_fields.go b/migrations/1692609521_copy_display_fields.go
deleted file mode 100644
index b2a20f5993..0000000000
--- a/migrations/1692609521_copy_display_fields.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package migrations
-
-import (
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// Copy the now deprecated RelationOptions.DisplayFields values from
-// all relation fields and register its value as Presentable under
-// the specific field in the related collection.
-//
-// If there is more than one relation to a single collection with explicitly
-// set DisplayFields only one of the configuration will be copied.
-func init() {
- AppMigrations.Register(func(db dbx.Builder) error {
- dao := daos.New(db)
-
- collections := []*models.Collection{}
- if err := dao.CollectionQuery().All(&collections); err != nil {
- return err
- }
-
- indexedCollections := make(map[string]*models.Collection, len(collections))
- for _, collection := range collections {
- indexedCollections[collection.Id] = collection
- }
-
- for _, collection := range indexedCollections {
- for _, f := range collection.Schema.Fields() {
- if f.Type != schema.FieldTypeRelation {
- continue
- }
-
- options, ok := f.Options.(*schema.RelationOptions)
- if !ok || len(options.DisplayFields) == 0 {
- continue
- }
-
- relCollection, ok := indexedCollections[options.CollectionId]
- if !ok {
- continue
- }
-
- for _, name := range options.DisplayFields {
- relField := relCollection.Schema.GetFieldByName(name)
- if relField != nil {
- relField.Presentable = true
- }
- }
-
- // only raw model save
- if err := dao.Save(relCollection); err != nil {
- return err
- }
- }
- }
-
- return nil
- }, nil)
-}
diff --git a/migrations/1717233556_v0.23_migrate.go b/migrations/1717233556_v0.23_migrate.go
new file mode 100644
index 0000000000..6be14bfb75
--- /dev/null
+++ b/migrations/1717233556_v0.23_migrate.go
@@ -0,0 +1,912 @@
+package migrations
+
+import (
+ "encoding/json/v2"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// note: this migration will be deleted in future version
+
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ // note: mfas and authOrigins tables are available only with v0.23
+ hasUpgraded := txApp.HasTable(core.CollectionNameMFAs) && txApp.HasTable(core.CollectionNameAuthOrigins)
+ if hasUpgraded {
+ return nil
+ }
+
+ oldSettings, err := loadOldSettings(txApp)
+ if err != nil {
+ return fmt.Errorf("failed to fetch old settings: %w", err)
+ }
+
+ if err = migrateOldCollections(txApp, oldSettings); err != nil {
+ return err
+ }
+
+ if err = migrateSuperusers(txApp, oldSettings); err != nil {
+ return fmt.Errorf("failed to migrate admins->superusers: %w", err)
+ }
+
+ if err = migrateSettings(txApp, oldSettings); err != nil {
+ return fmt.Errorf("failed to migrate settings: %w", err)
+ }
+
+ if err = migrateExternalAuths(txApp); err != nil {
+ return fmt.Errorf("failed to migrate externalAuths: %w", err)
+ }
+
+ if err = createMFAsCollection(txApp); err != nil {
+ return fmt.Errorf("failed to create mfas collection: %w", err)
+ }
+
+ if err = createOTPsCollection(txApp); err != nil {
+ return fmt.Errorf("failed to create otps collection: %w", err)
+ }
+
+ if err = createAuthOriginsCollection(txApp); err != nil {
+ return fmt.Errorf("failed to create authOrigins collection: %w", err)
+ }
+
+ err = os.Remove(filepath.Join(txApp.DataDir(), "logs.db"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ txApp.Logger().Warn("Failed to delete old logs.db file", "error", err)
+ }
+
+ return nil
+ }, nil)
+}
+
+// -------------------------------------------------------------------
+
+func migrateSuperusers(txApp core.App, oldSettings *oldSettingsModel) error {
+ // create new superusers collection and table
+ err := createSuperusersCollection(txApp)
+ if err != nil {
+ return err
+ }
+
+ // update with the token options from the old settings
+ superusersCollection, err := txApp.FindCollectionByNameOrId(core.CollectionNameSuperusers)
+ if err != nil {
+ return err
+ }
+
+ superusersCollection.AuthToken.Secret = zeroFallback(
+ cast.ToString(getMapVal(oldSettings.Value, "adminAuthToken", "secret")),
+ superusersCollection.AuthToken.Secret,
+ )
+ superusersCollection.AuthToken.Duration = zeroFallback(
+ cast.ToInt64(getMapVal(oldSettings.Value, "adminAuthToken", "duration")),
+ superusersCollection.AuthToken.Duration,
+ )
+ superusersCollection.PasswordResetToken.Secret = zeroFallback(
+ cast.ToString(getMapVal(oldSettings.Value, "adminPasswordResetToken", "secret")),
+ superusersCollection.PasswordResetToken.Secret,
+ )
+ superusersCollection.PasswordResetToken.Duration = zeroFallback(
+ cast.ToInt64(getMapVal(oldSettings.Value, "adminPasswordResetToken", "duration")),
+ superusersCollection.PasswordResetToken.Duration,
+ )
+ superusersCollection.FileToken.Secret = zeroFallback(
+ cast.ToString(getMapVal(oldSettings.Value, "adminFileToken", "secret")),
+ superusersCollection.FileToken.Secret,
+ )
+ superusersCollection.FileToken.Duration = zeroFallback(
+ cast.ToInt64(getMapVal(oldSettings.Value, "adminFileToken", "duration")),
+ superusersCollection.FileToken.Duration,
+ )
+ if err = txApp.Save(superusersCollection); err != nil {
+ return fmt.Errorf("failed to migrate token configs: %w", err)
+ }
+
+ // copy old admins records into the new one
+ _, err = txApp.DB().NewQuery(`
+ INSERT INTO {{` + core.CollectionNameSuperusers + `}} ([[id]], [[verified]], [[email]], [[password]], [[tokenKey]], [[created]], [[updated]])
+ SELECT [[id]], true, [[email]], [[passwordHash]], [[tokenKey]], [[created]], [[updated]] FROM {{_admins}};
+ `).Execute()
+ if err != nil {
+ return err
+ }
+
+ // remove old admins table
+ _, err = txApp.DB().DropTable("_admins").Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+type oldSettingsModel struct {
+ Id string `db:"id" json:"id"`
+ Key string `db:"key" json:"key"`
+ RawValue types.JSONRaw `db:"value" json:"value"`
+ Value map[string]any `db:"-" json:"-"`
+}
+
+func loadOldSettings(txApp core.App) (*oldSettingsModel, error) {
+ oldSettings := &oldSettingsModel{Value: map[string]any{}}
+ err := txApp.DB().Select().From("_params").Where(dbx.HashExp{"key": "settings"}).One(oldSettings)
+ if err != nil {
+ return nil, err
+ }
+
+ // try without decrypt
+ plainDecodeErr := json.Unmarshal(oldSettings.RawValue, &oldSettings.Value)
+
+ // failed, try to decrypt
+ if plainDecodeErr != nil {
+ encryptionKey := os.Getenv(txApp.EncryptionEnv())
+
+ // load without decryption has failed and there is no encryption key to use for decrypt
+ if encryptionKey == "" {
+ return nil, fmt.Errorf("invalid settings db data or missing encryption key %q", txApp.EncryptionEnv())
+ }
+
+ // decrypt
+ decrypted, decryptErr := security.Decrypt(string(oldSettings.RawValue), encryptionKey)
+ if decryptErr != nil {
+ return nil, decryptErr
+ }
+
+ // decode again
+ decryptedDecodeErr := json.Unmarshal(decrypted, &oldSettings.Value)
+ if decryptedDecodeErr != nil {
+ return nil, decryptedDecodeErr
+ }
+ }
+
+ return oldSettings, nil
+}
+
+func migrateSettings(txApp core.App, oldSettings *oldSettingsModel) error {
+ // renamed old params collection
+ _, err := txApp.DB().RenameTable("_params", "_params_old").Execute()
+ if err != nil {
+ return err
+ }
+
+ // create new params table
+ err = createParamsTable(txApp)
+ if err != nil {
+ return err
+ }
+
+ // migrate old settings
+ newSettings := txApp.Settings()
+ // ---
+ newSettings.Meta.AppName = cast.ToString(getMapVal(oldSettings.Value, "meta", "appName"))
+ newSettings.Meta.AppURL = strings.TrimSuffix(cast.ToString(getMapVal(oldSettings.Value, "meta", "appUrl")), "/")
+ newSettings.Meta.HideControls = cast.ToBool(getMapVal(oldSettings.Value, "meta", "hideControls"))
+ newSettings.Meta.SenderName = cast.ToString(getMapVal(oldSettings.Value, "meta", "senderName"))
+ newSettings.Meta.SenderAddress = cast.ToString(getMapVal(oldSettings.Value, "meta", "senderAddress"))
+ // ---
+ newSettings.Logs.MaxDays = cast.ToInt(getMapVal(oldSettings.Value, "logs", "maxDays"))
+ newSettings.Logs.MinLevel = cast.ToInt(getMapVal(oldSettings.Value, "logs", "minLevel"))
+ newSettings.Logs.LogIP = cast.ToBool(getMapVal(oldSettings.Value, "logs", "logIp"))
+ // ---
+ newSettings.SMTP.Enabled = cast.ToBool(getMapVal(oldSettings.Value, "smtp", "enabled"))
+ newSettings.SMTP.Port = cast.ToInt(getMapVal(oldSettings.Value, "smtp", "port"))
+ newSettings.SMTP.Host = cast.ToString(getMapVal(oldSettings.Value, "smtp", "host"))
+ newSettings.SMTP.Username = cast.ToString(getMapVal(oldSettings.Value, "smtp", "username"))
+ newSettings.SMTP.Password = cast.ToString(getMapVal(oldSettings.Value, "smtp", "password"))
+ newSettings.SMTP.AuthMethod = cast.ToString(getMapVal(oldSettings.Value, "smtp", "authMethod"))
+ newSettings.SMTP.TLS = cast.ToBool(getMapVal(oldSettings.Value, "smtp", "tls"))
+ newSettings.SMTP.LocalName = cast.ToString(getMapVal(oldSettings.Value, "smtp", "localName"))
+ // ---
+ newSettings.Backups.Cron = cast.ToString(getMapVal(oldSettings.Value, "backups", "cron"))
+ newSettings.Backups.CronMaxKeep = cast.ToInt(getMapVal(oldSettings.Value, "backups", "cronMaxKeep"))
+ newSettings.Backups.S3 = core.S3Config{
+ Enabled: cast.ToBool(getMapVal(oldSettings.Value, "backups", "s3", "enabled")),
+ Bucket: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "bucket")),
+ Region: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "region")),
+ Endpoint: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "endpoint")),
+ AccessKey: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "accessKey")),
+ Secret: cast.ToString(getMapVal(oldSettings.Value, "backups", "s3", "secret")),
+ ForcePathStyle: cast.ToBool(getMapVal(oldSettings.Value, "backups", "s3", "forcePathStyle")),
+ }
+ // ---
+ newSettings.S3 = core.S3Config{
+ Enabled: cast.ToBool(getMapVal(oldSettings.Value, "s3", "enabled")),
+ Bucket: cast.ToString(getMapVal(oldSettings.Value, "s3", "bucket")),
+ Region: cast.ToString(getMapVal(oldSettings.Value, "s3", "region")),
+ Endpoint: cast.ToString(getMapVal(oldSettings.Value, "s3", "endpoint")),
+ AccessKey: cast.ToString(getMapVal(oldSettings.Value, "s3", "accessKey")),
+ Secret: cast.ToString(getMapVal(oldSettings.Value, "s3", "secret")),
+ ForcePathStyle: cast.ToBool(getMapVal(oldSettings.Value, "s3", "forcePathStyle")),
+ }
+ // ---
+ err = txApp.Save(newSettings)
+ if err != nil {
+ return err
+ }
+
+ // remove old params table
+ _, err = txApp.DB().DropTable("_params_old").Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+func migrateExternalAuths(txApp core.App) error {
+ // renamed old externalAuths table
+ _, err := txApp.DB().RenameTable("_externalAuths", "_externalAuths_old").Execute()
+ if err != nil {
+ return err
+ }
+
+ // create new externalAuths collection and table
+ err = createExternalAuthsCollection(txApp)
+ if err != nil {
+ return err
+ }
+
+ // copy old externalAuths records into the new one
+ _, err = txApp.DB().NewQuery(`
+ INSERT INTO {{` + core.CollectionNameExternalAuths + `}} ([[id]], [[collectionRef]], [[recordRef]], [[provider]], [[providerId]], [[created]], [[updated]])
+ SELECT [[id]], [[collectionId]], [[recordId]], [[provider]], [[providerId]], [[created]], [[updated]] FROM {{_externalAuths_old}};
+ `).Execute()
+ if err != nil {
+ return err
+ }
+
+ // remove old externalAuths table
+ _, err = txApp.DB().DropTable("_externalAuths_old").Execute()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// -------------------------------------------------------------------
+
+func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error {
+ oldCollections := []*OldCollectionModel{}
+ err := txApp.DB().Select().From("_collections").All(&oldCollections)
+ if err != nil {
+ return err
+ }
+
+ for _, c := range oldCollections {
+ dummyAuthCollection := core.NewAuthCollection("test")
+
+ options := c.Options
+ c.Options = types.JSONMap[any]{} // reset
+
+ // update rules
+ // ---
+ c.ListRule = migrateRule(c.ListRule)
+ c.ViewRule = migrateRule(c.ViewRule)
+ c.CreateRule = migrateRule(c.CreateRule)
+ c.UpdateRule = migrateRule(c.UpdateRule)
+ c.DeleteRule = migrateRule(c.DeleteRule)
+
+ // migrate fields
+ // ---
+ for i, field := range c.Schema {
+ switch cast.ToString(field["type"]) {
+ case "bool":
+ field = toBoolField(field)
+ case "number":
+ field = toNumberField(field)
+ case "text":
+ field = toTextField(field)
+ case "url":
+ field = toURLField(field)
+ case "email":
+ field = toEmailField(field)
+ case "editor":
+ field = toEditorField(field)
+ case "date":
+ field = toDateField(field)
+ case "select":
+ field = toSelectField(field)
+ case "json":
+ field = toJSONField(field)
+ case "relation":
+ field = toRelationField(field)
+ case "file":
+ field = toFileField(field)
+ }
+ c.Schema[i] = field
+ }
+
+ // type specific changes
+ switch c.Type {
+ case "auth":
+ // token configs
+ // ---
+ c.Options["authToken"] = map[string]any{
+ "secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordAuthToken", "secret")), dummyAuthCollection.AuthToken.Secret),
+ "duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordAuthToken", "duration")), dummyAuthCollection.AuthToken.Duration),
+ }
+ c.Options["passwordResetToken"] = map[string]any{
+ "secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordPasswordResetToken", "secret")), dummyAuthCollection.PasswordResetToken.Secret),
+ "duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordPasswordResetToken", "duration")), dummyAuthCollection.PasswordResetToken.Duration),
+ }
+ c.Options["emailChangeToken"] = map[string]any{
+ "secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordEmailChangeToken", "secret")), dummyAuthCollection.EmailChangeToken.Secret),
+ "duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordEmailChangeToken", "duration")), dummyAuthCollection.EmailChangeToken.Duration),
+ }
+ c.Options["verificationToken"] = map[string]any{
+ "secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordVerificationToken", "secret")), dummyAuthCollection.VerificationToken.Secret),
+ "duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordVerificationToken", "duration")), dummyAuthCollection.VerificationToken.Duration),
+ }
+ c.Options["fileToken"] = map[string]any{
+ "secret": zeroFallback(cast.ToString(getMapVal(oldSettings.Value, "recordFileToken", "secret")), dummyAuthCollection.FileToken.Secret),
+ "duration": zeroFallback(cast.ToInt64(getMapVal(oldSettings.Value, "recordFileToken", "duration")), dummyAuthCollection.FileToken.Duration),
+ }
+
+ onlyVerified := cast.ToBool(options["onlyVerified"])
+ if onlyVerified {
+ c.Options["authRule"] = "verified=true"
+ } else {
+ c.Options["authRule"] = ""
+ }
+
+ c.Options["manageRule"] = nil
+ if options["manageRule"] != nil {
+ manageRule, err := cast.ToStringE(options["manageRule"])
+ if err == nil && manageRule != "" {
+ c.Options["manageRule"] = migrateRule(&manageRule)
+ }
+ }
+
+ // passwordAuth
+ identityFields := []string{}
+ if cast.ToBool(options["allowEmailAuth"]) {
+ identityFields = append(identityFields, "email")
+ }
+ if cast.ToBool(options["allowUsernameAuth"]) {
+ identityFields = append(identityFields, "username")
+ }
+ c.Options["passwordAuth"] = map[string]any{
+ "enabled": len(identityFields) > 0,
+ "identityFields": identityFields,
+ }
+
+ // oauth2
+ // ---
+ oauth2Providers := []map[string]any{}
+ providerNames := []string{
+ "googleAuth",
+ "facebookAuth",
+ "githubAuth",
+ "gitlabAuth",
+ "discordAuth",
+ "twitterAuth",
+ "microsoftAuth",
+ "spotifyAuth",
+ "kakaoAuth",
+ "twitchAuth",
+ "stravaAuth",
+ "giteeAuth",
+ "livechatAuth",
+ "giteaAuth",
+ "oidcAuth",
+ "oidc2Auth",
+ "oidc3Auth",
+ "appleAuth",
+ "instagramAuth",
+ "vkAuth",
+ "yandexAuth",
+ "patreonAuth",
+ "mailcowAuth",
+ "bitbucketAuth",
+ "planningcenterAuth",
+ }
+ for _, name := range providerNames {
+ if !cast.ToBool(getMapVal(oldSettings.Value, name, "enabled")) {
+ continue
+ }
+ oauth2Providers = append(oauth2Providers, map[string]any{
+ "name": strings.TrimSuffix(name, "Auth"),
+ "clientId": cast.ToString(getMapVal(oldSettings.Value, name, "clientId")),
+ "clientSecret": cast.ToString(getMapVal(oldSettings.Value, name, "clientSecret")),
+ "authURL": cast.ToString(getMapVal(oldSettings.Value, name, "authUrl")),
+ "tokenURL": cast.ToString(getMapVal(oldSettings.Value, name, "tokenUrl")),
+ "userInfoURL": cast.ToString(getMapVal(oldSettings.Value, name, "userApiUrl")),
+ "displayName": cast.ToString(getMapVal(oldSettings.Value, name, "displayName")),
+ "pkce": getMapVal(oldSettings.Value, name, "pkce"),
+ })
+ }
+
+ c.Options["oauth2"] = map[string]any{
+ "enabled": cast.ToBool(options["allowOAuth2Auth"]) && len(oauth2Providers) > 0,
+ "providers": oauth2Providers,
+ "mappedFields": map[string]string{
+ "username": "username",
+ },
+ }
+
+ // default email templates
+ // ---
+ emailTemplates := map[string]core.EmailTemplate{
+ "verificationTemplate": dummyAuthCollection.VerificationTemplate,
+ "resetPasswordTemplate": dummyAuthCollection.ResetPasswordTemplate,
+ "confirmEmailChangeTemplate": dummyAuthCollection.ConfirmEmailChangeTemplate,
+ }
+ for name, fallback := range emailTemplates {
+ c.Options[name] = map[string]any{
+ "subject": zeroFallback(
+ cast.ToString(getMapVal(oldSettings.Value, "meta", name, "subject")),
+ fallback.Subject,
+ ),
+ "body": zeroFallback(
+ strings.ReplaceAll(
+ cast.ToString(getMapVal(oldSettings.Value, "meta", name, "body")),
+ "{ACTION_URL}",
+ cast.ToString(getMapVal(oldSettings.Value, "meta", name, "actionUrl")),
+ ),
+ fallback.Body,
+ ),
+ }
+ }
+
+ // mfa
+ // ---
+ c.Options["mfa"] = map[string]any{
+ "enabled": dummyAuthCollection.MFA.Enabled,
+ "duration": dummyAuthCollection.MFA.Duration,
+ "rule": dummyAuthCollection.MFA.Rule,
+ }
+
+ // otp
+ // ---
+ c.Options["otp"] = map[string]any{
+ "enabled": dummyAuthCollection.OTP.Enabled,
+ "duration": dummyAuthCollection.OTP.Duration,
+ "length": dummyAuthCollection.OTP.Length,
+ "emailTemplate": map[string]any{
+ "subject": dummyAuthCollection.OTP.EmailTemplate.Subject,
+ "body": dummyAuthCollection.OTP.EmailTemplate.Body,
+ },
+ }
+
+ // auth alerts
+ // ---
+ c.Options["authAlert"] = map[string]any{
+ "enabled": dummyAuthCollection.AuthAlert.Enabled,
+ "emailTemplate": map[string]any{
+ "subject": dummyAuthCollection.AuthAlert.EmailTemplate.Subject,
+ "body": dummyAuthCollection.AuthAlert.EmailTemplate.Body,
+ },
+ }
+
+ // add system field indexes
+ // ---
+ c.Indexes = append(types.JSONArray[string]{
+ fmt.Sprintf("CREATE UNIQUE INDEX `_%s_username_idx` ON `%s` (username COLLATE NOCASE)", c.Id, c.Name),
+ fmt.Sprintf("CREATE UNIQUE INDEX `_%s_email_idx` ON `%s` (`email`) WHERE `email` != ''", c.Id, c.Name),
+ fmt.Sprintf("CREATE UNIQUE INDEX `_%s_tokenKey_idx` ON `%s` (`tokenKey`)", c.Id, c.Name),
+ }, c.Indexes...)
+
+ // prepend the auth system fields
+ // ---
+ tokenKeyField := map[string]any{
+ "id": fieldIdChecksum("text", "tokenKey"),
+ "type": "text",
+ "name": "tokenKey",
+ "system": true,
+ "hidden": true,
+ "required": true,
+ "presentable": false,
+ "primaryKey": false,
+ "min": 30,
+ "max": 60,
+ "pattern": "",
+ "autogeneratePattern": "[a-zA-Z0-9_]{50}",
+ }
+ passwordField := map[string]any{
+ "id": fieldIdChecksum("password", "password"),
+ "type": "password",
+ "name": "password",
+ "presentable": false,
+ "system": true,
+ "hidden": true,
+ "required": true,
+ "pattern": "",
+ "min": cast.ToInt(options["minPasswordLength"]),
+ "cost": bcrypt.DefaultCost, // new default
+ }
+ emailField := map[string]any{
+ "id": fieldIdChecksum("email", "email"),
+ "type": "email",
+ "name": "email",
+ "system": true,
+ "hidden": false,
+ "presentable": false,
+ "required": cast.ToBool(options["requireEmail"]),
+ "exceptDomains": cast.ToStringSlice(options["exceptEmailDomains"]),
+ "onlyDomains": cast.ToStringSlice(options["onlyEmailDomains"]),
+ }
+ emailVisibilityField := map[string]any{
+ "id": fieldIdChecksum("bool", "emailVisibility"),
+ "type": "bool",
+ "name": "emailVisibility",
+ "system": true,
+ "hidden": false,
+ "presentable": false,
+ "required": false,
+ }
+ verifiedField := map[string]any{
+ "id": fieldIdChecksum("bool", "verified"),
+ "type": "bool",
+ "name": "verified",
+ "system": true,
+ "hidden": false,
+ "presentable": false,
+ "required": false,
+ }
+ usernameField := map[string]any{
+ "id": fieldIdChecksum("text", "username"),
+ "type": "text",
+ "name": "username",
+ "system": false,
+ "hidden": false,
+ "required": true,
+ "presentable": false,
+ "primaryKey": false,
+ "min": 3,
+ "max": 150,
+ "pattern": `^[\w][\w\.\-]*$`,
+ "autogeneratePattern": "users[0-9]{6}",
+ }
+ c.Schema = append(types.JSONArray[types.JSONMap[any]]{
+ passwordField,
+ tokenKeyField,
+ emailField,
+ emailVisibilityField,
+ verifiedField,
+ usernameField,
+ }, c.Schema...)
+
+ // rename passwordHash records rable column to password
+ // ---
+ _, err = txApp.DB().RenameColumn(c.Name, "passwordHash", "password").Execute()
+ if err != nil {
+ return err
+ }
+
+ // delete unnecessary auth columns
+ dropColumns := []string{"lastResetSentAt", "lastVerificationSentAt", "lastLoginAlertSentAt"}
+ for _, drop := range dropColumns {
+ // ignore errors in case the columns don't exist
+ _, _ = txApp.DB().DropColumn(c.Name, drop).Execute()
+ }
+ case "view":
+ c.Options["viewQuery"] = cast.ToString(options["query"])
+ }
+
+ // prepend the id field
+ idField := map[string]any{
+ "id": fieldIdChecksum("text", "id"),
+ "type": "text",
+ "name": "id",
+ "system": true,
+ "required": true,
+ "presentable": false,
+ "hidden": false,
+ "primaryKey": true,
+ "min": 15,
+ "max": 15,
+ "pattern": "^[a-z0-9]+$",
+ "autogeneratePattern": "[a-z0-9]{15}",
+ }
+ c.Schema = append(types.JSONArray[types.JSONMap[any]]{idField}, c.Schema...)
+
+ var addCreated, addUpdated bool
+
+ if c.Type == "view" {
+ // manually check if the view has created/updated columns
+ columns, _ := txApp.TableColumns(c.Name)
+ for _, c := range columns {
+ if strings.EqualFold(c, "created") {
+ addCreated = true
+ } else if strings.EqualFold(c, "updated") {
+ addUpdated = true
+ }
+ }
+ } else {
+ addCreated = true
+ addUpdated = true
+ }
+
+ if addCreated {
+ createdField := map[string]any{
+ "id": fieldIdChecksum("autodate", "created"),
+ "type": "autodate",
+ "name": "created",
+ "system": false,
+ "presentable": false,
+ "hidden": false,
+ "onCreate": true,
+ "onUpdate": false,
+ }
+ c.Schema = append(c.Schema, createdField)
+ }
+
+ if addUpdated {
+ updatedField := map[string]any{
+ "id": fieldIdChecksum("autodate", "updated"),
+ "type": "autodate",
+ "name": "updated",
+ "system": false,
+ "presentable": false,
+ "hidden": false,
+ "onCreate": true,
+ "onUpdate": true,
+ }
+ c.Schema = append(c.Schema, updatedField)
+ }
+
+ if err = txApp.DB().Model(c).Update(); err != nil {
+ return err
+ }
+ }
+
+ _, err = txApp.DB().RenameColumn("_collections", "schema", "fields").Execute()
+ if err != nil {
+ return err
+ }
+
+ // run collection validations
+ collections, err := txApp.FindAllCollections()
+ if err != nil {
+ return fmt.Errorf("failed to retrieve all collections: %w", err)
+ }
+ for _, c := range collections {
+ err = txApp.Validate(c)
+ if err != nil {
+ return fmt.Errorf("migrated collection %q validation failure: %w", c.Name, err)
+ }
+ }
+
+ return nil
+}
+
+type OldCollectionModel struct {
+ Id string `db:"id" json:"id"`
+ Created types.DateTime `db:"created" json:"created"`
+ Updated types.DateTime `db:"updated" json:"updated"`
+ Name string `db:"name" json:"name"`
+ Type string `db:"type" json:"type"`
+ System bool `db:"system" json:"system"`
+ Schema types.JSONArray[types.JSONMap[any]] `db:"schema" json:"schema"`
+ Indexes types.JSONArray[string] `db:"indexes" json:"indexes"`
+ ListRule *string `db:"listRule" json:"listRule"`
+ ViewRule *string `db:"viewRule" json:"viewRule"`
+ CreateRule *string `db:"createRule" json:"createRule"`
+ UpdateRule *string `db:"updateRule" json:"updateRule"`
+ DeleteRule *string `db:"deleteRule" json:"deleteRule"`
+ Options types.JSONMap[any] `db:"options" json:"options"`
+}
+
+func (c OldCollectionModel) TableName() string {
+ return "_collections"
+}
+
+func migrateRule(rule *string) *string {
+ if rule == nil {
+ return nil
+ }
+
+ str := strings.ReplaceAll(*rule, "@request.data", "@request.body")
+
+ return &str
+}
+
+func toBoolField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "bool",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ }
+}
+
+func toNumberField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "number",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "onlyInt": cast.ToBool(getMapVal(data, "options", "noDecimal")),
+ "min": getMapVal(data, "options", "min"),
+ "max": getMapVal(data, "options", "max"),
+ }
+}
+
+func toTextField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "text",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "primaryKey": cast.ToBool(data["primaryKey"]),
+ "hidden": cast.ToBool(data["hidden"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "required": cast.ToBool(data["required"]),
+ "min": cast.ToInt(getMapVal(data, "options", "min")),
+ "max": cast.ToInt(getMapVal(data, "options", "max")),
+ "pattern": cast.ToString(getMapVal(data, "options", "pattern")),
+ "autogeneratePattern": cast.ToString(getMapVal(data, "options", "autogeneratePattern")),
+ }
+}
+
+func toEmailField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "email",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "exceptDomains": cast.ToStringSlice(getMapVal(data, "options", "exceptDomains")),
+ "onlyDomains": cast.ToStringSlice(getMapVal(data, "options", "onlyDomains")),
+ }
+}
+
+func toURLField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "url",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "exceptDomains": cast.ToStringSlice(getMapVal(data, "options", "exceptDomains")),
+ "onlyDomains": cast.ToStringSlice(getMapVal(data, "options", "onlyDomains")),
+ }
+}
+
+func toEditorField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "editor",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "convertURLs": cast.ToBool(getMapVal(data, "options", "convertUrls")),
+ }
+}
+
+func toDateField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "date",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "min": cast.ToString(getMapVal(data, "options", "min")),
+ "max": cast.ToString(getMapVal(data, "options", "max")),
+ }
+}
+
+func toJSONField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "json",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "maxSize": cast.ToInt64(getMapVal(data, "options", "maxSize")),
+ }
+}
+
+func toSelectField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "select",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "values": cast.ToStringSlice(getMapVal(data, "options", "values")),
+ "maxSelect": cast.ToInt(getMapVal(data, "options", "maxSelect")),
+ }
+}
+
+func toRelationField(data map[string]any) map[string]any {
+ maxSelect := cast.ToInt(getMapVal(data, "options", "maxSelect"))
+ if maxSelect <= 0 {
+ maxSelect = 2147483647
+ }
+
+ return map[string]any{
+ "type": "relation",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "collectionId": cast.ToString(getMapVal(data, "options", "collectionId")),
+ "cascadeDelete": cast.ToBool(getMapVal(data, "options", "cascadeDelete")),
+ "minSelect": cast.ToInt(getMapVal(data, "options", "minSelect")),
+ "maxSelect": maxSelect,
+ }
+}
+
+func toFileField(data map[string]any) map[string]any {
+ return map[string]any{
+ "type": "file",
+ "id": cast.ToString(data["id"]),
+ "name": cast.ToString(data["name"]),
+ "system": cast.ToBool(data["system"]),
+ "required": cast.ToBool(data["required"]),
+ "presentable": cast.ToBool(data["presentable"]),
+ "hidden": false,
+ "maxSelect": cast.ToInt(getMapVal(data, "options", "maxSelect")),
+ "maxSize": cast.ToInt64(getMapVal(data, "options", "maxSize")),
+ "thumbs": cast.ToStringSlice(getMapVal(data, "options", "thumbs")),
+ "mimeTypes": cast.ToStringSlice(getMapVal(data, "options", "mimeTypes")),
+ "protected": cast.ToBool(getMapVal(data, "options", "protected")),
+ }
+}
+
+func getMapVal(m map[string]any, keys ...string) any {
+ if len(keys) == 0 {
+ return nil
+ }
+
+ result, ok := m[keys[0]]
+ if !ok {
+ return nil
+ }
+
+ // end key reached
+ if len(keys) == 1 {
+ return result
+ }
+
+ if m, ok = result.(map[string]any); !ok {
+ return nil
+ }
+
+ return getMapVal(m, keys[1:]...)
+}
+
+func zeroFallback[T comparable](v T, fallback T) T {
+ var zero T
+
+ if v == zero {
+ return fallback
+ }
+
+ return v
+}
diff --git a/migrations/1717233557_v0.23_migrate2.go b/migrations/1717233557_v0.23_migrate2.go
new file mode 100644
index 0000000000..6a26e9c323
--- /dev/null
+++ b/migrations/1717233557_v0.23_migrate2.go
@@ -0,0 +1,35 @@
+package migrations
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// note: this migration will be deleted in future version
+
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ _, err := txApp.DB().NewQuery("CREATE INDEX IF NOT EXISTS idx__collections_type on {{_collections}} ([[type]]);").Execute()
+ if err != nil {
+ return err
+ }
+
+ // reset mfas and otps delete rule
+ collectionNames := []string{core.CollectionNameMFAs, core.CollectionNameOTPs}
+ for _, name := range collectionNames {
+ col, err := txApp.FindCollectionByNameOrId(name)
+ if err != nil {
+ return err
+ }
+
+ if col.DeleteRule != nil {
+ col.DeleteRule = nil
+ err = txApp.SaveNoValidate(col)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ }, nil)
+}
diff --git a/migrations/1717233558_v0.23_migrate3.go b/migrations/1717233558_v0.23_migrate3.go
new file mode 100644
index 0000000000..e14675a3b8
--- /dev/null
+++ b/migrations/1717233558_v0.23_migrate3.go
@@ -0,0 +1,145 @@
+package migrations
+
+import (
+ "hash/crc32"
+ "strconv"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// note: this migration will be deleted in future version
+
+func collectionIdChecksum(typ, name string) string {
+ return "pbc_" + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(typ+name))), 10)
+}
+
+func fieldIdChecksum(typ, name string) string {
+ return typ + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(name))), 10)
+}
+
+// normalize system collection and field ids
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ collections := []*core.Collection{}
+ err := txApp.CollectionQuery().
+ AndWhere(dbx.In(
+ "name",
+ core.CollectionNameMFAs,
+ core.CollectionNameOTPs,
+ core.CollectionNameExternalAuths,
+ core.CollectionNameAuthOrigins,
+ core.CollectionNameSuperusers,
+ )).
+ All(&collections)
+ if err != nil {
+ return err
+ }
+
+ for _, c := range collections {
+ var needUpdate bool
+
+ references, err := txApp.FindCollectionReferences(c, c.Id)
+ if err != nil {
+ return err
+ }
+
+ authOrigins, err := txApp.FindAllAuthOriginsByCollection(c)
+ if err != nil {
+ return err
+ }
+
+ mfas, err := txApp.FindAllMFAsByCollection(c)
+ if err != nil {
+ return err
+ }
+
+ otps, err := txApp.FindAllOTPsByCollection(c)
+ if err != nil {
+ return err
+ }
+
+ originalId := c.Id
+
+ // normalize collection id
+ if checksum := collectionIdChecksum(c.Type, c.Name); c.Id != checksum {
+ c.Id = checksum
+ needUpdate = true
+ }
+
+ // normalize system fields
+ for _, f := range c.Fields {
+ if !f.GetSystem() {
+ continue
+ }
+
+ if checksum := fieldIdChecksum(f.Type(), f.GetName()); f.GetId() != checksum {
+ f.SetId(checksum)
+ needUpdate = true
+ }
+ }
+
+ if !needUpdate {
+ continue
+ }
+
+ rawExport, err := c.DBExport(txApp)
+ if err != nil {
+ return err
+ }
+
+ _, err = txApp.DB().Update("_collections", rawExport, dbx.HashExp{"id": originalId}).Execute()
+ if err != nil {
+ return err
+ }
+
+ // make sure that the cached collection id is also updated
+ cached, err := txApp.FindCachedCollectionByNameOrId(c.Name)
+ if err != nil {
+ return err
+ }
+ cached.Id = c.Id
+
+ // update collection references
+ for refCollection, fields := range references {
+ for _, f := range fields {
+ relationField, ok := f.(*core.RelationField)
+ if !ok || relationField.CollectionId == originalId {
+ continue
+ }
+
+ relationField.CollectionId = c.Id
+ }
+ if err = txApp.SaveNoValidate(refCollection); err != nil {
+ return err
+ }
+ }
+
+ // update mfas references
+ for _, item := range mfas {
+ item.SetCollectionRef(c.Id)
+ if err = txApp.SaveNoValidate(item); err != nil {
+ return err
+ }
+ }
+
+ // update otps references
+ for _, item := range otps {
+ item.SetCollectionRef(c.Id)
+ if err = txApp.SaveNoValidate(item); err != nil {
+ return err
+ }
+ }
+
+ // update authOrigins references
+ for _, item := range authOrigins {
+ item.SetCollectionRef(c.Id)
+ if err = txApp.SaveNoValidate(item); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ }, nil)
+}
diff --git a/migrations/1717233559_v0.23_migrate4.go b/migrations/1717233559_v0.23_migrate4.go
new file mode 100644
index 0000000000..b471ddcda7
--- /dev/null
+++ b/migrations/1717233559_v0.23_migrate4.go
@@ -0,0 +1,30 @@
+package migrations
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+)
+
+// note: this migration will be deleted in future version
+
+// add new OTP sentTo text field (if not already)
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ otpCollection, err := txApp.FindCollectionByNameOrId(core.CollectionNameOTPs)
+ if err != nil {
+ return err
+ }
+
+ field := otpCollection.Fields.GetByName("sentTo")
+ if field != nil {
+ return nil // already exists
+ }
+
+ otpCollection.Fields.Add(&core.TextField{
+ Name: "sentTo",
+ System: true,
+ Hidden: true,
+ })
+
+ return txApp.Save(otpCollection)
+ }, nil)
+}
diff --git a/migrations/1763020353_update_default_auth_alert_templates.go b/migrations/1763020353_update_default_auth_alert_templates.go
new file mode 100644
index 0000000000..ca93667e5d
--- /dev/null
+++ b/migrations/1763020353_update_default_auth_alert_templates.go
@@ -0,0 +1,62 @@
+package migrations
+
+import (
+ "github.com/pocketbase/pocketbase/core"
+)
+
+const oldAuthAlertTemplate = `Hello,
+We noticed a login to your {APP_NAME} account from a new location.
+If this was you, you may disregard this email.
+If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.
+
+ Thanks,
+ {APP_NAME} team
+
`
+
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
+ if err != nil {
+ return err
+ }
+
+ newTemplate := core.NewAuthCollection("up").AuthAlert.EmailTemplate.Body
+
+ for _, c := range collections {
+ if c.AuthAlert.EmailTemplate.Body != oldAuthAlertTemplate {
+ continue
+ }
+
+ c.AuthAlert.EmailTemplate.Body = newTemplate
+
+ err = txApp.Save(c)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }, func(txApp core.App) error {
+ collections, err := txApp.FindAllCollections(core.CollectionTypeAuth)
+ if err != nil {
+ return err
+ }
+
+ newTemplate := core.NewAuthCollection("down").AuthAlert.EmailTemplate.Body
+
+ for _, c := range collections {
+ if c.AuthAlert.EmailTemplate.Body != newTemplate {
+ continue
+ }
+
+ c.AuthAlert.EmailTemplate.Body = oldAuthAlertTemplate
+
+ err = txApp.Save(c)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
diff --git a/migrations/1778828400_normalize_indexes.go b/migrations/1778828400_normalize_indexes.go
new file mode 100644
index 0000000000..6f67b6540b
--- /dev/null
+++ b/migrations/1778828400_normalize_indexes.go
@@ -0,0 +1,120 @@
+package migrations
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/pocketbase/dbx"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tools/dbutils"
+)
+
+// see https://github.com/pocketbase/pocketbase/issues/7689
+func init() {
+ core.SystemMigrations.Register(func(txApp core.App) error {
+ collections, err := txApp.FindAllCollections()
+ if err != nil {
+ return err
+ }
+
+ for _, collection := range collections {
+ // existing system collection indexes can't be modified and view don't have indexes
+ if collection.System || collection.IsView() {
+ continue
+ }
+
+ masterIndexes := []struct {
+ Name string `db:"name"`
+ SQL string `db:"sql"`
+ }{}
+
+ err := txApp.DB().Select("name", "sql").
+ From("sqlite_master").
+ AndWhere(dbx.HashExp{
+ "type": "index",
+ "tbl_name": collection.Name,
+ }).
+ AndWhere(dbx.NewExp("sql IS NOT NULL AND name NOT LIKE 'sqlite_autoindex_%'")).
+ All(&masterIndexes)
+ if err != nil {
+ return err
+ }
+
+ // no indexes
+ if len(masterIndexes) == 0 && len(collection.Indexes) == 0 {
+ continue
+ }
+
+ missingParsedIndexes := map[string]dbutils.Index{}
+
+ // find missing master indexes
+ masterLoop:
+ for _, masterIndex := range masterIndexes {
+ mParsed := dbutils.ParseIndex(masterIndex.SQL)
+ mParsed.SchemaName = ""
+ mParsed.TableName = collection.Name
+
+ for _, raw := range collection.Indexes {
+ cParsed := dbutils.ParseIndex(raw)
+
+ // index already exists (if needed it will be normalized on resave)
+ if cParsed.IndexName != "" && strings.EqualFold(cParsed.IndexName, mParsed.IndexName) {
+ continue masterLoop
+ }
+ }
+
+ missingParsedIndexes[masterIndex.Name] = mParsed
+ }
+
+ missingIndexesLoop:
+ for _, missing := range missingParsedIndexes {
+ missingSQL := missing.Build()
+
+ // it shouldn't be possible but for just in case if there is an edge case the regex doesn't cover
+ if missingSQL == "" {
+ return fmt.Errorf("failed to build sqlite_master index: %v", missing)
+ }
+
+ // drop the missing index to recreate later
+ _, err := txApp.DB().DropIndex(missing.TableName, missing.IndexName).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to drop index %s: %w", missing.IndexName, err)
+ }
+
+ // no recreate: duplicated single unique tokenKey or email
+ // (auth collections are guaranteed to have them)
+ if collection.IsAuth() && missing.Unique && len(missing.Columns) == 1 &&
+ (strings.EqualFold(missing.Columns[0].Name, core.FieldNameTokenKey) || strings.EqualFold(missing.Columns[0].Name, core.FieldNameEmail)) {
+ continue missingIndexesLoop
+ }
+
+ // no recreate: the same index definition alreay exists
+ // in the collection but with different name
+ for _, raw := range collection.Indexes {
+ cParsed := dbutils.ParseIndex(raw)
+ cParsed.IndexName = missing.IndexName
+ cParsed.SchemaName = missing.SchemaName
+ cParsed.TableName = missing.TableName
+ cSQL := cParsed.Build()
+
+ if missingSQL == cSQL {
+ continue missingIndexesLoop
+ }
+ }
+
+ // recreate: add the missing index to the collection list and
+ // leave the user to decide whether they want to keep it or not
+ // (the index could have been previously created externally, e.g. via the sqlite3 cli)
+ collection.Indexes = append(collection.Indexes, missingSQL)
+ }
+
+ // resave to trigger indexes normalization
+ err = txApp.Save(collection)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }, nil)
+}
diff --git a/migrations/logs/1640988000_init.go b/migrations/logs/1640988000_init.go
deleted file mode 100644
index 67d10f8aea..0000000000
--- a/migrations/logs/1640988000_init.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package logs
-
-import (
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/tools/migrate"
-)
-
-var LogsMigrations migrate.MigrationsList
-
-func init() {
- LogsMigrations.Register(func(db dbx.Builder) (err error) {
- _, err = db.NewQuery(`
- CREATE TABLE {{_requests}} (
- [[id]] TEXT PRIMARY KEY NOT NULL,
- [[url]] TEXT DEFAULT "" NOT NULL,
- [[method]] TEXT DEFAULT "get" NOT NULL,
- [[status]] INTEGER DEFAULT 200 NOT NULL,
- [[auth]] TEXT DEFAULT "guest" NOT NULL,
- [[ip]] TEXT DEFAULT "127.0.0.1" NOT NULL,
- [[referer]] TEXT DEFAULT "" NOT NULL,
- [[userAgent]] TEXT DEFAULT "" NOT NULL,
- [[meta]] JSON DEFAULT "{}" NOT NULL,
- [[created]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
- [[updated]] TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL
- );
-
- CREATE INDEX _request_status_idx on {{_requests}} ([[status]]);
- CREATE INDEX _request_auth_idx on {{_requests}} ([[auth]]);
- CREATE INDEX _request_ip_idx on {{_requests}} ([[ip]]);
- CREATE INDEX _request_created_hour_idx on {{_requests}} (strftime('%Y-%m-%d %H:00:00', [[created]]));
- `).Execute()
-
- return err
- }, func(db dbx.Builder) error {
- _, err := db.DropTable("_requests").Execute()
- return err
- })
-}
diff --git a/migrations/logs/1660821103_add_user_ip_column.go b/migrations/logs/1660821103_add_user_ip_column.go
deleted file mode 100644
index 1af099d500..0000000000
--- a/migrations/logs/1660821103_add_user_ip_column.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package logs
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-func init() {
- LogsMigrations.Register(func(db dbx.Builder) error {
- // delete old index (don't check for error because of backward compatibility with old installations)
- db.DropIndex("_requests", "_request_ip_idx").Execute()
-
- // rename ip -> remoteIp
- if _, err := db.RenameColumn("_requests", "ip", "remoteIp").Execute(); err != nil {
- return err
- }
-
- // add new userIp column
- if _, err := db.AddColumn("_requests", "userIp", `TEXT DEFAULT "127.0.0.1" NOT NULL`).Execute(); err != nil {
- return err
- }
-
- // add new indexes
- if _, err := db.CreateIndex("_requests", "_request_remote_ip_idx", "remoteIp").Execute(); err != nil {
- return err
- }
- if _, err := db.CreateIndex("_requests", "_request_user_ip_idx", "userIp").Execute(); err != nil {
- return err
- }
-
- return nil
- }, func(db dbx.Builder) error {
- // delete new indexes
- if _, err := db.DropIndex("_requests", "_request_remote_ip_idx").Execute(); err != nil {
- return err
- }
- if _, err := db.DropIndex("_requests", "_request_user_ip_idx").Execute(); err != nil {
- return err
- }
-
- // drop userIp column
- if _, err := db.DropColumn("_requests", "userIp").Execute(); err != nil {
- return err
- }
-
- // restore original remoteIp column name
- if _, err := db.RenameColumn("_requests", "remoteIp", "ip").Execute(); err != nil {
- return err
- }
-
- // restore original index
- if _, err := db.CreateIndex("_requests", "_request_ip_idx", "ip").Execute(); err != nil {
- return err
- }
-
- return nil
- })
-}
diff --git a/migrations/logs/1677760279_uppsercase_method.go b/migrations/logs/1677760279_uppsercase_method.go
deleted file mode 100644
index cef9696057..0000000000
--- a/migrations/logs/1677760279_uppsercase_method.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package logs
-
-import (
- "github.com/pocketbase/dbx"
-)
-
-// This migration normalizes the request logs method to UPPERCASE (eg. "get" => "GET").
-func init() {
- LogsMigrations.Register(func(db dbx.Builder) error {
- _, err := db.NewQuery("UPDATE {{_requests}} SET method=UPPER(method)").Execute()
-
- return err
- }, func(db dbx.Builder) error {
- _, err := db.NewQuery("UPDATE {{_requests}} SET method=LOWER(method)").Execute()
-
- return err
- })
-}
diff --git a/models/admin.go b/models/admin.go
deleted file mode 100644
index 047aff7671..0000000000
--- a/models/admin.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package models
-
-import (
- "errors"
-
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
- "golang.org/x/crypto/bcrypt"
-)
-
-var (
- _ Model = (*Admin)(nil)
-)
-
-type Admin struct {
- BaseModel
-
- Avatar int `db:"avatar" json:"avatar"`
- Email string `db:"email" json:"email"`
- TokenKey string `db:"tokenKey" json:"-"`
- PasswordHash string `db:"passwordHash" json:"-"`
- LastResetSentAt types.DateTime `db:"lastResetSentAt" json:"-"`
-}
-
-// TableName returns the Admin model SQL table name.
-func (m *Admin) TableName() string {
- return "_admins"
-}
-
-// ValidatePassword validates a plain password against the model's password.
-func (m *Admin) ValidatePassword(password string) bool {
- bytePassword := []byte(password)
- bytePasswordHash := []byte(m.PasswordHash)
-
- // comparing the password with the hash
- err := bcrypt.CompareHashAndPassword(bytePasswordHash, bytePassword)
-
- // nil means it is a match
- return err == nil
-}
-
-// SetPassword sets cryptographically secure string to `model.Password`.
-//
-// Additionally this method also resets the LastResetSentAt and the TokenKey fields.
-func (m *Admin) SetPassword(password string) error {
- if password == "" {
- return errors.New("The provided plain password is empty")
- }
-
- // hash the password
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
- if err != nil {
- return err
- }
-
- m.PasswordHash = string(hashedPassword)
- m.LastResetSentAt = types.DateTime{} // reset
-
- // invalidate previously issued tokens
- return m.RefreshTokenKey()
-}
-
-// RefreshTokenKey generates and sets new random token key.
-func (m *Admin) RefreshTokenKey() error {
- m.TokenKey = security.RandomString(50)
- return nil
-}
diff --git a/models/admin_test.go b/models/admin_test.go
deleted file mode 100644
index 261135dd27..0000000000
--- a/models/admin_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestAdminTableName(t *testing.T) {
- m := models.Admin{}
- if m.TableName() != "_admins" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
-
-func TestAdminValidatePassword(t *testing.T) {
- scenarios := []struct {
- admin models.Admin
- password string
- expected bool
- }{
- {
- // empty passwordHash + empty pass
- models.Admin{},
- "",
- false,
- },
- {
- // empty passwordHash + nonempty pass
- models.Admin{},
- "123456",
- false,
- },
- {
- // nonempty passwordHash + empty pass
- models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "",
- false,
- },
- {
- // nonempty passwordHash + wrong pass
- models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "654321",
- false,
- },
- {
- // nonempty passwordHash + correct pass
- models.Admin{PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv."},
- "123456",
- true,
- },
- }
-
- for i, s := range scenarios {
- result := s.admin.ValidatePassword(s.password)
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestAdminSetPassword(t *testing.T) {
- m := models.Admin{
- // 123456
- PasswordHash: "$2a$10$SKk/Y/Yc925PBtsSYBvq3Ous9Jy18m4KTn6b/PQQ.Y9QVjy3o/Fv.",
- LastResetSentAt: types.NowDateTime(),
- TokenKey: "test",
- }
-
- // empty pass
- err1 := m.SetPassword("")
- if err1 == nil {
- t.Fatal("Expected empty password error")
- }
-
- err2 := m.SetPassword("654321")
- if err2 != nil {
- t.Fatalf("Expected nil, got error %v", err2)
- }
-
- if !m.ValidatePassword("654321") {
- t.Fatalf("Password is invalid")
- }
-
- if m.TokenKey == "test" {
- t.Fatalf("Expected TokenKey to change, got %v", m.TokenKey)
- }
-
- if !m.LastResetSentAt.IsZero() {
- t.Fatalf("Expected LastResetSentAt to be zero datetime, got %v", m.LastResetSentAt)
- }
-}
-
-func TestAdminRefreshTokenKey(t *testing.T) {
- m := models.Admin{TokenKey: "test"}
-
- m.RefreshTokenKey()
-
- // empty pass
- if m.TokenKey == "" || m.TokenKey == "test" {
- t.Fatalf("Expected TokenKey to change, got %q", m.TokenKey)
- }
-}
diff --git a/models/backup_file_info.go b/models/backup_file_info.go
deleted file mode 100644
index 794900f3ac..0000000000
--- a/models/backup_file_info.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package models
-
-import "github.com/pocketbase/pocketbase/tools/types"
-
-type BackupFileInfo struct {
- Key string `json:"key"`
- Size int64 `json:"size"`
- Modified types.DateTime `json:"modified"`
-}
diff --git a/models/base.go b/models/base.go
deleted file mode 100644
index 44f5a76d70..0000000000
--- a/models/base.go
+++ /dev/null
@@ -1,122 +0,0 @@
-// Package models implements all PocketBase DB models and DTOs.
-package models
-
-import (
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-const (
- // DefaultIdLength is the default length of the generated model id.
- DefaultIdLength = 15
-
- // DefaultIdAlphabet is the default characters set used for generating the model id.
- DefaultIdAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
-)
-
-// ColumnValueMapper defines an interface for custom db model data serialization.
-type ColumnValueMapper interface {
- // ColumnValueMap returns the data to be used when persisting the model.
- ColumnValueMap() map[string]any
-}
-
-// FilesManager defines an interface with common methods that files manager models should implement.
-type FilesManager interface {
- // BaseFilesPath returns the storage dir path used by the interface instance.
- BaseFilesPath() string
-}
-
-// Model defines an interface with common methods that all db models should have.
-type Model interface {
- TableName() string
- IsNew() bool
- MarkAsNew()
- MarkAsNotNew()
- HasId() bool
- GetId() string
- SetId(id string)
- GetCreated() types.DateTime
- GetUpdated() types.DateTime
- RefreshId()
- RefreshCreated()
- RefreshUpdated()
-}
-
-// -------------------------------------------------------------------
-// BaseModel
-// -------------------------------------------------------------------
-
-// BaseModel defines common fields and methods used by all other models.
-type BaseModel struct {
- isNotNew bool
-
- Id string `db:"id" json:"id"`
- Created types.DateTime `db:"created" json:"created"`
- Updated types.DateTime `db:"updated" json:"updated"`
-}
-
-// HasId returns whether the model has a nonzero id.
-func (m *BaseModel) HasId() bool {
- return m.GetId() != ""
-}
-
-// GetId returns the model id.
-func (m *BaseModel) GetId() string {
- return m.Id
-}
-
-// SetId sets the model id to the provided string value.
-func (m *BaseModel) SetId(id string) {
- m.Id = id
-}
-
-// MarkAsNew marks the model as "new" (aka. enforces m.IsNew() to be true).
-func (m *BaseModel) MarkAsNew() {
- m.isNotNew = false
-}
-
-// MarkAsNotNew marks the model as "not new" (aka. enforces m.IsNew() to be false)
-func (m *BaseModel) MarkAsNotNew() {
- m.isNotNew = true
-}
-
-// IsNew indicates what type of db query (insert or update)
-// should be used with the model instance.
-func (m *BaseModel) IsNew() bool {
- return !m.isNotNew
-}
-
-// GetCreated returns the model Created datetime.
-func (m *BaseModel) GetCreated() types.DateTime {
- return m.Created
-}
-
-// GetUpdated returns the model Updated datetime.
-func (m *BaseModel) GetUpdated() types.DateTime {
- return m.Updated
-}
-
-// RefreshId generates and sets a new model id.
-//
-// The generated id is a cryptographically random 15 characters length string.
-func (m *BaseModel) RefreshId() {
- m.Id = security.RandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
-}
-
-// RefreshCreated updates the model Created field with the current datetime.
-func (m *BaseModel) RefreshCreated() {
- m.Created = types.NowDateTime()
-}
-
-// RefreshUpdated updates the model Updated field with the current datetime.
-func (m *BaseModel) RefreshUpdated() {
- m.Updated = types.NowDateTime()
-}
-
-// PostScan implements the [dbx.PostScanner] interface.
-//
-// It is executed right after the model was populated with the db row values.
-func (m *BaseModel) PostScan() error {
- m.MarkAsNotNew()
- return nil
-}
diff --git a/models/base_test.go b/models/base_test.go
deleted file mode 100644
index 764c26d2da..0000000000
--- a/models/base_test.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
-)
-
-func TestBaseModelHasId(t *testing.T) {
- scenarios := []struct {
- model models.BaseModel
- expected bool
- }{
- {
- models.BaseModel{},
- false,
- },
- {
- models.BaseModel{Id: ""},
- false,
- },
- {
- models.BaseModel{Id: "abc"},
- true,
- },
- }
-
- for i, s := range scenarios {
- result := s.model.HasId()
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestBaseModelId(t *testing.T) {
- m := models.BaseModel{}
-
- if m.GetId() != "" {
- t.Fatalf("Expected empty id value, got %v", m.GetId())
- }
-
- m.SetId("test")
-
- if m.GetId() != "test" {
- t.Fatalf("Expected %q id, got %v", "test", m.GetId())
- }
-
- m.RefreshId()
-
- if len(m.GetId()) != 15 {
- t.Fatalf("Expected 15 chars id, got %v", m.GetId())
- }
-}
-
-func TestBaseModelIsNew(t *testing.T) {
- m0 := models.BaseModel{}
- m1 := models.BaseModel{Id: ""}
- m2 := models.BaseModel{Id: "test"}
- m3 := models.BaseModel{}
- m3.MarkAsNotNew()
- m4 := models.BaseModel{Id: "test"}
- m4.MarkAsNotNew()
- m5 := models.BaseModel{Id: "test"}
- m5.MarkAsNew()
- m5.MarkAsNotNew()
- m6 := models.BaseModel{}
- m6.RefreshId()
- m7 := models.BaseModel{}
- m7.MarkAsNotNew()
- m7.RefreshId()
- m8 := models.BaseModel{}
- m8.PostScan()
-
- scenarios := []struct {
- model models.BaseModel
- expected bool
- }{
- {m0, true},
- {m1, true},
- {m2, true},
- {m3, false},
- {m4, false},
- {m5, false},
- {m6, true},
- {m7, false},
- {m8, false},
- }
-
- for i, s := range scenarios {
- result := s.model.IsNew()
- if result != s.expected {
- t.Errorf("(%d) Expected IsNew %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestBaseModelCreated(t *testing.T) {
- m := models.BaseModel{}
-
- if !m.GetCreated().IsZero() {
- t.Fatalf("Expected zero datetime, got %v", m.GetCreated())
- }
-
- m.RefreshCreated()
-
- if m.GetCreated().IsZero() {
- t.Fatalf("Expected non-zero datetime, got %v", m.GetCreated())
- }
-}
-
-func TestBaseModelUpdated(t *testing.T) {
- m := models.BaseModel{}
-
- if !m.GetUpdated().IsZero() {
- t.Fatalf("Expected zero datetime, got %v", m.GetUpdated())
- }
-
- m.RefreshUpdated()
-
- if m.GetUpdated().IsZero() {
- t.Fatalf("Expected non-zero datetime, got %v", m.GetUpdated())
- }
-}
diff --git a/models/collection.go b/models/collection.go
deleted file mode 100644
index b347e2c458..0000000000
--- a/models/collection.go
+++ /dev/null
@@ -1,219 +0,0 @@
-package models
-
-import (
- "encoding/json"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-var (
- _ Model = (*Collection)(nil)
- _ FilesManager = (*Collection)(nil)
-)
-
-const (
- CollectionTypeBase = "base"
- CollectionTypeAuth = "auth"
- CollectionTypeView = "view"
-)
-
-type Collection struct {
- BaseModel
-
- Name string `db:"name" json:"name"`
- Type string `db:"type" json:"type"`
- System bool `db:"system" json:"system"`
- Schema schema.Schema `db:"schema" json:"schema"`
- Indexes types.JsonArray[string] `db:"indexes" json:"indexes"`
-
- // rules
- ListRule *string `db:"listRule" json:"listRule"`
- ViewRule *string `db:"viewRule" json:"viewRule"`
- CreateRule *string `db:"createRule" json:"createRule"`
- UpdateRule *string `db:"updateRule" json:"updateRule"`
- DeleteRule *string `db:"deleteRule" json:"deleteRule"`
-
- Options types.JsonMap `db:"options" json:"options"`
-}
-
-// TableName returns the Collection model SQL table name.
-func (m *Collection) TableName() string {
- return "_collections"
-}
-
-// BaseFilesPath returns the storage dir path used by the collection.
-func (m *Collection) BaseFilesPath() string {
- return m.Id
-}
-
-// IsBase checks if the current collection has "base" type.
-func (m *Collection) IsBase() bool {
- return m.Type == CollectionTypeBase
-}
-
-// IsAuth checks if the current collection has "auth" type.
-func (m *Collection) IsAuth() bool {
- return m.Type == CollectionTypeAuth
-}
-
-// IsView checks if the current collection has "view" type.
-func (m *Collection) IsView() bool {
- return m.Type == CollectionTypeView
-}
-
-// MarshalJSON implements the [json.Marshaler] interface.
-func (m Collection) MarshalJSON() ([]byte, error) {
- type alias Collection // prevent recursion
-
- m.NormalizeOptions()
-
- return json.Marshal(alias(m))
-}
-
-// BaseOptions decodes the current collection options and returns them
-// as new [CollectionBaseOptions] instance.
-func (m *Collection) BaseOptions() CollectionBaseOptions {
- result := CollectionBaseOptions{}
- m.DecodeOptions(&result)
- return result
-}
-
-// AuthOptions decodes the current collection options and returns them
-// as new [CollectionAuthOptions] instance.
-func (m *Collection) AuthOptions() CollectionAuthOptions {
- result := CollectionAuthOptions{}
- m.DecodeOptions(&result)
- return result
-}
-
-// ViewOptions decodes the current collection options and returns them
-// as new [CollectionViewOptions] instance.
-func (m *Collection) ViewOptions() CollectionViewOptions {
- result := CollectionViewOptions{}
- m.DecodeOptions(&result)
- return result
-}
-
-// NormalizeOptions updates the current collection options with a
-// new normalized state based on the collection type.
-func (m *Collection) NormalizeOptions() error {
- var typedOptions any
- switch m.Type {
- case CollectionTypeAuth:
- typedOptions = m.AuthOptions()
- case CollectionTypeView:
- typedOptions = m.ViewOptions()
- default:
- typedOptions = m.BaseOptions()
- }
-
- // serialize
- raw, err := json.Marshal(typedOptions)
- if err != nil {
- return err
- }
-
- // load into a new JsonMap
- m.Options = types.JsonMap{}
- if err := json.Unmarshal(raw, &m.Options); err != nil {
- return err
- }
-
- return nil
-}
-
-// DecodeOptions decodes the current collection options into the
-// provided "result" (must be a pointer).
-func (m *Collection) DecodeOptions(result any) error {
- // raw serialize
- raw, err := json.Marshal(m.Options)
- if err != nil {
- return err
- }
-
- // decode into the provided result
- if err := json.Unmarshal(raw, result); err != nil {
- return err
- }
-
- return nil
-}
-
-// SetOptions normalizes and unmarshals the specified options into m.Options.
-func (m *Collection) SetOptions(typedOptions any) error {
- // serialize
- raw, err := json.Marshal(typedOptions)
- if err != nil {
- return err
- }
-
- m.Options = types.JsonMap{}
- if err := json.Unmarshal(raw, &m.Options); err != nil {
- return err
- }
-
- return m.NormalizeOptions()
-}
-
-// -------------------------------------------------------------------
-
-// CollectionBaseOptions defines the "base" Collection.Options fields.
-type CollectionBaseOptions struct {
-}
-
-// Validate implements [validation.Validatable] interface.
-func (o CollectionBaseOptions) Validate() error {
- return nil
-}
-
-// -------------------------------------------------------------------
-
-// CollectionAuthOptions defines the "auth" Collection.Options fields.
-type CollectionAuthOptions struct {
- ManageRule *string `form:"manageRule" json:"manageRule"`
- AllowOAuth2Auth bool `form:"allowOAuth2Auth" json:"allowOAuth2Auth"`
- AllowUsernameAuth bool `form:"allowUsernameAuth" json:"allowUsernameAuth"`
- AllowEmailAuth bool `form:"allowEmailAuth" json:"allowEmailAuth"`
- RequireEmail bool `form:"requireEmail" json:"requireEmail"`
- ExceptEmailDomains []string `form:"exceptEmailDomains" json:"exceptEmailDomains"`
- OnlyEmailDomains []string `form:"onlyEmailDomains" json:"onlyEmailDomains"`
- MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
-}
-
-// Validate implements [validation.Validatable] interface.
-func (o CollectionAuthOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(&o.ManageRule, validation.NilOrNotEmpty),
- validation.Field(
- &o.ExceptEmailDomains,
- validation.When(len(o.OnlyEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &o.OnlyEmailDomains,
- validation.When(len(o.ExceptEmailDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &o.MinPasswordLength,
- validation.When(o.AllowUsernameAuth || o.AllowEmailAuth, validation.Required),
- validation.Min(5),
- validation.Max(72),
- ),
- )
-}
-
-// -------------------------------------------------------------------
-
-// CollectionViewOptions defines the "view" Collection.Options fields.
-type CollectionViewOptions struct {
- Query string `form:"query" json:"query"`
-}
-
-// Validate implements [validation.Validatable] interface.
-func (o CollectionViewOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(&o.Query, validation.Required),
- )
-}
diff --git a/models/collection_test.go b/models/collection_test.go
deleted file mode 100644
index d752a549e1..0000000000
--- a/models/collection_test.go
+++ /dev/null
@@ -1,484 +0,0 @@
-package models_test
-
-import (
- "encoding/json"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestCollectionTableName(t *testing.T) {
- m := models.Collection{}
- if m.TableName() != "_collections" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
-
-func TestCollectionBaseFilesPath(t *testing.T) {
- m := models.Collection{}
-
- m.RefreshId()
-
- expected := m.Id
- if m.BaseFilesPath() != expected {
- t.Fatalf("Expected path %s, got %s", expected, m.BaseFilesPath())
- }
-}
-
-func TestCollectionIsBase(t *testing.T) {
- scenarios := []struct {
- collection models.Collection
- expected bool
- }{
- {models.Collection{}, false},
- {models.Collection{Type: "unknown"}, false},
- {models.Collection{Type: models.CollectionTypeBase}, true},
- {models.Collection{Type: models.CollectionTypeAuth}, false},
- }
-
- for i, s := range scenarios {
- result := s.collection.IsBase()
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestCollectionIsAuth(t *testing.T) {
- scenarios := []struct {
- collection models.Collection
- expected bool
- }{
- {models.Collection{}, false},
- {models.Collection{Type: "unknown"}, false},
- {models.Collection{Type: models.CollectionTypeBase}, false},
- {models.Collection{Type: models.CollectionTypeAuth}, true},
- }
-
- for i, s := range scenarios {
- result := s.collection.IsAuth()
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestCollectionMarshalJSON(t *testing.T) {
- scenarios := []struct {
- name string
- collection models.Collection
- expected string
- }{
- {
- "no type",
- models.Collection{Name: "test"},
- `{"id":"","created":"","updated":"","name":"test","type":"","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
- },
- {
- "unknown type + non empty options",
- models.Collection{Name: "test", Type: "unknown", ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}, Indexes: types.JsonArray[string]{"idx_test"}},
- `{"id":"","created":"","updated":"","name":"test","type":"unknown","system":false,"schema":[],"indexes":["idx_test"],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
- },
- {
- "base type + non empty options",
- models.Collection{Name: "test", Type: models.CollectionTypeBase, ListRule: types.Pointer("test_list"), Options: types.JsonMap{"test": 123}},
- `{"id":"","created":"","updated":"","name":"test","type":"base","system":false,"schema":[],"indexes":[],"listRule":"test_list","viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{}}`,
- },
- {
- "auth type + non empty options",
- models.Collection{BaseModel: models.BaseModel{Id: "test"}, Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "allowOAuth2Auth": true, "minPasswordLength": 4}},
- `{"id":"test","created":"","updated":"","name":"","type":"auth","system":false,"schema":[],"indexes":[],"listRule":null,"viewRule":null,"createRule":null,"updateRule":null,"deleteRule":null,"options":{"allowEmailAuth":false,"allowOAuth2Auth":true,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}}`,
- },
- }
-
- for _, s := range scenarios {
- result, err := s.collection.MarshalJSON()
- if err != nil {
- t.Errorf("[%s] Unexpected error %v", s.name, err)
- continue
- }
- if string(result) != s.expected {
- t.Errorf("[%s] Expected\n%v \ngot \n%v", s.name, s.expected, string(result))
- }
- }
-}
-
-func TestCollectionBaseOptions(t *testing.T) {
- scenarios := []struct {
- name string
- collection models.Collection
- expected string
- }{
- {
- "no type",
- models.Collection{Options: types.JsonMap{"test": 123}},
- "{}",
- },
- {
- "unknown type",
- models.Collection{Type: "anything", Options: types.JsonMap{"test": 123}},
- "{}",
- },
- {
- "different type",
- models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
- "{}",
- },
- {
- "base type",
- models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
- "{}",
- },
- }
-
- for _, s := range scenarios {
- result := s.collection.BaseOptions()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
-
- if strEncoded := string(encoded); strEncoded != s.expected {
- t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
- }
- }
-}
-
-func TestCollectionAuthOptions(t *testing.T) {
- options := types.JsonMap{"test": 123, "minPasswordLength": 4}
- expectedSerialization := `{"manageRule":null,"allowOAuth2Auth":false,"allowUsernameAuth":false,"allowEmailAuth":false,"requireEmail":false,"exceptEmailDomains":null,"onlyEmailDomains":null,"minPasswordLength":4}`
-
- scenarios := []struct {
- name string
- collection models.Collection
- expected string
- }{
- {
- "no type",
- models.Collection{Options: options},
- expectedSerialization,
- },
- {
- "unknown type",
- models.Collection{Type: "anything", Options: options},
- expectedSerialization,
- },
- {
- "different type",
- models.Collection{Type: models.CollectionTypeBase, Options: options},
- expectedSerialization,
- },
- {
- "auth type",
- models.Collection{Type: models.CollectionTypeAuth, Options: options},
- expectedSerialization,
- },
- }
-
- for _, s := range scenarios {
- result := s.collection.AuthOptions()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
-
- if strEncoded := string(encoded); strEncoded != s.expected {
- t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
- }
- }
-}
-
-func TestCollectionViewOptions(t *testing.T) {
- options := types.JsonMap{"query": "select id from demo1", "minPasswordLength": 4}
- expectedSerialization := `{"query":"select id from demo1"}`
-
- scenarios := []struct {
- name string
- collection models.Collection
- expected string
- }{
- {
- "no type",
- models.Collection{Options: options},
- expectedSerialization,
- },
- {
- "unknown type",
- models.Collection{Type: "anything", Options: options},
- expectedSerialization,
- },
- {
- "different type",
- models.Collection{Type: models.CollectionTypeBase, Options: options},
- expectedSerialization,
- },
- {
- "view type",
- models.Collection{Type: models.CollectionTypeView, Options: options},
- expectedSerialization,
- },
- }
-
- for _, s := range scenarios {
- result := s.collection.ViewOptions()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
-
- if strEncoded := string(encoded); strEncoded != s.expected {
- t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
- }
- }
-}
-
-func TestNormalizeOptions(t *testing.T) {
- scenarios := []struct {
- name string
- collection models.Collection
- expected string // serialized options
- }{
- {
- "unknown type",
- models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
- "{}",
- },
- {
- "base type",
- models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
- "{}",
- },
- {
- "auth type",
- models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123, "minPasswordLength": 4}},
- `{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
- },
- }
-
- for _, s := range scenarios {
- if err := s.collection.NormalizeOptions(); err != nil {
- t.Errorf("[%s] Unexpected error %v", s.name, err)
- continue
- }
-
- encoded, err := json.Marshal(s.collection.Options)
- if err != nil {
- t.Fatal(err)
- }
-
- if strEncoded := string(encoded); strEncoded != s.expected {
- t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
- }
- }
-}
-
-func TestDecodeOptions(t *testing.T) {
- m := models.Collection{
- Options: types.JsonMap{"test": 123},
- }
-
- result := struct {
- Test int
- }{}
-
- if err := m.DecodeOptions(&result); err != nil {
- t.Fatal(err)
- }
-
- if result.Test != 123 {
- t.Fatalf("Expected %v, got %v", 123, result.Test)
- }
-}
-
-func TestSetOptions(t *testing.T) {
- scenarios := []struct {
- name string
- collection models.Collection
- options any
- expected string // serialized options
- }{
- {
- "no type",
- models.Collection{},
- map[string]any{},
- "{}",
- },
- {
- "unknown type + non empty options",
- models.Collection{Type: "unknown", Options: types.JsonMap{"test": 123}},
- map[string]any{"test": 456, "minPasswordLength": 4},
- "{}",
- },
- {
- "base type",
- models.Collection{Type: models.CollectionTypeBase, Options: types.JsonMap{"test": 123}},
- map[string]any{"test": 456, "minPasswordLength": 4},
- "{}",
- },
- {
- "auth type",
- models.Collection{Type: models.CollectionTypeAuth, Options: types.JsonMap{"test": 123}},
- map[string]any{"test": 456, "minPasswordLength": 4},
- `{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":4,"onlyEmailDomains":null,"requireEmail":false}`,
- },
- }
-
- for _, s := range scenarios {
- if err := s.collection.SetOptions(s.options); err != nil {
- t.Errorf("[%s] Unexpected error %v", s.name, err)
- continue
- }
-
- encoded, err := json.Marshal(s.collection.Options)
- if err != nil {
- t.Fatal(err)
- }
-
- if strEncoded := string(encoded); strEncoded != s.expected {
- t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, strEncoded)
- }
- }
-}
-
-func TestCollectionBaseOptionsValidate(t *testing.T) {
- opt := models.CollectionBaseOptions{}
- if err := opt.Validate(); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestCollectionAuthOptionsValidate(t *testing.T) {
- scenarios := []struct {
- name string
- options models.CollectionAuthOptions
- expectedErrors []string
- }{
- {
- "empty",
- models.CollectionAuthOptions{},
- nil,
- },
- {
- "empty string ManageRule",
- models.CollectionAuthOptions{ManageRule: types.Pointer("")},
- []string{"manageRule"},
- },
- {
- "minPasswordLength < 5",
- models.CollectionAuthOptions{MinPasswordLength: 3},
- []string{"minPasswordLength"},
- },
- {
- "minPasswordLength > 72",
- models.CollectionAuthOptions{MinPasswordLength: 73},
- []string{"minPasswordLength"},
- },
- {
- "both OnlyDomains and ExceptDomains set",
- models.CollectionAuthOptions{
- OnlyEmailDomains: []string{"example.com", "test.com"},
- ExceptEmailDomains: []string{"example.com", "test.com"},
- },
- []string{"onlyEmailDomains", "exceptEmailDomains"},
- },
- {
- "only OnlyDomains set",
- models.CollectionAuthOptions{
- OnlyEmailDomains: []string{"example.com", "test.com"},
- },
- []string{},
- },
- {
- "only ExceptEmailDomains set",
- models.CollectionAuthOptions{
- ExceptEmailDomains: []string{"example.com", "test.com"},
- },
- []string{},
- },
- {
- "all fields with valid data",
- models.CollectionAuthOptions{
- ManageRule: types.Pointer("test"),
- AllowOAuth2Auth: true,
- AllowUsernameAuth: true,
- AllowEmailAuth: true,
- RequireEmail: true,
- ExceptEmailDomains: []string{"example.com", "test.com"},
- OnlyEmailDomains: nil,
- MinPasswordLength: 5,
- },
- []string{},
- },
- }
-
- for _, s := range scenarios {
- result := s.options.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
-
- if len(errs) != len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
- continue
- }
-
- for key := range errs {
- if !list.ExistInSlice(key, s.expectedErrors) {
- t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
- }
- }
- }
-}
-
-func TestCollectionViewOptionsValidate(t *testing.T) {
- scenarios := []struct {
- name string
- options models.CollectionViewOptions
- expectedErrors []string
- }{
- {
- "empty",
- models.CollectionViewOptions{},
- []string{"query"},
- },
- {
- "valid data",
- models.CollectionViewOptions{
- Query: "test123",
- },
- []string{},
- },
- }
-
- for _, s := range scenarios {
- result := s.options.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
-
- if len(errs) != len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got errors \n%v", s.name, s.expectedErrors, result)
- continue
- }
-
- for key := range errs {
- if !list.ExistInSlice(key, s.expectedErrors) {
- t.Errorf("[%s] Unexpected error key %q in \n%v", s.name, key, errs)
- }
- }
- }
-}
diff --git a/models/external_auth.go b/models/external_auth.go
deleted file mode 100644
index bf9e0314a1..0000000000
--- a/models/external_auth.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package models
-
-var _ Model = (*ExternalAuth)(nil)
-
-type ExternalAuth struct {
- BaseModel
-
- CollectionId string `db:"collectionId" json:"collectionId"`
- RecordId string `db:"recordId" json:"recordId"`
- Provider string `db:"provider" json:"provider"`
- ProviderId string `db:"providerId" json:"providerId"`
-}
-
-func (m *ExternalAuth) TableName() string {
- return "_externalAuths"
-}
diff --git a/models/external_auth_test.go b/models/external_auth_test.go
deleted file mode 100644
index 26c53b0670..0000000000
--- a/models/external_auth_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
-)
-
-func TestExternalAuthTableName(t *testing.T) {
- m := models.ExternalAuth{}
- if m.TableName() != "_externalAuths" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
diff --git a/models/param.go b/models/param.go
deleted file mode 100644
index cf5ef053db..0000000000
--- a/models/param.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package models
-
-import (
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-var _ Model = (*Param)(nil)
-
-const (
- ParamAppSettings = "settings"
-)
-
-type Param struct {
- BaseModel
-
- Key string `db:"key" json:"key"`
- Value types.JsonRaw `db:"value" json:"value"`
-}
-
-func (m *Param) TableName() string {
- return "_params"
-}
diff --git a/models/param_test.go b/models/param_test.go
deleted file mode 100644
index 42291551bd..0000000000
--- a/models/param_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
-)
-
-func TestParamTableName(t *testing.T) {
- m := models.Param{}
- if m.TableName() != "_params" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
diff --git a/models/record.go b/models/record.go
deleted file mode 100644
index 0d1d76d8b9..0000000000
--- a/models/record.go
+++ /dev/null
@@ -1,944 +0,0 @@
-package models
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "regexp"
- "strconv"
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
- "github.com/pocketbase/pocketbase/tools/store"
- "github.com/pocketbase/pocketbase/tools/types"
- "github.com/spf13/cast"
- "golang.org/x/crypto/bcrypt"
-)
-
-var (
- _ Model = (*Record)(nil)
- _ ColumnValueMapper = (*Record)(nil)
- _ FilesManager = (*Record)(nil)
-)
-
-type Record struct {
- BaseModel
-
- collection *Collection
-
- exportUnknown bool // whether to export unknown fields
- ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
- loaded bool
- originalData map[string]any // the original (aka. first loaded) model data
- expand *store.Store[any] // expanded relations
- data *store.Store[any] // any custom data in addition to the base model fields
-}
-
-// NewRecord initializes a new empty Record model.
-func NewRecord(collection *Collection) *Record {
- return &Record{
- collection: collection,
- data: store.New[any](nil),
- }
-}
-
-// nullStringMapValue returns the raw string value if it exist and
-// its not NULL, otherwise - nil.
-func nullStringMapValue(data dbx.NullStringMap, key string) any {
- nullString, ok := data[key]
-
- if ok && nullString.Valid {
- return nullString.String
- }
-
- return nil
-}
-
-// NewRecordFromNullStringMap initializes a single new Record model
-// with data loaded from the provided NullStringMap.
-//
-// Note that this method is intended to load and Scan data from a database
-// result and calls PostScan() which marks the record as "not new".
-func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
- resultMap := make(map[string]any, len(data))
-
- // load schema fields
- for _, field := range collection.Schema.Fields() {
- resultMap[field.Name] = nullStringMapValue(data, field.Name)
- }
-
- // load base model fields
- for _, name := range schema.BaseModelFieldNames() {
- resultMap[name] = nullStringMapValue(data, name)
- }
-
- // load auth fields
- if collection.IsAuth() {
- for _, name := range schema.AuthFieldNames() {
- resultMap[name] = nullStringMapValue(data, name)
- }
- }
-
- record := NewRecord(collection)
-
- record.Load(resultMap)
- record.PostScan()
-
- return record
-}
-
-// NewRecordsFromNullStringMaps initializes a new Record model for
-// each row in the provided NullStringMap slice.
-//
-// Note that this method is intended to load and Scan data from a database
-// result and calls PostScan() for each record marking them as "not new".
-func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
- result := make([]*Record, len(rows))
-
- for i, row := range rows {
- result[i] = NewRecordFromNullStringMap(collection, row)
- }
-
- return result
-}
-
-// TableName returns the table name associated to the current Record model.
-func (m *Record) TableName() string {
- return m.collection.Name
-}
-
-// Collection returns the Collection model associated to the current Record model.
-func (m *Record) Collection() *Collection {
- return m.collection
-}
-
-// OriginalCopy returns a copy of the current record model populated
-// with its ORIGINAL data state (aka. the initially loaded) and
-// everything else reset to the defaults.
-func (m *Record) OriginalCopy() *Record {
- newRecord := NewRecord(m.collection)
- newRecord.Load(m.originalData)
-
- if m.IsNew() {
- newRecord.MarkAsNew()
- } else {
- newRecord.MarkAsNotNew()
- }
-
- return newRecord
-}
-
-// CleanCopy returns a copy of the current record model populated only
-// with its LATEST data state and everything else reset to the defaults.
-func (m *Record) CleanCopy() *Record {
- newRecord := NewRecord(m.collection)
- newRecord.Load(m.data.GetAll())
- newRecord.Id = m.Id
- newRecord.Created = m.Created
- newRecord.Updated = m.Updated
-
- if m.IsNew() {
- newRecord.MarkAsNew()
- } else {
- newRecord.MarkAsNotNew()
- }
-
- return newRecord
-}
-
-// Expand returns a shallow copy of the current Record model expand data.
-func (m *Record) Expand() map[string]any {
- if m.expand == nil {
- m.expand = store.New[any](nil)
- }
-
- return m.expand.GetAll()
-}
-
-// SetExpand shallow copies the provided data to the current Record model's expand.
-func (m *Record) SetExpand(expand map[string]any) {
- if m.expand == nil {
- m.expand = store.New[any](nil)
- }
-
- m.expand.Reset(expand)
-}
-
-// MergeExpand merges recursively the provided expand data into
-// the current model's expand (if any).
-//
-// Note that if an expanded prop with the same key is a slice (old or new expand)
-// then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]).
-// Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew).
-func (m *Record) MergeExpand(expand map[string]any) {
- // nothing to merge
- if len(expand) == 0 {
- return
- }
-
- // no old expand
- if m.expand == nil {
- m.expand = store.New(expand)
- return
- }
-
- oldExpand := m.expand.GetAll()
-
- for key, new := range expand {
- old, ok := oldExpand[key]
- if !ok {
- oldExpand[key] = new
- continue
- }
-
- var wasOldSlice bool
- var oldSlice []*Record
- switch v := old.(type) {
- case *Record:
- oldSlice = []*Record{v}
- case []*Record:
- wasOldSlice = true
- oldSlice = v
- default:
- // invalid old expand data -> assign directly the new
- // (no matter whether new is valid or not)
- oldExpand[key] = new
- continue
- }
-
- var wasNewSlice bool
- var newSlice []*Record
- switch v := new.(type) {
- case *Record:
- newSlice = []*Record{v}
- case []*Record:
- wasNewSlice = true
- newSlice = v
- default:
- // invalid new expand data -> skip
- continue
- }
-
- oldIndexed := make(map[string]*Record, len(oldSlice))
- for _, oldRecord := range oldSlice {
- oldIndexed[oldRecord.Id] = oldRecord
- }
-
- for _, newRecord := range newSlice {
- oldRecord := oldIndexed[newRecord.Id]
- if oldRecord != nil {
- // note: there is no need to update oldSlice since oldRecord is a reference
- oldRecord.MergeExpand(newRecord.Expand())
- } else {
- // missing new entry
- oldSlice = append(oldSlice, newRecord)
- }
- }
-
- if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
- oldExpand[key] = oldSlice
- } else {
- oldExpand[key] = oldSlice[0]
- }
- }
-
- m.expand.Reset(oldExpand)
-}
-
-// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
-func (m *Record) SchemaData() map[string]any {
- result := make(map[string]any, len(m.collection.Schema.Fields()))
-
- data := m.data.GetAll()
-
- for _, field := range m.collection.Schema.Fields() {
- if v, ok := data[field.Name]; ok {
- result[field.Name] = v
- }
- }
-
- return result
-}
-
-// UnknownData returns a shallow copy ONLY of the unknown record fields data,
-// aka. fields that are neither one of the base and special system ones,
-// nor defined by the collection schema.
-func (m *Record) UnknownData() map[string]any {
- if m.data == nil {
- return nil
- }
-
- return m.extractUnknownData(m.data.GetAll())
-}
-
-// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
-func (m *Record) IgnoreEmailVisibility(state bool) {
- m.ignoreEmailVisibility = state
-}
-
-// WithUnknownData toggles the export/serialization of unknown data fields
-// (false by default).
-func (m *Record) WithUnknownData(state bool) {
- m.exportUnknown = state
-}
-
-// Set sets the provided key-value data pair for the current Record model.
-//
-// If the record collection has field with name matching the provided "key",
-// the value will be further normalized according to the field rules.
-func (m *Record) Set(key string, value any) {
- switch key {
- case schema.FieldNameId:
- m.Id = cast.ToString(value)
- case schema.FieldNameCreated:
- m.Created, _ = types.ParseDateTime(value)
- case schema.FieldNameUpdated:
- m.Updated, _ = types.ParseDateTime(value)
- case schema.FieldNameExpand:
- m.SetExpand(cast.ToStringMap(value))
- default:
- var v = value
-
- if field := m.Collection().Schema.GetFieldByName(key); field != nil {
- v = field.PrepareValue(value)
- } else if m.collection.IsAuth() {
- // normalize auth fields
- switch key {
- case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
- v = cast.ToBool(value)
- case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
- v, _ = types.ParseDateTime(value)
- case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
- v = cast.ToString(value)
- }
- }
-
- if m.data == nil {
- m.data = store.New[any](nil)
- }
-
- m.data.Set(key, v)
- }
-}
-
-// Get returns a normalized single record model data value for "key".
-func (m *Record) Get(key string) any {
- switch key {
- case schema.FieldNameId:
- return m.Id
- case schema.FieldNameCreated:
- return m.Created
- case schema.FieldNameUpdated:
- return m.Updated
- default:
- var v any
- if m.data != nil {
- v = m.data.Get(key)
- }
-
- // normalize the field value in case it is missing or an incorrect type was set
- // to ensure that the DB will always have normalized columns value.
- if field := m.Collection().Schema.GetFieldByName(key); field != nil {
- v = field.PrepareValue(v)
- } else if m.collection.IsAuth() {
- switch key {
- case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
- v = cast.ToBool(v)
- case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
- v, _ = types.ParseDateTime(v)
- case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
- v = cast.ToString(v)
- }
- }
-
- return v
- }
-}
-
-// GetBool returns the data value for "key" as a bool.
-func (m *Record) GetBool(key string) bool {
- return cast.ToBool(m.Get(key))
-}
-
-// GetString returns the data value for "key" as a string.
-func (m *Record) GetString(key string) string {
- return cast.ToString(m.Get(key))
-}
-
-// GetInt returns the data value for "key" as an int.
-func (m *Record) GetInt(key string) int {
- return cast.ToInt(m.Get(key))
-}
-
-// GetFloat returns the data value for "key" as a float64.
-func (m *Record) GetFloat(key string) float64 {
- return cast.ToFloat64(m.Get(key))
-}
-
-// GetTime returns the data value for "key" as a [time.Time] instance.
-func (m *Record) GetTime(key string) time.Time {
- return cast.ToTime(m.Get(key))
-}
-
-// GetDateTime returns the data value for "key" as a DateTime instance.
-func (m *Record) GetDateTime(key string) types.DateTime {
- d, _ := types.ParseDateTime(m.Get(key))
- return d
-}
-
-// GetStringSlice returns the data value for "key" as a slice of unique strings.
-func (m *Record) GetStringSlice(key string) []string {
- return list.ToUniqueStringSlice(m.Get(key))
-}
-
-// ExpandedOne retrieves a single relation Record from the already
-// loaded expand data of the current model.
-//
-// If the requested expand relation is multiple, this method returns
-// only first available Record from the expanded relation.
-//
-// Returns nil if there is no such expand relation loaded.
-func (m *Record) ExpandedOne(relField string) *Record {
- if m.expand == nil {
- return nil
- }
-
- rel := m.expand.Get(relField)
-
- switch v := rel.(type) {
- case *Record:
- return v
- case []*Record:
- if len(v) > 0 {
- return v[0]
- }
- }
-
- return nil
-}
-
-// ExpandedAll retrieves a slice of relation Records from the already
-// loaded expand data of the current model.
-//
-// If the requested expand relation is single, this method normalizes
-// the return result and will wrap the single model as a slice.
-//
-// Returns nil slice if there is no such expand relation loaded.
-func (m *Record) ExpandedAll(relField string) []*Record {
- if m.expand == nil {
- return nil
- }
-
- rel := m.expand.Get(relField)
-
- switch v := rel.(type) {
- case *Record:
- return []*Record{v}
- case []*Record:
- return v
- }
-
- return nil
-}
-
-// Retrieves the "key" json field value and unmarshals it into "result".
-//
-// Example
-//
-// result := struct {
-// FirstName string `json:"first_name"`
-// }{}
-// err := m.UnmarshalJSONField("my_field_name", &result)
-func (m *Record) UnmarshalJSONField(key string, result any) error {
- return json.Unmarshal([]byte(m.GetString(key)), &result)
-}
-
-// BaseFilesPath returns the storage dir path used by the record.
-func (m *Record) BaseFilesPath() string {
- return fmt.Sprintf("%s/%s", m.Collection().BaseFilesPath(), m.Id)
-}
-
-// FindFileFieldByFile returns the first file type field for which
-// any of the record's data contains the provided filename.
-func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
- for _, field := range m.Collection().Schema.Fields() {
- if field.Type == schema.FieldTypeFile {
- names := m.GetStringSlice(field.Name)
- if list.ExistInSlice(filename, names) {
- return field
- }
- }
- }
- return nil
-}
-
-// Load bulk loads the provided data into the current Record model.
-func (m *Record) Load(data map[string]any) {
- if !m.loaded {
- m.loaded = true
- m.originalData = data
- }
-
- for k, v := range data {
- m.Set(k, v)
- }
-}
-
-// ColumnValueMap implements [ColumnValueMapper] interface.
-func (m *Record) ColumnValueMap() map[string]any {
- result := make(map[string]any, len(m.collection.Schema.Fields())+3)
-
- // export schema field values
- for _, field := range m.collection.Schema.Fields() {
- result[field.Name] = m.getNormalizeDataValueForDB(field.Name)
- }
-
- // export auth collection fields
- if m.collection.IsAuth() {
- for _, name := range schema.AuthFieldNames() {
- result[name] = m.getNormalizeDataValueForDB(name)
- }
- }
-
- // export base model fields
- result[schema.FieldNameId] = m.getNormalizeDataValueForDB(schema.FieldNameId)
- result[schema.FieldNameCreated] = m.getNormalizeDataValueForDB(schema.FieldNameCreated)
- result[schema.FieldNameUpdated] = m.getNormalizeDataValueForDB(schema.FieldNameUpdated)
-
- return result
-}
-
-// PublicExport exports only the record fields that are safe to be public.
-//
-// For auth records, to force the export of the email field you need to set
-// `m.IgnoreEmailVisibility(true)`.
-func (m *Record) PublicExport() map[string]any {
- result := make(map[string]any, len(m.collection.Schema.Fields())+5)
-
- // export unknown data fields if allowed
- if m.exportUnknown {
- for k, v := range m.UnknownData() {
- result[k] = v
- }
- }
-
- // export schema field values
- for _, field := range m.collection.Schema.Fields() {
- result[field.Name] = m.Get(field.Name)
- }
-
- // export some of the safe auth collection fields
- if m.collection.IsAuth() {
- result[schema.FieldNameVerified] = m.Verified()
- result[schema.FieldNameUsername] = m.Username()
- result[schema.FieldNameEmailVisibility] = m.EmailVisibility()
- if m.ignoreEmailVisibility || m.EmailVisibility() {
- result[schema.FieldNameEmail] = m.Email()
- }
- }
-
- // export base model fields
- result[schema.FieldNameId] = m.GetId()
- if created := m.GetCreated(); !m.Collection().IsView() || !created.IsZero() {
- result[schema.FieldNameCreated] = created
- }
- if updated := m.GetUpdated(); !m.Collection().IsView() || !updated.IsZero() {
- result[schema.FieldNameUpdated] = updated
- }
-
- // add helper collection reference fields
- result[schema.FieldNameCollectionId] = m.collection.Id
- result[schema.FieldNameCollectionName] = m.collection.Name
-
- // add expand (if set)
- if m.expand != nil && m.expand.Length() > 0 {
- result[schema.FieldNameExpand] = m.expand.GetAll()
- }
-
- return result
-}
-
-// MarshalJSON implements the [json.Marshaler] interface.
-//
-// Only the data exported by `PublicExport()` will be serialized.
-func (m Record) MarshalJSON() ([]byte, error) {
- return json.Marshal(m.PublicExport())
-}
-
-// UnmarshalJSON implements the [json.Unmarshaler] interface.
-func (m *Record) UnmarshalJSON(data []byte) error {
- result := map[string]any{}
-
- if err := json.Unmarshal(data, &result); err != nil {
- return err
- }
-
- m.Load(result)
-
- return nil
-}
-
-// ReplaceModifers returns a new map with applied modifier
-// values based on the current record and the specified data.
-//
-// The resolved modifier keys will be removed.
-//
-// Multiple modifiers will be applied one after another,
-// while reusing the previous base key value result (eg. 1; -5; +2 => -2).
-//
-// Example usage:
-//
-// newData := record.ReplaceModifers(data)
-// // record: {"field": 10}
-// // data: {"field+": 5}
-// // newData: {"field": 15}
-func (m *Record) ReplaceModifers(data map[string]any) map[string]any {
- var clone = shallowCopy(data)
- if len(clone) == 0 {
- return clone
- }
-
- var recordDataCache map[string]any
-
- // export recordData lazily
- recordData := func() map[string]any {
- if recordDataCache == nil {
- recordDataCache = m.SchemaData()
- }
- return recordDataCache
- }
-
- modifiers := schema.FieldValueModifiers()
-
- for _, field := range m.Collection().Schema.Fields() {
- key := field.Name
-
- for _, m := range modifiers {
- if mv, mOk := clone[key+m]; mOk {
- if _, ok := clone[key]; !ok {
- // get base value from the merged data
- clone[key] = recordData()[key]
- }
-
- clone[key] = field.PrepareValueWithModifier(clone[key], m, mv)
- delete(clone, key+m)
- }
- }
-
- if field.Type != schema.FieldTypeFile {
- continue
- }
-
- // -----------------------------------------------------------
- // legacy file field modifiers (kept for backward compatibility)
- // -----------------------------------------------------------
-
- var oldNames []string
- var toDelete []string
- if _, ok := clone[key]; ok {
- oldNames = list.ToUniqueStringSlice(clone[key])
- } else {
- // get oldNames from the model
- oldNames = list.ToUniqueStringSlice(recordData()[key])
- }
-
- // search for individual file name to delete (eg. "file.test.png = null")
- for _, name := range oldNames {
- suffixedKey := key + "." + name
- if v, ok := clone[suffixedKey]; ok && cast.ToString(v) == "" {
- toDelete = append(toDelete, name)
- delete(clone, suffixedKey)
- continue
- }
- }
-
- // search for individual file index to delete (eg. "file.0 = null")
- keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
- for indexedKey := range clone {
- if keyExp.MatchString(indexedKey) && cast.ToString(clone[indexedKey]) == "" {
- index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
- if indexErr != nil || index < 0 || index >= len(oldNames) {
- continue
- }
- toDelete = append(toDelete, oldNames[index])
- delete(clone, indexedKey)
- }
- }
-
- if toDelete != nil {
- clone[key] = field.PrepareValue(list.SubtractSlice(oldNames, toDelete))
- }
- }
-
- return clone
-}
-
-// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
-func (m *Record) getNormalizeDataValueForDB(key string) any {
- var val any
-
- // normalize auth fields
- if m.collection.IsAuth() {
- switch key {
- case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
- return m.GetBool(key)
- case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
- return m.GetDateTime(key)
- case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
- return m.GetString(key)
- }
- }
-
- val = m.Get(key)
-
- switch ids := val.(type) {
- case []string:
- // encode string slice
- return append(types.JsonArray[string]{}, ids...)
- case []int:
- // encode int slice
- return append(types.JsonArray[int]{}, ids...)
- case []float64:
- // encode float64 slice
- return append(types.JsonArray[float64]{}, ids...)
- case []any:
- // encode interface slice
- return append(types.JsonArray[any]{}, ids...)
- default:
- // no changes
- return val
- }
-}
-
-// shallowCopy shallow copy data into a new map.
-func shallowCopy(data map[string]any) map[string]any {
- result := make(map[string]any, len(data))
-
- for k, v := range data {
- result[k] = v
- }
-
- return result
-}
-
-func (m *Record) extractUnknownData(data map[string]any) map[string]any {
- knownFields := map[string]struct{}{}
-
- for _, name := range schema.SystemFieldNames() {
- knownFields[name] = struct{}{}
- }
- for _, name := range schema.BaseModelFieldNames() {
- knownFields[name] = struct{}{}
- }
-
- for _, f := range m.collection.Schema.Fields() {
- knownFields[f.Name] = struct{}{}
- }
-
- if m.collection.IsAuth() {
- for _, name := range schema.AuthFieldNames() {
- knownFields[name] = struct{}{}
- }
- }
-
- result := map[string]any{}
-
- for k, v := range data {
- if _, ok := knownFields[k]; !ok {
- result[k] = v
- }
- }
-
- return result
-}
-
-// -------------------------------------------------------------------
-// Auth helpers
-// -------------------------------------------------------------------
-
-var notAuthRecordErr = errors.New("Not an auth collection record.")
-
-// Username returns the "username" auth record data value.
-func (m *Record) Username() string {
- return m.GetString(schema.FieldNameUsername)
-}
-
-// SetUsername sets the "username" auth record data value.
-//
-// This method doesn't check whether the provided value is a valid username.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetUsername(username string) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameUsername, username)
-
- return nil
-}
-
-// Email returns the "email" auth record data value.
-func (m *Record) Email() string {
- return m.GetString(schema.FieldNameEmail)
-}
-
-// SetEmail sets the "email" auth record data value.
-//
-// This method doesn't check whether the provided value is a valid email.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetEmail(email string) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameEmail, email)
-
- return nil
-}
-
-// Verified returns the "emailVisibility" auth record data value.
-func (m *Record) EmailVisibility() bool {
- return m.GetBool(schema.FieldNameEmailVisibility)
-}
-
-// SetEmailVisibility sets the "emailVisibility" auth record data value.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetEmailVisibility(visible bool) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameEmailVisibility, visible)
-
- return nil
-}
-
-// Verified returns the "verified" auth record data value.
-func (m *Record) Verified() bool {
- return m.GetBool(schema.FieldNameVerified)
-}
-
-// SetVerified sets the "verified" auth record data value.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetVerified(verified bool) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameVerified, verified)
-
- return nil
-}
-
-// TokenKey returns the "tokenKey" auth record data value.
-func (m *Record) TokenKey() string {
- return m.GetString(schema.FieldNameTokenKey)
-}
-
-// SetTokenKey sets the "tokenKey" auth record data value.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetTokenKey(key string) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameTokenKey, key)
-
- return nil
-}
-
-// RefreshTokenKey generates and sets new random auth record "tokenKey".
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) RefreshTokenKey() error {
- return m.SetTokenKey(security.RandomString(50))
-}
-
-// LastResetSentAt returns the "lastResentSentAt" auth record data value.
-func (m *Record) LastResetSentAt() types.DateTime {
- return m.GetDateTime(schema.FieldNameLastResetSentAt)
-}
-
-// SetLastResetSentAt sets the "lastResentSentAt" auth record data value.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameLastResetSentAt, dateTime)
-
- return nil
-}
-
-// LastVerificationSentAt returns the "lastVerificationSentAt" auth record data value.
-func (m *Record) LastVerificationSentAt() types.DateTime {
- return m.GetDateTime(schema.FieldNameLastVerificationSentAt)
-}
-
-// SetLastVerificationSentAt sets an "lastVerificationSentAt" auth record data value.
-//
-// Returns an error if the record is not from an auth collection.
-func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- m.Set(schema.FieldNameLastVerificationSentAt, dateTime)
-
- return nil
-}
-
-// PasswordHash returns the "passwordHash" auth record data value.
-func (m *Record) PasswordHash() string {
- return m.GetString(schema.FieldNamePasswordHash)
-}
-
-// ValidatePassword validates a plain password against the auth record password.
-//
-// Returns false if the password is incorrect or record is not from an auth collection.
-func (m *Record) ValidatePassword(password string) bool {
- if !m.collection.IsAuth() {
- return false
- }
-
- err := bcrypt.CompareHashAndPassword([]byte(m.PasswordHash()), []byte(password))
-
- return err == nil
-}
-
-// SetPassword sets cryptographically secure string to the auth record "password" field.
-// This method also resets the "lastResetSentAt" and the "tokenKey" fields.
-//
-// Returns an error if the record is not from an auth collection or
-// an empty password is provided.
-func (m *Record) SetPassword(password string) error {
- if !m.collection.IsAuth() {
- return notAuthRecordErr
- }
-
- if password == "" {
- return errors.New("The provided plain password is empty")
- }
-
- // hash the password
- hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
- if err != nil {
- return err
- }
-
- m.Set(schema.FieldNamePasswordHash, string(hashedPassword))
- m.Set(schema.FieldNameLastResetSentAt, types.DateTime{})
-
- // invalidate previously issued tokens
- return m.RefreshTokenKey()
-}
diff --git a/models/record_test.go b/models/record_test.go
deleted file mode 100644
index ec620dc169..0000000000
--- a/models/record_test.go
+++ /dev/null
@@ -1,2071 +0,0 @@
-package models_test
-
-import (
- "bytes"
- "database/sql"
- "encoding/json"
- "testing"
- "time"
-
- "github.com/pocketbase/dbx"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestNewRecord(t *testing.T) {
- collection := &models.Collection{
- Name: "test_collection",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "test",
- Type: schema.FieldTypeText,
- },
- ),
- }
-
- m := models.NewRecord(collection)
-
- if m.Collection().Name != collection.Name {
- t.Fatalf("Expected collection with name %q, got %q", collection.Id, m.Collection().Id)
- }
-
- if len(m.SchemaData()) != 0 {
- t.Fatalf("Expected empty schema data, got %v", m.SchemaData())
- }
-}
-
-func TestNewRecordFromNullStringMap(t *testing.T) {
- collection := &models.Collection{
- Name: "test",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeBool,
- },
- &schema.SchemaField{
- Name: "field4",
- Type: schema.FieldTypeNumber,
- },
- &schema.SchemaField{
- Name: "field5",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"test1", "test2"},
- MaxSelect: 1,
- },
- },
- &schema.SchemaField{
- Name: "field6",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 2,
- MaxSize: 1,
- },
- },
- ),
- }
-
- data := dbx.NullStringMap{
- "id": sql.NullString{
- String: "test_id",
- Valid: true,
- },
- "created": sql.NullString{
- String: "2022-01-01 10:00:00.123Z",
- Valid: true,
- },
- "updated": sql.NullString{
- String: "2022-01-01 10:00:00.456Z",
- Valid: true,
- },
- // auth collection specific fields
- "username": sql.NullString{
- String: "test_username",
- Valid: true,
- },
- "email": sql.NullString{
- String: "test_email",
- Valid: true,
- },
- "emailVisibility": sql.NullString{
- String: "true",
- Valid: true,
- },
- "verified": sql.NullString{
- String: "",
- Valid: false,
- },
- "tokenKey": sql.NullString{
- String: "test_tokenKey",
- Valid: true,
- },
- "passwordHash": sql.NullString{
- String: "test_passwordHash",
- Valid: true,
- },
- "lastResetSentAt": sql.NullString{
- String: "2022-01-02 10:00:00.123Z",
- Valid: true,
- },
- "lastVerificationSentAt": sql.NullString{
- String: "2022-02-03 10:00:00.456Z",
- Valid: true,
- },
- // custom schema fields
- "field1": sql.NullString{
- String: "test",
- Valid: true,
- },
- "field2": sql.NullString{
- String: "test",
- Valid: false, // test invalid db serialization
- },
- "field3": sql.NullString{
- String: "true",
- Valid: true,
- },
- "field4": sql.NullString{
- String: "123.123",
- Valid: true,
- },
- "field5": sql.NullString{
- String: `["test1","test2"]`, // will select only the last elem
- Valid: true,
- },
- "field6": sql.NullString{
- String: "test", // will be converted to slice
- Valid: true,
- },
- "unknown": sql.NullString{
- String: "test",
- Valid: true,
- },
- }
-
- scenarios := []struct {
- collectionType string
- expectedJson string
- }{
- {
- models.CollectionTypeBase,
- `{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
- },
- {
- models.CollectionTypeAuth,
- `{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
- },
- }
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
- m := models.NewRecordFromNullStringMap(collection, data)
- m.IgnoreEmailVisibility(true)
-
- encoded, err := m.MarshalJSON()
- if err != nil {
- t.Errorf("(%d) Unexpected error: %v", i, err)
- continue
- }
-
- if string(encoded) != s.expectedJson {
- t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, string(encoded))
- }
-
- // additional data checks
- if collection.IsAuth() {
- if v := m.GetString(schema.FieldNamePasswordHash); v != "test_passwordHash" {
- t.Errorf("(%d) Expected %q, got %q", i, "test_passwordHash", v)
- }
- if v := m.GetString(schema.FieldNameTokenKey); v != "test_tokenKey" {
- t.Errorf("(%d) Expected %q, got %q", i, "test_tokenKey", v)
- }
- if v := m.GetString(schema.FieldNameLastResetSentAt); v != "2022-01-02 10:00:00.123Z" {
- t.Errorf("(%d) Expected %q, got %q", i, "2022-01-02 10:00:00.123Z", v)
- }
- if v := m.GetString(schema.FieldNameLastVerificationSentAt); v != "2022-02-03 10:00:00.456Z" {
- t.Errorf("(%d) Expected %q, got %q", i, "2022-01-02 10:00:00.123Z", v)
- }
- }
- }
-}
-
-func TestNewRecordsFromNullStringMaps(t *testing.T) {
- collection := &models.Collection{
- Name: "test",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeUrl,
- },
- ),
- }
-
- data := []dbx.NullStringMap{
- {
- "id": sql.NullString{
- String: "test_id1",
- Valid: true,
- },
- "created": sql.NullString{
- String: "2022-01-01 10:00:00.123Z",
- Valid: true,
- },
- "updated": sql.NullString{
- String: "2022-01-01 10:00:00.456Z",
- Valid: true,
- },
- // partial auth fields
- "email": sql.NullString{
- String: "test_email",
- Valid: true,
- },
- "tokenKey": sql.NullString{
- String: "test_tokenKey",
- Valid: true,
- },
- "emailVisibility": sql.NullString{
- String: "true",
- Valid: true,
- },
- // custom schema fields
- "field1": sql.NullString{
- String: "test",
- Valid: true,
- },
- "field2": sql.NullString{
- String: "123.123",
- Valid: true,
- },
- "field3": sql.NullString{
- String: "test",
- Valid: false, // should force resolving to empty string
- },
- "unknown": sql.NullString{
- String: "test",
- Valid: true,
- },
- },
- {
- "field3": sql.NullString{
- String: "test",
- Valid: true,
- },
- "email": sql.NullString{
- String: "test_email",
- Valid: true,
- },
- "emailVisibility": sql.NullString{
- String: "false",
- Valid: true,
- },
- },
- }
-
- scenarios := []struct {
- collectionType string
- expectedJson string
- }{
- {
- models.CollectionTypeBase,
- `[{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":123.123,"field3":"","id":"test_id1","updated":"2022-01-01 10:00:00.456Z"},{"collectionId":"","collectionName":"test","created":"","field1":"","field2":0,"field3":"test","id":"","updated":""}]`,
- },
- {
- models.CollectionTypeAuth,
- `[{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":123.123,"field3":"","id":"test_id1","updated":"2022-01-01 10:00:00.456Z","username":"","verified":false},{"collectionId":"","collectionName":"test","created":"","emailVisibility":false,"field1":"","field2":0,"field3":"test","id":"","updated":"","username":"","verified":false}]`,
- },
- }
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
- result := models.NewRecordsFromNullStringMaps(collection, data)
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Errorf("(%d) Unexpected error: %v", i, err)
- continue
- }
-
- if string(encoded) != s.expectedJson {
- t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, string(encoded))
- }
- }
-}
-
-func TestRecordTableName(t *testing.T) {
- collection := &models.Collection{}
- collection.Name = "test"
- collection.RefreshId()
-
- m := models.NewRecord(collection)
-
- if m.TableName() != collection.Name {
- t.Fatalf("Expected table %q, got %q", collection.Name, m.TableName())
- }
-}
-
-func TestRecordCollection(t *testing.T) {
- collection := &models.Collection{}
- collection.RefreshId()
-
- m := models.NewRecord(collection)
-
- if m.Collection().Id != collection.Id {
- t.Fatalf("Expected collection with id %v, got %v", collection.Id, m.Collection().Id)
- }
-}
-
-func TestRecordOriginalCopy(t *testing.T) {
- m := models.NewRecord(&models.Collection{})
- m.Load(map[string]any{"f": "123"})
-
- // change the field
- m.Set("f", "456")
-
- if v := m.GetString("f"); v != "456" {
- t.Fatalf("Expected f to be %q, got %q", "456", v)
- }
-
- if v := m.OriginalCopy().GetString("f"); v != "123" {
- t.Fatalf("Expected the initial/original f to be %q, got %q", "123", v)
- }
-
- // loading new data shouldn't affect the original state
- m.Load(map[string]any{"f": "789"})
-
- if v := m.GetString("f"); v != "789" {
- t.Fatalf("Expected f to be %q, got %q", "789", v)
- }
-
- if v := m.OriginalCopy().GetString("f"); v != "123" {
- t.Fatalf("Expected the initial/original f still to be %q, got %q", "123", v)
- }
-}
-
-func TestRecordCleanCopy(t *testing.T) {
- m := models.NewRecord(&models.Collection{
- Name: "cname",
- Type: models.CollectionTypeAuth,
- })
- m.Load(map[string]any{
- "id": "id1",
- "created": "2023-01-01 00:00:00.000Z",
- "updated": "2023-01-02 00:00:00.000Z",
- "username": "test",
- "verified": true,
- "email": "test@example.com",
- "unknown": "456",
- })
-
- // make a change to ensure that the latest data is targeted
- m.Set("id", "id2")
-
- // allow the special flags and options to check whether they will be ignored
- m.SetExpand(map[string]any{"test": 123})
- m.IgnoreEmailVisibility(true)
- m.WithUnknownData(true)
-
- copy := m.CleanCopy()
- copyExport, _ := copy.MarshalJSON()
-
- expectedExport := []byte(`{"collectionId":"","collectionName":"cname","created":"2023-01-01 00:00:00.000Z","emailVisibility":false,"id":"id2","updated":"2023-01-02 00:00:00.000Z","username":"test","verified":true}`)
- if !bytes.Equal(copyExport, expectedExport) {
- t.Fatalf("Expected clean export \n%s, \ngot \n%s", expectedExport, copyExport)
- }
-}
-
-func TestRecordSetAndGetExpand(t *testing.T) {
- collection := &models.Collection{}
- m := models.NewRecord(collection)
-
- data := map[string]any{"test": 123}
-
- m.SetExpand(data)
-
- // change the original data to check if it was shallow copied
- data["test"] = 456
-
- expand := m.Expand()
- if v, ok := expand["test"]; !ok || v != 123 {
- t.Fatalf("Expected expand.test to be %v, got %v", 123, v)
- }
-}
-
-func TestRecordMergeExpand(t *testing.T) {
- collection := &models.Collection{}
- m := models.NewRecord(collection)
- m.Id = "m"
-
- // a
- a := models.NewRecord(collection)
- a.Id = "a"
- a1 := models.NewRecord(collection)
- a1.Id = "a1"
- a2 := models.NewRecord(collection)
- a2.Id = "a2"
- a3 := models.NewRecord(collection)
- a3.Id = "a3"
- a31 := models.NewRecord(collection)
- a31.Id = "a31"
- a32 := models.NewRecord(collection)
- a32.Id = "a32"
- a.SetExpand(map[string]any{
- "a1": a1,
- "a23": []*models.Record{a2, a3},
- })
- a3.SetExpand(map[string]any{
- "a31": a31,
- "a32": []*models.Record{a32},
- })
-
- // b
- b := models.NewRecord(collection)
- b.Id = "b"
- b1 := models.NewRecord(collection)
- b1.Id = "b1"
- b.SetExpand(map[string]any{
- "b1": b1,
- })
-
- // c
- c := models.NewRecord(collection)
- c.Id = "c"
-
- // load initial expand
- m.SetExpand(map[string]any{
- "a": a,
- "b": b,
- "c": []*models.Record{c},
- })
-
- // a (new)
- aNew := models.NewRecord(collection)
- aNew.Id = a.Id
- a3New := models.NewRecord(collection)
- a3New.Id = a3.Id
- a32New := models.NewRecord(collection)
- a32New.Id = "a32New"
- a33New := models.NewRecord(collection)
- a33New.Id = "a33New"
- a3New.SetExpand(map[string]any{
- "a32": []*models.Record{a32New},
- "a33New": a33New,
- })
- aNew.SetExpand(map[string]any{
- "a23": []*models.Record{a2, a3New},
- })
-
- // b (new)
- bNew := models.NewRecord(collection)
- bNew.Id = "bNew"
- dNew := models.NewRecord(collection)
- dNew.Id = "dNew"
-
- // merge expands
- m.MergeExpand(map[string]any{
- "a": aNew,
- "b": []*models.Record{bNew},
- "dNew": dNew,
- })
-
- result := m.Expand()
-
- raw, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
- rawStr := string(raw)
-
- expected := `{"a":{"collectionId":"","collectionName":"","created":"","expand":{"a1":{"collectionId":"","collectionName":"","created":"","id":"a1","updated":""},"a23":[{"collectionId":"","collectionName":"","created":"","id":"a2","updated":""},{"collectionId":"","collectionName":"","created":"","expand":{"a31":{"collectionId":"","collectionName":"","created":"","id":"a31","updated":""},"a32":[{"collectionId":"","collectionName":"","created":"","id":"a32","updated":""},{"collectionId":"","collectionName":"","created":"","id":"a32New","updated":""}],"a33New":{"collectionId":"","collectionName":"","created":"","id":"a33New","updated":""}},"id":"a3","updated":""}]},"id":"a","updated":""},"b":[{"collectionId":"","collectionName":"","created":"","expand":{"b1":{"collectionId":"","collectionName":"","created":"","id":"b1","updated":""}},"id":"b","updated":""},{"collectionId":"","collectionName":"","created":"","id":"bNew","updated":""}],"c":[{"collectionId":"","collectionName":"","created":"","id":"c","updated":""}],"dNew":{"collectionId":"","collectionName":"","created":"","id":"dNew","updated":""}}`
-
- if expected != rawStr {
- t.Fatalf("Expected \n%v, \ngot \n%v", expected, rawStr)
- }
-}
-
-func TestRecordMergeExpandNilCheck(t *testing.T) {
- collection := &models.Collection{}
-
- scenarios := []struct {
- name string
- expand map[string]any
- expected string
- }{
- {
- "nil expand",
- nil,
- `{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
- },
- {
- "empty expand",
- map[string]any{},
- `{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
- },
- {
- "non-empty expand",
- map[string]any{"test": models.NewRecord(collection)},
- `{"collectionId":"","collectionName":"","created":"","expand":{"test":{"collectionId":"","collectionName":"","created":"","id":"","updated":""}},"id":"","updated":""}`,
- },
- }
-
- for _, s := range scenarios {
- m := models.NewRecord(collection)
- m.MergeExpand(s.expand)
-
- raw, err := json.Marshal(m)
- if err != nil {
- t.Fatal(err)
- }
- rawStr := string(raw)
-
- if rawStr != s.expected {
- t.Fatalf("[%s] Expected \n%v, \ngot \n%v", s.name, s.expected, rawStr)
- }
- }
-}
-
-func TestRecordExpandedRel(t *testing.T) {
- collection := &models.Collection{}
-
- main := models.NewRecord(collection)
-
- single := models.NewRecord(collection)
- single.Id = "single"
-
- multiple1 := models.NewRecord(collection)
- multiple1.Id = "multiple1"
-
- multiple2 := models.NewRecord(collection)
- multiple2.Id = "multiple2"
-
- main.SetExpand(map[string]any{
- "single": single,
- "multiple": []*models.Record{multiple1, multiple2},
- })
-
- if v := main.ExpandedOne("missing"); v != nil {
- t.Fatalf("Expected nil, got %v", v)
- }
-
- if v := main.ExpandedOne("single"); v == nil || v.Id != "single" {
- t.Fatalf("Expected record with id %q, got %v", "single", v)
- }
-
- if v := main.ExpandedOne("multiple"); v == nil || v.Id != "multiple1" {
- t.Fatalf("Expected record with id %q, got %v", "multiple1", v)
- }
-}
-
-func TestRecordExpandedAll(t *testing.T) {
- collection := &models.Collection{}
-
- main := models.NewRecord(collection)
-
- single := models.NewRecord(collection)
- single.Id = "single"
-
- multiple1 := models.NewRecord(collection)
- multiple1.Id = "multiple1"
-
- multiple2 := models.NewRecord(collection)
- multiple2.Id = "multiple2"
-
- main.SetExpand(map[string]any{
- "single": single,
- "multiple": []*models.Record{multiple1, multiple2},
- })
-
- if v := main.ExpandedAll("missing"); v != nil {
- t.Fatalf("Expected nil, got %v", v)
- }
-
- if v := main.ExpandedAll("single"); len(v) != 1 || v[0].Id != "single" {
- t.Fatalf("Expected [single] slice, got %v", v)
- }
-
- if v := main.ExpandedAll("multiple"); len(v) != 2 || v[0].Id != "multiple1" || v[1].Id != "multiple2" {
- t.Fatalf("Expected [multiple1, multiple2] slice, got %v", v)
- }
-}
-
-func TestRecordSchemaData(t *testing.T) {
- collection := &models.Collection{
- Type: models.CollectionTypeAuth,
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- ),
- }
-
- m := models.NewRecord(collection)
- m.Set("email", "test@example.com")
- m.Set("field1", 123)
- m.Set("field2", 456)
- m.Set("unknown", 789)
-
- encoded, err := json.Marshal(m.SchemaData())
- if err != nil {
- t.Fatal(err)
- }
-
- expected := `{"field1":"123","field2":456}`
-
- if v := string(encoded); v != expected {
- t.Fatalf("Expected \n%v \ngot \n%v", v, expected)
- }
-}
-
-func TestRecordUnknownData(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- ),
- }
-
- data := map[string]any{
- "id": "test_id",
- "created": "2022-01-01 00:00:00.000",
- "updated": "2022-01-01 00:00:00.000",
- "collectionId": "test_collectionId",
- "collectionName": "test_collectionName",
- "expand": "test_expand",
- "field1": "test_field1",
- "field2": "test_field1",
- "unknown1": "test_unknown1",
- "unknown2": "test_unknown2",
- "passwordHash": "test_passwordHash",
- "username": "test_username",
- "emailVisibility": true,
- "email": "test_email",
- "verified": true,
- "tokenKey": "test_tokenKey",
- "lastResetSentAt": "2022-01-01 00:00:00.000",
- "lastVerificationSentAt": "2022-01-01 00:00:00.000",
- }
-
- scenarios := []struct {
- collectionType string
- expectedKeys []string
- }{
- {
- models.CollectionTypeBase,
- []string{
- "unknown1",
- "unknown2",
- "passwordHash",
- "username",
- "emailVisibility",
- "email",
- "verified",
- "tokenKey",
- "lastResetSentAt",
- "lastVerificationSentAt",
- },
- },
- {
- models.CollectionTypeAuth,
- []string{"unknown1", "unknown2"},
- },
- }
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
- m := models.NewRecord(collection)
- m.Load(data)
-
- result := m.UnknownData()
-
- if len(result) != len(s.expectedKeys) {
- t.Errorf("(%d) Expected data \n%v \ngot \n%v", i, s.expectedKeys, result)
- continue
- }
-
- for _, key := range s.expectedKeys {
- if _, ok := result[key]; !ok {
- t.Errorf("(%d) Missing expected key %q in \n%v", i, key, result)
- }
- }
- }
-}
-
-func TestRecordSetAndGet(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- // fields that are not explicitly set to check
- // the default retrieval value (single and multiple)
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeBool,
- },
- &schema.SchemaField{
- Name: "field4",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- &schema.SchemaField{
- Name: "field5",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- ),
- }
-
- m := models.NewRecord(collection)
- m.Set("id", "test_id")
- m.Set("created", "2022-09-15 00:00:00.123Z")
- m.Set("updated", "invalid")
- m.Set("field1", 123) // should be casted to string
- m.Set("field2", "invlaid") // should be casted to zero-number
- m.Set("unknown", 456) // undefined fields are allowed but not exported by default
- m.Set("expand", map[string]any{"test": 123}) // should store the value in m.expand
-
- if v := m.Get("id"); v != "test_id" {
- t.Fatalf("Expected id %q, got %q", "test_id", v)
- }
-
- if v := m.GetString("created"); v != "2022-09-15 00:00:00.123Z" {
- t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", v)
- }
-
- if v := m.GetString("updated"); v != "" {
- t.Fatalf("Expected updated to be empty, got %q", v)
- }
-
- if v, ok := m.Get("field1").(string); !ok || v != "123" {
- t.Fatalf("Expected field1 %#v, got %#v", "123", m.Get("field1"))
- }
-
- if v, ok := m.Get("field2").(float64); !ok || v != 0.0 {
- t.Fatalf("Expected field2 %#v, got %#v", 0.0, m.Get("field2"))
- }
-
- if v, ok := m.Get("field3").(bool); !ok || v != false {
- t.Fatalf("Expected field3 %#v, got %#v", false, m.Get("field3"))
- }
-
- if v, ok := m.Get("field4").([]string); !ok || len(v) != 0 {
- t.Fatalf("Expected field4 %#v, got %#v", "[]", m.Get("field4"))
- }
-
- if v, ok := m.Get("field5").(string); !ok || len(v) != 0 {
- t.Fatalf("Expected field5 %#v, got %#v", "", m.Get("field5"))
- }
-
- if v := m.Get("unknown"); v != 456 {
- t.Fatalf("Expected unknown %v, got %v", 456, v)
- }
-
- if m.Expand()["test"] != 123 {
- t.Fatalf("Expected expand to be %v, got %v", map[string]any{"test": 123}, m.Expand())
- }
-}
-
-func TestRecordGetBool(t *testing.T) {
- scenarios := []struct {
- value any
- expected bool
- }{
- {nil, false},
- {"", false},
- {0, false},
- {1, true},
- {[]string{"true"}, false},
- {time.Now(), false},
- {"test", false},
- {"false", false},
- {"true", true},
- {false, false},
- {true, true},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetBool("test")
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetString(t *testing.T) {
- scenarios := []struct {
- value any
- expected string
- }{
- {nil, ""},
- {"", ""},
- {0, "0"},
- {1.4, "1.4"},
- {[]string{"true"}, ""},
- {map[string]int{"test": 1}, ""},
- {[]byte("abc"), "abc"},
- {"test", "test"},
- {false, "false"},
- {true, "true"},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetString("test")
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetInt(t *testing.T) {
- scenarios := []struct {
- value any
- expected int
- }{
- {nil, 0},
- {"", 0},
- {[]string{"true"}, 0},
- {map[string]int{"test": 1}, 0},
- {time.Now(), 0},
- {"test", 0},
- {123, 123},
- {2.4, 2},
- {"123", 123},
- {"123.5", 0},
- {false, 0},
- {true, 1},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetInt("test")
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetFloat(t *testing.T) {
- scenarios := []struct {
- value any
- expected float64
- }{
- {nil, 0},
- {"", 0},
- {[]string{"true"}, 0},
- {map[string]int{"test": 1}, 0},
- {time.Now(), 0},
- {"test", 0},
- {123, 123},
- {2.4, 2.4},
- {"123", 123},
- {"123.5", 123.5},
- {false, 0},
- {true, 1},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetFloat("test")
- if result != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetTime(t *testing.T) {
- nowTime := time.Now()
- testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000Z")
-
- scenarios := []struct {
- value any
- expected time.Time
- }{
- {nil, time.Time{}},
- {"", time.Time{}},
- {false, time.Time{}},
- {true, time.Time{}},
- {"test", time.Time{}},
- {[]string{"true"}, time.Time{}},
- {map[string]int{"test": 1}, time.Time{}},
- {1641024040, testTime},
- {"2022-01-01 08:00:40.000", testTime},
- {nowTime, nowTime},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetTime("test")
- if !result.Equal(s.expected) {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetDateTime(t *testing.T) {
- nowTime := time.Now()
- testTime, _ := time.Parse(types.DefaultDateLayout, "2022-01-01 08:00:40.000Z")
-
- scenarios := []struct {
- value any
- expected time.Time
- }{
- {nil, time.Time{}},
- {"", time.Time{}},
- {false, time.Time{}},
- {true, time.Time{}},
- {"test", time.Time{}},
- {[]string{"true"}, time.Time{}},
- {map[string]int{"test": 1}, time.Time{}},
- {1641024040, testTime},
- {"2022-01-01 08:00:40.000", testTime},
- {nowTime, nowTime},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetDateTime("test")
- if !result.Time().Equal(s.expected) {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, result)
- }
- }
-}
-
-func TestRecordGetStringSlice(t *testing.T) {
- nowTime := time.Now()
-
- scenarios := []struct {
- value any
- expected []string
- }{
- {nil, []string{}},
- {"", []string{}},
- {false, []string{"false"}},
- {true, []string{"true"}},
- {nowTime, []string{}},
- {123, []string{"123"}},
- {"test", []string{"test"}},
- {map[string]int{"test": 1}, []string{}},
- {`["test1", "test2"]`, []string{"test1", "test2"}},
- {[]int{123, 123, 456}, []string{"123", "456"}},
- {[]string{"test", "test", "123"}, []string{"test", "123"}},
- }
-
- collection := &models.Collection{}
-
- for i, s := range scenarios {
- m := models.NewRecord(collection)
- m.Set("test", s.value)
-
- result := m.GetStringSlice("test")
-
- if len(result) != len(s.expected) {
- t.Errorf("(%d) Expected %d elements, got %d: %v", i, len(s.expected), len(result), result)
- continue
- }
-
- for _, v := range result {
- if !list.ExistInSlice(v, s.expected) {
- t.Errorf("(%d) Cannot find %v in %v", i, v, s.expected)
- }
- }
- }
-}
-
-func TestRecordUnmarshalJSONField(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(&schema.SchemaField{
- Name: "field",
- Type: schema.FieldTypeJson,
- }),
- }
- m := models.NewRecord(collection)
-
- var testPointer *string
- var testStr string
- var testInt int
- var testBool bool
- var testSlice []int
- var testMap map[string]any
-
- scenarios := []struct {
- value any
- destination any
- expectError bool
- expectedJson string
- }{
- {nil, testStr, true, `""`},
- {"", testStr, false, `""`},
- {1, testInt, false, `1`},
- {true, testBool, false, `true`},
- {[]int{1, 2, 3}, testSlice, false, `[1,2,3]`},
- {map[string]any{"test": 123}, testMap, false, `{"test":123}`},
- // json encoded values
- {`null`, testPointer, false, `null`},
- {`true`, testBool, false, `true`},
- {`456`, testInt, false, `456`},
- {`"test"`, testStr, false, `"test"`},
- {`[4,5,6]`, testSlice, false, `[4,5,6]`},
- {`{"test":456}`, testMap, false, `{"test":456}`},
- }
-
- for i, s := range scenarios {
- m.Set("field", s.value)
-
- err := m.UnmarshalJSONField("field", &s.destination)
- hasErr := err != nil
-
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr %v, got %v", i, s.expectError, hasErr)
- continue
- }
-
- raw, _ := json.Marshal(s.destination)
- if v := string(raw); v != s.expectedJson {
- t.Errorf("(%d) Expected %q, got %q", i, s.expectedJson, v)
- }
- }
-}
-
-func TestRecordBaseFilesPath(t *testing.T) {
- collection := &models.Collection{}
- collection.RefreshId()
- collection.Name = "test"
-
- m := models.NewRecord(collection)
- m.RefreshId()
-
- expected := collection.BaseFilesPath() + "/" + m.Id
- result := m.BaseFilesPath()
-
- if result != expected {
- t.Fatalf("Expected %q, got %q", expected, result)
- }
-}
-
-func TestRecordFindFileFieldByFile(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 1,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 2,
- MaxSize: 1,
- },
- },
- ),
- }
-
- m := models.NewRecord(collection)
- m.Set("field1", "test")
- m.Set("field2", "test.png")
- m.Set("field3", []string{"test1.png", "test2.png"})
-
- scenarios := []struct {
- filename string
- expectField string
- }{
- {"", ""},
- {"test", ""},
- {"test2", ""},
- {"test.png", "field2"},
- {"test2.png", "field3"},
- }
-
- for i, s := range scenarios {
- result := m.FindFileFieldByFile(s.filename)
-
- var fieldName string
- if result != nil {
- fieldName = result.Name
- }
-
- if s.expectField != fieldName {
- t.Errorf("(%d) Expected field %v, got %v", i, s.expectField, result)
- continue
- }
- }
-}
-
-func TestRecordLoadAndData(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- ),
- }
-
- data := map[string]any{
- "id": "test_id",
- "created": "2022-01-01 10:00:00.123Z",
- "updated": "2022-01-01 10:00:00.456Z",
- "field1": "test_field",
- "field2": "123", // should be casted to float
- "unknown": "test_unknown",
- // auth collection sepcific casting test
- "passwordHash": "test_passwordHash",
- "emailVisibility": "12345", // should be casted to bool only for auth collections
- "username": 123, // should be casted to string only for auth collections
- "email": "test_email",
- "verified": true,
- "tokenKey": "test_tokenKey",
- "lastResetSentAt": "2022-01-01 11:00:00.000", // should be casted to DateTime only for auth collections
- "lastVerificationSentAt": "2022-01-01 12:00:00.000", // should be casted to DateTime only for auth collections
- }
-
- scenarios := []struct {
- collectionType string
- }{
- {models.CollectionTypeBase},
- {models.CollectionTypeAuth},
- }
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
- m := models.NewRecord(collection)
-
- m.Load(data)
-
- expectations := map[string]any{}
- for k, v := range data {
- expectations[k] = v
- }
-
- expectations["created"], _ = types.ParseDateTime("2022-01-01 10:00:00.123Z")
- expectations["updated"], _ = types.ParseDateTime("2022-01-01 10:00:00.456Z")
- expectations["field2"] = 123.0
-
- // extra casting test
- if collection.IsAuth() {
- lastResetSentAt, _ := types.ParseDateTime(expectations["lastResetSentAt"])
- lastVerificationSentAt, _ := types.ParseDateTime(expectations["lastVerificationSentAt"])
- expectations["emailVisibility"] = false
- expectations["username"] = "123"
- expectations["verified"] = true
- expectations["lastResetSentAt"] = lastResetSentAt
- expectations["lastVerificationSentAt"] = lastVerificationSentAt
- }
-
- for k, v := range expectations {
- if m.Get(k) != v {
- t.Errorf("(%d) Expected field %s to be %v, got %v", i, k, v, m.Get(k))
- }
- }
- }
-}
-
-func TestRecordColumnValueMap(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 1,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- MaxSelect: 2,
- Values: []string{"test1", "test2", "test3"},
- },
- },
- &schema.SchemaField{
- Name: "field4",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{
- MaxSelect: types.Pointer(2),
- },
- },
- ),
- }
-
- scenarios := []struct {
- collectionType string
- expectedJson string
- }{
- {
- models.CollectionTypeBase,
- `{"created":"2022-01-01 10:00:30.123Z","field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","updated":""}`,
- },
- {
- models.CollectionTypeAuth,
- `{"created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"test.png","field3":["test1","test2"],"field4":["test11","test12"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","updated":"","username":"test_username","verified":false}`,
- },
- }
-
- created, _ := types.ParseDateTime("2022-01-01 10:00:30.123Z")
- lastResetSentAt, _ := types.ParseDateTime("2022-01-02 10:00:30.123Z")
- data := map[string]any{
- "id": "test_id",
- "created": created,
- "field1": "test",
- "field2": "test.png",
- "field3": []string{"test1", "test2"},
- "field4": []string{"test11", "test12", "test11"}, // strip duplicate,
- "unknown": "test_unknown",
- "passwordHash": "test_passwordHash",
- "username": "test_username",
- "emailVisibility": true,
- "email": "test_email",
- "verified": "invalid", // should be casted
- "tokenKey": "test_tokenKey",
- "lastResetSentAt": lastResetSentAt,
- }
-
- m := models.NewRecord(collection)
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
-
- m.Load(data)
-
- result := m.ColumnValueMap()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Errorf("(%d) Unexpected error %v", i, err)
- continue
- }
-
- if str := string(encoded); str != s.expectedJson {
- t.Errorf("(%d) Expected \n%v \ngot \n%v", i, s.expectedJson, str)
- }
- }
-}
-
-func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
- collection := &models.Collection{
- Name: "c_name",
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{
- MaxSelect: 1,
- MaxSize: 1,
- },
- },
- &schema.SchemaField{
- Name: "field3",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- MaxSelect: 2,
- Values: []string{"test1", "test2", "test3"},
- },
- },
- ),
- }
- collection.Id = "c_id"
-
- scenarios := []struct {
- collectionType string
- exportHidden bool
- exportUnknown bool
- expectedJson string
- }{
- // base
- {
- models.CollectionTypeBase,
- false,
- false,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":""}`,
- },
- {
- models.CollectionTypeBase,
- true,
- false,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":""}`,
- },
- {
- models.CollectionTypeBase,
- false,
- true,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"test_lastVerificationSentAt","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","updated":"","username":123,"verified":true}`,
- },
- {
- models.CollectionTypeBase,
- true,
- true,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","lastResetSentAt":"2022-01-02 10:00:30.123Z","lastVerificationSentAt":"test_lastVerificationSentAt","passwordHash":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","updated":"","username":123,"verified":true}`,
- },
-
- // auth
- {
- models.CollectionTypeAuth,
- false,
- false,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":"","username":"123","verified":true}`,
- },
- {
- models.CollectionTypeAuth,
- true,
- false,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","updated":"","username":"123","verified":true}`,
- },
- {
- models.CollectionTypeAuth,
- false,
- true,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","updated":"","username":"123","verified":true}`,
- },
- {
- models.CollectionTypeAuth,
- true,
- true,
- `{"collectionId":"c_id","collectionName":"c_name","created":"2022-01-01 10:00:30.123Z","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"test","field2":"test.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","updated":"","username":"123","verified":true}`,
- },
- }
-
- created, _ := types.ParseDateTime("2022-01-01 10:00:30.123Z")
- lastResetSentAt, _ := types.ParseDateTime("2022-01-02 10:00:30.123Z")
-
- data := map[string]any{
- "id": "test_id",
- "created": created,
- "field1": "test",
- "field2": "test.png",
- "field3": []string{"test1", "test2"},
- "expand": map[string]any{"test": 123},
- "collectionId": "m_id", // should be always ignored
- "collectionName": "m_name", // should be always ignored
- "unknown": "test_unknown",
- "passwordHash": "test_passwordHash",
- "username": 123, // for auth collections should be casted to string
- "emailVisibility": "test_invalid", // for auth collections should be casted to bool
- "email": "test_email",
- "verified": true,
- "tokenKey": "test_tokenKey",
- "lastResetSentAt": lastResetSentAt,
- "lastVerificationSentAt": "test_lastVerificationSentAt",
- }
-
- m := models.NewRecord(collection)
-
- for i, s := range scenarios {
- collection.Type = s.collectionType
-
- m.Load(data)
- m.IgnoreEmailVisibility(s.exportHidden)
- m.WithUnknownData(s.exportUnknown)
-
- exportResult, err := json.Marshal(m.PublicExport())
- if err != nil {
- t.Errorf("(%d) Unexpected error %v", i, err)
- continue
- }
- exportResultStr := string(exportResult)
-
- // MarshalJSON and PublicExport should return the same
- marshalResult, err := m.MarshalJSON()
- if err != nil {
- t.Errorf("(%d) Unexpected error %v", i, err)
- continue
- }
- marshalResultStr := string(marshalResult)
-
- if exportResultStr != marshalResultStr {
- t.Errorf("(%d) Expected the PublicExport to be the same as MarshalJSON, but got \n%v \nvs \n%v", i, exportResultStr, marshalResultStr)
- }
-
- if exportResultStr != s.expectedJson {
- t.Errorf("(%d) Expected json \n%v \ngot \n%v", i, s.expectedJson, exportResultStr)
- }
- }
-}
-
-func TestRecordUnmarshalJSON(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "field1",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "field2",
- Type: schema.FieldTypeNumber,
- },
- ),
- }
-
- data := map[string]any{
- "id": "test_id",
- "created": "2022-01-01 10:00:00.123Z",
- "updated": "2022-01-01 10:00:00.456Z",
- "field1": "test_field",
- "field2": "123", // should be casted to float
- "unknown": "test_unknown",
- // auth collection sepcific casting test
- "passwordHash": "test_passwordHash",
- "emailVisibility": "12345", // should be casted to bool only for auth collections
- "username": 123.123, // should be casted to string only for auth collections
- "email": "test_email",
- "verified": true,
- "tokenKey": "test_tokenKey",
- "lastResetSentAt": "2022-01-01 11:00:00.000", // should be casted to DateTime only for auth collections
- "lastVerificationSentAt": "2022-01-01 12:00:00.000", // should be casted to DateTime only for auth collections
- }
- dataRaw, err := json.Marshal(data)
- if err != nil {
- t.Fatalf("Unexpected data marshal error %v", err)
- }
-
- scenarios := []struct {
- collectionType string
- }{
- {models.CollectionTypeBase},
- {models.CollectionTypeAuth},
- }
-
- // with invalid data
- m0 := models.NewRecord(collection)
- if err := m0.UnmarshalJSON([]byte("test")); err == nil {
- t.Fatal("Expected error, got nil")
- }
-
- // with valid data (it should be pretty much the same as load)
- for i, s := range scenarios {
- collection.Type = s.collectionType
- m := models.NewRecord(collection)
-
- err := m.UnmarshalJSON(dataRaw)
- if err != nil {
- t.Errorf("(%d) Unexpected error %v", i, err)
- continue
- }
-
- expectations := map[string]any{}
- for k, v := range data {
- expectations[k] = v
- }
-
- expectations["created"], _ = types.ParseDateTime("2022-01-01 10:00:00.123Z")
- expectations["updated"], _ = types.ParseDateTime("2022-01-01 10:00:00.456Z")
- expectations["field2"] = 123.0
-
- // extra casting test
- if collection.IsAuth() {
- lastResetSentAt, _ := types.ParseDateTime(expectations["lastResetSentAt"])
- lastVerificationSentAt, _ := types.ParseDateTime(expectations["lastVerificationSentAt"])
- expectations["emailVisibility"] = false
- expectations["username"] = "123.123"
- expectations["verified"] = true
- expectations["lastResetSentAt"] = lastResetSentAt
- expectations["lastVerificationSentAt"] = lastVerificationSentAt
- }
-
- for k, v := range expectations {
- if m.Get(k) != v {
- t.Errorf("(%d) Expected field %s to be %v, got %v", i, k, v, m.Get(k))
- }
- }
- }
-}
-
-func TestRecordReplaceModifers(t *testing.T) {
- collection := &models.Collection{
- Schema: schema.NewSchema(
- &schema.SchemaField{
- Name: "text",
- Type: schema.FieldTypeText,
- },
- &schema.SchemaField{
- Name: "number",
- Type: schema.FieldTypeNumber,
- },
- &schema.SchemaField{
- Name: "rel_one",
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- &schema.SchemaField{
- Name: "rel_many",
- Type: schema.FieldTypeRelation,
- },
- &schema.SchemaField{
- Name: "select_one",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 1},
- },
- &schema.SchemaField{
- Name: "select_many",
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 10},
- },
- &schema.SchemaField{
- Name: "file_one",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 1},
- },
- &schema.SchemaField{
- Name: "file_one_index",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 1},
- },
- &schema.SchemaField{
- Name: "file_one_name",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 1},
- },
- &schema.SchemaField{
- Name: "file_many",
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 10},
- },
- ),
- }
-
- record := models.NewRecord(collection)
-
- record.Load(map[string]any{
- "text": "test",
- "number": 10,
- "rel_one": "a",
- "rel_many": []string{"a", "b"},
- "select_one": "a",
- "select_many": []string{"a", "b", "c"},
- "file_one": "a",
- "file_one_index": "b",
- "file_one_name": "c",
- "file_many": []string{"a", "b", "c", "d", "e", "f"},
- })
-
- result := record.ReplaceModifers(map[string]any{
- "text-": "m-",
- "text+": "m+",
- "number-": 3,
- "number+": 5,
- "rel_one-": "a",
- "rel_one+": "b",
- "rel_many-": []string{"a"},
- "rel_many+": []string{"c", "d", "e"},
- "select_one-": "a",
- "select_one+": "c",
- "select_many-": []string{"b", "c"},
- "select_many+": []string{"d", "e"},
- "file_one+": "skip", // should be ignored
- "file_one-": "a",
- "file_one_index.0": "",
- "file_one_name.c": "",
- "file_many+": []string{"e", "f"}, // should be ignored
- "file_many-": []string{"c", "d"},
- "file_many.f": nil,
- "file_many.0": nil,
- })
-
- raw, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
-
- expected := `{"file_many":["b","e"],"file_one":"","file_one_index":"","file_one_name":"","number":12,"rel_many":["b","c","d","e"],"rel_one":"b","select_many":["a","d","e"],"select_one":"c","text":"test"}`
-
- if v := string(raw); v != expected {
- t.Fatalf("Expected \n%s, \ngot \n%s", expected, v)
- }
-}
-
-// -------------------------------------------------------------------
-// Auth helpers:
-// -------------------------------------------------------------------
-
-func TestRecordUsername(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- testValue := "test 1232 !@#%" // formatting isn't checked
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetUsername(testValue); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.Username(); v != "" {
- t.Fatalf("(%d) Expected empty string, got %q", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameUsername); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameUsername, v)
- }
- } else {
- if err := m.SetUsername(testValue); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.Username(); v != testValue {
- t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameUsername); v != testValue {
- t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
- }
- }
- }
-}
-
-func TestRecordEmail(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- testValue := "test 1232 !@#%" // formatting isn't checked
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetEmail(testValue); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.Email(); v != "" {
- t.Fatalf("(%d) Expected empty string, got %q", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameEmail); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameEmail, v)
- }
- } else {
- if err := m.SetEmail(testValue); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.Email(); v != testValue {
- t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameEmail); v != testValue {
- t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
- }
- }
- }
-}
-
-func TestRecordEmailVisibility(t *testing.T) {
- scenarios := []struct {
- collectionType string
- value bool
- expectError bool
- }{
- {models.CollectionTypeBase, true, true},
- {models.CollectionTypeBase, true, true},
- {models.CollectionTypeAuth, false, false},
- {models.CollectionTypeAuth, true, false},
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetEmailVisibility(s.value); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.EmailVisibility(); v != false {
- t.Fatalf("(%d) Expected empty string, got %v", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameEmailVisibility); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameEmailVisibility, v)
- }
- } else {
- if err := m.SetEmailVisibility(s.value); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.EmailVisibility(); v != s.value {
- t.Fatalf("(%d) Expected %v, got %v", i, s.value, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameEmailVisibility); v != s.value {
- t.Fatalf("(%d) Expected data field value %v, got %v", i, s.value, v)
- }
- }
- }
-}
-
-func TestRecordEmailVerified(t *testing.T) {
- scenarios := []struct {
- collectionType string
- value bool
- expectError bool
- }{
- {models.CollectionTypeBase, true, true},
- {models.CollectionTypeBase, true, true},
- {models.CollectionTypeAuth, false, false},
- {models.CollectionTypeAuth, true, false},
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetVerified(s.value); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.Verified(); v != false {
- t.Fatalf("(%d) Expected empty string, got %v", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameVerified); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameVerified, v)
- }
- } else {
- if err := m.SetVerified(s.value); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.Verified(); v != s.value {
- t.Fatalf("(%d) Expected %v, got %v", i, s.value, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameVerified); v != s.value {
- t.Fatalf("(%d) Expected data field value %v, got %v", i, s.value, v)
- }
- }
- }
-}
-
-func TestRecordTokenKey(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- testValue := "test 1232 !@#%" // formatting isn't checked
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetTokenKey(testValue); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.TokenKey(); v != "" {
- t.Fatalf("(%d) Expected empty string, got %q", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameTokenKey); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameTokenKey, v)
- }
- } else {
- if err := m.SetTokenKey(testValue); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.TokenKey(); v != testValue {
- t.Fatalf("(%d) Expected %q, got %q", i, testValue, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameTokenKey); v != testValue {
- t.Fatalf("(%d) Expected data field value %q, got %q", i, testValue, v)
- }
- }
- }
-}
-
-func TestRecordRefreshTokenKey(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.RefreshTokenKey(); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.TokenKey(); v != "" {
- t.Fatalf("(%d) Expected empty string, got %q", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameTokenKey); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameTokenKey, v)
- }
- } else {
- if err := m.RefreshTokenKey(); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.TokenKey(); len(v) != 50 {
- t.Fatalf("(%d) Expected 50 chars, got %d", i, len(v))
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameTokenKey); v != m.TokenKey() {
- t.Fatalf("(%d) Expected data field value %q, got %q", i, m.TokenKey(), v)
- }
- }
- }
-}
-
-func TestRecordLastResetSentAt(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- testValue, err := types.ParseDateTime("2022-01-01 00:00:00.123Z")
- if err != nil {
- t.Fatal(err)
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetLastResetSentAt(testValue); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.LastResetSentAt(); !v.IsZero() {
- t.Fatalf("(%d) Expected empty value, got %v", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameLastResetSentAt); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameLastResetSentAt, v)
- }
- } else {
- if err := m.SetLastResetSentAt(testValue); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.LastResetSentAt(); v != testValue {
- t.Fatalf("(%d) Expected %v, got %v", i, testValue, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameLastResetSentAt); v != testValue {
- t.Fatalf("(%d) Expected data field value %v, got %v", i, testValue, v)
- }
- }
- }
-}
-
-func TestRecordLastVerificationSentAt(t *testing.T) {
- scenarios := []struct {
- collectionType string
- expectError bool
- }{
- {models.CollectionTypeBase, true},
- {models.CollectionTypeAuth, false},
- }
-
- testValue, err := types.ParseDateTime("2022-01-01 00:00:00.123Z")
- if err != nil {
- t.Fatal(err)
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetLastVerificationSentAt(testValue); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.LastVerificationSentAt(); !v.IsZero() {
- t.Fatalf("(%d) Expected empty value, got %v", i, v)
- }
- // verify that nothing is stored in the record data slice
- if v := m.Get(schema.FieldNameLastVerificationSentAt); v != nil {
- t.Fatalf("(%d) Didn't expect data field %q: %v", i, schema.FieldNameLastVerificationSentAt, v)
- }
- } else {
- if err := m.SetLastVerificationSentAt(testValue); err != nil {
- t.Fatalf("(%d) Expected nil, got error %v", i, err)
- }
- if v := m.LastVerificationSentAt(); v != testValue {
- t.Fatalf("(%d) Expected %v, got %v", i, testValue, v)
- }
- // verify that the field is stored in the record data slice
- if v := m.Get(schema.FieldNameLastVerificationSentAt); v != testValue {
- t.Fatalf("(%d) Expected data field value %v, got %v", i, testValue, v)
- }
- }
- }
-}
-
-func TestRecordPasswordHash(t *testing.T) {
- m := models.NewRecord(&models.Collection{})
-
- if v := m.PasswordHash(); v != "" {
- t.Errorf("Expected PasswordHash() to be empty, got %v", v)
- }
-
- m.Set(schema.FieldNamePasswordHash, "test")
-
- if v := m.PasswordHash(); v != "test" {
- t.Errorf("Expected PasswordHash() to be 'test', got %v", v)
- }
-}
-
-func TestRecordValidatePassword(t *testing.T) {
- // 123456
- hash := "$2a$10$YKU8mPP8sTE3xZrpuM.xQuq27KJ7aIJB2oUeKPsDDqZshbl5g5cDK"
-
- scenarios := []struct {
- collectionType string
- password string
- hash string
- expected bool
- }{
- {models.CollectionTypeBase, "123456", hash, false},
- {models.CollectionTypeAuth, "", "", false},
- {models.CollectionTypeAuth, "", hash, false},
- {models.CollectionTypeAuth, "123456", hash, true},
- {models.CollectionTypeAuth, "654321", hash, false},
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
- m.Set(schema.FieldNamePasswordHash, hash)
-
- if v := m.ValidatePassword(s.password); v != s.expected {
- t.Errorf("(%d) Expected %v, got %v", i, s.expected, v)
- }
- }
-}
-
-func TestRecordSetPassword(t *testing.T) {
- scenarios := []struct {
- collectionType string
- password string
- expectError bool
- }{
- {models.CollectionTypeBase, "", true},
- {models.CollectionTypeBase, "123456", true},
- {models.CollectionTypeAuth, "", true},
- {models.CollectionTypeAuth, "123456", false},
- }
-
- for i, s := range scenarios {
- collection := &models.Collection{Type: s.collectionType}
- m := models.NewRecord(collection)
-
- if s.expectError {
- if err := m.SetPassword(s.password); err == nil {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- if v := m.GetString(schema.FieldNamePasswordHash); v != "" {
- t.Errorf("(%d) Expected empty hash, got %q", i, v)
- }
- } else {
- if err := m.SetPassword(s.password); err != nil {
- t.Errorf("(%d) Expected nil, got err", i)
- }
- if v := m.GetString(schema.FieldNamePasswordHash); v == "" {
- t.Errorf("(%d) Expected non empty hash", i)
- }
- if !m.ValidatePassword(s.password) {
- t.Errorf("(%d) Expected true, got false", i)
- }
- }
- }
-}
diff --git a/models/request.go b/models/request.go
deleted file mode 100644
index dbefc91c7c..0000000000
--- a/models/request.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package models
-
-import "github.com/pocketbase/pocketbase/tools/types"
-
-var _ Model = (*Request)(nil)
-
-// list with the supported values for `Request.Auth`
-const (
- RequestAuthGuest = "guest"
- RequestAuthAdmin = "admin"
- RequestAuthRecord = "auth_record"
-)
-
-type Request struct {
- BaseModel
-
- Url string `db:"url" json:"url"`
- Method string `db:"method" json:"method"`
- Status int `db:"status" json:"status"`
- Auth string `db:"auth" json:"auth"`
- UserIp string `db:"userIp" json:"userIp"`
- RemoteIp string `db:"remoteIp" json:"remoteIp"`
- Referer string `db:"referer" json:"referer"`
- UserAgent string `db:"userAgent" json:"userAgent"`
- Meta types.JsonMap `db:"meta" json:"meta"`
-}
-
-func (m *Request) TableName() string {
- return "_requests"
-}
diff --git a/models/request_info.go b/models/request_info.go
deleted file mode 100644
index d83885210c..0000000000
--- a/models/request_info.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package models
-
-import (
- "strings"
-
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-// RequestInfo defines a HTTP request data struct, usually used
-// as part of the `@request.*` filter resolver.
-type RequestInfo struct {
- Method string `json:"method"`
- Query map[string]any `json:"query"`
- Data map[string]any `json:"data"`
- Headers map[string]any `json:"headers"`
- AuthRecord *Record `json:"authRecord"`
- Admin *Admin `json:"admin"`
-}
-
-// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
-func (r *RequestInfo) HasModifierDataKeys() bool {
- allModifiers := schema.FieldValueModifiers()
-
- for key := range r.Data {
- for _, m := range allModifiers {
- if strings.HasSuffix(key, m) {
- return true
- }
- }
- }
-
- return false
-}
diff --git a/models/request_info_test.go b/models/request_info_test.go
deleted file mode 100644
index bc5be1e5b4..0000000000
--- a/models/request_info_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
-)
-
-func TestRequestInfoHasModifierDataKeys(t *testing.T) {
- scenarios := []struct {
- name string
- requestInfo *models.RequestInfo
- expected bool
- }{
- {
- "empty",
- &models.RequestInfo{},
- false,
- },
- {
- "Data with regular fields",
- &models.RequestInfo{
- Query: map[string]any{"data+": "demo"}, // should be ignored
- Data: map[string]any{"a": 123, "b": "test", "c.d": false},
- },
- false,
- },
- {
- "Data with +modifier fields",
- &models.RequestInfo{
- Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
- },
- true,
- },
- {
- "Data with -modifier fields",
- &models.RequestInfo{
- Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
- },
- true,
- },
- {
- "Data with mixed modifier fields",
- &models.RequestInfo{
- Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
- },
- true,
- },
- }
-
- for _, s := range scenarios {
- result := s.requestInfo.HasModifierDataKeys()
-
- if result != s.expected {
- t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
- }
- }
-}
diff --git a/models/request_test.go b/models/request_test.go
deleted file mode 100644
index 0f1f99e59e..0000000000
--- a/models/request_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package models_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models"
-)
-
-func TestRequestTableName(t *testing.T) {
- m := models.Request{}
- if m.TableName() != "_requests" {
- t.Fatalf("Unexpected table name, got %q", m.TableName())
- }
-}
diff --git a/models/schema/schema.go b/models/schema/schema.go
deleted file mode 100644
index d48b0550be..0000000000
--- a/models/schema/schema.go
+++ /dev/null
@@ -1,240 +0,0 @@
-// Package schema implements custom Schema and SchemaField datatypes
-// for handling the Collection schema definitions.
-package schema
-
-import (
- "database/sql/driver"
- "encoding/json"
- "fmt"
- "strconv"
- "strings"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// NewSchema creates a new Schema instance with the provided fields.
-func NewSchema(fields ...*SchemaField) Schema {
- s := Schema{}
-
- for _, f := range fields {
- s.AddField(f)
- }
-
- return s
-}
-
-// Schema defines a dynamic db schema as a slice of `SchemaField`s.
-type Schema struct {
- fields []*SchemaField
-}
-
-// Fields returns the registered schema fields.
-func (s *Schema) Fields() []*SchemaField {
- return s.fields
-}
-
-// InitFieldsOptions calls `InitOptions()` for all schema fields.
-func (s *Schema) InitFieldsOptions() error {
- for _, field := range s.Fields() {
- if err := field.InitOptions(); err != nil {
- return err
- }
- }
- return nil
-}
-
-// Clone creates a deep clone of the current schema.
-func (s *Schema) Clone() (*Schema, error) {
- copyRaw, err := json.Marshal(s)
- if err != nil {
- return nil, err
- }
-
- result := &Schema{}
- if err := json.Unmarshal(copyRaw, result); err != nil {
- return nil, err
- }
-
- return result, nil
-}
-
-// AsMap returns a map with all registered schema field.
-// The returned map is indexed with each field name.
-func (s *Schema) AsMap() map[string]*SchemaField {
- result := map[string]*SchemaField{}
-
- for _, field := range s.fields {
- result[field.Name] = field
- }
-
- return result
-}
-
-// GetFieldById returns a single field by its id.
-func (s *Schema) GetFieldById(id string) *SchemaField {
- for _, field := range s.fields {
- if field.Id == id {
- return field
- }
- }
- return nil
-}
-
-// GetFieldByName returns a single field by its name.
-func (s *Schema) GetFieldByName(name string) *SchemaField {
- for _, field := range s.fields {
- if field.Name == name {
- return field
- }
- }
- return nil
-}
-
-// RemoveField removes a single schema field by its id.
-//
-// This method does nothing if field with `id` doesn't exist.
-func (s *Schema) RemoveField(id string) {
- for i, field := range s.fields {
- if field.Id == id {
- s.fields = append(s.fields[:i], s.fields[i+1:]...)
- return
- }
- }
-}
-
-// AddField registers the provided newField to the current schema.
-//
-// If field with `newField.Id` already exist, the existing field is
-// replaced with the new one.
-//
-// Otherwise the new field is appended to the other schema fields.
-func (s *Schema) AddField(newField *SchemaField) {
- if newField.Id == "" {
- // set default id
- newField.Id = strings.ToLower(security.PseudorandomString(8))
- }
-
- for i, field := range s.fields {
- // replace existing
- if field.Id == newField.Id {
- s.fields[i] = newField
- return
- }
- }
-
- // add new field
- s.fields = append(s.fields, newField)
-}
-
-// Validate makes Schema validatable by implementing [validation.Validatable] interface.
-//
-// Internally calls each individual field's validator and additionally
-// checks for invalid renamed fields and field name duplications.
-func (s Schema) Validate() error {
- return validation.Validate(&s.fields, validation.By(func(value any) error {
- fields := s.fields // use directly the schema value to avoid unnecessary interface casting
-
- ids := []string{}
- names := []string{}
- for i, field := range fields {
- if list.ExistInSlice(field.Id, ids) {
- return validation.Errors{
- strconv.Itoa(i): validation.Errors{
- "id": validation.NewError(
- "validation_duplicated_field_id",
- "Duplicated or invalid schema field id",
- ),
- },
- }
- }
-
- // field names are used as db columns and should be case insensitive
- nameLower := strings.ToLower(field.Name)
-
- if list.ExistInSlice(nameLower, names) {
- return validation.Errors{
- strconv.Itoa(i): validation.Errors{
- "name": validation.NewError(
- "validation_duplicated_field_name",
- "Duplicated or invalid schema field name",
- ),
- },
- }
- }
-
- ids = append(ids, field.Id)
- names = append(names, nameLower)
- }
-
- return nil
- }))
-}
-
-// MarshalJSON implements the [json.Marshaler] interface.
-func (s Schema) MarshalJSON() ([]byte, error) {
- if s.fields == nil {
- s.fields = []*SchemaField{}
- }
- return json.Marshal(s.fields)
-}
-
-// UnmarshalJSON implements the [json.Unmarshaler] interface.
-//
-// On success, all schema field options are auto initialized.
-func (s *Schema) UnmarshalJSON(data []byte) error {
- fields := []*SchemaField{}
- if err := json.Unmarshal(data, &fields); err != nil {
- return err
- }
-
- s.fields = []*SchemaField{}
-
- for _, f := range fields {
- s.AddField(f)
- }
-
- for _, field := range s.fields {
- if err := field.InitOptions(); err != nil {
- // ignore the error and remove the invalid field
- s.RemoveField(field.Id)
- }
- }
-
- return nil
-}
-
-// Value implements the [driver.Valuer] interface.
-func (s Schema) Value() (driver.Value, error) {
- if s.fields == nil {
- // initialize an empty slice to ensure that `[]` is returned
- s.fields = []*SchemaField{}
- }
-
- data, err := json.Marshal(s.fields)
-
- return string(data), err
-}
-
-// Scan implements [sql.Scanner] interface to scan the provided value
-// into the current Schema instance.
-func (s *Schema) Scan(value any) error {
- var data []byte
- switch v := value.(type) {
- case nil:
- // no cast needed
- case []byte:
- data = v
- case string:
- data = []byte(v)
- default:
- return fmt.Errorf("Failed to unmarshal Schema value %q.", value)
- }
-
- if len(data) == 0 {
- data = []byte("[]")
- }
-
- return s.UnmarshalJSON(data)
-}
diff --git a/models/schema/schema_field.go b/models/schema/schema_field.go
deleted file mode 100644
index f39db3e877..0000000000
--- a/models/schema/schema_field.go
+++ /dev/null
@@ -1,708 +0,0 @@
-package schema
-
-import (
- "encoding/json"
- "errors"
- "regexp"
- "strconv"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/tools/filesystem"
- "github.com/pocketbase/pocketbase/tools/list"
- "github.com/pocketbase/pocketbase/tools/types"
- "github.com/spf13/cast"
-)
-
-var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
-
-// field value modifiers
-const (
- FieldValueModifierAdd string = "+"
- FieldValueModifierSubtract string = "-"
-)
-
-// FieldValueModifiers returns a list with all available field modifier tokens.
-func FieldValueModifiers() []string {
- return []string{
- FieldValueModifierAdd,
- FieldValueModifierSubtract,
- }
-}
-
-// commonly used field names
-const (
- FieldNameId string = "id"
- FieldNameCreated string = "created"
- FieldNameUpdated string = "updated"
- FieldNameCollectionId string = "collectionId"
- FieldNameCollectionName string = "collectionName"
- FieldNameExpand string = "expand"
- FieldNameUsername string = "username"
- FieldNameEmail string = "email"
- FieldNameEmailVisibility string = "emailVisibility"
- FieldNameVerified string = "verified"
- FieldNameTokenKey string = "tokenKey"
- FieldNamePasswordHash string = "passwordHash"
- FieldNameLastResetSentAt string = "lastResetSentAt"
- FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
-)
-
-// BaseModelFieldNames returns the field names that all models have (id, created, updated).
-func BaseModelFieldNames() []string {
- return []string{
- FieldNameId,
- FieldNameCreated,
- FieldNameUpdated,
- }
-}
-
-// SystemFields returns special internal field names that are usually readonly.
-func SystemFieldNames() []string {
- return []string{
- FieldNameCollectionId,
- FieldNameCollectionName,
- FieldNameExpand,
- }
-}
-
-// AuthFieldNames returns the reserved "auth" collection auth field names.
-func AuthFieldNames() []string {
- return []string{
- FieldNameUsername,
- FieldNameEmail,
- FieldNameEmailVisibility,
- FieldNameVerified,
- FieldNameTokenKey,
- FieldNamePasswordHash,
- FieldNameLastResetSentAt,
- FieldNameLastVerificationSentAt,
- }
-}
-
-// All valid field types
-const (
- FieldTypeText string = "text"
- FieldTypeNumber string = "number"
- FieldTypeBool string = "bool"
- FieldTypeEmail string = "email"
- FieldTypeUrl string = "url"
- FieldTypeEditor string = "editor"
- FieldTypeDate string = "date"
- FieldTypeSelect string = "select"
- FieldTypeJson string = "json"
- FieldTypeFile string = "file"
- FieldTypeRelation string = "relation"
-
- // Deprecated: Will be removed in v0.9+
- FieldTypeUser string = "user"
-)
-
-// FieldTypes returns slice with all supported field types.
-func FieldTypes() []string {
- return []string{
- FieldTypeText,
- FieldTypeNumber,
- FieldTypeBool,
- FieldTypeEmail,
- FieldTypeUrl,
- FieldTypeEditor,
- FieldTypeDate,
- FieldTypeSelect,
- FieldTypeJson,
- FieldTypeFile,
- FieldTypeRelation,
- }
-}
-
-// ArraybleFieldTypes returns slice with all array value supported field types.
-func ArraybleFieldTypes() []string {
- return []string{
- FieldTypeSelect,
- FieldTypeFile,
- FieldTypeRelation,
- }
-}
-
-// SchemaField defines a single schema field structure.
-type SchemaField struct {
- System bool `form:"system" json:"system"`
- Id string `form:"id" json:"id"`
- Name string `form:"name" json:"name"`
- Type string `form:"type" json:"type"`
- Required bool `form:"required" json:"required"`
-
- // Presentable indicates whether the field is suitable for
- // visualization purposes (eg. in the Admin UI relation views).
- Presentable bool `form:"presentable" json:"presentable"`
-
- // Deprecated: This field is no-op and will be removed in future versions.
- // Please use the collection.Indexes field to define a unique constraint.
- Unique bool `form:"unique" json:"unique"`
-
- Options any `form:"options" json:"options"`
-}
-
-// ColDefinition returns the field db column type definition as string.
-func (f *SchemaField) ColDefinition() string {
- switch f.Type {
- case FieldTypeNumber:
- return "NUMERIC DEFAULT 0 NOT NULL"
- case FieldTypeBool:
- return "BOOLEAN DEFAULT FALSE NOT NULL"
- case FieldTypeJson:
- return "JSON DEFAULT NULL"
- default:
- if opt, ok := f.Options.(MultiValuer); ok && opt.IsMultiple() {
- return "JSON DEFAULT '[]' NOT NULL"
- }
-
- return "TEXT DEFAULT '' NOT NULL"
- }
-}
-
-// String serializes and returns the current field as string.
-func (f SchemaField) String() string {
- data, _ := f.MarshalJSON()
- return string(data)
-}
-
-// MarshalJSON implements the [json.Marshaler] interface.
-func (f SchemaField) MarshalJSON() ([]byte, error) {
- type alias SchemaField // alias to prevent recursion
-
- f.InitOptions()
-
- return json.Marshal(alias(f))
-}
-
-// UnmarshalJSON implements the [json.Unmarshaler] interface.
-//
-// The schema field options are auto initialized on success.
-func (f *SchemaField) UnmarshalJSON(data []byte) error {
- type alias *SchemaField // alias to prevent recursion
-
- a := alias(f)
-
- if err := json.Unmarshal(data, a); err != nil {
- return err
- }
-
- return f.InitOptions()
-}
-
-// Validate makes `SchemaField` validatable by implementing [validation.Validatable] interface.
-func (f SchemaField) Validate() error {
- // init field options (if not already)
- f.InitOptions()
-
- excludeNames := BaseModelFieldNames()
- // exclude special filter literals
- excludeNames = append(excludeNames, "null", "true", "false", "_rowid_")
- // exclude system literals
- excludeNames = append(excludeNames, SystemFieldNames()...)
-
- return validation.ValidateStruct(&f,
- validation.Field(&f.Options, validation.Required, validation.By(f.checkOptions)),
- validation.Field(&f.Id, validation.Required, validation.Length(5, 255)),
- validation.Field(
- &f.Name,
- validation.Required,
- validation.Length(1, 255),
- validation.Match(schemaFieldNameRegex),
- validation.NotIn(list.ToInterfaceSlice(excludeNames)...),
- ),
- validation.Field(&f.Type, validation.Required, validation.In(list.ToInterfaceSlice(FieldTypes())...)),
- // currently file fields cannot be unique because a proper
- // hash/content check could cause performance issues
- validation.Field(&f.Unique, validation.When(f.Type == FieldTypeFile, validation.Empty)),
- )
-}
-
-func (f *SchemaField) checkOptions(value any) error {
- v, ok := value.(FieldOptions)
- if !ok {
- return validation.NewError("validation_invalid_options", "Failed to initialize field options")
- }
-
- return v.Validate()
-}
-
-// InitOptions initializes the current field options based on its type.
-//
-// Returns error on unknown field type.
-func (f *SchemaField) InitOptions() error {
- if _, ok := f.Options.(FieldOptions); ok {
- return nil // already inited
- }
-
- serialized, err := json.Marshal(f.Options)
- if err != nil {
- return err
- }
-
- var options any
- switch f.Type {
- case FieldTypeText:
- options = &TextOptions{}
- case FieldTypeNumber:
- options = &NumberOptions{}
- case FieldTypeBool:
- options = &BoolOptions{}
- case FieldTypeEmail:
- options = &EmailOptions{}
- case FieldTypeUrl:
- options = &UrlOptions{}
- case FieldTypeEditor:
- options = &EditorOptions{}
- case FieldTypeDate:
- options = &DateOptions{}
- case FieldTypeSelect:
- options = &SelectOptions{}
- case FieldTypeJson:
- options = &JsonOptions{}
- case FieldTypeFile:
- options = &FileOptions{}
- case FieldTypeRelation:
- options = &RelationOptions{}
-
- // Deprecated: Will be removed in v0.9+
- case FieldTypeUser:
- options = &UserOptions{}
-
- default:
- return errors.New("Missing or unknown field field type.")
- }
-
- if err := json.Unmarshal(serialized, options); err != nil {
- return err
- }
-
- f.Options = options
-
- return nil
-}
-
-// PrepareValue returns normalized and properly formatted field value.
-func (f *SchemaField) PrepareValue(value any) any {
- // init field options (if not already)
- f.InitOptions()
-
- switch f.Type {
- case FieldTypeText, FieldTypeEmail, FieldTypeUrl, FieldTypeEditor:
- return cast.ToString(value)
- case FieldTypeJson:
- val := value
-
- if str, ok := val.(string); ok {
- // in order to support seamlessly both json and multipart/form-data requests,
- // the following normalization rules are applied for plain string values:
- // - "true" is converted to the json `true`
- // - "false" is converted to the json `false`
- // - "null" is converted to the json `null`
- // - "[1,2,3]" is converted to the json `[1,2,3]`
- // - "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
- // - numeric strings are converted to json number
- // - double quoted strings are left as they are (aka. without normalizations)
- // - any other string (empty string too) is double quoted
- if str == "" {
- val = strconv.Quote(str)
- } else if str == "null" || str == "true" || str == "false" {
- val = str
- } else if ((str[0] >= '0' && str[0] <= '9') ||
- str[0] == '"' ||
- str[0] == '[' ||
- str[0] == '{') &&
- is.JSON.Validate(str) == nil {
- val = str
- } else {
- val = strconv.Quote(str)
- }
- }
-
- val, _ = types.ParseJsonRaw(val)
- return val
- case FieldTypeNumber:
- return cast.ToFloat64(value)
- case FieldTypeBool:
- return cast.ToBool(value)
- case FieldTypeDate:
- val, _ := types.ParseDateTime(value)
- return val
- case FieldTypeSelect:
- val := list.ToUniqueStringSlice(value)
-
- options, _ := f.Options.(*SelectOptions)
- if !options.IsMultiple() {
- if len(val) > 0 {
- return val[len(val)-1] // the last selected
- }
- return ""
- }
-
- return val
- case FieldTypeFile:
- val := list.ToUniqueStringSlice(value)
-
- options, _ := f.Options.(*FileOptions)
- if !options.IsMultiple() {
- if len(val) > 0 {
- return val[len(val)-1] // the last selected
- }
- return ""
- }
-
- return val
- case FieldTypeRelation:
- ids := list.ToUniqueStringSlice(value)
-
- options, _ := f.Options.(*RelationOptions)
- if !options.IsMultiple() {
- if len(ids) > 0 {
- return ids[len(ids)-1] // the last selected
- }
- return ""
- }
-
- return ids
- default:
- return value // unmodified
- }
-}
-
-// PrepareValueWithModifier returns normalized and properly formatted field value
-// by "merging" baseValue with the modifierValue based on the specified modifier (+ or -).
-func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, modifierValue any) any {
- resolvedValue := baseValue
-
- switch f.Type {
- case FieldTypeNumber:
- switch modifier {
- case FieldValueModifierAdd:
- resolvedValue = cast.ToFloat64(baseValue) + cast.ToFloat64(modifierValue)
- case FieldValueModifierSubtract:
- resolvedValue = cast.ToFloat64(baseValue) - cast.ToFloat64(modifierValue)
- }
- case FieldTypeSelect, FieldTypeRelation:
- switch modifier {
- case FieldValueModifierAdd:
- resolvedValue = append(
- list.ToUniqueStringSlice(baseValue),
- list.ToUniqueStringSlice(modifierValue)...,
- )
- case FieldValueModifierSubtract:
- resolvedValue = list.SubtractSlice(
- list.ToUniqueStringSlice(baseValue),
- list.ToUniqueStringSlice(modifierValue),
- )
- }
- case FieldTypeFile:
- // note: file for now supports only the subtract modifier
- if modifier == FieldValueModifierSubtract {
- resolvedValue = list.SubtractSlice(
- list.ToUniqueStringSlice(baseValue),
- list.ToUniqueStringSlice(modifierValue),
- )
- }
- }
-
- return f.PrepareValue(resolvedValue)
-}
-
-// -------------------------------------------------------------------
-
-// MultiValuer defines common interface methods that every multi-valued (eg. with MaxSelect) field option struct has.
-type MultiValuer interface {
- IsMultiple() bool
-}
-
-// FieldOptions defines common interface methods that every field option struct has.
-type FieldOptions interface {
- Validate() error
-}
-
-type TextOptions struct {
- Min *int `form:"min" json:"min"`
- Max *int `form:"max" json:"max"`
- Pattern string `form:"pattern" json:"pattern"`
-}
-
-func (o TextOptions) Validate() error {
- minVal := 0
- if o.Min != nil {
- minVal = *o.Min
- }
-
- return validation.ValidateStruct(&o,
- validation.Field(&o.Min, validation.Min(0)),
- validation.Field(&o.Max, validation.Min(minVal)),
- validation.Field(&o.Pattern, validation.By(o.checkRegex)),
- )
-}
-
-func (o *TextOptions) checkRegex(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- if _, err := regexp.Compile(v); err != nil {
- return validation.NewError("validation_invalid_regex", err.Error())
- }
-
- return nil
-}
-
-// -------------------------------------------------------------------
-
-type NumberOptions struct {
- Min *float64 `form:"min" json:"min"`
- Max *float64 `form:"max" json:"max"`
- NoDecimal bool `form:"noDecimal" json:"noDecimal"`
-}
-
-func (o NumberOptions) Validate() error {
- var maxRules []validation.Rule
- if o.Min != nil && o.Max != nil {
- maxRules = append(maxRules, validation.Min(*o.Min), validation.By(o.checkNoDecimal))
- }
-
- return validation.ValidateStruct(&o,
- validation.Field(&o.Min, validation.By(o.checkNoDecimal)),
- validation.Field(&o.Max, maxRules...),
- )
-}
-
-func (o *NumberOptions) checkNoDecimal(value any) error {
- v, _ := value.(*float64)
- if v == nil || !o.NoDecimal {
- return nil // nothing to check
- }
-
- if *v != float64(int64(*v)) {
- return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed.")
- }
-
- return nil
-}
-
-// -------------------------------------------------------------------
-
-type BoolOptions struct {
-}
-
-func (o BoolOptions) Validate() error {
- return nil
-}
-
-// -------------------------------------------------------------------
-
-type EmailOptions struct {
- ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
- OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
-}
-
-func (o EmailOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(
- &o.ExceptDomains,
- validation.When(len(o.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &o.OnlyDomains,
- validation.When(len(o.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- )
-}
-
-// -------------------------------------------------------------------
-
-type UrlOptions struct {
- ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
- OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
-}
-
-func (o UrlOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(
- &o.ExceptDomains,
- validation.When(len(o.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- validation.Field(
- &o.OnlyDomains,
- validation.When(len(o.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
- ),
- )
-}
-
-// -------------------------------------------------------------------
-
-type EditorOptions struct {
- // ConvertUrls is usually used to instruct the editor whether to
- // apply url conversion (eg. stripping the domain name in case the
- // urls are using the same domain as the one where the editor is loaded).
- //
- // (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls)
- ConvertUrls bool `form:"convertUrls" json:"convertUrls"`
-}
-
-func (o EditorOptions) Validate() error {
- return nil
-}
-
-// -------------------------------------------------------------------
-
-type DateOptions struct {
- Min types.DateTime `form:"min" json:"min"`
- Max types.DateTime `form:"max" json:"max"`
-}
-
-func (o DateOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(&o.Max, validation.By(o.checkRange(o.Min, o.Max))),
- )
-}
-
-func (o *DateOptions) checkRange(min types.DateTime, max types.DateTime) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(types.DateTime)
-
- if v.IsZero() || min.IsZero() || max.IsZero() {
- return nil // nothing to check
- }
-
- return validation.Date(types.DefaultDateLayout).
- Min(min.Time()).
- Max(max.Time()).
- Validate(v.String())
- }
-}
-
-// -------------------------------------------------------------------
-
-type SelectOptions struct {
- MaxSelect int `form:"maxSelect" json:"maxSelect"`
- Values []string `form:"values" json:"values"`
-}
-
-func (o SelectOptions) Validate() error {
- max := len(o.Values)
- if max == 0 {
- max = 1
- }
-
- return validation.ValidateStruct(&o,
- validation.Field(&o.Values, validation.Required),
- validation.Field(
- &o.MaxSelect,
- validation.Required,
- validation.Min(1),
- validation.Max(max),
- ),
- )
-}
-
-// IsMultiple implements MultiValuer interface and checks whether the
-// current field options support multiple values.
-func (o SelectOptions) IsMultiple() bool {
- return o.MaxSelect > 1
-}
-
-// -------------------------------------------------------------------
-
-type JsonOptions struct {
-}
-
-func (o JsonOptions) Validate() error {
- return nil
-}
-
-// -------------------------------------------------------------------
-
-var _ MultiValuer = (*FileOptions)(nil)
-
-type FileOptions struct {
- MaxSelect int `form:"maxSelect" json:"maxSelect"`
- MaxSize int `form:"maxSize" json:"maxSize"` // in bytes
- MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
- Thumbs []string `form:"thumbs" json:"thumbs"`
- Protected bool `form:"protected" json:"protected"`
-}
-
-func (o FileOptions) Validate() error {
- return validation.ValidateStruct(&o,
- validation.Field(&o.MaxSelect, validation.Required, validation.Min(1)),
- validation.Field(&o.MaxSize, validation.Required, validation.Min(1)),
- validation.Field(&o.Thumbs, validation.Each(
- validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
- validation.Match(filesystem.ThumbSizeRegex),
- )),
- )
-}
-
-// IsMultiple implements MultiValuer interface and checks whether the
-// current field options support multiple values.
-func (o FileOptions) IsMultiple() bool {
- return o.MaxSelect > 1
-}
-
-// -------------------------------------------------------------------
-
-var _ MultiValuer = (*RelationOptions)(nil)
-
-type RelationOptions struct {
- // CollectionId is the id of the related collection.
- CollectionId string `form:"collectionId" json:"collectionId"`
-
- // CascadeDelete indicates whether the root model should be deleted
- // in case of delete of all linked relations.
- CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
-
- // MinSelect indicates the min number of allowed relation records
- // that could be linked to the main model.
- //
- // If nil no limits are applied.
- MinSelect *int `form:"minSelect" json:"minSelect"`
-
- // MaxSelect indicates the max number of allowed relation records
- // that could be linked to the main model.
- //
- // If nil no limits are applied.
- MaxSelect *int `form:"maxSelect" json:"maxSelect"`
-
- // Deprecated: This field is no-op and will be removed in future versions.
- // Instead use the individula SchemaField.Presentable option for each field in the relation collection.
- DisplayFields []string `form:"displayFields" json:"displayFields"`
-}
-
-func (o RelationOptions) Validate() error {
- minVal := 0
- if o.MinSelect != nil {
- minVal = *o.MinSelect
- }
-
- return validation.ValidateStruct(&o,
- validation.Field(&o.CollectionId, validation.Required),
- validation.Field(&o.MinSelect, validation.Min(0)),
- validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
- )
-}
-
-// IsMultiple implements MultiValuer interface and checks whether the
-// current field options support multiple values.
-func (o RelationOptions) IsMultiple() bool {
- return o.MaxSelect == nil || *o.MaxSelect > 1
-}
-
-// -------------------------------------------------------------------
-
-// Deprecated: Will be removed in v0.9+
-type UserOptions struct {
- MaxSelect int `form:"maxSelect" json:"maxSelect"`
- CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
-}
-
-// Deprecated: Will be removed in v0.9+
-func (o UserOptions) Validate() error {
- return nil
-}
diff --git a/models/schema/schema_field_test.go b/models/schema/schema_field_test.go
deleted file mode 100644
index bf6907236f..0000000000
--- a/models/schema/schema_field_test.go
+++ /dev/null
@@ -1,2252 +0,0 @@
-package schema_test
-
-import (
- "encoding/json"
- "fmt"
- "testing"
- "time"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tools/types"
-)
-
-func TestBaseModelFieldNames(t *testing.T) {
- result := schema.BaseModelFieldNames()
- expected := 3
-
- if len(result) != expected {
- t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
- }
-}
-
-func TestSystemFieldNames(t *testing.T) {
- result := schema.SystemFieldNames()
- expected := 3
-
- if len(result) != expected {
- t.Fatalf("Expected %d field names, got %d (%v)", expected, len(result), result)
- }
-}
-
-func TestAuthFieldNames(t *testing.T) {
- result := schema.AuthFieldNames()
- expected := 8
-
- if len(result) != expected {
- t.Fatalf("Expected %d auth field names, got %d (%v)", expected, len(result), result)
- }
-}
-
-func TestFieldTypes(t *testing.T) {
- result := schema.FieldTypes()
- expected := 11
-
- if len(result) != expected {
- t.Fatalf("Expected %d types, got %d (%v)", expected, len(result), result)
- }
-}
-
-func TestArraybleFieldTypes(t *testing.T) {
- result := schema.ArraybleFieldTypes()
- expected := 3
-
- if len(result) != expected {
- t.Fatalf("Expected %d arrayble types, got %d (%v)", expected, len(result), result)
- }
-}
-
-func TestSchemaFieldColDefinition(t *testing.T) {
- scenarios := []struct {
- field schema.SchemaField
- expected string
- }{
- {
- schema.SchemaField{Type: schema.FieldTypeText, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeNumber, Name: "test"},
- "NUMERIC DEFAULT 0 NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
- "BOOLEAN DEFAULT FALSE NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeEmail, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeUrl, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeEditor, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeDate, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeJson, Name: "test"},
- "JSON DEFAULT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeSelect, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeSelect, Name: "test_multiple", Options: &schema.SelectOptions{MaxSelect: 2}},
- "JSON DEFAULT '[]' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeFile, Name: "test"},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeFile, Name: "test_multiple", Options: &schema.FileOptions{MaxSelect: 2}},
- "JSON DEFAULT '[]' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test", Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "TEXT DEFAULT '' NOT NULL",
- },
- {
- schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test_multiple", Options: &schema.RelationOptions{MaxSelect: nil}},
- "JSON DEFAULT '[]' NOT NULL",
- },
- }
-
- for i, s := range scenarios {
- def := s.field.ColDefinition()
- if def != s.expected {
- t.Errorf("(%d) Expected definition %q, got %q", i, s.expected, def)
- }
- }
-}
-
-func TestSchemaFieldString(t *testing.T) {
- f := schema.SchemaField{
- Id: "abc",
- Name: "test",
- Type: schema.FieldTypeText,
- Required: true,
- Presentable: true,
- System: true,
- Options: &schema.TextOptions{
- Pattern: "test",
- },
- }
-
- result := f.String()
- expected := `{"system":true,"id":"abc","name":"test","type":"text","required":true,"presentable":true,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`
-
- if result != expected {
- t.Errorf("Expected \n%v, got \n%v", expected, result)
- }
-}
-
-func TestSchemaFieldMarshalJSON(t *testing.T) {
- scenarios := []struct {
- field schema.SchemaField
- expected string
- }{
- // empty
- {
- schema.SchemaField{},
- `{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- // without defined options
- {
- schema.SchemaField{
- Id: "abc",
- Name: "test",
- Type: schema.FieldTypeText,
- Required: true,
- Presentable: true,
- System: true,
- },
- `{"system":true,"id":"abc","name":"test","type":"text","required":true,"presentable":true,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
- },
- // with defined options
- {
- schema.SchemaField{
- Name: "test",
- Type: schema.FieldTypeText,
- Required: true,
- Unique: false,
- System: true,
- Options: &schema.TextOptions{
- Pattern: "test",
- },
- },
- `{"system":true,"id":"","name":"test","type":"text","required":true,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
- },
- }
-
- for i, s := range scenarios {
- result, err := s.field.MarshalJSON()
- if err != nil {
- t.Fatalf("(%d) %v", i, err)
- }
-
- if string(result) != s.expected {
- t.Errorf("(%d), Expected \n%v, got \n%v", i, s.expected, string(result))
- }
- }
-}
-
-func TestSchemaFieldUnmarshalJSON(t *testing.T) {
- scenarios := []struct {
- data []byte
- expectError bool
- expectJson string
- }{
- {
- nil,
- true,
- `{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- []byte{},
- true,
- `{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- []byte(`{"system": true}`),
- true,
- `{"system":true,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- []byte(`{"invalid"`),
- true,
- `{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- []byte(`{"type":"text","system":true}`),
- false,
- `{"system":true,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
- },
- {
- []byte(`{"type":"text","options":{"pattern":"test"}}`),
- false,
- `{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
- },
- }
-
- for i, s := range scenarios {
- f := schema.SchemaField{}
- err := f.UnmarshalJSON(s.data)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if f.String() != s.expectJson {
- t.Errorf("(%d), Expected json \n%v, got \n%v", i, s.expectJson, f.String())
- }
- }
-}
-
-func TestSchemaFieldValidate(t *testing.T) {
- scenarios := []struct {
- name string
- field schema.SchemaField
- expectedErrors []string
- }{
- {
- "empty field",
- schema.SchemaField{},
- []string{"id", "options", "name", "type"},
- },
- {
- "missing id",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "",
- Name: "test",
- },
- []string{"id"},
- },
- {
- "invalid id length check",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234",
- Name: "test",
- },
- []string{"id"},
- },
- {
- "valid id length check",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "12345",
- Name: "test",
- },
- []string{},
- },
- {
- "invalid name format",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "test!@#",
- },
- []string{"name"},
- },
- {
- "reserved name (null)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "null",
- },
- []string{"name"},
- },
- {
- "reserved name (true)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "null",
- },
- []string{"name"},
- },
- {
- "reserved name (false)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "false",
- },
- []string{"name"},
- },
- {
- "reserved name (_rowid_)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "_rowid_",
- },
- []string{"name"},
- },
- {
- "reserved name (id)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameId,
- },
- []string{"name"},
- },
- {
- "reserved name (created)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameCreated,
- },
- []string{"name"},
- },
- {
- "reserved name (updated)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameUpdated,
- },
- []string{"name"},
- },
- {
- "reserved name (collectionId)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameCollectionId,
- },
- []string{"name"},
- },
- {
- "reserved name (collectionName)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameCollectionName,
- },
- []string{"name"},
- },
- {
- "reserved name (expand)",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: schema.FieldNameExpand,
- },
- []string{"name"},
- },
- {
- "valid name",
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Id: "1234567890",
- Name: "test",
- },
- []string{},
- },
- {
- "unique check for type file",
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Id: "1234567890",
- Name: "test",
- Unique: true,
- Options: &schema.FileOptions{MaxSelect: 1, MaxSize: 1},
- },
- []string{"unique"},
- },
- {
- "trigger options validator (auto init)",
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Id: "1234567890",
- Name: "test",
- },
- []string{"options"},
- },
- {
- "trigger options validator (invalid option field value)",
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Id: "1234567890",
- Name: "test",
- Options: &schema.FileOptions{MaxSelect: 0, MaxSize: 0},
- },
- []string{"options"},
- },
- {
- "trigger options validator (valid option field value)",
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Id: "1234567890",
- Name: "test",
- Options: &schema.FileOptions{MaxSelect: 1, MaxSize: 1},
- },
- []string{},
- },
- }
-
- for _, s := range scenarios {
- result := s.field.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
- }
- }
- }
-}
-
-func TestSchemaFieldInitOptions(t *testing.T) {
- scenarios := []struct {
- field schema.SchemaField
- expectError bool
- expectJson string
- }{
- {
- schema.SchemaField{},
- true,
- `{"system":false,"id":"","name":"","type":"","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- schema.SchemaField{Type: "unknown"},
- true,
- `{"system":false,"id":"","name":"","type":"unknown","required":false,"presentable":false,"unique":false,"options":null}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeText},
- false,
- `{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeNumber},
- false,
- `{"system":false,"id":"","name":"","type":"number","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"noDecimal":false}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeBool},
- false,
- `{"system":false,"id":"","name":"","type":"bool","required":false,"presentable":false,"unique":false,"options":{}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeEmail},
- false,
- `{"system":false,"id":"","name":"","type":"email","required":false,"presentable":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeUrl},
- false,
- `{"system":false,"id":"","name":"","type":"url","required":false,"presentable":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeEditor},
- false,
- `{"system":false,"id":"","name":"","type":"editor","required":false,"presentable":false,"unique":false,"options":{"convertUrls":false}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeDate},
- false,
- `{"system":false,"id":"","name":"","type":"date","required":false,"presentable":false,"unique":false,"options":{"min":"","max":""}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeSelect},
- false,
- `{"system":false,"id":"","name":"","type":"select","required":false,"presentable":false,"unique":false,"options":{"maxSelect":0,"values":null}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeJson},
- false,
- `{"system":false,"id":"","name":"","type":"json","required":false,"presentable":false,"unique":false,"options":{}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeFile},
- false,
- `{"system":false,"id":"","name":"","type":"file","required":false,"presentable":false,"unique":false,"options":{"maxSelect":0,"maxSize":0,"mimeTypes":null,"thumbs":null,"protected":false}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeRelation},
- false,
- `{"system":false,"id":"","name":"","type":"relation","required":false,"presentable":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"minSelect":null,"maxSelect":null,"displayFields":null}}`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeUser},
- false,
- `{"system":false,"id":"","name":"","type":"user","required":false,"presentable":false,"unique":false,"options":{"maxSelect":0,"cascadeDelete":false}}`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeText,
- Options: &schema.TextOptions{Pattern: "test"},
- },
- false,
- `{"system":false,"id":"","name":"","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}`,
- },
- }
-
- for i, s := range scenarios {
- err := s.field.InitOptions()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
- }
-
- if s.field.String() != s.expectJson {
- t.Errorf("(%d), Expected %v, got %v", i, s.expectJson, s.field.String())
- }
- }
-}
-
-func TestSchemaFieldPrepareValue(t *testing.T) {
- scenarios := []struct {
- field schema.SchemaField
- value any
- expectJson string
- }{
- {schema.SchemaField{Type: "unknown"}, "test", `"test"`},
- {schema.SchemaField{Type: "unknown"}, 123, "123"},
- {schema.SchemaField{Type: "unknown"}, []int{1, 2, 1}, "[1,2,1]"},
-
- // text
- {schema.SchemaField{Type: schema.FieldTypeText}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeText}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeText}, []int{1, 2}, `""`},
- {schema.SchemaField{Type: schema.FieldTypeText}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeText}, 123, `"123"`},
-
- // email
- {schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeEmail}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeEmail}, []int{1, 2}, `""`},
- {schema.SchemaField{Type: schema.FieldTypeEmail}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeEmail}, 123, `"123"`},
-
- // url
- {schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeUrl}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeUrl}, []int{1, 2}, `""`},
- {schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
-
- // editor
- {schema.SchemaField{Type: schema.FieldTypeEditor}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeEditor}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeEditor}, []int{1, 2}, `""`},
- {schema.SchemaField{Type: schema.FieldTypeEditor}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeEditor}, 123, `"123"`},
-
- // json
- {schema.SchemaField{Type: schema.FieldTypeJson}, nil, "null"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "null", "null"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, 123, "123"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "123", "123"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, 123.456, "123.456"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "123.456", "123.456"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "123.456 abc", `"123.456 abc"`}, // invalid numeric string
- {schema.SchemaField{Type: schema.FieldTypeJson}, true, "true"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "true", "true"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, false, "false"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "false", "false"},
- {schema.SchemaField{Type: schema.FieldTypeJson}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `test`, `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `"test"`, `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `{test":1}`, `"{test\":1}"`}, // invalid object string
- {schema.SchemaField{Type: schema.FieldTypeJson}, `[1 2 3]`, `"[1 2 3]"`}, // invalid array string
- {schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{}, `{}`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `{}`, `{}`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{"test": 123}, `{"test":123}`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `{"test":123}`, `{"test":123}`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, []int{}, `[]`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `[]`, `[]`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
- {schema.SchemaField{Type: schema.FieldTypeJson}, `[1,2,1]`, `[1,2,1]`},
-
- // number
- {schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
- {schema.SchemaField{Type: schema.FieldTypeNumber}, "", "0"},
- {schema.SchemaField{Type: schema.FieldTypeNumber}, "test", "0"},
- {schema.SchemaField{Type: schema.FieldTypeNumber}, 1, "1"},
- {schema.SchemaField{Type: schema.FieldTypeNumber}, 1.5, "1.5"},
- {schema.SchemaField{Type: schema.FieldTypeNumber}, "1.5", "1.5"},
-
- // bool
- {schema.SchemaField{Type: schema.FieldTypeBool}, nil, "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, 1, "true"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, 0, "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, "", "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, "test", "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, "false", "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, "true", "true"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, false, "false"},
- {schema.SchemaField{Type: schema.FieldTypeBool}, true, "true"},
-
- // date
- {schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000Z"`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123Z"`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123Z", `"2022-01-01 11:27:10.123Z"`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
- {schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
-
- // select (single)
- {schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
- {schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test2"`},
- {
- // no values validation/filtering
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{
- Values: []string{"test1", "test2"},
- },
- },
- "test",
- `"test"`,
- },
- // select (multiple)
- {
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- nil,
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- "",
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- []string{},
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- 123,
- `["123"]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- "test",
- `["test"]`,
- },
- {
- // no values validation
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- []string{"test1", "test2", "test3"},
- `["test1","test2","test3"]`,
- },
- {
- // duplicated values
- schema.SchemaField{
- Type: schema.FieldTypeSelect,
- Options: &schema.SelectOptions{MaxSelect: 2},
- },
- []string{"test1", "test2", "test1"},
- `["test1","test2"]`,
- },
-
- // file (single)
- {schema.SchemaField{Type: schema.FieldTypeFile}, nil, `""`},
- {schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
- {schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
- {schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
- {schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test2"`},
- // file (multiple)
- {
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- nil,
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- "",
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- []string{},
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- 123,
- `["123"]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- "test",
- `["test"]`,
- },
- {
- // no values validation
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- []string{"test1", "test2", "test3"},
- `["test1","test2","test3"]`,
- },
- {
- // duplicated values
- schema.SchemaField{
- Type: schema.FieldTypeFile,
- Options: &schema.FileOptions{MaxSelect: 2},
- },
- []string{"test1", "test2", "test1"},
- `["test1","test2"]`,
- },
-
- // relation (single)
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- nil,
- `""`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- "",
- `""`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- 123,
- `"123"`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- "abc",
- `"abc"`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
- },
- "1ba88b4f-e9da-42f0-9764-9a55c953e724",
- `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
- },
- {
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `"2ba88b4f-e9da-42f0-9764-9a55c953e724"`,
- },
- // relation (multiple)
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- nil,
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- "",
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- []string{},
- `[]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- 123,
- `["123"]`,
- },
- {
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- []string{"", "abc"},
- `["abc"]`,
- },
- {
- // no values validation
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
- },
- {
- // duplicated values
- schema.SchemaField{
- Type: schema.FieldTypeRelation,
- Options: &schema.RelationOptions{MaxSelect: types.Pointer(2)},
- },
- []string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724", "1ba88b4f-e9da-42f0-9764-9a55c953e724"},
- `["1ba88b4f-e9da-42f0-9764-9a55c953e724","2ba88b4f-e9da-42f0-9764-9a55c953e724"]`,
- },
- }
-
- for i, s := range scenarios {
- result := s.field.PrepareValue(s.value)
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Errorf("(%d) %v", i, err)
- continue
- }
-
- if string(encoded) != s.expectJson {
- t.Errorf("(%d), Expected %v, got %v", i, s.expectJson, string(encoded))
- }
- }
-}
-
-func TestSchemaFieldPrepareValueWithModifier(t *testing.T) {
- scenarios := []struct {
- name string
- field schema.SchemaField
- baseValue any
- modifier string
- modifierValue any
- expectJson string
- }{
- // text
- {
- "text with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeText},
- "base",
- "+",
- "new",
- `"base"`,
- },
- {
- "text with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeText},
- "base",
- "-",
- "new",
- `"base"`,
- },
- {
- "text with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeText},
- "base",
- "?",
- "new",
- `"base"`,
- },
- {
- "text cast check",
- schema.SchemaField{Type: schema.FieldTypeText},
- 123,
- "?",
- "new",
- `"123"`,
- },
-
- // number
- {
- "number with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeNumber},
- 1,
- "+",
- 4,
- `5`,
- },
- {
- "number with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeNumber},
- 1,
- "-",
- 4,
- `-3`,
- },
- {
- "number with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeNumber},
- "1",
- "?",
- 4,
- `1`,
- },
- {
- "number cast check",
- schema.SchemaField{Type: schema.FieldTypeNumber},
- "test",
- "+",
- "4",
- `4`,
- },
-
- // bool
- {
- "bool with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeBool},
- true,
- "+",
- false,
- `true`,
- },
- {
- "bool with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeBool},
- true,
- "-",
- false,
- `true`,
- },
- {
- "bool with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeBool},
- true,
- "?",
- false,
- `true`,
- },
- {
- "bool cast check",
- schema.SchemaField{Type: schema.FieldTypeBool},
- "true",
- "?",
- false,
- `true`,
- },
-
- // email
- {
- "email with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeEmail},
- "base",
- "+",
- "new",
- `"base"`,
- },
- {
- "email with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeEmail},
- "base",
- "-",
- "new",
- `"base"`,
- },
- {
- "email with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeEmail},
- "base",
- "?",
- "new",
- `"base"`,
- },
- {
- "email cast check",
- schema.SchemaField{Type: schema.FieldTypeEmail},
- 123,
- "?",
- "new",
- `"123"`,
- },
-
- // url
- {
- "url with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeUrl},
- "base",
- "+",
- "new",
- `"base"`,
- },
- {
- "url with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeUrl},
- "base",
- "-",
- "new",
- `"base"`,
- },
- {
- "url with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeUrl},
- "base",
- "?",
- "new",
- `"base"`,
- },
- {
- "url cast check",
- schema.SchemaField{Type: schema.FieldTypeUrl},
- 123,
- "-",
- "new",
- `"123"`,
- },
-
- // editor
- {
- "editor with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeEditor},
- "base",
- "+",
- "new",
- `"base"`,
- },
- {
- "editor with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeEditor},
- "base",
- "-",
- "new",
- `"base"`,
- },
- {
- "editor with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeEditor},
- "base",
- "?",
- "new",
- `"base"`,
- },
- {
- "editor cast check",
- schema.SchemaField{Type: schema.FieldTypeEditor},
- 123,
- "-",
- "new",
- `"123"`,
- },
-
- // date
- {
- "date with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeDate},
- "2023-01-01 00:00:00.123",
- "+",
- "2023-02-01 00:00:00.456",
- `"2023-01-01 00:00:00.123Z"`,
- },
- {
- "date with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeDate},
- "2023-01-01 00:00:00.123Z",
- "-",
- "2023-02-01 00:00:00.456Z",
- `"2023-01-01 00:00:00.123Z"`,
- },
- {
- "date with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeDate},
- "2023-01-01 00:00:00.123",
- "?",
- "2023-01-01 00:00:00.456",
- `"2023-01-01 00:00:00.123Z"`,
- },
- {
- "date cast check",
- schema.SchemaField{Type: schema.FieldTypeDate},
- 1672524000, // 2022-12-31 22:00:00.000Z
- "+",
- 100,
- `"2022-12-31 22:00:00.000Z"`,
- },
-
- // json
- {
- "json with '+' modifier",
- schema.SchemaField{Type: schema.FieldTypeJson},
- 10,
- "+",
- 5,
- `10`,
- },
- {
- "json with '+' modifier (slice)",
- schema.SchemaField{Type: schema.FieldTypeJson},
- []string{"a", "b"},
- "+",
- "c",
- `["a","b"]`,
- },
- {
- "json with '-' modifier",
- schema.SchemaField{Type: schema.FieldTypeJson},
- 10,
- "-",
- 5,
- `10`,
- },
- {
- "json with '-' modifier (slice)",
- schema.SchemaField{Type: schema.FieldTypeJson},
- `["a","b"]`,
- "-",
- "c",
- `["a","b"]`,
- },
- {
- "json with unknown modifier",
- schema.SchemaField{Type: schema.FieldTypeJson},
- `"base"`,
- "?",
- `"new"`,
- `"base"`,
- },
-
- // single select
- {
- "single select with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "",
- "+",
- "b",
- `"b"`,
- },
- {
- "single select with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "a",
- "+",
- "b",
- `"b"`,
- },
- {
- "single select with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "",
- "-",
- "a",
- `""`,
- },
- {
- "single select with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "a",
- "-",
- "",
- `"a"`,
- },
- {
- "single select with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "a",
- "-",
- "b",
- `"a"`,
- },
- {
- "single select with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "a",
- "-",
- "a",
- `""`,
- },
- {
- "single select with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "a",
- "-",
- []string{"b", "a", "c", "123"},
- `""`,
- },
- {
- "single select with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
- "",
- "?",
- "a",
- `""`,
- },
-
- // multi select
- {
- "multi select with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- nil,
- "+",
- "b",
- `["b"]`,
- },
- {
- "multi select with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- []string{"a"},
- "+",
- []string{"b", "c"},
- `["a","b","c"]`,
- },
- {
- "multi select with '+' modifier (nonempty base; already existing value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- []string{"a", "b"},
- "+",
- "b",
- `["a","b"]`,
- },
- {
- "multi select with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- nil,
- "-",
- []string{"a"},
- `[]`,
- },
- {
- "multi select with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- "a",
- "-",
- "",
- `["a"]`,
- },
- {
- "multi select with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- "a",
- "-",
- "b",
- `["a"]`,
- },
- {
- "multi select with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- []string{"a", "b", "c", "d"},
- "-",
- "c",
- `["a","b","d"]`,
- },
- {
- "multi select with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- []string{"a", "b", "c", "d"},
- "-",
- []string{"b", "a", "123"},
- `["c","d"]`,
- },
- {
- "multi select with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
- []string{"a", "b"},
- "?",
- "a",
- `["a","b"]`,
- },
-
- // single relation
- {
- "single relation with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "",
- "+",
- "b",
- `"b"`,
- },
- {
- "single relation with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "a",
- "+",
- "b",
- `"b"`,
- },
- {
- "single relation with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "",
- "-",
- "a",
- `""`,
- },
- {
- "single relation with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "a",
- "-",
- "",
- `"a"`,
- },
- {
- "single relation with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "a",
- "-",
- "b",
- `"a"`,
- },
- {
- "single relation with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "a",
- "-",
- "a",
- `""`,
- },
- {
- "single relation with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "a",
- "-",
- []string{"b", "a", "c", "123"},
- `""`,
- },
- {
- "single relation with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
- "",
- "?",
- "a",
- `""`,
- },
-
- // multi relation
- {
- "multi relation with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- nil,
- "+",
- "b",
- `["b"]`,
- },
- {
- "multi relation with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- []string{"a"},
- "+",
- []string{"b", "c"},
- `["a","b","c"]`,
- },
- {
- "multi relation with '+' modifier (nonempty base; already existing value)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- []string{"a", "b"},
- "+",
- "b",
- `["a","b"]`,
- },
- {
- "multi relation with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- nil,
- "-",
- []string{"a"},
- `[]`,
- },
- {
- "multi relation with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- "a",
- "-",
- "",
- `["a"]`,
- },
- {
- "multi relation with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- "a",
- "-",
- "b",
- `["a"]`,
- },
- {
- "multi relation with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- []string{"a", "b", "c", "d"},
- "-",
- "c",
- `["a","b","d"]`,
- },
- {
- "multi relation with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- []string{"a", "b", "c", "d"},
- "-",
- []string{"b", "a", "123"},
- `["c","d"]`,
- },
- {
- "multi relation with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeRelation},
- []string{"a", "b"},
- "?",
- "a",
- `["a","b"]`,
- },
-
- // single file
- {
- "single file with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "",
- "+",
- "b",
- `""`,
- },
- {
- "single file with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "a",
- "+",
- "b",
- `"a"`,
- },
- {
- "single file with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "",
- "-",
- "a",
- `""`,
- },
- {
- "single file with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "a",
- "-",
- "",
- `"a"`,
- },
- {
- "single file with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "a",
- "-",
- "b",
- `"a"`,
- },
- {
- "single file with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "a",
- "-",
- "a",
- `""`,
- },
- {
- "single file with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "a",
- "-",
- []string{"b", "a", "c", "123"},
- `""`,
- },
- {
- "single file with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
- "",
- "?",
- "a",
- `""`,
- },
-
- // multi file
- {
- "multi file with '+' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- nil,
- "+",
- "b",
- `[]`,
- },
- {
- "multi file with '+' modifier (nonempty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- []string{"a"},
- "+",
- []string{"b", "c"},
- `["a"]`,
- },
- {
- "multi file with '+' modifier (nonempty base; already existing value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- []string{"a", "b"},
- "+",
- "b",
- `["a","b"]`,
- },
- {
- "multi file with '-' modifier (empty base)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- nil,
- "-",
- []string{"a"},
- `[]`,
- },
- {
- "multi file with '-' modifier (nonempty base and empty modifier value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- "a",
- "-",
- "",
- `["a"]`,
- },
- {
- "multi file with '-' modifier (nonempty base and different value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- "a",
- "-",
- "b",
- `["a"]`,
- },
- {
- "multi file with '-' modifier (nonempty base and matching value)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- []string{"a", "b", "c", "d"},
- "-",
- "c",
- `["a","b","d"]`,
- },
- {
- "multi file with '-' modifier (nonempty base and matching value in a slice)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- []string{"a", "b", "c", "d"},
- "-",
- []string{"b", "a", "123"},
- `["c","d"]`,
- },
- {
- "multi file with unknown modifier (nonempty)",
- schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
- []string{"a", "b"},
- "?",
- "a",
- `["a","b"]`,
- },
- }
-
- for _, s := range scenarios {
- result := s.field.PrepareValueWithModifier(s.baseValue, s.modifier, s.modifierValue)
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatalf("[%s] %v", s.name, err)
- }
-
- if string(encoded) != s.expectJson {
- t.Fatalf("[%s], Expected %v, got %v", s.name, s.expectJson, string(encoded))
- }
- }
-}
-
-// -------------------------------------------------------------------
-
-type fieldOptionsScenario struct {
- name string
- options schema.FieldOptions
- expectedErrors []string
-}
-
-func checkFieldOptionsScenarios(t *testing.T, scenarios []fieldOptionsScenario) {
- for i, s := range scenarios {
- result := s.options.Validate()
-
- prefix := fmt.Sprintf("%d", i)
- if s.name != "" {
- prefix = s.name
- }
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", prefix, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
- }
- }
- }
-}
-
-func TestTextOptionsValidate(t *testing.T) {
- minus := -1
- number0 := 0
- number1 := 10
- number2 := 20
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.TextOptions{},
- []string{},
- },
- {
- "min - failure",
- schema.TextOptions{
- Min: &minus,
- },
- []string{"min"},
- },
- {
- "min - success",
- schema.TextOptions{
- Min: &number0,
- },
- []string{},
- },
- {
- "max - failure without min",
- schema.TextOptions{
- Max: &minus,
- },
- []string{"max"},
- },
- {
- "max - failure with min",
- schema.TextOptions{
- Min: &number2,
- Max: &number1,
- },
- []string{"max"},
- },
- {
- "max - success",
- schema.TextOptions{
- Min: &number1,
- Max: &number2,
- },
- []string{},
- },
- {
- "pattern - failure",
- schema.TextOptions{Pattern: "(test"},
- []string{"pattern"},
- },
- {
- "pattern - success",
- schema.TextOptions{Pattern: `^\#?\w+$`},
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestNumberOptionsValidate(t *testing.T) {
- int1 := 10.0
- int2 := 20.0
-
- decimal1 := 10.5
- decimal2 := 20.5
-
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.NumberOptions{},
- []string{},
- },
- {
- "max - without min",
- schema.NumberOptions{
- Max: &int1,
- },
- []string{},
- },
- {
- "max - failure with min",
- schema.NumberOptions{
- Min: &int2,
- Max: &int1,
- },
- []string{"max"},
- },
- {
- "max - success with min",
- schema.NumberOptions{
- Min: &int1,
- Max: &int2,
- },
- []string{},
- },
- {
- "NoDecimal range failure",
- schema.NumberOptions{
- Min: &decimal1,
- Max: &decimal2,
- NoDecimal: true,
- },
- []string{"min", "max"},
- },
- {
- "NoDecimal range success",
- schema.NumberOptions{
- Min: &int1,
- Max: &int2,
- NoDecimal: true,
- },
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestBoolOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.BoolOptions{},
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestEmailOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.EmailOptions{},
- []string{},
- },
- {
- "ExceptDomains failure",
- schema.EmailOptions{
- ExceptDomains: []string{"invalid"},
- },
- []string{"exceptDomains"},
- },
- {
- "ExceptDomains success",
- schema.EmailOptions{
- ExceptDomains: []string{"example.com", "sub.example.com"},
- },
- []string{},
- },
- {
- "OnlyDomains check",
- schema.EmailOptions{
- OnlyDomains: []string{"invalid"},
- },
- []string{"onlyDomains"},
- },
- {
- "OnlyDomains success",
- schema.EmailOptions{
- OnlyDomains: []string{"example.com", "sub.example.com"},
- },
- []string{},
- },
- {
- "OnlyDomains + ExceptDomains at the same time",
- schema.EmailOptions{
- ExceptDomains: []string{"test1.com"},
- OnlyDomains: []string{"test2.com"},
- },
- []string{"exceptDomains", "onlyDomains"},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestUrlOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.UrlOptions{},
- []string{},
- },
- {
- "ExceptDomains failure",
- schema.UrlOptions{
- ExceptDomains: []string{"invalid"},
- },
- []string{"exceptDomains"},
- },
- {
- "ExceptDomains success",
- schema.UrlOptions{
- ExceptDomains: []string{"example.com", "sub.example.com"},
- },
- []string{},
- },
- {
- "OnlyDomains check",
- schema.UrlOptions{
- OnlyDomains: []string{"invalid"},
- },
- []string{"onlyDomains"},
- },
- {
- "OnlyDomains success",
- schema.UrlOptions{
- OnlyDomains: []string{"example.com", "sub.example.com"},
- },
- []string{},
- },
- {
- "OnlyDomains + ExceptDomains at the same time",
- schema.UrlOptions{
- ExceptDomains: []string{"test1.com"},
- OnlyDomains: []string{"test2.com"},
- },
- []string{"exceptDomains", "onlyDomains"},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestEditorOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.EditorOptions{},
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestDateOptionsValidate(t *testing.T) {
- date1 := types.NowDateTime()
- date2, _ := types.ParseDateTime(date1.Time().AddDate(1, 0, 0))
-
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.DateOptions{},
- []string{},
- },
- {
- "min only",
- schema.DateOptions{
- Min: date1,
- },
- []string{},
- },
- {
- "max only",
- schema.DateOptions{
- Min: date1,
- },
- []string{},
- },
- {
- "zero min + max",
- schema.DateOptions{
- Min: types.DateTime{},
- Max: date1,
- },
- []string{},
- },
- {
- "min + zero max",
- schema.DateOptions{
- Min: date1,
- Max: types.DateTime{},
- },
- []string{},
- },
- {
- "min > max",
- schema.DateOptions{
- Min: date2,
- Max: date1,
- },
- []string{"max"},
- },
- {
- "min == max",
- schema.DateOptions{
- Min: date1,
- Max: date1,
- },
- []string{"max"},
- },
- {
- "min < max",
- schema.DateOptions{
- Min: date1,
- Max: date2,
- },
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestSelectOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.SelectOptions{},
- []string{"values", "maxSelect"},
- },
- {
- "MaxSelect <= 0",
- schema.SelectOptions{
- Values: []string{"test1", "test2"},
- MaxSelect: 0,
- },
- []string{"maxSelect"},
- },
- {
- "MaxSelect > Values",
- schema.SelectOptions{
- Values: []string{"test1", "test2"},
- MaxSelect: 3,
- },
- []string{"maxSelect"},
- },
- {
- "MaxSelect <= Values",
- schema.SelectOptions{
- Values: []string{"test1", "test2"},
- MaxSelect: 2,
- },
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestSelectOptionsIsMultiple(t *testing.T) {
- scenarios := []struct {
- maxSelect int
- expect bool
- }{
- {-1, false},
- {0, false},
- {1, false},
- {2, true},
- }
-
- for i, s := range scenarios {
- opt := schema.SelectOptions{
- MaxSelect: s.maxSelect,
- }
-
- if v := opt.IsMultiple(); v != s.expect {
- t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
- }
- }
-}
-
-func TestJsonOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.JsonOptions{},
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestFileOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.FileOptions{},
- []string{"maxSelect", "maxSize"},
- },
- {
- "MaxSelect <= 0 && maxSize <= 0",
- schema.FileOptions{
- MaxSize: 0,
- MaxSelect: 0,
- },
- []string{"maxSelect", "maxSize"},
- },
- {
- "MaxSelect > 0 && maxSize > 0",
- schema.FileOptions{
- MaxSize: 2,
- MaxSelect: 1,
- },
- []string{},
- },
- {
- "invalid thumbs format",
- schema.FileOptions{
- MaxSize: 1,
- MaxSelect: 2,
- Thumbs: []string{"100", "200x100"},
- },
- []string{"thumbs"},
- },
- {
- "invalid thumbs format - zero width and height",
- schema.FileOptions{
- MaxSize: 1,
- MaxSelect: 2,
- Thumbs: []string{"0x0", "0x0t", "0x0b", "0x0f"},
- },
- []string{"thumbs"},
- },
- {
- "valid thumbs format",
- schema.FileOptions{
- MaxSize: 1,
- MaxSelect: 2,
- Thumbs: []string{
- "100x100", "200x100", "0x100", "100x0",
- "10x10t", "10x10b", "10x10f",
- },
- },
- []string{},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestFileOptionsIsMultiple(t *testing.T) {
- scenarios := []struct {
- maxSelect int
- expect bool
- }{
- {-1, false},
- {0, false},
- {1, false},
- {2, true},
- }
-
- for i, s := range scenarios {
- opt := schema.FileOptions{
- MaxSelect: s.maxSelect,
- }
-
- if v := opt.IsMultiple(); v != s.expect {
- t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
- }
- }
-}
-
-func TestRelationOptionsValidate(t *testing.T) {
- scenarios := []fieldOptionsScenario{
- {
- "empty",
- schema.RelationOptions{},
- []string{"collectionId"},
- },
- {
- "empty CollectionId",
- schema.RelationOptions{
- CollectionId: "",
- MaxSelect: types.Pointer(1),
- },
- []string{"collectionId"},
- },
- {
- "MinSelect < 0",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: types.Pointer(-1),
- },
- []string{"minSelect"},
- },
- {
- "MinSelect >= 0",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: types.Pointer(0),
- },
- []string{},
- },
- {
- "MaxSelect <= 0",
- schema.RelationOptions{
- CollectionId: "abc",
- MaxSelect: types.Pointer(0),
- },
- []string{"maxSelect"},
- },
- {
- "MaxSelect > 0 && nonempty CollectionId",
- schema.RelationOptions{
- CollectionId: "abc",
- MaxSelect: types.Pointer(1),
- },
- []string{},
- },
- {
- "MinSelect < MaxSelect",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: nil,
- MaxSelect: types.Pointer(1),
- },
- []string{},
- },
- {
- "MinSelect = MaxSelect (non-zero)",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: types.Pointer(1),
- MaxSelect: types.Pointer(1),
- },
- []string{},
- },
- {
- "MinSelect = MaxSelect (both zero)",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: types.Pointer(0),
- MaxSelect: types.Pointer(0),
- },
- []string{"maxSelect"},
- },
- {
- "MinSelect > MaxSelect",
- schema.RelationOptions{
- CollectionId: "abc",
- MinSelect: types.Pointer(2),
- MaxSelect: types.Pointer(1),
- },
- []string{"maxSelect"},
- },
- }
-
- checkFieldOptionsScenarios(t, scenarios)
-}
-
-func TestRelationOptionsIsMultiple(t *testing.T) {
- scenarios := []struct {
- maxSelect *int
- expect bool
- }{
- {nil, true},
- {types.Pointer(-1), false},
- {types.Pointer(0), false},
- {types.Pointer(1), false},
- {types.Pointer(2), true},
- }
-
- for i, s := range scenarios {
- opt := schema.RelationOptions{
- MaxSelect: s.maxSelect,
- }
-
- if v := opt.IsMultiple(); v != s.expect {
- t.Errorf("[%d] Expected %v, got %v", i, s.expect, v)
- }
- }
-}
diff --git a/models/schema/schema_test.go b/models/schema/schema_test.go
deleted file mode 100644
index 7ff4afac0f..0000000000
--- a/models/schema/schema_test.go
+++ /dev/null
@@ -1,414 +0,0 @@
-package schema_test
-
-import (
- "testing"
-
- "github.com/pocketbase/pocketbase/models/schema"
-)
-
-func TestNewSchemaAndFields(t *testing.T) {
- testSchema := schema.NewSchema(
- &schema.SchemaField{Id: "id1", Name: "test1"},
- &schema.SchemaField{Name: "test2"},
- &schema.SchemaField{Id: "id1", Name: "test1_new"}, // should replace the original id1 field
- )
-
- fields := testSchema.Fields()
-
- if len(fields) != 2 {
- t.Fatalf("Expected 2 fields, got %d (%v)", len(fields), fields)
- }
-
- for _, f := range fields {
- if f.Id == "" {
- t.Fatalf("Expected field id to be set, found empty id for field %v", f)
- }
- }
-
- if fields[0].Name != "test1_new" {
- t.Fatalf("Expected field with name test1_new, got %s", fields[0].Name)
- }
-
- if fields[1].Name != "test2" {
- t.Fatalf("Expected field with name test2, got %s", fields[1].Name)
- }
-}
-
-func TestSchemaInitFieldsOptions(t *testing.T) {
- f0 := &schema.SchemaField{Name: "test1", Type: "unknown"}
- schema0 := schema.NewSchema(f0)
-
- err0 := schema0.InitFieldsOptions()
- if err0 == nil {
- t.Fatalf("Expected unknown field schema to fail, got nil")
- }
-
- // ---
-
- f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
- schema1 := schema.NewSchema(f1, f2)
-
- err1 := schema1.InitFieldsOptions()
- if err1 != nil {
- t.Fatal(err1)
- }
-
- if _, ok := f1.Options.(*schema.TextOptions); !ok {
- t.Fatalf("Failed to init f1 options")
- }
-
- if _, ok := f2.Options.(*schema.EmailOptions); !ok {
- t.Fatalf("Failed to init f2 options")
- }
-}
-
-func TestSchemaClone(t *testing.T) {
- f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
- s1 := schema.NewSchema(f1, f2)
-
- s2, err := s1.Clone()
- if err != nil {
- t.Fatal(err)
- }
-
- s1Encoded, _ := s1.MarshalJSON()
- s2Encoded, _ := s2.MarshalJSON()
-
- if string(s1Encoded) != string(s2Encoded) {
- t.Fatalf("Expected the cloned schema to be equal, got %v VS\n %v", s1, s2)
- }
-
- // change in one schema shouldn't result to change in the other
- // (aka. check if it is a deep clone)
- s1.Fields()[0].Name = "test1_update"
- if s2.Fields()[0].Name != "test1" {
- t.Fatalf("Expected s2 field name to not change, got %q", s2.Fields()[0].Name)
- }
-}
-
-func TestSchemaAsMap(t *testing.T) {
- f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeEmail}
- testSchema := schema.NewSchema(f1, f2)
-
- result := testSchema.AsMap()
-
- if len(result) != 2 {
- t.Fatalf("Expected 2 map elements, got %d (%v)", len(result), result)
- }
-
- expectedIndexes := []string{f1.Name, f2.Name}
-
- for _, index := range expectedIndexes {
- if _, ok := result[index]; !ok {
- t.Fatalf("Missing index %q", index)
- }
- }
-}
-
-func TestSchemaGetFieldByName(t *testing.T) {
- f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Name: "test2", Type: schema.FieldTypeText}
- testSchema := schema.NewSchema(f1, f2)
-
- // missing field
- result1 := testSchema.GetFieldByName("missing")
- if result1 != nil {
- t.Fatalf("Found unexpected field %v", result1)
- }
-
- // existing field
- result2 := testSchema.GetFieldByName("test1")
- if result2 == nil || result2.Name != "test1" {
- t.Fatalf("Cannot find field with Name 'test1', got %v ", result2)
- }
-}
-
-func TestSchemaGetFieldById(t *testing.T) {
- f1 := &schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText}
- testSchema := schema.NewSchema(f1, f2)
-
- // missing field id
- result1 := testSchema.GetFieldById("test1")
- if result1 != nil {
- t.Fatalf("Found unexpected field %v", result1)
- }
-
- // existing field id
- result2 := testSchema.GetFieldById("id2")
- if result2 == nil || result2.Id != "id2" {
- t.Fatalf("Cannot find field with id 'id2', got %v ", result2)
- }
-}
-
-func TestSchemaRemoveField(t *testing.T) {
- f1 := &schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText}
- f3 := &schema.SchemaField{Id: "id3", Name: "test3", Type: schema.FieldTypeText}
- testSchema := schema.NewSchema(f1, f2, f3)
-
- testSchema.RemoveField("id2")
- testSchema.RemoveField("test3") // should do nothing
-
- expected := []string{"test1", "test3"}
-
- if len(testSchema.Fields()) != len(expected) {
- t.Fatalf("Expected %d, got %d (%v)", len(expected), len(testSchema.Fields()), testSchema)
- }
-
- for _, name := range expected {
- if f := testSchema.GetFieldByName(name); f == nil {
- t.Fatalf("Missing field %q", name)
- }
- }
-}
-
-func TestSchemaAddField(t *testing.T) {
- f1 := &schema.SchemaField{Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{Id: "f2Id", Name: "test2", Type: schema.FieldTypeText}
- f3 := &schema.SchemaField{Id: "f3Id", Name: "test3", Type: schema.FieldTypeText}
- testSchema := schema.NewSchema(f1, f2, f3)
-
- f2New := &schema.SchemaField{Id: "f2Id", Name: "test2_new", Type: schema.FieldTypeEmail}
- f4 := &schema.SchemaField{Name: "test4", Type: schema.FieldTypeUrl}
-
- testSchema.AddField(f2New)
- testSchema.AddField(f4)
-
- if len(testSchema.Fields()) != 4 {
- t.Fatalf("Expected %d, got %d (%v)", 4, len(testSchema.Fields()), testSchema)
- }
-
- // check if each field has id
- for _, f := range testSchema.Fields() {
- if f.Id == "" {
- t.Fatalf("Expected field id to be set, found empty id for field %v", f)
- }
- }
-
- // check if f2 field was replaced
- if f := testSchema.GetFieldById("f2Id"); f == nil || f.Type != schema.FieldTypeEmail {
- t.Fatalf("Expected f2 field to be replaced, found %v", f)
- }
-
- // check if f4 was added
- if f := testSchema.GetFieldByName("test4"); f == nil || f.Name != "test4" {
- t.Fatalf("Expected f4 field to be added, found %v", f)
- }
-}
-
-func TestSchemaValidate(t *testing.T) {
- // emulate duplicated field ids
- duplicatedIdsSchema := schema.NewSchema(
- &schema.SchemaField{Id: "id1", Name: "test1", Type: schema.FieldTypeText},
- &schema.SchemaField{Id: "id2", Name: "test2", Type: schema.FieldTypeText},
- )
- duplicatedIdsSchema.Fields()[1].Id = "id1" // manually set existing id
-
- scenarios := []struct {
- schema schema.Schema
- expectError bool
- }{
- // no fields
- {
- schema.NewSchema(),
- false,
- },
- // duplicated field ids
- {
- duplicatedIdsSchema,
- true,
- },
- // duplicated field names (case insensitive)
- {
- schema.NewSchema(
- &schema.SchemaField{Name: "test", Type: schema.FieldTypeText},
- &schema.SchemaField{Name: "TeSt", Type: schema.FieldTypeText},
- ),
- true,
- },
- // failure - base individual fields validation
- {
- schema.NewSchema(
- &schema.SchemaField{Name: "", Type: schema.FieldTypeText},
- ),
- true,
- },
- // success - base individual fields validation
- {
- schema.NewSchema(
- &schema.SchemaField{Name: "test", Type: schema.FieldTypeText},
- ),
- false,
- },
- // failure - individual field options validation
- {
- schema.NewSchema(
- &schema.SchemaField{Name: "test", Type: schema.FieldTypeFile},
- ),
- true,
- },
- // success - individual field options validation
- {
- schema.NewSchema(
- &schema.SchemaField{Name: "test", Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1, MaxSize: 1}},
- ),
- false,
- },
- }
-
- for i, s := range scenarios {
- err := s.schema.Validate()
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
- continue
- }
- }
-}
-
-func TestSchemaMarshalJSON(t *testing.T) {
- f1 := &schema.SchemaField{Id: "f1id", Name: "test1", Type: schema.FieldTypeText}
- f2 := &schema.SchemaField{
- Id: "f2id",
- Name: "test2",
- Type: schema.FieldTypeText,
- Options: &schema.TextOptions{Pattern: "test"},
- }
- testSchema := schema.NewSchema(f1, f2)
-
- result, err := testSchema.MarshalJSON()
- if err != nil {
- t.Fatal(err)
- }
-
- expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"id":"f2id","name":"test2","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
-
- if string(result) != expected {
- t.Fatalf("Expected %s, got %s", expected, string(result))
- }
-}
-
-func TestSchemaUnmarshalJSON(t *testing.T) {
- encoded := `[{"system":false,"id":"fid1", "name":"test1","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"name":"test2","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`
- testSchema := schema.Schema{}
- testSchema.AddField(&schema.SchemaField{Name: "tempField", Type: schema.FieldTypeUrl})
- err := testSchema.UnmarshalJSON([]byte(encoded))
- if err != nil {
- t.Fatal(err)
- }
-
- fields := testSchema.Fields()
- if len(fields) != 2 {
- t.Fatalf("Expected 2 fields, found %v", fields)
- }
-
- f1 := testSchema.GetFieldByName("test1")
- if f1 == nil {
- t.Fatal("Expected to find field 'test1', got nil")
- }
- if f1.Id != "fid1" {
- t.Fatalf("Expected fid1 id, got %s", f1.Id)
- }
- _, ok := f1.Options.(*schema.TextOptions)
- if !ok {
- t.Fatal("'test1' field options are not inited.")
- }
-
- f2 := testSchema.GetFieldByName("test2")
- if f2 == nil {
- t.Fatal("Expected to find field 'test2', got nil")
- }
- if f2.Id == "" {
- t.Fatal("Expected f2 id to be set, got empty string")
- }
- o2, ok := f2.Options.(*schema.TextOptions)
- if !ok {
- t.Fatal("'test2' field options are not inited.")
- }
- if o2.Pattern != "test" {
- t.Fatalf("Expected pattern to be %q, got %q", "test", o2.Pattern)
- }
-}
-
-func TestSchemaValue(t *testing.T) {
- // empty schema
- s1 := schema.Schema{}
- v1, err := s1.Value()
- if err != nil {
- t.Fatal(err)
- }
- if v1 != "[]" {
- t.Fatalf("Expected nil, got %v", v1)
- }
-
- // schema with fields
- f1 := &schema.SchemaField{Id: "f1id", Name: "test1", Type: schema.FieldTypeText}
- s2 := schema.NewSchema(f1)
-
- v2, err := s2.Value()
- if err != nil {
- t.Fatal(err)
- }
- expected := `[{"system":false,"id":"f1id","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`
-
- if v2 != expected {
- t.Fatalf("Expected %v, got %v", expected, v2)
- }
-}
-
-func TestSchemaScan(t *testing.T) {
- scenarios := []struct {
- data any
- expectError bool
- expectJson string
- }{
- {nil, false, "[]"},
- {"", false, "[]"},
- {[]byte{}, false, "[]"},
- {"[]", false, "[]"},
- {"invalid", true, "[]"},
- {123, true, "[]"},
- // no field type
- {`[{}]`, true, `[]`},
- // unknown field type
- {
- `[{"system":false,"id":"123","name":"test1","type":"unknown","required":false,"presentable":false,"unique":false}]`,
- true,
- `[]`,
- },
- // without options
- {
- `[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false}]`,
- false,
- `[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
- },
- // with options
- {
- `[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
- false,
- `[{"system":false,"id":"123","name":"test1","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":"test"}}]`,
- },
- }
-
- for i, s := range scenarios {
- testSchema := schema.Schema{}
-
- err := testSchema.Scan(s.data)
-
- hasErr := err != nil
- if hasErr != s.expectError {
- t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, err)
- continue
- }
-
- json, _ := testSchema.MarshalJSON()
- if string(json) != s.expectJson {
- t.Errorf("(%d) Expected json %v, got %v", i, s.expectJson, string(json))
- }
- }
-}
diff --git a/models/settings/settings.go b/models/settings/settings.go
deleted file mode 100644
index ca47d0a1b3..0000000000
--- a/models/settings/settings.go
+++ /dev/null
@@ -1,661 +0,0 @@
-package settings
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "sync"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/go-ozzo/ozzo-validation/v4/is"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/cron"
- "github.com/pocketbase/pocketbase/tools/mailer"
- "github.com/pocketbase/pocketbase/tools/rest"
- "github.com/pocketbase/pocketbase/tools/security"
-)
-
-// SecretMask is the default settings secrets replacement value
-// (see Settings.RedactClone()).
-const SecretMask string = "******"
-
-// Settings defines common app configuration options.
-type Settings struct {
- mux sync.RWMutex
-
- Meta MetaConfig `form:"meta" json:"meta"`
- Logs LogsConfig `form:"logs" json:"logs"`
- Smtp SmtpConfig `form:"smtp" json:"smtp"`
- S3 S3Config `form:"s3" json:"s3"`
- Backups BackupsConfig `form:"backups" json:"backups"`
-
- AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
- AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
- AdminFileToken TokenConfig `form:"adminFileToken" json:"adminFileToken"`
- RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
- RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
- RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
- RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
- RecordFileToken TokenConfig `form:"recordFileToken" json:"recordFileToken"`
-
- // Deprecated: Will be removed in v0.9+
- EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
-
- GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
- FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
- GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
- GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"`
- DiscordAuth AuthProviderConfig `form:"discordAuth" json:"discordAuth"`
- TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"`
- MicrosoftAuth AuthProviderConfig `form:"microsoftAuth" json:"microsoftAuth"`
- SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
- KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
- TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
- StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
- GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
- LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
- GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
- OIDCAuth AuthProviderConfig `form:"oidcAuth" json:"oidcAuth"`
- OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
- OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
- AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
- InstagramAuth AuthProviderConfig `form:"instagramAuth" json:"instagramAuth"`
- VKAuth AuthProviderConfig `form:"vkAuth" json:"vkAuth"`
- YandexAuth AuthProviderConfig `form:"yandexAuth" json:"yandexAuth"`
-}
-
-// New creates and returns a new default Settings instance.
-func New() *Settings {
- return &Settings{
- Meta: MetaConfig{
- AppName: "Acme",
- AppUrl: "http://localhost:8090",
- HideControls: false,
- SenderName: "Support",
- SenderAddress: "support@example.com",
- VerificationTemplate: defaultVerificationTemplate,
- ResetPasswordTemplate: defaultResetPasswordTemplate,
- ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
- },
- Logs: LogsConfig{
- MaxDays: 5,
- },
- Smtp: SmtpConfig{
- Enabled: false,
- Host: "smtp.example.com",
- Port: 587,
- Username: "",
- Password: "",
- Tls: false,
- },
- Backups: BackupsConfig{
- CronMaxKeep: 3,
- },
- AdminAuthToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 1209600, // 14 days
- },
- AdminPasswordResetToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 1800, // 30 minutes
- },
- AdminFileToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 120, // 2 minutes
- },
- RecordAuthToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 1209600, // 14 days
- },
- RecordPasswordResetToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 1800, // 30 minutes
- },
- RecordVerificationToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 604800, // 7 days
- },
- RecordFileToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 120, // 2 minutes
- },
- RecordEmailChangeToken: TokenConfig{
- Secret: security.RandomString(50),
- Duration: 1800, // 30 minutes
- },
- GoogleAuth: AuthProviderConfig{
- Enabled: false,
- },
- FacebookAuth: AuthProviderConfig{
- Enabled: false,
- },
- GithubAuth: AuthProviderConfig{
- Enabled: false,
- },
- GitlabAuth: AuthProviderConfig{
- Enabled: false,
- },
- DiscordAuth: AuthProviderConfig{
- Enabled: false,
- },
- TwitterAuth: AuthProviderConfig{
- Enabled: false,
- },
- MicrosoftAuth: AuthProviderConfig{
- Enabled: false,
- },
- SpotifyAuth: AuthProviderConfig{
- Enabled: false,
- },
- KakaoAuth: AuthProviderConfig{
- Enabled: false,
- },
- TwitchAuth: AuthProviderConfig{
- Enabled: false,
- },
- StravaAuth: AuthProviderConfig{
- Enabled: false,
- },
- GiteeAuth: AuthProviderConfig{
- Enabled: false,
- },
- LivechatAuth: AuthProviderConfig{
- Enabled: false,
- },
- GiteaAuth: AuthProviderConfig{
- Enabled: false,
- },
- OIDCAuth: AuthProviderConfig{
- Enabled: false,
- },
- OIDC2Auth: AuthProviderConfig{
- Enabled: false,
- },
- OIDC3Auth: AuthProviderConfig{
- Enabled: false,
- },
- AppleAuth: AuthProviderConfig{
- Enabled: false,
- },
- InstagramAuth: AuthProviderConfig{
- Enabled: false,
- },
- VKAuth: AuthProviderConfig{
- Enabled: false,
- },
- YandexAuth: AuthProviderConfig{
- Enabled: false,
- },
- }
-}
-
-// Validate makes Settings validatable by implementing [validation.Validatable] interface.
-func (s *Settings) Validate() error {
- s.mux.Lock()
- defer s.mux.Unlock()
-
- return validation.ValidateStruct(s,
- validation.Field(&s.Meta),
- validation.Field(&s.Logs),
- validation.Field(&s.AdminAuthToken),
- validation.Field(&s.AdminPasswordResetToken),
- validation.Field(&s.AdminFileToken),
- validation.Field(&s.RecordAuthToken),
- validation.Field(&s.RecordPasswordResetToken),
- validation.Field(&s.RecordEmailChangeToken),
- validation.Field(&s.RecordVerificationToken),
- validation.Field(&s.RecordFileToken),
- validation.Field(&s.Smtp),
- validation.Field(&s.S3),
- validation.Field(&s.Backups),
- validation.Field(&s.GoogleAuth),
- validation.Field(&s.FacebookAuth),
- validation.Field(&s.GithubAuth),
- validation.Field(&s.GitlabAuth),
- validation.Field(&s.DiscordAuth),
- validation.Field(&s.TwitterAuth),
- validation.Field(&s.MicrosoftAuth),
- validation.Field(&s.SpotifyAuth),
- validation.Field(&s.KakaoAuth),
- validation.Field(&s.TwitchAuth),
- validation.Field(&s.StravaAuth),
- validation.Field(&s.GiteeAuth),
- validation.Field(&s.LivechatAuth),
- validation.Field(&s.GiteaAuth),
- validation.Field(&s.OIDCAuth),
- validation.Field(&s.OIDC2Auth),
- validation.Field(&s.OIDC3Auth),
- validation.Field(&s.AppleAuth),
- validation.Field(&s.InstagramAuth),
- validation.Field(&s.VKAuth),
- validation.Field(&s.YandexAuth),
- )
-}
-
-// Merge merges `other` settings into the current one.
-func (s *Settings) Merge(other *Settings) error {
- s.mux.Lock()
- defer s.mux.Unlock()
-
- bytes, err := json.Marshal(other)
- if err != nil {
- return err
- }
-
- return json.Unmarshal(bytes, s)
-}
-
-// Clone creates a new deep copy of the current settings.
-func (s *Settings) Clone() (*Settings, error) {
- clone := &Settings{}
- if err := clone.Merge(s); err != nil {
- return nil, err
- }
- return clone, nil
-}
-
-// RedactClone creates a new deep copy of the current settings,
-// while replacing the secret values with `******`.
-func (s *Settings) RedactClone() (*Settings, error) {
- clone, err := s.Clone()
- if err != nil {
- return nil, err
- }
-
- sensitiveFields := []*string{
- &clone.Smtp.Password,
- &clone.S3.Secret,
- &clone.Backups.S3.Secret,
- &clone.AdminAuthToken.Secret,
- &clone.AdminPasswordResetToken.Secret,
- &clone.AdminFileToken.Secret,
- &clone.RecordAuthToken.Secret,
- &clone.RecordPasswordResetToken.Secret,
- &clone.RecordEmailChangeToken.Secret,
- &clone.RecordVerificationToken.Secret,
- &clone.RecordFileToken.Secret,
- &clone.GoogleAuth.ClientSecret,
- &clone.FacebookAuth.ClientSecret,
- &clone.GithubAuth.ClientSecret,
- &clone.GitlabAuth.ClientSecret,
- &clone.DiscordAuth.ClientSecret,
- &clone.TwitterAuth.ClientSecret,
- &clone.MicrosoftAuth.ClientSecret,
- &clone.SpotifyAuth.ClientSecret,
- &clone.KakaoAuth.ClientSecret,
- &clone.TwitchAuth.ClientSecret,
- &clone.StravaAuth.ClientSecret,
- &clone.GiteeAuth.ClientSecret,
- &clone.LivechatAuth.ClientSecret,
- &clone.GiteaAuth.ClientSecret,
- &clone.OIDCAuth.ClientSecret,
- &clone.OIDC2Auth.ClientSecret,
- &clone.OIDC3Auth.ClientSecret,
- &clone.AppleAuth.ClientSecret,
- &clone.InstagramAuth.ClientSecret,
- &clone.VKAuth.ClientSecret,
- &clone.YandexAuth.ClientSecret,
- }
-
- // mask all sensitive fields
- for _, v := range sensitiveFields {
- if v != nil && *v != "" {
- *v = SecretMask
- }
- }
-
- return clone, nil
-}
-
-// NamedAuthProviderConfigs returns a map with all registered OAuth2
-// provider configurations (indexed by their name identifier).
-func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
- s.mux.RLock()
- defer s.mux.RUnlock()
-
- return map[string]AuthProviderConfig{
- auth.NameGoogle: s.GoogleAuth,
- auth.NameFacebook: s.FacebookAuth,
- auth.NameGithub: s.GithubAuth,
- auth.NameGitlab: s.GitlabAuth,
- auth.NameDiscord: s.DiscordAuth,
- auth.NameTwitter: s.TwitterAuth,
- auth.NameMicrosoft: s.MicrosoftAuth,
- auth.NameSpotify: s.SpotifyAuth,
- auth.NameKakao: s.KakaoAuth,
- auth.NameTwitch: s.TwitchAuth,
- auth.NameStrava: s.StravaAuth,
- auth.NameGitee: s.GiteeAuth,
- auth.NameLivechat: s.LivechatAuth,
- auth.NameGitea: s.GiteaAuth,
- auth.NameOIDC: s.OIDCAuth,
- auth.NameOIDC + "2": s.OIDC2Auth,
- auth.NameOIDC + "3": s.OIDC3Auth,
- auth.NameApple: s.AppleAuth,
- auth.NameInstagram: s.InstagramAuth,
- auth.NameVK: s.VKAuth,
- auth.NameYandex: s.YandexAuth,
- }
-}
-
-// -------------------------------------------------------------------
-
-type TokenConfig struct {
- Secret string `form:"secret" json:"secret"`
- Duration int64 `form:"duration" json:"duration"`
-}
-
-// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface.
-func (c TokenConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.Secret, validation.Required, validation.Length(30, 300)),
- validation.Field(&c.Duration, validation.Required, validation.Min(5), validation.Max(63072000)),
- )
-}
-
-// -------------------------------------------------------------------
-
-type SmtpConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- Host string `form:"host" json:"host"`
- Port int `form:"port" json:"port"`
- Username string `form:"username" json:"username"`
- Password string `form:"password" json:"password"`
-
- // SMTP AUTH - PLAIN (default) or LOGIN
- AuthMethod string `form:"authMethod" json:"authMethod"`
-
- // Whether to enforce TLS encryption for the mail server connection.
- //
- // When set to false StartTLS command is send, leaving the server
- // to decide whether to upgrade the connection or not.
- Tls bool `form:"tls" json:"tls"`
-
- // LocalName is optional domain name or IP address used for the
- // EHLO/HELO exchange (if not explicitly set, defaults to "localhost").
- //
- // This is required only by some SMTP servers, such as Gmail SMTP-relay.
- LocalName string `form:"localName" json:"localName"`
-}
-
-// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
-func (c SmtpConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(
- &c.Host,
- validation.When(c.Enabled, validation.Required),
- is.Host,
- ),
- validation.Field(
- &c.Port,
- validation.When(c.Enabled, validation.Required),
- validation.Min(0),
- ),
- validation.Field(
- &c.AuthMethod,
- // don't require it for backward compatibility
- // (fallback internally to PLAIN)
- // validation.When(c.Enabled, validation.Required),
- validation.In(mailer.SmtpAuthLogin, mailer.SmtpAuthPlain),
- ),
- validation.Field(&c.LocalName, is.Host),
- )
-}
-
-// -------------------------------------------------------------------
-
-type S3Config struct {
- Enabled bool `form:"enabled" json:"enabled"`
- Bucket string `form:"bucket" json:"bucket"`
- Region string `form:"region" json:"region"`
- Endpoint string `form:"endpoint" json:"endpoint"`
- AccessKey string `form:"accessKey" json:"accessKey"`
- Secret string `form:"secret" json:"secret"`
- ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
-}
-
-// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
-func (c S3Config) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)),
- )
-}
-
-// -------------------------------------------------------------------
-
-type BackupsConfig struct {
- // Cron is a cron expression to schedule auto backups, eg. "* * * * *".
- //
- // Leave it empty to disable the auto backups functionality.
- Cron string `form:"cron" json:"cron"`
-
- // CronMaxKeep is the the max number of cron generated backups to
- // keep before removing older entries.
- //
- // This field works only when the cron config has valid cron expression.
- CronMaxKeep int `form:"cronMaxKeep" json:"cronMaxKeep"`
-
- // S3 is an optional S3 storage config specifying where to store the app backups.
- S3 S3Config `form:"s3" json:"s3"`
-}
-
-// Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface.
-func (c BackupsConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.S3),
- validation.Field(&c.Cron, validation.By(checkCronExpression)),
- validation.Field(
- &c.CronMaxKeep,
- validation.When(c.Cron != "", validation.Required),
- validation.Min(1),
- ),
- )
-}
-
-func checkCronExpression(value any) error {
- v, _ := value.(string)
- if v == "" {
- return nil // nothing to check
- }
-
- _, err := cron.NewSchedule(v)
- if err != nil {
- return validation.NewError("validation_invalid_cron", err.Error())
- }
-
- return nil
-}
-
-// -------------------------------------------------------------------
-
-type MetaConfig struct {
- AppName string `form:"appName" json:"appName"`
- AppUrl string `form:"appUrl" json:"appUrl"`
- HideControls bool `form:"hideControls" json:"hideControls"`
- SenderName string `form:"senderName" json:"senderName"`
- SenderAddress string `form:"senderAddress" json:"senderAddress"`
- VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"`
- ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"`
- ConfirmEmailChangeTemplate EmailTemplate `form:"confirmEmailChangeTemplate" json:"confirmEmailChangeTemplate"`
-}
-
-// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface.
-func (c MetaConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)),
- validation.Field(&c.AppUrl, validation.Required, is.URL),
- validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
- validation.Field(&c.SenderAddress, is.EmailFormat, validation.Required),
- validation.Field(&c.VerificationTemplate, validation.Required),
- validation.Field(&c.ResetPasswordTemplate, validation.Required),
- validation.Field(&c.ConfirmEmailChangeTemplate, validation.Required),
- )
-}
-
-type EmailTemplate struct {
- Body string `form:"body" json:"body"`
- Subject string `form:"subject" json:"subject"`
- ActionUrl string `form:"actionUrl" json:"actionUrl"`
-}
-
-// Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface.
-func (t EmailTemplate) Validate() error {
- return validation.ValidateStruct(&t,
- validation.Field(&t.Subject, validation.Required),
- validation.Field(
- &t.Body,
- validation.Required,
- validation.By(checkPlaceholderParams(EmailPlaceholderActionUrl)),
- ),
- validation.Field(
- &t.ActionUrl,
- validation.Required,
- validation.By(checkPlaceholderParams(EmailPlaceholderToken)),
- ),
- )
-}
-
-func checkPlaceholderParams(params ...string) validation.RuleFunc {
- return func(value any) error {
- v, _ := value.(string)
-
- for _, param := range params {
- if !strings.Contains(v, param) {
- return validation.NewError(
- "validation_missing_required_param",
- fmt.Sprintf("Missing required parameter %q", param),
- )
- }
- }
-
- return nil
- }
-}
-
-// Resolve replaces the placeholder parameters in the current email
-// template and returns its components as ready-to-use strings.
-func (t EmailTemplate) Resolve(
- appName string,
- appUrl,
- token string,
-) (subject, body, actionUrl string) {
- // replace action url placeholder params (if any)
- actionUrlParams := map[string]string{
- EmailPlaceholderAppName: appName,
- EmailPlaceholderAppUrl: appUrl,
- EmailPlaceholderToken: token,
- }
- actionUrl = t.ActionUrl
- for k, v := range actionUrlParams {
- actionUrl = strings.ReplaceAll(actionUrl, k, v)
- }
- actionUrl, _ = rest.NormalizeUrl(actionUrl)
-
- // replace body placeholder params (if any)
- bodyParams := map[string]string{
- EmailPlaceholderAppName: appName,
- EmailPlaceholderAppUrl: appUrl,
- EmailPlaceholderToken: token,
- EmailPlaceholderActionUrl: actionUrl,
- }
- body = t.Body
- for k, v := range bodyParams {
- body = strings.ReplaceAll(body, k, v)
- }
-
- // replace subject placeholder params (if any)
- subjectParams := map[string]string{
- EmailPlaceholderAppName: appName,
- EmailPlaceholderAppUrl: appUrl,
- }
- subject = t.Subject
- for k, v := range subjectParams {
- subject = strings.ReplaceAll(subject, k, v)
- }
-
- return subject, body, actionUrl
-}
-
-// -------------------------------------------------------------------
-
-type LogsConfig struct {
- MaxDays int `form:"maxDays" json:"maxDays"`
-}
-
-// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
-func (c LogsConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.MaxDays, validation.Min(0)),
- )
-}
-
-// -------------------------------------------------------------------
-
-type AuthProviderConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- ClientId string `form:"clientId" json:"clientId"`
- ClientSecret string `form:"clientSecret" json:"clientSecret"`
- AuthUrl string `form:"authUrl" json:"authUrl"`
- TokenUrl string `form:"tokenUrl" json:"tokenUrl"`
- UserApiUrl string `form:"userApiUrl" json:"userApiUrl"`
-}
-
-// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
-func (c AuthProviderConfig) Validate() error {
- return validation.ValidateStruct(&c,
- validation.Field(&c.ClientId, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.ClientSecret, validation.When(c.Enabled, validation.Required)),
- validation.Field(&c.AuthUrl, is.URL),
- validation.Field(&c.TokenUrl, is.URL),
- validation.Field(&c.UserApiUrl, is.URL),
- )
-}
-
-// SetupProvider loads the current AuthProviderConfig into the specified provider.
-func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
- if !c.Enabled {
- return errors.New("The provider is not enabled.")
- }
-
- if c.ClientId != "" {
- provider.SetClientId(c.ClientId)
- }
-
- if c.ClientSecret != "" {
- provider.SetClientSecret(c.ClientSecret)
- }
-
- if c.AuthUrl != "" {
- provider.SetAuthUrl(c.AuthUrl)
- }
-
- if c.UserApiUrl != "" {
- provider.SetUserApiUrl(c.UserApiUrl)
- }
-
- if c.TokenUrl != "" {
- provider.SetTokenUrl(c.TokenUrl)
- }
-
- return nil
-}
-
-// -------------------------------------------------------------------
-
-// Deprecated: Will be removed in v0.9+
-type EmailAuthConfig struct {
- Enabled bool `form:"enabled" json:"enabled"`
- ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
- OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
- MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
-}
-
-// Deprecated: Will be removed in v0.9+
-func (c EmailAuthConfig) Validate() error {
- return nil
-}
diff --git a/models/settings/settings_templates.go b/models/settings/settings_templates.go
deleted file mode 100644
index a0f913a38c..0000000000
--- a/models/settings/settings_templates.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package settings
-
-// Common settings placeholder tokens
-const (
- EmailPlaceholderAppName string = "{APP_NAME}"
- EmailPlaceholderAppUrl string = "{APP_URL}"
- EmailPlaceholderToken string = "{TOKEN}"
- EmailPlaceholderActionUrl string = "{ACTION_URL}"
-)
-
-var defaultVerificationTemplate = EmailTemplate{
- Subject: "Verify your " + EmailPlaceholderAppName + " email",
- Body: `Hello,
-Thank you for joining us at ` + EmailPlaceholderAppName + `.
-Click on the button below to verify your email address.
-
- Verify
-
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken,
-}
-
-var defaultResetPasswordTemplate = EmailTemplate{
- Subject: "Reset your " + EmailPlaceholderAppName + " password",
- Body: `Hello,
-Click on the button below to reset your password.
-
- Reset password
-
-If you didn't ask to reset your password, you can ignore this email.
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken,
-}
-
-var defaultConfirmEmailChangeTemplate = EmailTemplate{
- Subject: "Confirm your " + EmailPlaceholderAppName + " new email address",
- Body: `Hello,
-Click on the button below to confirm your new email address.
-
- Confirm new email
-
-If you didn't ask to change your email address, you can ignore this email.
-
- Thanks,
- ` + EmailPlaceholderAppName + ` team
-
`,
- ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken,
-}
diff --git a/models/settings/settings_test.go b/models/settings/settings_test.go
deleted file mode 100644
index 8ee9288178..0000000000
--- a/models/settings/settings_test.go
+++ /dev/null
@@ -1,989 +0,0 @@
-package settings_test
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "strings"
- "testing"
-
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/pocketbase/models/settings"
- "github.com/pocketbase/pocketbase/tools/auth"
- "github.com/pocketbase/pocketbase/tools/mailer"
-)
-
-func TestSettingsValidate(t *testing.T) {
- s := settings.New()
-
- // set invalid settings data
- s.Meta.AppName = ""
- s.Logs.MaxDays = -10
- s.Smtp.Enabled = true
- s.Smtp.Host = ""
- s.S3.Enabled = true
- s.S3.Endpoint = "invalid"
- s.AdminAuthToken.Duration = -10
- s.AdminPasswordResetToken.Duration = -10
- s.AdminFileToken.Duration = -10
- s.RecordAuthToken.Duration = -10
- s.RecordPasswordResetToken.Duration = -10
- s.RecordEmailChangeToken.Duration = -10
- s.RecordVerificationToken.Duration = -10
- s.RecordFileToken.Duration = -10
- s.GoogleAuth.Enabled = true
- s.GoogleAuth.ClientId = ""
- s.FacebookAuth.Enabled = true
- s.FacebookAuth.ClientId = ""
- s.GithubAuth.Enabled = true
- s.GithubAuth.ClientId = ""
- s.GitlabAuth.Enabled = true
- s.GitlabAuth.ClientId = ""
- s.DiscordAuth.Enabled = true
- s.DiscordAuth.ClientId = ""
- s.TwitterAuth.Enabled = true
- s.TwitterAuth.ClientId = ""
- s.MicrosoftAuth.Enabled = true
- s.MicrosoftAuth.ClientId = ""
- s.SpotifyAuth.Enabled = true
- s.SpotifyAuth.ClientId = ""
- s.KakaoAuth.Enabled = true
- s.KakaoAuth.ClientId = ""
- s.TwitchAuth.Enabled = true
- s.TwitchAuth.ClientId = ""
- s.StravaAuth.Enabled = true
- s.StravaAuth.ClientId = ""
- s.GiteeAuth.Enabled = true
- s.GiteeAuth.ClientId = ""
- s.LivechatAuth.Enabled = true
- s.LivechatAuth.ClientId = ""
- s.GiteaAuth.Enabled = true
- s.GiteaAuth.ClientId = ""
- s.OIDCAuth.Enabled = true
- s.OIDCAuth.ClientId = ""
- s.OIDC2Auth.Enabled = true
- s.OIDC2Auth.ClientId = ""
- s.OIDC3Auth.Enabled = true
- s.OIDC3Auth.ClientId = ""
- s.AppleAuth.Enabled = true
- s.AppleAuth.ClientId = ""
- s.InstagramAuth.Enabled = true
- s.InstagramAuth.ClientId = ""
- s.VKAuth.Enabled = true
- s.VKAuth.ClientId = ""
- s.YandexAuth.Enabled = true
- s.YandexAuth.ClientId = ""
-
- // check if Validate() is triggering the members validate methods.
- err := s.Validate()
- if err == nil {
- t.Fatalf("Expected error, got nil")
- }
-
- expectations := []string{
- `"meta":{`,
- `"logs":{`,
- `"smtp":{`,
- `"s3":{`,
- `"adminAuthToken":{`,
- `"adminPasswordResetToken":{`,
- `"adminFileToken":{`,
- `"recordAuthToken":{`,
- `"recordPasswordResetToken":{`,
- `"recordEmailChangeToken":{`,
- `"recordVerificationToken":{`,
- `"recordFileToken":{`,
- `"googleAuth":{`,
- `"facebookAuth":{`,
- `"githubAuth":{`,
- `"gitlabAuth":{`,
- `"discordAuth":{`,
- `"twitterAuth":{`,
- `"microsoftAuth":{`,
- `"spotifyAuth":{`,
- `"kakaoAuth":{`,
- `"twitchAuth":{`,
- `"stravaAuth":{`,
- `"giteeAuth":{`,
- `"livechatAuth":{`,
- `"giteaAuth":{`,
- `"oidcAuth":{`,
- `"oidc2Auth":{`,
- `"oidc3Auth":{`,
- `"appleAuth":{`,
- `"instagramAuth":{`,
- `"vkAuth":{`,
- `"yandexAuth":{`,
- }
-
- errBytes, _ := json.Marshal(err)
- jsonErr := string(errBytes)
- for _, expected := range expectations {
- if !strings.Contains(jsonErr, expected) {
- t.Errorf("Expected error key %s in %v", expected, jsonErr)
- }
- }
-}
-
-func TestSettingsMerge(t *testing.T) {
- s1 := settings.New()
- s1.Meta.AppUrl = "old_app_url"
-
- s2 := settings.New()
- s2.Meta.AppName = "test"
- s2.Logs.MaxDays = 123
- s2.Smtp.Host = "test"
- s2.Smtp.Enabled = true
- s2.S3.Enabled = true
- s2.S3.Endpoint = "test"
- s2.Backups.Cron = "* * * * *"
- s2.AdminAuthToken.Duration = 1
- s2.AdminPasswordResetToken.Duration = 2
- s2.AdminFileToken.Duration = 2
- s2.RecordAuthToken.Duration = 3
- s2.RecordPasswordResetToken.Duration = 4
- s2.RecordEmailChangeToken.Duration = 5
- s2.RecordVerificationToken.Duration = 6
- s2.RecordFileToken.Duration = 7
- s2.GoogleAuth.Enabled = true
- s2.GoogleAuth.ClientId = "google_test"
- s2.FacebookAuth.Enabled = true
- s2.FacebookAuth.ClientId = "facebook_test"
- s2.GithubAuth.Enabled = true
- s2.GithubAuth.ClientId = "github_test"
- s2.GitlabAuth.Enabled = true
- s2.GitlabAuth.ClientId = "gitlab_test"
- s2.DiscordAuth.Enabled = true
- s2.DiscordAuth.ClientId = "discord_test"
- s2.TwitterAuth.Enabled = true
- s2.TwitterAuth.ClientId = "twitter_test"
- s2.MicrosoftAuth.Enabled = true
- s2.MicrosoftAuth.ClientId = "microsoft_test"
- s2.SpotifyAuth.Enabled = true
- s2.SpotifyAuth.ClientId = "spotify_test"
- s2.KakaoAuth.Enabled = true
- s2.KakaoAuth.ClientId = "kakao_test"
- s2.TwitchAuth.Enabled = true
- s2.TwitchAuth.ClientId = "twitch_test"
- s2.StravaAuth.Enabled = true
- s2.StravaAuth.ClientId = "strava_test"
- s2.GiteeAuth.Enabled = true
- s2.GiteeAuth.ClientId = "gitee_test"
- s2.LivechatAuth.Enabled = true
- s2.LivechatAuth.ClientId = "livechat_test"
- s2.GiteaAuth.Enabled = true
- s2.GiteaAuth.ClientId = "gitea_test"
- s2.OIDCAuth.Enabled = true
- s2.OIDCAuth.ClientId = "oidc_test"
- s2.OIDC2Auth.Enabled = true
- s2.OIDC2Auth.ClientId = "oidc2_test"
- s2.OIDC3Auth.Enabled = true
- s2.OIDC3Auth.ClientId = "oidc3_test"
- s2.AppleAuth.Enabled = true
- s2.AppleAuth.ClientId = "apple_test"
- s2.InstagramAuth.Enabled = true
- s2.InstagramAuth.ClientId = "instagram_test"
- s2.VKAuth.Enabled = true
- s2.VKAuth.ClientId = "vk_test"
- s2.YandexAuth.Enabled = true
- s2.YandexAuth.ClientId = "yandex_test"
-
- if err := s1.Merge(s2); err != nil {
- t.Fatal(err)
- }
-
- s1Encoded, err := json.Marshal(s1)
- if err != nil {
- t.Fatal(err)
- }
-
- s2Encoded, err := json.Marshal(s2)
- if err != nil {
- t.Fatal(err)
- }
-
- if string(s1Encoded) != string(s2Encoded) {
- t.Fatalf("Expected the same serialization, got %v VS %v", string(s1Encoded), string(s2Encoded))
- }
-}
-
-func TestSettingsClone(t *testing.T) {
- s1 := settings.New()
-
- s2, err := s1.Clone()
- if err != nil {
- t.Fatal(err)
- }
-
- s1Bytes, err := json.Marshal(s1)
- if err != nil {
- t.Fatal(err)
- }
-
- s2Bytes, err := json.Marshal(s2)
- if err != nil {
- t.Fatal(err)
- }
-
- if string(s1Bytes) != string(s2Bytes) {
- t.Fatalf("Expected equivalent serialization, got %v VS %v", string(s1Bytes), string(s2Bytes))
- }
-
- // verify that it is a deep copy
- s1.Meta.AppName = "new"
- if s1.Meta.AppName == s2.Meta.AppName {
- t.Fatalf("Expected s1 and s2 to have different Meta.AppName, got %s", s1.Meta.AppName)
- }
-}
-
-func TestSettingsRedactClone(t *testing.T) {
- testSecret := "test_secret"
-
- s1 := settings.New()
-
- // control fields
- s1.Meta.AppName = "test123"
-
- // secrets
- s1.Smtp.Password = testSecret
- s1.S3.Secret = testSecret
- s1.Backups.S3.Secret = testSecret
- s1.AdminAuthToken.Secret = testSecret
- s1.AdminPasswordResetToken.Secret = testSecret
- s1.AdminFileToken.Secret = testSecret
- s1.RecordAuthToken.Secret = testSecret
- s1.RecordPasswordResetToken.Secret = testSecret
- s1.RecordEmailChangeToken.Secret = testSecret
- s1.RecordVerificationToken.Secret = testSecret
- s1.RecordFileToken.Secret = testSecret
- s1.GoogleAuth.ClientSecret = testSecret
- s1.FacebookAuth.ClientSecret = testSecret
- s1.GithubAuth.ClientSecret = testSecret
- s1.GitlabAuth.ClientSecret = testSecret
- s1.DiscordAuth.ClientSecret = testSecret
- s1.TwitterAuth.ClientSecret = testSecret
- s1.MicrosoftAuth.ClientSecret = testSecret
- s1.SpotifyAuth.ClientSecret = testSecret
- s1.KakaoAuth.ClientSecret = testSecret
- s1.TwitchAuth.ClientSecret = testSecret
- s1.StravaAuth.ClientSecret = testSecret
- s1.GiteeAuth.ClientSecret = testSecret
- s1.LivechatAuth.ClientSecret = testSecret
- s1.GiteaAuth.ClientSecret = testSecret
- s1.OIDCAuth.ClientSecret = testSecret
- s1.OIDC2Auth.ClientSecret = testSecret
- s1.OIDC3Auth.ClientSecret = testSecret
- s1.AppleAuth.ClientSecret = testSecret
- s1.InstagramAuth.ClientSecret = testSecret
- s1.VKAuth.ClientSecret = testSecret
- s1.YandexAuth.ClientSecret = testSecret
-
- s1Bytes, err := json.Marshal(s1)
- if err != nil {
- t.Fatal(err)
- }
-
- s2, err := s1.RedactClone()
- if err != nil {
- t.Fatal(err)
- }
-
- s2Bytes, err := json.Marshal(s2)
- if err != nil {
- t.Fatal(err)
- }
-
- if bytes.Equal(s1Bytes, s2Bytes) {
- t.Fatalf("Expected the 2 settings to differ, got \n%s", s2Bytes)
- }
-
- if strings.Contains(string(s2Bytes), testSecret) {
- t.Fatalf("Expected %q secret to be replaced with mask, got \n%s", testSecret, s2Bytes)
- }
-
- if !strings.Contains(string(s2Bytes), settings.SecretMask) {
- t.Fatalf("Expected the secrets to be replaced with the secret mask, got \n%s", s2Bytes)
- }
-
- if !strings.Contains(string(s2Bytes), `"appName":"test123"`) {
- t.Fatalf("Missing control field in \n%s", s2Bytes)
- }
-}
-
-func TestNamedAuthProviderConfigs(t *testing.T) {
- s := settings.New()
-
- s.GoogleAuth.ClientId = "google_test"
- s.FacebookAuth.ClientId = "facebook_test"
- s.GithubAuth.ClientId = "github_test"
- s.GitlabAuth.ClientId = "gitlab_test"
- s.GitlabAuth.Enabled = true // control
- s.DiscordAuth.ClientId = "discord_test"
- s.TwitterAuth.ClientId = "twitter_test"
- s.MicrosoftAuth.ClientId = "microsoft_test"
- s.SpotifyAuth.ClientId = "spotify_test"
- s.KakaoAuth.ClientId = "kakao_test"
- s.TwitchAuth.ClientId = "twitch_test"
- s.StravaAuth.ClientId = "strava_test"
- s.GiteeAuth.ClientId = "gitee_test"
- s.LivechatAuth.ClientId = "livechat_test"
- s.GiteaAuth.ClientId = "gitea_test"
- s.OIDCAuth.ClientId = "oidc_test"
- s.OIDC2Auth.ClientId = "oidc2_test"
- s.OIDC3Auth.ClientId = "oidc3_test"
- s.AppleAuth.ClientId = "apple_test"
- s.InstagramAuth.ClientId = "instagram_test"
- s.VKAuth.ClientId = "vk_test"
- s.YandexAuth.ClientId = "yandex_test"
-
- result := s.NamedAuthProviderConfigs()
-
- encoded, err := json.Marshal(result)
- if err != nil {
- t.Fatal(err)
- }
- encodedStr := string(encoded)
-
- expectedParts := []string{
- `"discord":{"enabled":false,"clientId":"discord_test"`,
- `"facebook":{"enabled":false,"clientId":"facebook_test"`,
- `"github":{"enabled":false,"clientId":"github_test"`,
- `"gitlab":{"enabled":true,"clientId":"gitlab_test"`,
- `"google":{"enabled":false,"clientId":"google_test"`,
- `"microsoft":{"enabled":false,"clientId":"microsoft_test"`,
- `"spotify":{"enabled":false,"clientId":"spotify_test"`,
- `"twitter":{"enabled":false,"clientId":"twitter_test"`,
- `"kakao":{"enabled":false,"clientId":"kakao_test"`,
- `"twitch":{"enabled":false,"clientId":"twitch_test"`,
- `"strava":{"enabled":false,"clientId":"strava_test"`,
- `"gitee":{"enabled":false,"clientId":"gitee_test"`,
- `"livechat":{"enabled":false,"clientId":"livechat_test"`,
- `"gitea":{"enabled":false,"clientId":"gitea_test"`,
- `"oidc":{"enabled":false,"clientId":"oidc_test"`,
- `"oidc2":{"enabled":false,"clientId":"oidc2_test"`,
- `"oidc3":{"enabled":false,"clientId":"oidc3_test"`,
- `"apple":{"enabled":false,"clientId":"apple_test"`,
- `"instagram":{"enabled":false,"clientId":"instagram_test"`,
- `"vk":{"enabled":false,"clientId":"vk_test"`,
- `"yandex":{"enabled":false,"clientId":"yandex_test"`,
- }
- for _, p := range expectedParts {
- if !strings.Contains(encodedStr, p) {
- t.Fatalf("Expected \n%s \nin \n%s", p, encodedStr)
- }
- }
-}
-
-func TestTokenConfigValidate(t *testing.T) {
- scenarios := []struct {
- config settings.TokenConfig
- expectError bool
- }{
- // zero values
- {
- settings.TokenConfig{},
- true,
- },
- // invalid data
- {
- settings.TokenConfig{
- Secret: strings.Repeat("a", 5),
- Duration: 4,
- },
- true,
- },
- // valid secret but invalid duration
- {
- settings.TokenConfig{
- Secret: strings.Repeat("a", 30),
- Duration: 63072000 + 1,
- },
- true,
- },
- // valid data
- {
- settings.TokenConfig{
- Secret: strings.Repeat("a", 30),
- Duration: 100,
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestSmtpConfigValidate(t *testing.T) {
- scenarios := []struct {
- config settings.SmtpConfig
- expectError bool
- }{
- // zero values (disabled)
- {
- settings.SmtpConfig{},
- false,
- },
- // zero values (enabled)
- {
- settings.SmtpConfig{Enabled: true},
- true,
- },
- // invalid data
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "test:test:test",
- Port: -10,
- },
- true,
- },
- // invalid auth method
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "example.com",
- Port: 100,
- AuthMethod: "example",
- },
- true,
- },
- // valid data (no explicit auth method)
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "example.com",
- Port: 100,
- Tls: true,
- },
- false,
- },
- // valid data (explicit auth method - login)
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "example.com",
- Port: 100,
- AuthMethod: mailer.SmtpAuthLogin,
- },
- false,
- },
- // invalid ehlo/helo name
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "example.com",
- Port: 100,
- LocalName: "invalid!",
- },
- true,
- },
- // valid ehlo/helo name
- {
- settings.SmtpConfig{
- Enabled: true,
- Host: "example.com",
- Port: 100,
- LocalName: "example.com",
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestS3ConfigValidate(t *testing.T) {
- scenarios := []struct {
- config settings.S3Config
- expectError bool
- }{
- // zero values (disabled)
- {
- settings.S3Config{},
- false,
- },
- // zero values (enabled)
- {
- settings.S3Config{Enabled: true},
- true,
- },
- // invalid data
- {
- settings.S3Config{
- Enabled: true,
- Endpoint: "test:test:test",
- },
- true,
- },
- // valid data (url endpoint)
- {
- settings.S3Config{
- Enabled: true,
- Endpoint: "https://localhost:8090",
- Bucket: "test",
- Region: "test",
- AccessKey: "test",
- Secret: "test",
- },
- false,
- },
- // valid data (hostname endpoint)
- {
- settings.S3Config{
- Enabled: true,
- Endpoint: "example.com",
- Bucket: "test",
- Region: "test",
- AccessKey: "test",
- Secret: "test",
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestMetaConfigValidate(t *testing.T) {
- invalidTemplate := settings.EmailTemplate{
- Subject: "test",
- ActionUrl: "test",
- Body: "test",
- }
-
- noPlaceholdersTemplate := settings.EmailTemplate{
- Subject: "test",
- ActionUrl: "http://example.com",
- Body: "test",
- }
-
- withPlaceholdersTemplate := settings.EmailTemplate{
- Subject: "test",
- ActionUrl: "http://example.com" + settings.EmailPlaceholderToken,
- Body: "test" + settings.EmailPlaceholderActionUrl,
- }
-
- scenarios := []struct {
- config settings.MetaConfig
- expectError bool
- }{
- // zero values
- {
- settings.MetaConfig{},
- true,
- },
- // invalid data
- {
- settings.MetaConfig{
- AppName: strings.Repeat("a", 300),
- AppUrl: "test",
- SenderName: strings.Repeat("a", 300),
- SenderAddress: "invalid_email",
- VerificationTemplate: invalidTemplate,
- ResetPasswordTemplate: invalidTemplate,
- ConfirmEmailChangeTemplate: invalidTemplate,
- },
- true,
- },
- // invalid data (missing required placeholders)
- {
- settings.MetaConfig{
- AppName: "test",
- AppUrl: "https://example.com",
- SenderName: "test",
- SenderAddress: "test@example.com",
- VerificationTemplate: noPlaceholdersTemplate,
- ResetPasswordTemplate: noPlaceholdersTemplate,
- ConfirmEmailChangeTemplate: noPlaceholdersTemplate,
- },
- true,
- },
- // valid data
- {
- settings.MetaConfig{
- AppName: "test",
- AppUrl: "https://example.com",
- SenderName: "test",
- SenderAddress: "test@example.com",
- VerificationTemplate: withPlaceholdersTemplate,
- ResetPasswordTemplate: withPlaceholdersTemplate,
- ConfirmEmailChangeTemplate: withPlaceholdersTemplate,
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestBackupsConfigValidate(t *testing.T) {
- scenarios := []struct {
- name string
- config settings.BackupsConfig
- expectedErrors []string
- }{
- {
- "zero value",
- settings.BackupsConfig{},
- []string{},
- },
- {
- "invalid cron",
- settings.BackupsConfig{
- Cron: "invalid",
- CronMaxKeep: 0,
- },
- []string{"cron", "cronMaxKeep"},
- },
- {
- "invalid enabled S3",
- settings.BackupsConfig{
- S3: settings.S3Config{
- Enabled: true,
- },
- },
- []string{"s3"},
- },
- {
- "valid data",
- settings.BackupsConfig{
- S3: settings.S3Config{
- Enabled: true,
- Endpoint: "example.com",
- Bucket: "test",
- Region: "test",
- AccessKey: "test",
- Secret: "test",
- },
- Cron: "*/10 * * * *",
- CronMaxKeep: 1,
- },
- []string{},
- },
- }
-
- for _, s := range scenarios {
- result := s.config.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("[%s] Failed to parse errors %v", s.name, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
- }
- }
- }
-}
-
-func TestEmailTemplateValidate(t *testing.T) {
- scenarios := []struct {
- emailTemplate settings.EmailTemplate
- expectedErrors []string
- }{
- // require values
- {
- settings.EmailTemplate{},
- []string{"subject", "actionUrl", "body"},
- },
- // missing placeholders
- {
- settings.EmailTemplate{
- Subject: "test",
- ActionUrl: "test",
- Body: "test",
- },
- []string{"actionUrl", "body"},
- },
- // valid data
- {
- settings.EmailTemplate{
- Subject: "test",
- ActionUrl: "test" + settings.EmailPlaceholderToken,
- Body: "test" + settings.EmailPlaceholderActionUrl,
- },
- []string{},
- },
- }
-
- for i, s := range scenarios {
- result := s.emailTemplate.Validate()
-
- // parse errors
- errs, ok := result.(validation.Errors)
- if !ok && result != nil {
- t.Errorf("(%d) Failed to parse errors %v", i, result)
- continue
- }
-
- // check errors
- if len(errs) > len(s.expectedErrors) {
- t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
- }
- for _, k := range s.expectedErrors {
- if _, ok := errs[k]; !ok {
- t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
- }
- }
- }
-}
-
-func TestEmailTemplateResolve(t *testing.T) {
- allPlaceholders := settings.EmailPlaceholderActionUrl + settings.EmailPlaceholderToken + settings.EmailPlaceholderAppName + settings.EmailPlaceholderAppUrl
-
- scenarios := []struct {
- emailTemplate settings.EmailTemplate
- expectedSubject string
- expectedBody string
- expectedActionUrl string
- }{
- // no placeholders
- {
- emailTemplate: settings.EmailTemplate{
- Subject: "subject:",
- Body: "body:",
- ActionUrl: "/actionUrl////",
- },
- expectedSubject: "subject:",
- expectedActionUrl: "/actionUrl/",
- expectedBody: "body:",
- },
- // with placeholders
- {
- emailTemplate: settings.EmailTemplate{
- ActionUrl: "/actionUrl////" + allPlaceholders,
- Subject: "subject:" + allPlaceholders,
- Body: "body:" + allPlaceholders,
- },
- expectedActionUrl: fmt.Sprintf(
- "/actionUrl/%%7BACTION_URL%%7D%s%s%s",
- "token_test",
- "name_test",
- "url_test",
- ),
- expectedSubject: fmt.Sprintf(
- "subject:%s%s%s%s",
- settings.EmailPlaceholderActionUrl,
- settings.EmailPlaceholderToken,
- "name_test",
- "url_test",
- ),
- expectedBody: fmt.Sprintf(
- "body:%s%s%s%s",
- fmt.Sprintf(
- "/actionUrl/%%7BACTION_URL%%7D%s%s%s",
- "token_test",
- "name_test",
- "url_test",
- ),
- "token_test",
- "name_test",
- "url_test",
- ),
- },
- }
-
- for i, s := range scenarios {
- subject, body, actionUrl := s.emailTemplate.Resolve("name_test", "url_test", "token_test")
-
- if s.expectedSubject != subject {
- t.Errorf("(%d) Expected subject %q got %q", i, s.expectedSubject, subject)
- }
-
- if s.expectedBody != body {
- t.Errorf("(%d) Expected body \n%v got \n%v", i, s.expectedBody, body)
- }
-
- if s.expectedActionUrl != actionUrl {
- t.Errorf("(%d) Expected actionUrl \n%v got \n%v", i, s.expectedActionUrl, actionUrl)
- }
- }
-}
-
-func TestLogsConfigValidate(t *testing.T) {
- scenarios := []struct {
- config settings.LogsConfig
- expectError bool
- }{
- // zero values
- {
- settings.LogsConfig{},
- false,
- },
- // invalid data
- {
- settings.LogsConfig{MaxDays: -10},
- true,
- },
- // valid data
- {
- settings.LogsConfig{MaxDays: 1},
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestAuthProviderConfigValidate(t *testing.T) {
- scenarios := []struct {
- config settings.AuthProviderConfig
- expectError bool
- }{
- // zero values (disabled)
- {
- settings.AuthProviderConfig{},
- false,
- },
- // zero values (enabled)
- {
- settings.AuthProviderConfig{Enabled: true},
- true,
- },
- // invalid data
- {
- settings.AuthProviderConfig{
- Enabled: true,
- ClientId: "",
- ClientSecret: "",
- AuthUrl: "test",
- TokenUrl: "test",
- UserApiUrl: "test",
- },
- true,
- },
- // valid data (only the required)
- {
- settings.AuthProviderConfig{
- Enabled: true,
- ClientId: "test",
- ClientSecret: "test",
- },
- false,
- },
- // valid data (fill all fields)
- {
- settings.AuthProviderConfig{
- Enabled: true,
- ClientId: "test",
- ClientSecret: "test",
- AuthUrl: "https://example.com",
- TokenUrl: "https://example.com",
- UserApiUrl: "https://example.com",
- },
- false,
- },
- }
-
- for i, scenario := range scenarios {
- result := scenario.config.Validate()
-
- if result != nil && !scenario.expectError {
- t.Errorf("(%d) Didn't expect error, got %v", i, result)
- }
-
- if result == nil && scenario.expectError {
- t.Errorf("(%d) Expected error, got nil", i)
- }
- }
-}
-
-func TestAuthProviderConfigSetupProvider(t *testing.T) {
- provider := auth.NewGithubProvider()
-
- // disabled config
- c1 := settings.AuthProviderConfig{Enabled: false}
- if err := c1.SetupProvider(provider); err == nil {
- t.Errorf("Expected error, got nil")
- }
-
- c2 := settings.AuthProviderConfig{
- Enabled: true,
- ClientId: "test_ClientId",
- ClientSecret: "test_ClientSecret",
- AuthUrl: "test_AuthUrl",
- UserApiUrl: "test_UserApiUrl",
- TokenUrl: "test_TokenUrl",
- }
- if err := c2.SetupProvider(provider); err != nil {
- t.Error(err)
- }
-
- if provider.ClientId() != c2.ClientId {
- t.Fatalf("Expected ClientId %s, got %s", c2.ClientId, provider.ClientId())
- }
-
- if provider.ClientSecret() != c2.ClientSecret {
- t.Fatalf("Expected ClientSecret %s, got %s", c2.ClientSecret, provider.ClientSecret())
- }
-
- if provider.AuthUrl() != c2.AuthUrl {
- t.Fatalf("Expected AuthUrl %s, got %s", c2.AuthUrl, provider.AuthUrl())
- }
-
- if provider.UserApiUrl() != c2.UserApiUrl {
- t.Fatalf("Expected UserApiUrl %s, got %s", c2.UserApiUrl, provider.UserApiUrl())
- }
-
- if provider.TokenUrl() != c2.TokenUrl {
- t.Fatalf("Expected TokenUrl %s, got %s", c2.TokenUrl, provider.TokenUrl())
- }
-}
diff --git a/models/table_info.go b/models/table_info.go
deleted file mode 100644
index ef62bf887b..0000000000
--- a/models/table_info.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package models
-
-import "github.com/pocketbase/pocketbase/tools/types"
-
-type TableInfoRow struct {
- // the `db:"pk"` tag has special semantic so we cannot rename
- // the original field without specifying a custom mapper
- PK int
-
- Index int `db:"cid"`
- Name string `db:"name"`
- Type string `db:"type"`
- NotNull bool `db:"notnull"`
- DefaultValue types.JsonRaw `db:"dflt_value"`
-}
diff --git a/modernc_versions_check.go b/modernc_versions_check.go
new file mode 100644
index 0000000000..bd807650d9
--- /dev/null
+++ b/modernc_versions_check.go
@@ -0,0 +1,83 @@
+package pocketbase
+
+import (
+ "fmt"
+ "log/slog"
+ "runtime/debug"
+
+ "github.com/fatih/color"
+ "github.com/pocketbase/pocketbase/core"
+)
+
+const (
+ expectedDriverVersion = "v1.60.1"
+ expectedLibcVersion = "v1.77.1"
+
+ // ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
+ // It could be used for removing/unbinding the hook if you don't want the checks.
+ ModerncDepsCheckHookId = "pbModerncDepsCheck"
+)
+
+// checkModerncDeps checks whether the current binary was built with the
+// expected and tested modernc driver dependencies.
+//
+// This is needed because modernc.org/libc doesn't follow semantic versioning
+// and using a version different from the one in the go.mod of modernc.org/sqlite
+// could have unintended side-effects and cause obscure build and runtime bugs
+// (https://github.com/pocketbase/pocketbase/issues/6136).
+func checkModerncDeps(app core.App) {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return // no build info (probably compiled without module support)
+ }
+
+ var driverVersion, libcVersion string
+
+ for _, dep := range info.Deps {
+ switch dep.Path {
+ case "modernc.org/libc":
+ libcVersion = dep.Version
+ case "modernc.org/sqlite":
+ driverVersion = dep.Version
+ }
+
+ // no need to further search if both deps are located
+ if driverVersion != "" && libcVersion != "" {
+ break
+ }
+ }
+
+ // not using the default driver
+ if driverVersion == "" {
+ return
+ }
+
+ var msg string
+ if driverVersion != expectedDriverVersion {
+ msg = fmt.Sprintf(
+ "You are using modernc.org/sqlite %s which differs from the expected and tested %s.\n"+
+ "Make sure to either manually update in your go.mod the dependency version to the expected one OR if you want to keep yours "+
+ "ensure that its indirect modernc.org/libc dependency has the same version as in the https://gitlab.com/cznic/sqlite/-/blob/master/go.mod, "+
+ "otherwise it could result in unexpected build or runtime errors.",
+ driverVersion,
+ expectedDriverVersion,
+ )
+ app.Logger().Warn(msg, slog.String("current", driverVersion), slog.String("expected", expectedDriverVersion))
+ } else if libcVersion != expectedLibcVersion {
+ msg = fmt.Sprintf(
+ "You are using modernc.org/libc %s which differs from the expected and tested %s.\n"+
+ "Please update your go.mod and manually set modernc.org/libc to %s, otherwise it could result in unexpected build or runtime errors "+
+ "(you may have to also run 'go clean -modcache' to clear the cache if the warning persists).",
+ libcVersion,
+ expectedLibcVersion,
+ expectedLibcVersion,
+ )
+ app.Logger().Warn(msg, slog.String("current", libcVersion), slog.String("expected", expectedLibcVersion))
+ }
+
+ // ensure that the message is printed to the default stderr too
+ // (when in dev mode this is not needed because we print all logs)
+ if msg != "" && !app.IsDev() {
+ color.Yellow("\nWARN " + msg + "\n\n")
+ }
+}
diff --git a/plugins/ghupdate/ghupdate.go b/plugins/ghupdate/ghupdate.go
index e2db880920..340af1ac42 100644
--- a/plugins/ghupdate/ghupdate.go
+++ b/plugins/ghupdate/ghupdate.go
@@ -8,11 +8,11 @@ package ghupdate
import (
"context"
- "encoding/json"
+ "encoding/json/v2"
"errors"
"fmt"
"io"
- "log"
+ "log/slog"
"net/http"
"os"
"path/filepath"
@@ -20,10 +20,10 @@ import (
"strconv"
"strings"
- "github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/archive"
+ "github.com/pocketbase/pocketbase/tools/osutils"
"github.com/spf13/cobra"
)
@@ -46,6 +46,12 @@ type Config struct {
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
ArchiveExecutable string
+ // BaseURL is the base URL of the GitHub API (or similar compatible)
+ // used to fetch the latest releases information.
+ //
+ // Defaults to "https://api.github.com".
+ BaseURL string
+
// Optional context to use when fetching and downloading the latest release.
Context context.Context
@@ -82,6 +88,12 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
p.config.ArchiveExecutable = "pocketbase"
}
+ if p.config.BaseURL == "" {
+ p.config.BaseURL = "https://api.github.com"
+ } else {
+ p.config.BaseURL = strings.TrimRight(p.config.BaseURL, "/")
+ }
+
if p.config.HttpClient == nil {
p.config.HttpClient = http.DefaultClient
}
@@ -97,20 +109,17 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error {
type plugin struct {
app core.App
- currentVersion string
config Config
+ currentVersion string
}
func (p *plugin) updateCmd() *cobra.Command {
var withBackup bool
command := &cobra.Command{
- Use: "update",
- Short: "Automatically updates the current PocketBase executable with the latest available version",
- // @todo remove after logs generalization
- // prevents printing the error log twice
- SilenceErrors: true,
- SilenceUsage: true,
+ Use: "update",
+ Short: "Automatically updates the current app executable with the latest available version",
+ SilenceUsage: true,
RunE: func(command *cobra.Command, args []string) error {
var needConfirm bool
if isMaybeRunningInDocker() {
@@ -124,11 +133,7 @@ func (p *plugin) updateCmd() *cobra.Command {
}
if needConfirm {
- confirm := false
- prompt := &survey.Confirm{
- Message: "Do you want to proceed with the update?",
- }
- survey.AskOne(prompt, &confirm)
+ confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false)
if !confirm {
fmt.Println("The command has been cancelled.")
return nil
@@ -152,18 +157,15 @@ func (p *plugin) updateCmd() *cobra.Command {
func (p *plugin) update(withBackup bool) error {
color.Yellow("Fetching release information...")
- latest, err := fetchLatestRelease(
- p.config.Context,
- p.config.HttpClient,
- p.config.Owner,
- p.config.Repo,
- )
+ url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", p.config.BaseURL, p.config.Owner, p.config.Repo)
+
+ latest, err := fetchLatestRelease(p.config.Context, p.config.HttpClient, url)
if err != nil {
return err
}
if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
- color.Green("You already have the latest PocketBase %s.", p.currentVersion)
+ color.Green("You already have the latest version %s.", p.currentVersion)
return nil
}
@@ -211,25 +213,30 @@ func (p *plugin) update(withBackup bool) error {
// try again with an .exe extension
newExec = newExec + ".exe"
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
- return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
+ return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
}
}
// rename the current executable
if err := os.Rename(oldExec, renamedOldExec); err != nil {
- return fmt.Errorf("Failed to rename the current executable: %w", err)
+ return fmt.Errorf("failed to rename the current executable: %w", err)
}
tryToRevertExecChanges := func() {
- if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
- log.Println(revertErr)
+ if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
+ p.app.Logger().Debug(
+ "Failed to revert executable",
+ slog.String("old", renamedOldExec),
+ slog.String("new", oldExec),
+ slog.String("error", revertErr.Error()),
+ )
}
}
// replace with the extracted binary
if err := os.Rename(newExec, oldExec); err != nil {
tryToRevertExecChanges()
- return fmt.Errorf("Failed replacing the executable: %w", err)
+ return fmt.Errorf("failed replacing the executable: %w", err)
}
if withBackup {
@@ -245,17 +252,25 @@ func (p *plugin) update(withBackup bool) error {
color.HiBlack("---")
color.Green("Update completed successfully! You can start the executable as usual.")
+ // print the release notes
+ if latest.Body != "" {
+ fmt.Print("\n")
+ color.Cyan("Here is a list with some of the %s changes:", latest.Tag)
+ // remove the update command note to avoid "stuttering"
+ // (@todo consider moving to a config option)
+ releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
+ color.Cyan(releaseNotes)
+ fmt.Print("\n")
+ }
+
return nil
}
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
- owner string,
- repo string,
+ url string,
) (*release, error) {
- url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
-
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
diff --git a/plugins/ghupdate/ghupdate_test.go b/plugins/ghupdate/ghupdate_test.go
index d13bbdccb8..e692cc8e8a 100644
--- a/plugins/ghupdate/ghupdate_test.go
+++ b/plugins/ghupdate/ghupdate_test.go
@@ -21,15 +21,18 @@ func TestCompareVersions(t *testing.T) {
{"0.0.2", "0.0.1", -1},
{"0.16.2", "0.17.0", 1},
{"1.15.0", "0.16.1", -1},
+ {"1.2.9", "1.2.10", 1},
{"3.2", "4.0", 1},
{"3.2.4", "3.2.3", -1},
}
- for i, s := range scenarios {
- result := compareVersions(s.a, s.b)
+ for _, s := range scenarios {
+ t.Run(s.a+"VS"+s.b, func(t *testing.T) {
+ result := compareVersions(s.a, s.b)
- if result != s.expected {
- t.Fatalf("[%d] Expected %q vs %q to result in %d, got %d", i, s.a, s.b, s.expected, result)
- }
+ if result != s.expected {
+ t.Fatalf("Expected %q vs %q to result in %d, got %d", s.a, s.b, s.expected, result)
+ }
+ })
}
}
diff --git a/plugins/ghupdate/release.go b/plugins/ghupdate/release.go
index fd39179f0a..2cd84fcd38 100644
--- a/plugins/ghupdate/release.go
+++ b/plugins/ghupdate/release.go
@@ -6,19 +6,20 @@ import (
)
type releaseAsset struct {
- Id int `json:"id"`
Name string `json:"name"`
- Size int `json:"size"`
DownloadUrl string `json:"browser_download_url"`
+ Id int `json:"id"`
+ Size int `json:"size"`
}
type release struct {
- Id int `json:"id"`
Name string `json:"name"`
Tag string `json:"tag_name"`
Published string `json:"published_at"`
Url string `json:"html_url"`
+ Body string `json:"body"`
Assets []*releaseAsset `json:"assets"`
+ Id int `json:"id"`
}
// findAssetBySuffix returns the first available asset containing the specified suffix.
diff --git a/plugins/jsvm/binds.go b/plugins/jsvm/binds.go
index 051caddf0b..df31022234 100644
--- a/plugins/jsvm/binds.go
+++ b/plugins/jsvm/binds.go
@@ -3,39 +3,39 @@ package jsvm
import (
"bytes"
"context"
- "encoding/json"
+ "encoding/json/v2"
"errors"
- "fmt"
"io"
+ "io/fs"
+ "log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
+ "slices"
+ "sort"
"strings"
"time"
"github.com/dop251/goja"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/labstack/echo/v5"
+ "github.com/golang-jwt/jwt/v5"
"github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/mails"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
- "github.com/pocketbase/pocketbase/tokens"
- "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/inflector"
- "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/mailer"
- "github.com/pocketbase/pocketbase/tools/rest"
+ "github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/store"
+ "github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/pocketbase/pocketbase/tools/types"
+ "github.com/spf13/cast"
"github.com/spf13/cobra"
)
@@ -46,11 +46,11 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
appType := reflect.TypeOf(app)
appValue := reflect.ValueOf(app)
totalMethods := appType.NumMethod()
- excludeHooks := []string{"OnBeforeServe"}
+ excludeHooks := []string{"OnServe"}
for i := 0; i < totalMethods; i++ {
method := appType.Method(i)
- if !strings.HasPrefix(method.Name, "On") || list.ExistInSlice(method.Name, excludeHooks) {
+ if !strings.HasPrefix(method.Name, "On") || slices.Contains(excludeHooks, method.Name) {
continue // not a hook or excluded
}
@@ -58,7 +58,9 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
// register the hook to the loader
loader.Set(jsName, func(callback string, tags ...string) {
- pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
+ // overwrite the global $app with the hook scoped instance
+ callback = `function(e) { $app = e.app; return (` + callback + `).call(undefined, e) }`
+ pr := goja.MustCompile(defaultScriptPath, "{("+callback+").apply(undefined, __args)}", true)
tagsAsValues := make([]reflect.Value, len(tags))
for i, tag := range tags {
@@ -66,9 +68,9 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
}
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
- addFunc := hookInstance.MethodByName("Add")
+ hookBindFunc := hookInstance.MethodByName("BindFunc")
- handlerType := addFunc.Type().In(0)
+ handlerType := hookBindFunc.Type().In(0)
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
handlerArgs := make([]any, len(args))
@@ -77,81 +79,72 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
}
err := executors.run(func(executor *goja.Runtime) error {
+ oldApp := executor.Get("$app")
executor.Set("__args", handlerArgs)
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
+ executor.Set("$app", oldApp) // reset to its default for the executor
- // check for returned error or false
- if res != nil {
- switch v := res.Export().(type) {
- case error:
- return v
- case bool:
- if !v {
- return hook.StopPropagation
- }
- }
+ // check for returned Go error value
+ if resErr := checkGojaValueForError(app, res); resErr != nil {
+ return resErr
}
- return err
+ return normalizeException(err)
})
return []reflect.Value{reflect.ValueOf(&err).Elem()}
})
// register the wrapped hook handler
- addFunc.Call([]reflect.Value{handler})
+ hookBindFunc.Call([]reflect.Value{handler})
})
}
}
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
- scheduler := cron.New()
-
- var wasServeTriggered bool
-
- loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
- pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
+ cronAdd := func(jobId, cronExpr, handler string) {
+ pr := goja.MustCompile(defaultScriptPath, "{("+handler+").apply(undefined)}", true)
- err := scheduler.Add(jobId, cronExpr, func() {
+ err := app.Cron().Add(jobId, cronExpr, func() {
err := executors.run(func(executor *goja.Runtime) error {
_, err := executor.RunProgram(pr)
return err
})
- if err != nil && app.IsDebug() {
- fmt.Println("[cronAdd] failed to execute cron job " + jobId + ": " + err.Error())
+ if err != nil {
+ app.Logger().Error(
+ "[cronAdd] failed to execute cron job",
+ slog.String("jobId", jobId),
+ slog.String("error", err.Error()),
+ )
}
})
if err != nil {
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
}
+ }
+ loader.Set("cronAdd", cronAdd)
- // start the ticker (if not already)
- if wasServeTriggered && scheduler.Total() > 0 && !scheduler.HasStarted() {
- scheduler.Start()
- }
- })
-
- loader.Set("cronRemove", func(jobId string) {
- scheduler.Remove(jobId)
-
- // stop the ticker if there are no other jobs
- if scheduler.Total() == 0 {
- scheduler.Stop()
- }
- })
+ cronRemove := func(jobId string) {
+ app.Cron().Remove(jobId)
+ }
+ loader.Set("cronRemove", cronRemove)
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- // start the ticker (if not already)
- if scheduler.Total() > 0 && !scheduler.HasStarted() {
- scheduler.Start()
- }
+ // register the removal helper also in the executors to allow removing cron jobs from everywhere
+ oldFactory := executors.factory
+ executors.factory = func() *goja.Runtime {
+ vm := oldFactory()
- wasServeTriggered = true
+ vm.Set("cronAdd", cronAdd)
+ vm.Set("cronRemove", cronRemove)
- return nil
- })
+ return vm
+ }
+ for _, item := range executors.items {
+ item.vm.Set("cronAdd", cronAdd)
+ item.vm.Set("cronRemove", cronRemove)
+ }
}
func routerBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
@@ -161,15 +154,15 @@ func routerBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
panic("[routerAdd] failed to wrap middlewares: " + err.Error())
}
- wrappedHandler, err := wrapHandler(executors, handler)
+ wrappedHandler, err := wrapHandlerFunc(executors, handler)
if err != nil {
panic("[routerAdd] failed to wrap handler: " + err.Error())
}
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- e.Router.Add(strings.ToUpper(method), path, wrappedHandler, wrappedMiddlewares...)
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
+ e.Router.Route(strings.ToUpper(method), path, wrappedHandler).Bind(wrappedMiddlewares...)
- return nil
+ return e.Next()
})
})
@@ -179,51 +172,40 @@ func routerBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
panic("[routerUse] failed to wrap middlewares: " + err.Error())
}
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- e.Router.Use(wrappedMiddlewares...)
- return nil
- })
- })
-
- loader.Set("routerPre", func(middlewares ...goja.Value) {
- wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
- if err != nil {
- panic("[routerPre] failed to wrap middlewares: " + err.Error())
- }
-
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- e.Router.Pre(wrappedMiddlewares...)
- return nil
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
+ e.Router.Bind(wrappedMiddlewares...)
+ return e.Next()
})
})
}
-func wrapHandler(executors *vmsPool, handler goja.Value) (echo.HandlerFunc, error) {
+func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.RequestEvent) error, error) {
if handler == nil {
return nil, errors.New("handler must be non-nil")
}
switch h := handler.Export().(type) {
- case echo.HandlerFunc:
- // "native" handler - no need to wrap
+ case func(*core.RequestEvent) error:
+ // "native" handler func - no need to wrap
return h, nil
case func(goja.FunctionCall) goja.Value, string:
- pr := goja.MustCompile("", "{("+handler.String()+").apply(undefined, __args)}", true)
+ pr := goja.MustCompile(defaultScriptPath, "{("+handler.String()+").apply(undefined, __args)}", true)
- wrappedHandler := func(c echo.Context) error {
+ wrappedHandler := func(e *core.RequestEvent) error {
return executors.run(func(executor *goja.Runtime) error {
- executor.Set("__args", []any{c})
+ oldApp := executor.Get("$app")
+ executor.Set("$app", e.App) // overwrite the global $app with the hook scoped instance
+ executor.Set("__args", []any{e})
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
+ executor.Set("$app", oldApp)
- // check for returned error
- if res != nil {
- if v, ok := res.Export().(error); ok {
- return v
- }
+ // check for returned Go error value
+ if resErr := checkGojaValueForError(e.App, res); resErr != nil {
+ return resErr
}
- return err
+ return normalizeException(err)
})
}
@@ -233,40 +215,78 @@ func wrapHandler(executors *vmsPool, handler goja.Value) (echo.HandlerFunc, erro
}
}
-func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]echo.MiddlewareFunc, error) {
- wrappedMiddlewares := make([]echo.MiddlewareFunc, len(rawMiddlewares))
+type gojaHookHandler struct {
+ id string
+ serializedFunc string
+ priority int
+}
+
+func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.Handler[*core.RequestEvent], error) {
+ wrappedMiddlewares := make([]*hook.Handler[*core.RequestEvent], len(rawMiddlewares))
for i, m := range rawMiddlewares {
if m == nil {
- return nil, errors.New("middleware func must be non-nil")
+ return nil, errors.New("middleware must be non-nil")
}
switch v := m.Export().(type) {
- case echo.MiddlewareFunc:
- // "native" middleware - no need to wrap
+ case *hook.Handler[*core.RequestEvent]:
+ // "native" middleware handler - no need to wrap
wrappedMiddlewares[i] = v
+ case func(*core.RequestEvent) error:
+ // "native" middleware func - wrap as handler
+ wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
+ Func: v,
+ }
+ case *gojaHookHandler:
+ if v.serializedFunc == "" {
+ return nil, errors.New("missing or invalid Middleware function")
+ }
+
+ pr := goja.MustCompile(defaultScriptPath, "{("+v.serializedFunc+").apply(undefined, __args)}", true)
+
+ wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
+ Id: v.id,
+ Priority: v.priority,
+ Func: func(e *core.RequestEvent) error {
+ return executors.run(func(executor *goja.Runtime) error {
+ oldApp := executor.Get("$app")
+ executor.Set("$app", e.App) // overwrite the global $app with the hook scoped instance
+ executor.Set("__args", []any{e})
+ res, err := executor.RunProgram(pr)
+ executor.Set("__args", goja.Undefined())
+ executor.Set("$app", oldApp)
+
+ // check for returned Go error value
+ if resErr := checkGojaValueForError(e.App, res); resErr != nil {
+ return resErr
+ }
+
+ return normalizeException(err)
+ })
+ },
+ }
case func(goja.FunctionCall) goja.Value, string:
- pr := goja.MustCompile("", "{(("+m.String()+").apply(undefined, __args)).apply(undefined, __args2)}", true)
+ pr := goja.MustCompile(defaultScriptPath, "{("+m.String()+").apply(undefined, __args)}", true)
- wrappedMiddlewares[i] = func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
+ wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
+ Func: func(e *core.RequestEvent) error {
return executors.run(func(executor *goja.Runtime) error {
- executor.Set("__args", []any{next})
- executor.Set("__args2", []any{c})
+ oldApp := executor.Get("$app")
+ executor.Set("$app", e.App) // overwrite the global $app with the hook scoped instance
+ executor.Set("__args", []any{e})
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
- executor.Set("__args2", goja.Undefined())
+ executor.Set("$app", oldApp)
- // check for returned error
- if res != nil {
- if v, ok := res.Export().(error); ok {
- return v
- }
+ // check for returned Go error value
+ if resErr := checkGojaValueForError(e.App, res); resErr != nil {
+ return resErr
}
- return err
+ return normalizeException(err)
})
- }
+ },
}
default:
return nil, errors.New("unsupported goja middleware type")
@@ -276,12 +296,19 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]echo.M
return wrappedMiddlewares, nil
}
-func baseBinds(vm *goja.Runtime) {
+// -------------------------------------------------------------------
+
+var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
+
+// BindCore registers common core objects and functions such as sleep,
+// toString, DynamicModel, etc. into the provided runtime.
+func BindCore(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{})
+ // deprecated: use toString
vm.Set("readerToString", func(r io.Reader, maxBytes int) (string, error) {
if maxBytes == 0 {
- maxBytes = rest.DefaultMaxMemory
+ maxBytes = router.DefaultMaxMemory
}
limitReader := io.LimitReader(r, int64(maxBytes))
@@ -294,12 +321,113 @@ func baseBinds(vm *goja.Runtime) {
return string(bodyBytes), nil
})
+ // note: throw only on reader error
+ vm.Set("toBytes", func(raw any, maxReaderBytes int) ([]byte, error) {
+ switch v := raw.(type) {
+ case nil:
+ return []byte{}, nil
+ case string:
+ return []byte(v), nil
+ case []byte:
+ return v, nil
+ case types.JSONRaw:
+ return v, nil
+ case io.Reader:
+ if maxReaderBytes == 0 {
+ maxReaderBytes = router.DefaultMaxMemory
+ }
+
+ limitReader := io.LimitReader(v, int64(maxReaderBytes))
+
+ return io.ReadAll(limitReader)
+ default:
+ b, err := cast.ToUint8SliceE(v)
+ if err == nil {
+ return b, nil
+ }
+
+ str, err := cast.ToStringE(v)
+ if err == nil {
+ return []byte(str), nil
+ }
+
+ // as a last attempt try to json encode the value
+ rawBytes, _ := json.Marshal(raw, json.Deterministic(true))
+
+ return rawBytes, nil
+ }
+ })
+
+ // note: throw only on reader error
+ vm.Set("toString", func(raw any, maxReaderBytes int) (string, error) {
+ switch v := raw.(type) {
+ case io.Reader:
+ if maxReaderBytes == 0 {
+ maxReaderBytes = router.DefaultMaxMemory
+ }
+
+ limitReader := io.LimitReader(v, int64(maxReaderBytes))
+
+ bodyBytes, readErr := io.ReadAll(limitReader)
+ if readErr != nil {
+ return "", readErr
+ }
+
+ return string(bodyBytes), nil
+ default:
+ str, err := cast.ToStringE(v)
+ if err == nil {
+ return str, nil
+ }
+
+ // as a last attempt try to json encode the value
+ rawBytes, _ := json.Marshal(raw, json.Deterministic(true))
+
+ return string(rawBytes), nil
+ }
+ })
+
+ vm.Set("sleep", func(milliseconds int64) {
+ time.Sleep(time.Duration(milliseconds) * time.Millisecond)
+ })
+
vm.Set("arrayOf", func(model any) any {
mt := reflect.TypeOf(model)
- st := reflect.SliceOf(mt)
- elem := reflect.New(st).Elem()
+ st := cachedArrayOfTypes.GetOrSet(mt, func() reflect.Type {
+ return reflect.SliceOf(mt)
+ })
+
+ return reflect.New(st).Elem().Addr().Interface()
+ })
+
+ vm.Set("unmarshal", func(data, dst any) error {
+ raw, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ return json.Unmarshal(raw, &dst)
+ })
- return elem.Addr().Interface()
+ vm.Set("Context", func(call goja.ConstructorCall) *goja.Object {
+ var instance context.Context
+
+ oldCtx, ok := call.Argument(0).Export().(context.Context)
+ if ok {
+ instance = oldCtx
+ } else {
+ instance = context.Background()
+ }
+
+ key := call.Argument(1).Export()
+ if key != nil {
+ instance = context.WithValue(instance, key, call.Argument(2).Export())
+ }
+
+ instanceValue := vm.ToValue(instance).(*goja.Object)
+ instanceValue.SetPrototype(call.This.Prototype())
+
+ return instanceValue
})
vm.Set("DynamicModel", func(call goja.ConstructorCall) *goja.Object {
@@ -315,18 +443,44 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue
})
+ // nullable helpers usually used as DynamicModel shape values
+ vm.Set("nullString", func() *string {
+ var v string
+ return &v
+ })
+ vm.Set("nullFloat", func() *float64 {
+ var v float64
+ return &v
+ })
+ vm.Set("nullInt", func() *int64 {
+ var v int64
+ return &v
+ })
+ vm.Set("nullBool", func() *bool {
+ var v bool
+ return &v
+ })
+ vm.Set("nullArray", func() *types.JSONArray[any] {
+ var v types.JSONArray[any]
+ return &v
+ })
+ vm.Set("nullObject", func() *types.JSONMap[any] {
+ var v types.JSONMap[any]
+ return &v
+ })
+
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
- var instance *models.Record
+ var instance *core.Record
- collection, ok := call.Argument(0).Export().(*models.Collection)
+ collection, ok := call.Argument(0).Export().(*core.Collection)
if ok {
- instance = models.NewRecord(collection)
+ instance = core.NewRecord(collection)
data, ok := call.Argument(1).Export().(map[string]any)
if ok {
instance.Load(data)
}
} else {
- instance = &models.Record{}
+ instance = &core.Record{}
}
instanceValue := vm.ToValue(instance).(*goja.Object)
@@ -336,24 +490,92 @@ func baseBinds(vm *goja.Runtime) {
})
vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object {
- instance := &models.Collection{}
+ instance := &core.Collection{}
return structConstructorUnmarshal(vm, call, instance)
})
- vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object {
- instance := &models.Admin{}
+ vm.Set("FieldsList", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.FieldsList{}
return structConstructorUnmarshal(vm, call, instance)
})
- vm.Set("Schema", func(call goja.ConstructorCall) *goja.Object {
- instance := &schema.Schema{}
+ // fields
+ // ---
+ vm.Set("Field", func(call goja.ConstructorCall) *goja.Object {
+ data, _ := call.Argument(0).Export().(map[string]any)
+ rawDataSlice, _ := json.Marshal([]any{data})
+
+ fieldsList := core.NewFieldsList()
+ _ = fieldsList.UnmarshalJSON(rawDataSlice)
+
+ if len(fieldsList) == 0 {
+ return nil
+ }
+
+ field := fieldsList[0]
+
+ fieldValue := vm.ToValue(field).(*goja.Object)
+ fieldValue.SetPrototype(call.This.Prototype())
+
+ return fieldValue
+ })
+ vm.Set("NumberField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.NumberField{}
return structConstructorUnmarshal(vm, call, instance)
})
-
- vm.Set("SchemaField", func(call goja.ConstructorCall) *goja.Object {
- instance := &schema.SchemaField{}
+ vm.Set("BoolField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.BoolField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("TextField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.TextField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("URLField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.URLField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("EmailField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.EmailField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("EditorField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.EditorField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("PasswordField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.PasswordField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("DateField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.DateField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("AutodateField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.AutodateField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("JSONField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.JSONField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("RelationField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.RelationField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("SelectField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.SelectField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("FileField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.FileField{}
+ return structConstructorUnmarshal(vm, call, instance)
+ })
+ vm.Set("GeoPointField", func(call goja.ConstructorCall) *goja.Object {
+ instance := &core.GeoPointField{}
return structConstructorUnmarshal(vm, call, instance)
})
+ // ---
vm.Set("MailerMessage", func(call goja.ConstructorCall) *goja.Object {
instance := &mailer.Message{}
@@ -366,16 +588,58 @@ func baseBinds(vm *goja.Runtime) {
})
vm.Set("RequestInfo", func(call goja.ConstructorCall) *goja.Object {
- instance := &models.RequestInfo{}
+ instance := &core.RequestInfo{Context: core.RequestInfoContextDefault}
return structConstructor(vm, call, instance)
})
+ // ```js
+ // new Middleware((e) => {
+ // return e.next()
+ // }, 100, "example_middleware")
+ // ```
+ vm.Set("Middleware", func(call goja.ConstructorCall) *goja.Object {
+ instance := &gojaHookHandler{}
+
+ instance.serializedFunc = call.Argument(0).String()
+ instance.priority = cast.ToInt(call.Argument(1).Export())
+ instance.id = cast.ToString(call.Argument(2).Export())
+
+ instanceValue := vm.ToValue(instance).(*goja.Object)
+ instanceValue.SetPrototype(call.This.Prototype())
+
+ return instanceValue
+ })
+
+ // note: named Timezone to avoid conflicts with the JS Location interface.
+ vm.Set("Timezone", func(call goja.ConstructorCall) *goja.Object {
+ name, _ := call.Argument(0).Export().(string)
+
+ instance, err := time.LoadLocation(name)
+ if err != nil {
+ instance = time.UTC
+ }
+
+ instanceValue := vm.ToValue(instance).(*goja.Object)
+ instanceValue.SetPrototype(call.This.Prototype())
+
+ return instanceValue
+ })
+
vm.Set("DateTime", func(call goja.ConstructorCall) *goja.Object {
instance := types.NowDateTime()
- val, _ := call.Argument(0).Export().(string)
- if val != "" {
- instance, _ = types.ParseDateTime(val)
+ rawDate, _ := call.Argument(0).Export().(string)
+ locName, _ := call.Argument(1).Export().(string)
+ if rawDate != "" && locName != "" {
+ loc, err := time.LoadLocation(locName)
+ if err != nil {
+ loc = time.UTC
+ }
+
+ instance, _ = types.ParseDateTime(cast.ToTimeInDefaultLocation(rawDate, loc))
+ } else if rawDate != "" {
+ // forward directly to ParseDateTime to preserve the original behavior
+ instance, _ = types.ParseDateTime(rawDate)
}
instanceValue := vm.ToValue(instance).(*goja.Object)
@@ -395,26 +659,21 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue
})
- vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object {
- concurrentDB, _ := call.Argument(0).Export().(dbx.Builder)
- if concurrentDB == nil {
- panic("[Dao] missing required Dao(concurrentDB, [nonconcurrentDB]) argument")
- }
-
- nonConcurrentDB, _ := call.Argument(1).Export().(dbx.Builder)
- if nonConcurrentDB == nil {
- nonConcurrentDB = concurrentDB
- }
-
- instance := daos.NewMultiDB(concurrentDB, nonConcurrentDB)
- instanceValue := vm.ToValue(instance).(*goja.Object)
- instanceValue.SetPrototype(call.This.Prototype())
+ vm.Set("Cookie", func(call goja.ConstructorCall) *goja.Object {
+ instance := &http.Cookie{}
+ return structConstructor(vm, call, instance)
+ })
- return instanceValue
+ vm.Set("SubscriptionMessage", func(call goja.ConstructorCall) *goja.Object {
+ instance := &subscriptions.Message{}
+ return structConstructor(vm, call, instance)
})
}
-func dbxBinds(vm *goja.Runtime) {
+// BindDbx registers $dbx.* namespaced object with dbx database builder related methods.
+//
+// See https://pocketbase.io/jsvm/modules/_dbx.html.
+func BindDbx(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$dbx", obj)
@@ -437,37 +696,24 @@ func dbxBinds(vm *goja.Runtime) {
obj.Set("notBetween", dbx.NotBetween)
}
-func mailsBinds(vm *goja.Runtime) {
+// BindMails registers $mail.* namespaced object with common mail related helpers.
+//
+// See https://pocketbase.io/jsvm/modules/_mails.html.
+func BindMails(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$mails", obj)
- // admin
- obj.Set("sendAdminPasswordReset", mails.SendAdminPasswordReset)
-
- // record
obj.Set("sendRecordPasswordReset", mails.SendRecordPasswordReset)
obj.Set("sendRecordVerification", mails.SendRecordVerification)
obj.Set("sendRecordChangeEmail", mails.SendRecordChangeEmail)
+ obj.Set("sendRecordOTP", mails.SendRecordOTP)
+ obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert)
}
-func tokensBinds(vm *goja.Runtime) {
- obj := vm.NewObject()
- vm.Set("$tokens", obj)
-
- // admin
- obj.Set("adminAuthToken", tokens.NewAdminAuthToken)
- obj.Set("adminResetPasswordToken", tokens.NewAdminResetPasswordToken)
- obj.Set("adminFileToken", tokens.NewAdminFileToken)
-
- // record
- obj.Set("recordAuthToken", tokens.NewRecordAuthToken)
- obj.Set("recordVerifyToken", tokens.NewRecordVerifyToken)
- obj.Set("recordResetPasswordToken", tokens.NewRecordResetPasswordToken)
- obj.Set("recordChangeEmailToken", tokens.NewRecordChangeEmailToken)
- obj.Set("recordFileToken", tokens.NewRecordFileToken)
-}
-
-func securityBinds(vm *goja.Runtime) {
+// BindSecurity registers $security.* namespaced object with common security related helpers.
+//
+// See https://pocketbase.io/jsvm/modules/_security.html.
+func BindSecurity(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$security", obj)
@@ -481,14 +727,21 @@ func securityBinds(vm *goja.Runtime) {
// random
obj.Set("randomString", security.RandomString)
+ obj.Set("randomStringByRegex", security.RandomStringByRegex)
obj.Set("randomStringWithAlphabet", security.RandomStringWithAlphabet)
obj.Set("pseudorandomString", security.PseudorandomString)
obj.Set("pseudorandomStringWithAlphabet", security.PseudorandomStringWithAlphabet)
// jwt
- obj.Set("parseUnverifiedJWT", security.ParseUnverifiedJWT)
- obj.Set("parseJWT", security.ParseJWT)
- obj.Set("createJWT", security.NewJWT)
+ obj.Set("parseUnverifiedJWT", func(token string) (map[string]any, error) {
+ return security.ParseUnverifiedJWT(token)
+ })
+ obj.Set("parseJWT", func(token string, verificationKey string) (map[string]any, error) {
+ return security.ParseJWT(token, verificationKey)
+ })
+ obj.Set("createJWT", func(payload jwt.MapClaims, signingKey string, secDuration int) (string, error) {
+ return security.NewJWT(payload, signingKey, time.Duration(secDuration)*time.Second)
+ })
// encryption
obj.Set("encrypt", security.Encrypt)
@@ -503,16 +756,36 @@ func securityBinds(vm *goja.Runtime) {
})
}
-func filesystemBinds(vm *goja.Runtime) {
+// BindFilesystem registers $filesystem.* namespaced object with
+// common filesystem package related helpers.
+//
+// See https://pocketbase.io/jsvm/modules/_filesystem.html.
+func BindFilesystem(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$filesystem", obj)
+ obj.Set("s3", filesystem.NewS3)
+ obj.Set("local", filesystem.NewLocal)
obj.Set("fileFromPath", filesystem.NewFileFromPath)
obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
+ obj.Set("fileFromURL", func(url string, secTimeout int) (*filesystem.File, error) {
+ if secTimeout == 0 {
+ secTimeout = 120
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(secTimeout)*time.Second)
+ defer cancel()
+
+ return filesystem.NewFileFromURL(ctx, url)
+ })
}
-func filepathBinds(vm *goja.Runtime) {
+// BindFilepath registers $filepath.* namespaced object with
+// common std Go filepath package related exports.
+//
+// See https://pocketbase.io/jsvm/modules/_filepath.html.
+func BindFilepath(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$filepath", obj)
@@ -533,15 +806,21 @@ func filepathBinds(vm *goja.Runtime) {
obj.Set("walkDir", filepath.WalkDir)
}
-func osBinds(vm *goja.Runtime) {
+// BindOS registers $os.* namespaced object with
+// common std Go os package related exports.
+//
+// See https://pocketbase.io/jsvm/modules/_os.html.
+func BindOS(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$os", obj)
obj.Set("args", os.Args)
- obj.Set("exec", exec.Command)
+ obj.Set("exec", exec.Command) // @deprecated
+ obj.Set("cmd", exec.Command)
obj.Set("exit", os.Exit)
obj.Set("getenv", os.Getenv)
obj.Set("dirFS", os.DirFS)
+ obj.Set("stat", os.Stat)
obj.Set("readFile", os.ReadFile)
obj.Set("writeFile", os.WriteFile)
obj.Set("readDir", os.ReadDir)
@@ -553,91 +832,129 @@ func osBinds(vm *goja.Runtime) {
obj.Set("rename", os.Rename)
obj.Set("remove", os.Remove)
obj.Set("removeAll", os.RemoveAll)
+ obj.Set("openRoot", os.OpenRoot)
+ obj.Set("openInRoot", os.OpenInRoot)
}
-func formsBinds(vm *goja.Runtime) {
- registerFactoryAsConstructor(vm, "AdminLoginForm", forms.NewAdminLogin)
- registerFactoryAsConstructor(vm, "AdminPasswordResetConfirmForm", forms.NewAdminPasswordResetConfirm)
- registerFactoryAsConstructor(vm, "AdminPasswordResetRequestForm", forms.NewAdminPasswordResetRequest)
- registerFactoryAsConstructor(vm, "AdminUpsertForm", forms.NewAdminUpsert)
+// BindForms registers various application form constructors.
+// These bindings are mostly used internally and/or preserved for backward compatibility with earlier versions.
+func BindForms(vm *goja.Runtime) {
registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
- registerFactoryAsConstructor(vm, "CollectionUpsertForm", forms.NewCollectionUpsert)
- registerFactoryAsConstructor(vm, "CollectionsImportForm", forms.NewCollectionsImport)
- registerFactoryAsConstructor(vm, "RealtimeSubscribeForm", forms.NewRealtimeSubscribe)
- registerFactoryAsConstructor(vm, "RecordEmailChangeConfirmForm", forms.NewRecordEmailChangeConfirm)
- registerFactoryAsConstructor(vm, "RecordEmailChangeRequestForm", forms.NewRecordEmailChangeRequest)
- registerFactoryAsConstructor(vm, "RecordOAuth2LoginForm", forms.NewRecordOAuth2Login)
- registerFactoryAsConstructor(vm, "RecordPasswordLoginForm", forms.NewRecordPasswordLogin)
- registerFactoryAsConstructor(vm, "RecordPasswordResetConfirmForm", forms.NewRecordPasswordResetConfirm)
- registerFactoryAsConstructor(vm, "RecordPasswordResetRequestForm", forms.NewRecordPasswordResetRequest)
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
- registerFactoryAsConstructor(vm, "RecordVerificationConfirmForm", forms.NewRecordVerificationConfirm)
- registerFactoryAsConstructor(vm, "RecordVerificationRequestForm", forms.NewRecordVerificationRequest)
- registerFactoryAsConstructor(vm, "SettingsUpsertForm", forms.NewSettingsUpsert)
registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend)
registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem)
}
-func apisBinds(vm *goja.Runtime) {
+// BindApis registers $apis.* namespaced object with reusable Web API
+// handlers, middlewares and other related helpers.
+//
+// See https://pocketbase.io/jsvm/modules/_apis.html.
+func BindApis(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$apis", obj)
- obj.Set("staticDirectoryHandler", func(dir string, indexFallback bool) echo.HandlerFunc {
- return apis.StaticDirectoryHandler(os.DirFS(dir), indexFallback)
+ obj.Set("static", func(dirOrFS any, indexFallback bool) func(*core.RequestEvent) error {
+ switch v := dirOrFS.(type) {
+ case fs.FS:
+ return apis.Static(v, indexFallback)
+ case string:
+ return apis.Static(os.DirFS(v), indexFallback)
+ default:
+ panic("$apis.static expects the first argument to be either a plain string path or fs.FS value")
+ }
})
// middlewares
- obj.Set("requireRecordAuth", apis.RequireRecordAuth)
- obj.Set("requireAdminAuth", apis.RequireAdminAuth)
- obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
- obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
- obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
- obj.Set("activityLogger", apis.ActivityLogger)
+ obj.Set("requireGuestOnly", apis.RequireGuestOnly)
+ obj.Set("requireAuth", apis.RequireAuth)
+ obj.Set("requireSuperuserAuth", apis.RequireSuperuserAuth)
+ obj.Set("requireSuperuserOrOwnerAuth", apis.RequireSuperuserOrOwnerAuth)
+ obj.Set("skipSuccessActivityLog", apis.SkipSuccessActivityLog)
+ obj.Set("gzip", apis.Gzip)
+ obj.Set("bodyLimit", apis.BodyLimit)
// record helpers
- obj.Set("requestInfo", apis.RequestInfo)
obj.Set("recordAuthResponse", apis.RecordAuthResponse)
obj.Set("enrichRecord", apis.EnrichRecord)
obj.Set("enrichRecords", apis.EnrichRecords)
// api errors
- registerFactoryAsConstructor(vm, "ApiError", apis.NewApiError)
- registerFactoryAsConstructor(vm, "NotFoundError", apis.NewNotFoundError)
- registerFactoryAsConstructor(vm, "BadRequestError", apis.NewBadRequestError)
- registerFactoryAsConstructor(vm, "ForbiddenError", apis.NewForbiddenError)
- registerFactoryAsConstructor(vm, "UnauthorizedError", apis.NewUnauthorizedError)
+ registerFactoryAsConstructor(vm, "ApiError", router.NewApiError)
+ registerFactoryAsConstructor(vm, "NotFoundError", router.NewNotFoundError)
+ registerFactoryAsConstructor(vm, "BadRequestError", router.NewBadRequestError)
+ registerFactoryAsConstructor(vm, "ForbiddenError", router.NewForbiddenError)
+ registerFactoryAsConstructor(vm, "UnauthorizedError", router.NewUnauthorizedError)
+ registerFactoryAsConstructor(vm, "TooManyRequestsError", router.NewTooManyRequestsError)
+ registerFactoryAsConstructor(vm, "InternalServerError", router.NewInternalServerError)
}
-func httpClientBinds(vm *goja.Runtime) {
+// BindHTTP registers $http.* namespaced object with common utils
+// for sending HTTP requests.
+//
+// See https://pocketbase.io/jsvm/modules/_http.html.
+func BindHTTP(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$http", obj)
+ vm.Set("FormData", func(call goja.ConstructorCall) *goja.Object {
+ instance := FormData{}
+
+ instanceValue := vm.ToValue(instance).(*goja.Object)
+ instanceValue.SetPrototype(call.This.Prototype())
+
+ return instanceValue
+ })
+
type sendResult struct {
- StatusCode int
- Raw string
- Json any
+ JSON any `json:"json"`
+ Headers map[string][]string `json:"headers"`
+ Cookies map[string]*http.Cookie `json:"cookies"`
+
+ // Deprecated: consider using Body instead
+ Raw string `json:"raw"`
+
+ Body []byte `json:"body"`
+ StatusCode int `json:"statusCode"`
}
type sendConfig struct {
+ // Deprecated: consider using Body instead
+ Data map[string]any
+
+ Body any // raw string or FormData
+ Headers map[string]string
Method string
Url string
- Body string
- Headers map[string]string
- Timeout int // seconds (default to 120)
- Data map[string]any // deprecated, consider using Body instead
+ Timeout int // seconds (default to 120)
}
obj.Set("send", func(params map[string]any) (*sendResult, error) {
- rawParams, err := json.Marshal(params)
- if err != nil {
- return nil, err
- }
-
config := sendConfig{
Method: "GET",
}
- if err := json.Unmarshal(rawParams, &config); err != nil {
- return nil, err
+
+ if v, ok := params["data"]; ok {
+ config.Data = cast.ToStringMap(v)
+ }
+
+ if v, ok := params["body"]; ok {
+ config.Body = v
+ }
+
+ if v, ok := params["headers"]; ok {
+ config.Headers = cast.ToStringMapString(v)
+ }
+
+ if v, ok := params["method"]; ok {
+ config.Method = cast.ToString(v)
+ }
+
+ if v, ok := params["url"]; ok {
+ config.Url = cast.ToString(v)
+ }
+
+ if v, ok := params["timeout"]; ok {
+ config.Timeout = cast.ToInt(v)
}
if config.Timeout <= 0 {
@@ -648,6 +965,7 @@ func httpClientBinds(vm *goja.Runtime) {
defer cancel()
var reqBody io.Reader
+ var contentType string
// legacy json body data
if len(config.Data) != 0 {
@@ -656,10 +974,21 @@ func httpClientBinds(vm *goja.Runtime) {
return nil, err
}
reqBody = bytes.NewReader(encoded)
- }
+ } else {
+ switch v := config.Body.(type) {
+ case io.Reader:
+ reqBody = v
+ case FormData:
+ body, mp, err := v.toMultipart()
+ if err != nil {
+ return nil, err
+ }
- if config.Body != "" {
- reqBody = strings.NewReader(config.Body)
+ reqBody = body
+ contentType = mp.FormDataContentType()
+ default:
+ reqBody = strings.NewReader(cast.ToString(config.Body))
+ }
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
@@ -671,9 +1000,10 @@ func httpClientBinds(vm *goja.Runtime) {
req.Header.Add(k, v)
}
- // set default content-type header (if missing)
- if req.Header.Get("content-type") == "" {
- req.Header.Set("content-type", "application/json")
+ // set the explicit content type
+ // (overwriting the user provided header value if any)
+ if contentType != "" {
+ req.Header.Set("content-type", contentType)
}
res, err := http.DefaultClient.Do(req)
@@ -686,17 +1016,28 @@ func httpClientBinds(vm *goja.Runtime) {
result := &sendResult{
StatusCode: res.StatusCode,
+ Headers: map[string][]string{},
+ Cookies: map[string]*http.Cookie{},
Raw: string(bodyRaw),
+ Body: bodyRaw,
+ }
+
+ for k, v := range res.Header {
+ result.Headers[k] = v
+ }
+
+ for _, v := range res.Cookies() {
+ result.Cookies[v.Name] = v
}
- if len(result.Raw) != 0 {
+ if len(result.Body) > 0 {
// try as map
- result.Json = map[string]any{}
- if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
+ result.JSON = map[string]any{}
+ if err := json.Unmarshal(bodyRaw, &result.JSON); err != nil {
// try as slice
- result.Json = []any{}
- if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
- result.Json = nil
+ result.JSON = []any{}
+ if err := json.Unmarshal(bodyRaw, &result.JSON); err != nil {
+ result.JSON = nil
}
}
}
@@ -707,12 +1048,65 @@ func httpClientBinds(vm *goja.Runtime) {
// -------------------------------------------------------------------
+// checkGojaValueForError resolves the provided goja.Value and tries
+// to extract its underlying error value (if any).
+func checkGojaValueForError(app core.App, value goja.Value) error {
+ if value == nil {
+ return nil
+ }
+
+ exported := value.Export()
+ switch v := exported.(type) {
+ case error:
+ return v
+ case *goja.Promise:
+ // Promise as return result is not officially supported but try to
+ // resolve any thrown exception to avoid silently ignoring it
+ app.Logger().Warn("the handler must a non-async function and not return a Promise")
+ if promiseErr, ok := v.Result().Export().(error); ok {
+ return normalizeException(promiseErr)
+ }
+ }
+
+ return nil
+}
+
+// normalizeException checks if the provided error is a goja.Exception
+// and attempts to return its underlying Go error.
+//
+// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
+func normalizeException(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ jsException, ok := err.(*goja.Exception)
+ if !ok {
+ return err // no exception
+ }
+
+ switch v := jsException.Value().Export().(type) {
+ case error:
+ err = v
+ case map[string]any: // goja.GoError
+ if vErr, ok := v["value"].(error); ok {
+ err = vErr
+ }
+ }
+
+ return err
+}
+
+var cachedFactoryFuncTypes = store.New[string, reflect.Type](nil)
+
// registerFactoryAsConstructor registers the factory function as native JS constructor.
//
// If there is missing or nil arguments, their type zero value is used.
func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) {
rv := reflect.ValueOf(factoryFunc)
- rt := reflect.TypeOf(factoryFunc)
+ rt := cachedFactoryFuncTypes.GetOrSet(constructorName, func() reflect.Type {
+ return reflect.TypeOf(factoryFunc)
+ })
totalArgs := rt.NumIn()
vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object {
@@ -768,7 +1162,7 @@ func structConstructor(vm *goja.Runtime, call goja.ConstructorCall, instance any
func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object {
if data := call.Argument(0).Export(); data != nil {
if raw, err := json.Marshal(data); err == nil {
- json.Unmarshal(raw, instance)
+ _ = json.Unmarshal(raw, instance)
}
}
@@ -778,9 +1172,26 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
return instanceValue
}
+var cachedDynamicModelStructs = store.New[string, reflect.Type](nil)
+
// newDynamicModel creates a new dynamic struct with fields based
// on the specified "shape".
//
+// The "shape" values are used as defaults and could be of type:
+//
+// - int64 (ex.: 0)
+// - *int64 (ex.: nullInt())
+// - float64 (ex.: -0)
+// - *float64 (ex.: nullFloat())
+// - string (ex.: "")
+// - *string (ex.: nullString())
+// - bool (ex.: false)
+// - *bool (ex.: nullBool())
+// - slice/arr (ex.: [])
+// - *slice/arr (ex.: nullArray())
+// - map (ex.: {})
+// - *map (ex.: nullObject())
+//
// Example:
//
// m := newDynamicModel(map[string]any{
@@ -788,42 +1199,75 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
// "total": 0,
// })
func newDynamicModel(shape map[string]any) any {
- shapeValues := make([]reflect.Value, 0, len(shape))
- structFields := make([]reflect.StructField, 0, len(shape))
+ info := make([]*shapeFieldInfo, 0, len(shape))
+
+ var hash strings.Builder
- for k, v := range shape {
+ sortedKeys := make([]string, 0, len(shape))
+ for k := range shape {
+ sortedKeys = append(sortedKeys, k)
+ }
+ sort.Strings(sortedKeys)
+
+ for _, k := range sortedKeys {
+ v := shape[k]
vt := reflect.TypeOf(v)
- switch kind := vt.Kind(); kind {
+ switch vt.Kind() {
case reflect.Map:
raw, _ := json.Marshal(v)
- newV := types.JsonMap{}
- newV.Scan(raw)
+ newV := types.JSONMap[any]{}
+ _ = newV.Scan(raw)
v = newV
vt = reflect.TypeOf(v)
case reflect.Slice, reflect.Array:
raw, _ := json.Marshal(v)
- newV := types.JsonArray[any]{}
- newV.Scan(raw)
+ newV := types.JSONArray[any]{}
+ _ = newV.Scan(raw)
v = newV
vt = reflect.TypeOf(newV)
+ case reflect.Pointer:
+ // for pointers always fallback to nil as their default value
+ v = nil
}
- shapeValues = append(shapeValues, reflect.ValueOf(v))
+ hash.WriteString(k)
+ hash.WriteString(":")
+ hash.WriteString(vt.String()) // it doesn't guarantee to be unique across all types but it should be fine with the primitive types DynamicModel is used
+ hash.WriteString("|")
- structFields = append(structFields, reflect.StructField{
- Name: inflector.UcFirst(k), // ensures that the field is exportable
- Type: vt,
- Tag: reflect.StructTag(`db:"` + k + `" json:"` + k + `" form:"` + k + `"`),
- })
+ info = append(info, &shapeFieldInfo{key: k, value: v, valueType: vt})
}
- st := reflect.StructOf(structFields)
+ st := cachedDynamicModelStructs.GetOrSet(hash.String(), func() reflect.Type {
+ structFields := make([]reflect.StructField, len(info))
+
+ for i, item := range info {
+ structFields[i] = reflect.StructField{
+ Name: inflector.UcFirst(item.key), // ensures that the field is exportable
+ Type: item.valueType,
+ Tag: reflect.StructTag(`db:"` + item.key + `" json:"` + item.key + `" form:"` + item.key + `"`),
+ }
+ }
+
+ return reflect.StructOf(structFields)
+ })
+
elem := reflect.New(st).Elem()
- for i, v := range shapeValues {
- elem.Field(i).Set(v)
+ // load default values into the new model
+ for i, item := range info {
+ if item.value == nil {
+ continue
+ }
+ elem.Field(i).Set(reflect.ValueOf(item.value))
}
return elem.Addr().Interface()
}
+
+type shapeFieldInfo struct {
+ value any
+ valueType reflect.Type
+ key string
+}
diff --git a/plugins/jsvm/binds_app_reset_test.go b/plugins/jsvm/binds_app_reset_test.go
new file mode 100644
index 0000000000..1c6f551d6b
--- /dev/null
+++ b/plugins/jsvm/binds_app_reset_test.go
@@ -0,0 +1,314 @@
+package jsvm
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/dop251/goja"
+ "github.com/pocketbase/pocketbase/apis"
+ "github.com/pocketbase/pocketbase/core"
+ "github.com/pocketbase/pocketbase/tests"
+)
+
+func TestHooksAppReset(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ createVM := func() *goja.Runtime {
+ vm := goja.New()
+ vm.SetFieldNameMapper(FieldMapper{})
+ vm.Set("$app", testApp)
+ return vm
+ }
+
+ loader := createVM()
+
+ pool := newPool(1, createVM)
+
+ hooksBinds(testApp, loader, pool)
+
+ // register any hook
+ _, err := loader.RunScript("stub", `
+ onRecordCreate((e) => {
+ e.next()
+
+ $app = 123; // overwrite
+ })
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // insert a dummy record to trigger the JS hook
+ {
+ collection, err := testApp.FindCollectionByNameOrId("demo2")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record := core.NewRecord(collection)
+ record.Set("title", "test")
+ if err := testApp.Save(record); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // check the executor state
+ pool.run(func(vm *goja.Runtime) error {
+ val, err := vm.RunScript("verify", `$app`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if valApp := val.Export(); valApp != testApp {
+ t.Fatalf("Expected $app to reset to its original value, got %v", valApp)
+ }
+
+ return nil
+ })
+}
+
+func TestRouterHandlerAppReset(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ createVM := func() *goja.Runtime {
+ vm := goja.New()
+ vm.SetFieldNameMapper(FieldMapper{})
+ vm.Set("$app", testApp)
+ return vm
+ }
+
+ loader := createVM()
+
+ pool := newPool(1, createVM)
+
+ routerBinds(testApp, loader, pool)
+
+ // register route handler hook
+ _, err := loader.RunScript("stub", `
+ routerAdd("GET", "/test", (e) => {
+ $app = 123; // overwrite
+
+ return e.string(200, "test")
+ })
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create mock web server
+ {
+ baseRouter, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // manually trigger the serve event to ensure that custom app routes and middlewares are registered
+ serveEvent := new(core.ServeEvent)
+ serveEvent.App = testApp
+ serveEvent.Router = baseRouter
+ _ = testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
+ req := httptest.NewRequest("GET", "/test", nil)
+
+ recorder := httptest.NewRecorder()
+
+ mux, err := e.Router.BuildMux()
+ if err != nil {
+ t.Fatalf("Failed to build router mux: %v", err)
+ }
+ mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != 200 {
+ t.Fatalf("Expected status code %d, got %d", 200, recorder.Code)
+ }
+
+ body := recorder.Body.String()
+ if body != "test" {
+ t.Fatalf("Expected body %q, got %q", "test", body)
+ }
+
+ return nil
+ })
+ }
+
+ pool.run(func(vm *goja.Runtime) error {
+ val, err := vm.RunScript("verify", `$app`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if valApp := val.Export(); valApp != testApp {
+ t.Fatalf("Expected $app to reset to its original value, got %v", valApp)
+ }
+
+ return nil
+ })
+}
+
+func TestRouterMiddlewareFuncAppReset(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ createVM := func() *goja.Runtime {
+ vm := goja.New()
+ vm.SetFieldNameMapper(FieldMapper{})
+ vm.Set("$app", testApp)
+ return vm
+ }
+
+ loader := createVM()
+
+ pool := newPool(1, createVM)
+
+ routerBinds(testApp, loader, pool)
+
+ // register route middleware func
+ _, err := loader.RunScript("stub", `
+ routerUse((e) => {
+ e.string(200, "test")
+
+ $app = 123; // overwrite
+ })
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create mock web server
+ {
+ baseRouter, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // manually trigger the serve event to ensure that custom app routes and middlewares are registered
+ serveEvent := new(core.ServeEvent)
+ serveEvent.App = testApp
+ serveEvent.Router = baseRouter
+ _ = testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
+ // it doesn't matter as long as the middleware is called
+ req := httptest.NewRequest("GET", "/anything", nil)
+
+ recorder := httptest.NewRecorder()
+
+ mux, err := e.Router.BuildMux()
+ if err != nil {
+ t.Fatalf("Failed to build router mux: %v", err)
+ }
+ mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != 200 {
+ t.Fatalf("Expected status code %d, got %d", 200, recorder.Code)
+ }
+
+ body := recorder.Body.String()
+ if body != "test" {
+ t.Fatalf("Expected body %q, got %q", "test", body)
+ }
+
+ return nil
+ })
+ }
+
+ pool.run(func(vm *goja.Runtime) error {
+ val, err := vm.RunScript("verify", `$app`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if valApp := val.Export(); valApp != testApp {
+ t.Fatalf("Expected $app to reset to its original value, got %v", valApp)
+ }
+
+ return nil
+ })
+}
+
+func TestRouterMiddlewareClassAppReset(t *testing.T) {
+ t.Parallel()
+
+ testApp, _ := tests.NewTestApp()
+ defer testApp.Cleanup()
+
+ createVM := func() *goja.Runtime {
+ vm := goja.New()
+ vm.SetFieldNameMapper(FieldMapper{})
+ vm.Set("$app", testApp)
+ BindCore(vm)
+ return vm
+ }
+
+ loader := createVM()
+
+ pool := newPool(1, createVM)
+
+ routerBinds(testApp, loader, pool)
+
+ // register route middleware class
+ _, err := loader.RunScript("stub", `
+ routerUse(new Middleware((e) => {
+ e.string(200, "test")
+
+ $app = 123; // overwrite
+ }))
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create mock web server
+ {
+ baseRouter, err := apis.NewRouter(testApp)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // manually trigger the serve event to ensure that custom app routes and middlewares are registered
+ serveEvent := new(core.ServeEvent)
+ serveEvent.App = testApp
+ serveEvent.Router = baseRouter
+ _ = testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
+ // it doesn't matter as long as the middleware is called
+ req := httptest.NewRequest("GET", "/anything", nil)
+
+ recorder := httptest.NewRecorder()
+
+ mux, err := e.Router.BuildMux()
+ if err != nil {
+ t.Fatalf("Failed to build router mux: %v", err)
+ }
+ mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != 200 {
+ t.Fatalf("Expected status code %d, got %d", 200, recorder.Code)
+ }
+
+ body := recorder.Body.String()
+ if body != "test" {
+ t.Fatalf("Expected body %q, got %q", "test", body)
+ }
+
+ return nil
+ })
+ }
+
+ pool.run(func(vm *goja.Runtime) error {
+ val, err := vm.RunScript("verify", `$app`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if valApp := val.Export(); valApp != testApp {
+ t.Fatalf("Expected $app to reset to its original value, got %v", valApp)
+ }
+
+ return nil
+ })
+}
diff --git a/plugins/jsvm/binds_test.go b/plugins/jsvm/binds_test.go
index 5b0a369475..a93a58b8c5 100644
--- a/plugins/jsvm/binds_test.go
+++ b/plugins/jsvm/binds_test.go
@@ -1,11 +1,15 @@
package jsvm
import (
- "encoding/json"
+ "bytes"
+ "encoding/json/v2"
+ "errors"
+ "fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
"strconv"
"strings"
@@ -13,17 +17,14 @@ import (
"time"
"github.com/dop251/goja"
- validation "github.com/go-ozzo/ozzo-validation/v4"
- "github.com/pocketbase/dbx"
+ validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
- "github.com/pocketbase/pocketbase/daos"
- "github.com/pocketbase/pocketbase/models"
- "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/mailer"
- "github.com/pocketbase/pocketbase/tools/security"
+ "github.com/pocketbase/pocketbase/tools/router"
+ "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
@@ -42,19 +43,35 @@ func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T)
// note: this test is useful as a reminder to update the tests in case
// a new base binding is added.
-func TestBaseBindsCount(t *testing.T) {
+func TestBindCoreCount(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
- testBindsCount(vm, "this", 14, t)
+ testBindsCount(vm, "this", 41, t)
}
-func TestBaseBindsReaderToString(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
+func TestBindCoreSleep(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("reader", strings.NewReader("test"))
+
+ start := time.Now()
+ _, err := vm.RunString(`
+ sleep(100);
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lasted := time.Since(start).Milliseconds()
+ if lasted < 100 || lasted > 150 {
+ t.Fatalf("Expected to sleep for ~100ms, got %d", lasted)
+ }
+}
+
+func TestBindCoreReaderToString(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
vm.Set("reader", strings.NewReader("test"))
_, err := vm.RunString(`
@@ -69,17 +86,193 @@ func TestBaseBindsReaderToString(t *testing.T) {
}
}
-func TestBaseBindsRecord(t *testing.T) {
+func TestBindCoreToString(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("scenarios", []struct {
+ Name string
+ Value any
+ Expected string
+ }{
+ {"null", nil, ""},
+ {"string", "test", "test"},
+ {"number", -12.4, "-12.4"},
+ {"bool", true, "true"},
+ {"arr", []int{1, 2, 3}, `[1,2,3]`},
+ {"obj", map[string]any{"test": 123}, `{"test":123}`},
+ {"reader", strings.NewReader("test"), "test"},
+ {"struct", struct {
+ Name string
+ private string
+ }{Name: "123", private: "456"}, `{"Name":"123"}`},
+ })
+
+ _, err := vm.RunString(`
+ for (let s of scenarios) {
+ let str = toString(s.value)
+ if (str != s.expected) {
+ throw new Error('[' + s.name + '] Expected string ' + s.expected + ', got ' + str);
+ }
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreToBytes(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("bytesEqual", bytes.Equal)
+ vm.Set("scenarios", []struct {
+ Name string
+ Value any
+ Expected []byte
+ }{
+ {"null", nil, []byte{}},
+ {"string", "test", []byte("test")},
+ {"number", -12.4, []byte("-12.4")},
+ {"bool", true, []byte("true")},
+ {"arr", []int{1, 2, 3}, []byte{1, 2, 3}},
+ {"jsonraw", types.JSONRaw{1, 2, 3}, []byte{1, 2, 3}},
+ {"reader", strings.NewReader("test"), []byte("test")},
+ {"obj", map[string]any{"test": 123}, []byte(`{"test":123}`)},
+ {"struct", struct {
+ Name string
+ private string
+ }{Name: "123", private: "456"}, []byte(`{"Name":"123"}`)},
+ })
+
+ _, err := vm.RunString(`
+ for (let s of scenarios) {
+ let b = toBytes(s.value)
+ if (!Array.isArray(b)) {
+ throw new Error('[' + s.name + '] Expected toBytes to return an array');
+ }
+
+ if (!bytesEqual(b, s.expected)) {
+ throw new Error('[' + s.name + '] Expected bytes ' + s.expected + ', got ' + b);
+ }
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreUnmarshal(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("data", &map[string]any{"a": 123})
+
+ _, err := vm.RunString(`
+ unmarshal({"b": 456}, data)
+
+ if (data.a != 123) {
+ throw new Error('Expected data.a 123, got ' + data.a);
+ }
+
+ if (data.b != 456) {
+ throw new Error('Expected data.b 456, got ' + data.b);
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreContext(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+
+ _, err := vm.RunString(`
+ const base = new Context(null, "a", 123);
+ const sub = new Context(base, "b", 456);
+
+ const scenarios = [
+ {key: "a", expected: 123},
+ {key: "b", expected: 456},
+ ];
+
+ for (let s of scenarios) {
+ if (sub.value(s.key) != s.expected) {
+ throw new("Expected " +s.key + " value " + s.expected + ", got " + sub.value(s.key));
+ }
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreCookie(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+
+ _, err := vm.RunString(`
+ const cookie = new Cookie({
+ name: "example_name",
+ value: "example_value",
+ path: "/example_path",
+ domain: "example.com",
+ maxAge: 10,
+ secure: true,
+ httpOnly: true,
+ sameSite: 3,
+ });
+
+ const result = cookie.string();
+
+ const expected = "example_name=example_value; Path=/example_path; Domain=example.com; Max-Age=10; HttpOnly; Secure; SameSite=Strict";
+
+ if (expected != result) {
+ throw new("Expected \n" + expected + "\ngot\n" + result);
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreSubscriptionMessage(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("bytesToString", func(b []byte) string {
+ return string(b)
+ })
+
+ _, err := vm.RunString(`
+ const payload = {
+ name: "test",
+ data: '{"test":123}'
+ }
+
+ const result = new SubscriptionMessage(payload);
+
+ if (result.name != payload.name) {
+ throw new("Expected name " + payload.name + ", got " + result.name);
+ }
+
+ if (bytesToString(result.data) != payload.data) {
+ throw new("Expected data '" + payload.data + "', got '" + bytesToString(result.data) + "'");
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- collection, err := app.Dao().FindCollectionByNameOrId("users")
+ collection, err := app.FindCachedCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
vm.Set("collection", collection)
// without record data
@@ -89,7 +282,7 @@ func TestBaseBindsRecord(t *testing.T) {
t.Fatal(err)
}
- m1, ok := v1.Export().(*models.Record)
+ m1, ok := v1.Export().(*core.Record)
if !ok {
t.Fatalf("Expected m1 to be models.Record, got \n%v", m1)
}
@@ -101,9 +294,9 @@ func TestBaseBindsRecord(t *testing.T) {
t.Fatal(err)
}
- m2, ok := v2.Export().(*models.Record)
+ m2, ok := v2.Export().(*core.Record)
if !ok {
- t.Fatalf("Expected m2 to be models.Record, got \n%v", m2)
+ t.Fatalf("Expected m2 to be core.Record, got \n%v", m2)
}
if m2.Collection().Name != "users" {
@@ -115,18 +308,18 @@ func TestBaseBindsRecord(t *testing.T) {
}
}
-func TestBaseBindsCollection(t *testing.T) {
+func TestBindCoreCollection(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
- v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", schema: [{name: "title", "type": "text"}] })`)
+ v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`)
if err != nil {
t.Fatal(err)
}
- m, ok := v.Export().(*models.Collection)
+ m, ok := v.Export().(*core.Collection)
if !ok {
- t.Fatalf("Expected models.Collection, got %v", m)
+ t.Fatalf("Expected core.Collection, got %v", m)
}
if m.Name != "test" {
@@ -138,67 +331,148 @@ func TestBaseBindsCollection(t *testing.T) {
t.Fatalf("Expected create rule %q, got %v", "@request.auth.id != ''", m.CreateRule)
}
- if f := m.Schema.GetFieldByName("title"); f == nil {
- t.Fatalf("Expected schema to be set, got %v", m.Schema)
+ if f := m.Fields.GetByName("title"); f == nil {
+ t.Fatalf("Expected fields to be set, got %v", m.Fields)
}
}
-func TestBaseVMAdminBind(t *testing.T) {
+func TestBindCoreFieldsList(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
- v, err := vm.RunString(`new Admin({ email: "test@example.com" })`)
+ v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`)
if err != nil {
t.Fatal(err)
}
- m, ok := v.Export().(*models.Admin)
+ m, ok := v.Export().(*core.FieldsList)
if !ok {
- t.Fatalf("Expected models.Admin, got %v", m)
+ t.Fatalf("Expected core.FieldsList, got %v", m)
+ }
+
+ if f := m.GetByName("title"); f == nil {
+ t.Fatalf("Expected fields list to be loaded, got %v", m)
}
}
-func TestBaseBindsSchema(t *testing.T) {
+func TestBindCoreField(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
- v, err := vm.RunString(`new Schema([{name: "title", "type": "text"}])`)
+ v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`)
if err != nil {
t.Fatal(err)
}
- m, ok := v.Export().(*schema.Schema)
+ f, ok := v.Export().(*core.BoolField)
if !ok {
- t.Fatalf("Expected schema.Schema, got %v", m)
+ t.Fatalf("Expected *core.BoolField, got %v", f)
}
- if f := m.GetFieldByName("title"); f == nil {
- t.Fatalf("Expected schema fields to be loaded, got %v", m.Fields())
+ if f.Name != "test" {
+ t.Fatalf("Expected field %q, got %v", "test", f)
}
}
-func TestBaseBindsSchemaField(t *testing.T) {
+func isType[T any](v any) bool {
+ _, ok := v.(T)
+ return ok
+}
+
+func TestBindCoreNamedFields(t *testing.T) {
+ t.Parallel()
+
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
- v, err := vm.RunString(`new SchemaField({name: "title", "type": "text"})`)
- if err != nil {
- t.Fatal(err)
+ scenarios := []struct {
+ js string
+ typeFunc func(v any) bool
+ }{
+ {
+ "new NumberField({name: 'test'})",
+ isType[*core.NumberField],
+ },
+ {
+ "new BoolField({name: 'test'})",
+ isType[*core.BoolField],
+ },
+ {
+ "new TextField({name: 'test'})",
+ isType[*core.TextField],
+ },
+ {
+ "new URLField({name: 'test'})",
+ isType[*core.URLField],
+ },
+ {
+ "new EmailField({name: 'test'})",
+ isType[*core.EmailField],
+ },
+ {
+ "new EditorField({name: 'test'})",
+ isType[*core.EditorField],
+ },
+ {
+ "new PasswordField({name: 'test'})",
+ isType[*core.PasswordField],
+ },
+ {
+ "new DateField({name: 'test'})",
+ isType[*core.DateField],
+ },
+ {
+ "new AutodateField({name: 'test'})",
+ isType[*core.AutodateField],
+ },
+ {
+ "new JSONField({name: 'test'})",
+ isType[*core.JSONField],
+ },
+ {
+ "new RelationField({name: 'test'})",
+ isType[*core.RelationField],
+ },
+ {
+ "new SelectField({name: 'test'})",
+ isType[*core.SelectField],
+ },
+ {
+ "new FileField({name: 'test'})",
+ isType[*core.FileField],
+ },
+ {
+ "new GeoPointField({name: 'test'})",
+ isType[*core.GeoPointField],
+ },
}
- f, ok := v.Export().(*schema.SchemaField)
- if !ok {
- t.Fatalf("Expected schema.SchemaField, got %v", f)
- }
+ for _, s := range scenarios {
+ t.Run(s.js, func(t *testing.T) {
+ v, err := vm.RunString(s.js)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ f, ok := v.Export().(core.Field)
+ if !ok {
+ t.Fatalf("Expected core.Field instance, got %T (%v)", f, f)
+ }
- if f.Name != "title" {
- t.Fatalf("Expected field %q, got %v", "title", f)
+ if !s.typeFunc(f) {
+ t.Fatalf("Unexpected field type %T (%v)", f, f)
+ }
+
+ if f.GetName() != "test" {
+ t.Fatalf("Expected field %q, got %v", "test", f)
+ }
+ })
}
}
-func TestBaseBindsMailerMessage(t *testing.T) {
+func TestBindCoreMailerMessage(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
v, err := vm.RunString(`new MailerMessage({
from: {name: "test_from", address: "test_from@example.com"},
@@ -231,21 +505,21 @@ func TestBaseBindsMailerMessage(t *testing.T) {
t.Fatalf("Expected mailer.Message, got %v", m)
}
- raw, err := json.Marshal(m)
+ raw, err := json.Marshal(m, json.Deterministic(true))
if err != nil {
t.Fatal(err)
}
- expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null}`
+ expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":{},"inlineAttachments":{}}`
if string(raw) != expected {
t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw)
}
}
-func TestBaseBindsCommand(t *testing.T) {
+func TestBindCoreCommand(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
_, err := vm.RunString(`
let runCalls = 0;
@@ -272,22 +546,37 @@ func TestBaseBindsCommand(t *testing.T) {
}
}
-func TestBaseBindsRequestInfo(t *testing.T) {
+func TestBindCoreRequestInfo(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
_, err := vm.RunString(`
- let info = new RequestInfo({
- admin: new Admin({id: "test1"}),
- data: {"name": "test2"}
+ const info = new RequestInfo({
+ body: {"name": "test2"}
});
- if (info.admin?.id != "test1") {
- throw new Error('Expected info.admin.id to be test1, got: ' + info.admin?.id);
+ if (info.body?.name != "test2") {
+ throw new Error('Expected info.body.name to be test2, got: ' + info.body?.name);
}
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
- if (info.data?.name != "test2") {
- throw new Error('Expected info.data.name to be test2, got: ' + info.data?.name);
+func TestBindCoreMiddleware(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+
+ _, err := vm.RunString(`
+ const m = new Middleware(
+ (e) => {},
+ 10,
+ "test"
+ );
+
+ if (!m) {
+ throw new Error('Expected non-empty Middleware instance');
}
`)
if err != nil {
@@ -295,20 +584,24 @@ func TestBaseBindsRequestInfo(t *testing.T) {
}
}
-func TestBaseBindsDateTime(t *testing.T) {
+func TestBindCoreTimezone(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
_, err := vm.RunString(`
- const v0 = new DateTime();
- if (v0.isZero()) {
- throw new Error('Expected to fallback to now, got zero value');
+ const v0 = (new Timezone()).string();
+ if (v0 != "UTC") {
+ throw new Error("(v0) Expected UTC got " + v0)
+ }
+
+ const v1 = (new Timezone("invalid")).string();
+ if (v1 != "UTC") {
+ throw new Error("(v1) Expected UTC got " + v1)
}
- const v1 = new DateTime('2023-01-01 00:00:00.000Z');
- const expected = "2023-01-01 00:00:00.000Z"
- if (v1.string() != expected) {
- throw new Error('Expected ' + expected + ', got ', v1.string());
+ const v2 = (new Timezone("EET")).string();
+ if (v2 != "EET") {
+ throw new Error("(v2) Expected EET got " + v2)
}
`)
if err != nil {
@@ -316,9 +609,50 @@ func TestBaseBindsDateTime(t *testing.T) {
}
}
-func TestBaseBindsValidationError(t *testing.T) {
+func TestBindCoreDateTime(t *testing.T) {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
+
+ _, err := vm.RunString(`
+ const now = new DateTime();
+ if (now.isZero()) {
+ throw new Error('(now) Expected to fallback to now, got zero value');
+ }
+
+ const nowPart = now.string().substring(0, 19)
+
+ const scenarios = [
+ // empty datetime string and no custom location
+ {date: new DateTime(''), expected: nowPart},
+ // empty datetime string and custom default location (should be ignored)
+ {date: new DateTime('', 'Asia/Tokyo'), expected: nowPart},
+ // full datetime string and no custom default location
+ {date: new DateTime('2023-01-01 00:00:00.000Z'), expected: "2023-01-01 00:00:00.000Z"},
+ // invalid location (fallback to UTC)
+ {date: new DateTime('2025-10-26 03:00:00', 'invalid'), expected: "2025-10-26 03:00:00.000Z"},
+ // CET
+ {date: new DateTime('2025-10-26 03:00:00', 'Europe/Amsterdam'), expected: "2025-10-26 02:00:00.000Z"},
+ // CEST
+ {date: new DateTime('2025-10-26 01:00:00', 'Europe/Amsterdam'), expected: "2025-10-25 23:00:00.000Z"},
+ // with timezone/offset in the date string (aka. should ignore the custom default location)
+ {date: new DateTime('2025-10-26 01:00:00 +0200', 'Asia/Tokyo'), expected: "2025-10-25 23:00:00.000Z"},
+ ];
+
+ for (let i = 0; i < scenarios.length; i++) {
+ const s = scenarios[i];
+ if (!s.date.string().includes(s.expected)) {
+ throw new Error('(' + i + ') ' + s.date.string() + ' does not contain expected ' + s.expected);
+ }
+ }
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestBindCoreValidationError(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
scenarios := []struct {
js string
@@ -363,61 +697,14 @@ func TestBaseBindsValidationError(t *testing.T) {
}
}
-func TestBaseBindsDao(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- vm := goja.New()
- baseBinds(vm)
- vm.Set("db", app.Dao().ConcurrentDB())
- vm.Set("db2", app.Dao().NonconcurrentDB())
-
- scenarios := []struct {
- js string
- concurrentDB dbx.Builder
- nonconcurrentDB dbx.Builder
- }{
- {
- js: "new Dao(db)",
- concurrentDB: app.Dao().ConcurrentDB(),
- nonconcurrentDB: app.Dao().ConcurrentDB(),
- },
- {
- js: "new Dao(db, db2)",
- concurrentDB: app.Dao().ConcurrentDB(),
- nonconcurrentDB: app.Dao().NonconcurrentDB(),
- },
- }
-
- for _, s := range scenarios {
- v, err := vm.RunString(s.js)
- if err != nil {
- t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err)
- }
-
- d, ok := v.Export().(*daos.Dao)
- if !ok {
- t.Fatalf("[%s] Expected daos.Dao, got %v", s.js, d)
- }
-
- if d.ConcurrentDB() != s.concurrentDB {
- t.Fatalf("[%s] The ConcurrentDB instances doesn't match", s.js)
- }
-
- if d.NonconcurrentDB() != s.nonconcurrentDB {
- t.Fatalf("[%s] The NonconcurrentDB instances doesn't match", s.js)
- }
- }
-}
-
-func TestDbxBinds(t *testing.T) {
+func TestBindDbx(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
- vm.Set("db", app.Dao().DB())
- baseBinds(vm)
- dbxBinds(vm)
+ vm.Set("db", app.DB())
+ BindCore(vm)
+ BindDbx(vm)
testBindsCount(vm, "$dbx", 15, t)
@@ -505,154 +792,70 @@ func TestDbxBinds(t *testing.T) {
}
}
-func TestMailsBindsCount(t *testing.T) {
+func TestBindMailsCount(t *testing.T) {
vm := goja.New()
- mailsBinds(vm)
+ BindMails(vm)
- testBindsCount(vm, "$mails", 4, t)
+ testBindsCount(vm, "$mails", 5, t)
}
-func TestMailsBinds(t *testing.T) {
+func TestBindMails(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
- admin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- record, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
+ record, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
vm := goja.New()
- baseBinds(vm)
- mailsBinds(vm)
+ BindCore(vm)
+ BindMails(vm)
vm.Set("$app", app)
- vm.Set("admin", admin)
vm.Set("record", record)
_, vmErr := vm.RunString(`
- $mails.sendAdminPasswordReset($app, admin);
- if (!$app.testMailer.lastMessage.html.includes("/_/#/confirm-password-reset/")) {
- throw new Error("Expected admin password reset email")
- }
-
$mails.sendRecordPasswordReset($app, record);
- if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-password-reset/")) {
- throw new Error("Expected record password reset email")
+ if (!$app.testMailer.lastMessage().html.includes("/_/#/auth/confirm-password-reset/")) {
+ throw new Error("Expected record password reset email, got:" + JSON.stringify($app.testMailer.lastMessage()))
}
$mails.sendRecordVerification($app, record);
- if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-verification/")) {
- throw new Error("Expected record verification email")
+ if (!$app.testMailer.lastMessage().html.includes("/_/#/auth/confirm-verification/")) {
+ throw new Error("Expected record verification email, got:" + JSON.stringify($app.testMailer.lastMessage()))
}
$mails.sendRecordChangeEmail($app, record, "new@example.com");
- if (!$app.testMailer.lastMessage.html.includes("/_/#/auth/confirm-email-change/")) {
- throw new Error("Expected record email change email")
+ if (!$app.testMailer.lastMessage().html.includes("/_/#/auth/confirm-email-change/")) {
+ throw new Error("Expected record email change email, got:" + JSON.stringify($app.testMailer.lastMessage()))
}
- `)
- if vmErr != nil {
- t.Fatal(vmErr)
- }
-}
-
-func TestTokensBindsCount(t *testing.T) {
- vm := goja.New()
- tokensBinds(vm)
-
- testBindsCount(vm, "$tokens", 8, t)
-}
-
-func TestTokensBinds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- admin, err := app.Dao().FindAdminByEmail("test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- record, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
- if err != nil {
- t.Fatal(err)
- }
-
- vm := goja.New()
- baseBinds(vm)
- tokensBinds(vm)
- vm.Set("$app", app)
- vm.Set("admin", admin)
- vm.Set("record", record)
- sceneraios := []struct {
- js string
- key string
- }{
- {
- `$tokens.adminAuthToken($app, admin)`,
- admin.TokenKey + app.Settings().AdminAuthToken.Secret,
- },
- {
- `$tokens.adminResetPasswordToken($app, admin)`,
- admin.TokenKey + app.Settings().AdminPasswordResetToken.Secret,
- },
- {
- `$tokens.adminFileToken($app, admin)`,
- admin.TokenKey + app.Settings().AdminFileToken.Secret,
- },
- {
- `$tokens.recordAuthToken($app, record)`,
- record.TokenKey() + app.Settings().RecordAuthToken.Secret,
- },
- {
- `$tokens.recordVerifyToken($app, record)`,
- record.TokenKey() + app.Settings().RecordVerificationToken.Secret,
- },
- {
- `$tokens.recordResetPasswordToken($app, record)`,
- record.TokenKey() + app.Settings().RecordPasswordResetToken.Secret,
- },
- {
- `$tokens.recordChangeEmailToken($app, record)`,
- record.TokenKey() + app.Settings().RecordEmailChangeToken.Secret,
- },
- {
- `$tokens.recordFileToken($app, record)`,
- record.TokenKey() + app.Settings().RecordFileToken.Secret,
- },
- }
-
- for _, s := range sceneraios {
- result, err := vm.RunString(s.js)
- if err != nil {
- t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err)
+ $mails.sendRecordOTP($app, record, "test_otp_id", "test_otp_pass");
+ if (!$app.testMailer.lastMessage().html.includes("test_otp_pass")) {
+ throw new Error("Expected record OTP email, got:" + JSON.stringify($app.testMailer.lastMessage()))
}
- v, _ := result.Export().(string)
-
- if _, err := security.ParseJWT(v, s.key); err != nil {
- t.Fatalf("[%s] Failed to parse JWT %v, got %v", s.js, v, err)
+ $mails.sendRecordAuthAlert($app, record, "test_alert_info");
+ if (!$app.testMailer.lastMessage().html.includes("test_alert_info")) {
+ throw new Error("Expected record OTP email, got:" + JSON.stringify($app.testMailer.lastMessage()))
}
+ `)
+ if vmErr != nil {
+ t.Fatal(vmErr)
}
}
-func TestSecurityBindsCount(t *testing.T) {
+func TestBindSecurityCount(t *testing.T) {
vm := goja.New()
- securityBinds(vm)
+ BindSecurity(vm)
- testBindsCount(vm, "$security", 15, t)
+ testBindsCount(vm, "$security", 16, t)
}
func TestSecurityCryptoBinds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
vm := goja.New()
- baseBinds(vm)
- securityBinds(vm)
+ BindCore(vm)
+ BindSecurity(vm)
sceneraios := []struct {
js string
@@ -684,12 +887,9 @@ func TestSecurityCryptoBinds(t *testing.T) {
}
func TestSecurityRandomStringBinds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
vm := goja.New()
- baseBinds(vm)
- securityBinds(vm)
+ BindCore(vm)
+ BindSecurity(vm)
sceneraios := []struct {
js string
@@ -699,6 +899,7 @@ func TestSecurityRandomStringBinds(t *testing.T) {
{`$security.randomStringWithAlphabet(7, "abc")`, 7},
{`$security.pseudorandomString(8)`, 8},
{`$security.pseudorandomStringWithAlphabet(9, "abc")`, 9},
+ {`$security.randomStringByRegex("abc")`, 3},
}
for _, s := range sceneraios {
@@ -718,54 +919,66 @@ func TestSecurityRandomStringBinds(t *testing.T) {
}
func TestSecurityJWTBinds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
- vm := goja.New()
- baseBinds(vm)
- securityBinds(vm)
-
sceneraios := []struct {
- js string
- expected string
+ name string
+ js string
}{
{
- `$security.parseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY")`,
- `{"name":"John Doe","sub":"1234567890"}`,
+ "$security.parseUnverifiedJWT",
+ `
+ const result = $security.parseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY")
+ if (result.name != "John Doe") {
+ throw new Error("Expected result.name 'John Doe', got " + result.name)
+ }
+ if (result.sub != "1234567890") {
+ throw new Error("Expected result.sub '1234567890', got " + result.sub)
+ }
+ `,
},
{
- `$security.parseJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY", "test")`,
- `{"name":"John Doe","sub":"1234567890"}`,
+ "$security.parseJWT",
+ `
+ const result = $security.parseJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY", "test")
+ if (result.name != "John Doe") {
+ throw new Error("Expected result.name 'John Doe', got " + result.name)
+ }
+ if (result.sub != "1234567890") {
+ throw new Error("Expected result.sub '1234567890', got " + result.sub)
+ }
+ `,
},
{
- `$security.createJWT({"exp": 123}, "test", 0)`, // overwrite the exp claim for static token
- `"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyM30.7gbv7w672gApdBRASI6OniCtKwkKjhieSxsr6vxSrtw"`,
+ "$security.createJWT",
+ `
+ // overwrite the exp claim for static token
+ const result = $security.createJWT({"exp": 123}, "test", 0)
+
+ const expected = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyM30.7gbv7w672gApdBRASI6OniCtKwkKjhieSxsr6vxSrtw";
+ if (result != expected) {
+ throw new Error("Expected token \n" + expected + ", got \n" + result)
+ }
+ `,
},
}
for _, s := range sceneraios {
- t.Run(s.js, func(t *testing.T) {
- result, err := vm.RunString(s.js)
+ t.Run(s.name, func(t *testing.T) {
+ vm := goja.New()
+ BindCore(vm)
+ BindSecurity(vm)
+
+ _, err := vm.RunString(s.js)
if err != nil {
t.Fatalf("Failed to execute js script, got %v", err)
}
-
- raw, _ := json.Marshal(result.Export())
-
- if string(raw) != s.expected {
- t.Fatalf("Expected \n%s, \ngot \n%s", s.expected, raw)
- }
})
}
}
func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
vm := goja.New()
- baseBinds(vm)
- securityBinds(vm)
+ BindCore(vm)
+ BindSecurity(vm)
_, err := vm.RunString(`
const key = "abcdabcdabcdabcdabcdabcdabcdabcd"
@@ -783,17 +996,60 @@ func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
}
}
-func TestFilesystemBinds(t *testing.T) {
+func TestBindFilesystem(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/error" {
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+
+ fmt.Fprintf(w, "test")
+ }))
+ defer srv.Close()
+
+ tmpDir, err := os.MkdirTemp("", "jsvm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
vm := goja.New()
vm.Set("mh", &multipart.FileHeader{Filename: "test"})
+ vm.Set("tmpDir", tmpDir)
vm.Set("testFile", filepath.Join(app.DataDir(), "data.db"))
- baseBinds(vm)
- filesystemBinds(vm)
+ vm.Set("baseURL", srv.URL)
+ BindCore(vm)
+ BindFilesystem(vm)
+
+ testBindsCount(vm, "$filesystem", 6, t)
+
+ // s3
+ {
+ v, err := vm.RunString(`$filesystem.s3("bucketName", "region", "endpoint", "accessKey", "secretKey", true)`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fsys, ok := v.Export().(*filesystem.System)
+ if !ok {
+ t.Fatalf("[s3] Expected System instance got %v", fsys)
+ }
+ }
+
+ // local
+ {
+ v, err := vm.RunString(`$filesystem.local(tmpDir)`)
+ if err != nil {
+ t.Fatal(err)
+ }
- testBindsCount(vm, "$filesystem", 3, t)
+ fsys, ok := v.Export().(*filesystem.System)
+ if !ok {
+ t.Fatalf("[s3] Expected System instance got %v", fsys)
+ }
+ }
// fileFromPath
{
@@ -836,36 +1092,52 @@ func TestFilesystemBinds(t *testing.T) {
t.Fatalf("[fileFromMultipart] Expected file with name %q, got %v", file.OriginalName, file)
}
}
+
+ // fileFromURL (success)
+ {
+ v, err := vm.RunString(`$filesystem.fileFromURL(baseURL + "/test")`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ file, _ := v.Export().(*filesystem.File)
+
+ if file == nil || file.OriginalName != "test" {
+ t.Fatalf("[fileFromURL] Expected file with name %q, got %v", file.OriginalName, file)
+ }
+ }
+
+ // fileFromURL (failure)
+ {
+ _, err := vm.RunString(`$filesystem.fileFromURL(baseURL + "/error")`)
+ if err == nil {
+ t.Fatal("Expected url fetch error")
+ }
+ }
}
-func TestFormsBinds(t *testing.T) {
+func TestBindForms(t *testing.T) {
vm := goja.New()
- formsBinds(vm)
+ BindForms(vm)
- testBindsCount(vm, "this", 20, t)
+ testBindsCount(vm, "this", 4, t)
}
-func TestApisBindsCount(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
+func TestBindApisCount(t *testing.T) {
vm := goja.New()
- apisBinds(vm)
+ BindApis(vm)
- testBindsCount(vm, "this", 6, t)
+ testBindsCount(vm, "this", 8, t)
testBindsCount(vm, "$apis", 11, t)
}
-func TestApisBindsApiError(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
+func TestBindApisErrors(t *testing.T) {
vm := goja.New()
- apisBinds(vm)
+ BindApis(vm)
scenarios := []struct {
js string
- expectCode int
+ expectStatus int
expectMessage string
expectData string
}{
@@ -877,8 +1149,12 @@ func TestApisBindsApiError(t *testing.T) {
{"new BadRequestError('test', {'test': 1})", 400, "Test.", `{"test":1}`},
{"new ForbiddenError()", 403, "You are not allowed to perform this request.", "null"},
{"new ForbiddenError('test', {'test': 1})", 403, "Test.", `{"test":1}`},
- {"new UnauthorizedError()", 401, "Missing or invalid authentication token.", "null"},
+ {"new UnauthorizedError()", 401, "Missing or invalid authentication.", "null"},
{"new UnauthorizedError('test', {'test': 1})", 401, "Test.", `{"test":1}`},
+ {"new TooManyRequestsError()", 429, "Too Many Requests.", "null"},
+ {"new TooManyRequestsError('test', {'test': 1})", 429, "Test.", `{"test":1}`},
+ {"new InternalServerError()", 500, "Something went wrong while processing your request.", "null"},
+ {"new InternalServerError('test', {'test': 1})", 500, "Test.", `{"test":1}`},
}
for _, s := range scenarios {
@@ -888,21 +1164,21 @@ func TestApisBindsApiError(t *testing.T) {
continue
}
- apiErr, ok := v.Export().(*apis.ApiError)
+ apiErr, ok := v.Export().(*router.ApiError)
if !ok {
t.Errorf("[%s] Expected ApiError, got %v", s.js, v)
continue
}
- if apiErr.Code != s.expectCode {
- t.Errorf("[%s] Expected Code %d, got %d", s.js, s.expectCode, apiErr.Code)
+ if apiErr.Status != s.expectStatus {
+ t.Errorf("[%s] Expected Status %d, got %d", s.js, s.expectStatus, apiErr.Status)
}
if apiErr.Message != s.expectMessage {
t.Errorf("[%s] Expected Message %q, got %q", s.js, s.expectMessage, apiErr.Message)
}
- dataRaw, _ := json.Marshal(apiErr.RawData())
+ dataRaw, _ := json.Marshal(apiErr.RawData(), json.Deterministic(true))
if string(dataRaw) != s.expectData {
t.Errorf("[%s] Expected Data %q, got %q", s.js, s.expectData, dataRaw)
}
@@ -914,50 +1190,154 @@ func TestLoadingDynamicModel(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
- baseBinds(vm)
- dbxBinds(vm)
+ BindCore(vm)
+ BindDbx(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
let result = new DynamicModel({
- text: "",
- bool: false,
- number: 0,
- select_many: [],
- json: [],
- // custom map-like field
- obj: {},
+ string: "",
+ nullString: nullString(),
+ nullStringEmpty: nullString(),
+
+ bool: false,
+ nullBool: nullBool(),
+ nullBoolEmpty: nullBool(),
+
+ int: 0,
+ nullInt: nullInt(),
+ nullIntEmpty: nullInt(),
+
+ float: -0,
+ nullFloat: nullFloat(),
+ nullFloatEmpty: nullFloat(),
+
+ array: [],
+ nullArray: nullArray(),
+ nullArrayEmpty: nullArray(),
+
+ object: {},
+ nullObject: nullObject(),
+ nullObjectEmpty: nullObject(),
})
- $app.dao().db()
- .select("text", "bool", "number", "select_many", "json", "('{\"test\": 1}') as obj")
- .from("demo1")
- .where($dbx.hashExp({"id": "84nmscqy84lsi1t"}))
- .limit(1)
+ const expectations = {
+ "string": "a",
+ "nullString": "b",
+ "nullStringEmpty": null,
+
+ "bool": false,
+ "nullBool": true,
+ "nullBoolEmpty": null,
+
+ "int": 1,
+ "nullInt": 2,
+ "nullIntEmpty": null,
+
+ "float": 1.1,
+ "nullFloat": 1.2,
+ "nullFloatEmpty": null,
+
+ "array": [1,2],
+ "nullArray": [3,4],
+ "nullArrayEmpty": null,
+
+ "object": {a:1},
+ "nullObject": {a:2},
+ "nullObjectEmpty": null,
+ };
+
+ // construct dummy SELECT column value literals based on the expectations
+ const selectColumns = [];
+ for (const col in expectations) {
+ const val = expectations[col]
+
+ if (val === null) {
+ selectColumns.push("null as [[" + col + "]]")
+ } else if (typeof val === "string") {
+ selectColumns.push("'" + val + "' as [[" + col + "]]")
+ } else if (typeof val === "object") {
+ selectColumns.push("'" + JSON.stringify(val) + "' as [[" + col + "]]")
+ } else {
+ selectColumns.push(val + " as [[" + col + "]]")
+ }
+ }
+
+ $app.db()
+ .newQuery("SELECT " + selectColumns.join(", "))
.one(result)
- if (result.text != "test") {
- throw new Error('Expected text "test", got ' + result.text);
- }
+ for (const col in expectations) {
+ let expVal = expectations[col];
+ let resVal = result[col];
- if (result.bool != true) {
- throw new Error('Expected bool true, got ' + result.bool);
- }
+ if (expVal !== null && typeof expVal === "object") {
+ expVal = JSON.stringify(expVal)
+ resVal = JSON.stringify(resVal)
+ }
- if (result.number != 123456) {
- throw new Error('Expected number 123456, got ' + result.number);
+ if (expVal != resVal) {
+ throw new Error("Expected '" + col + "' value " + expVal + ", got " + resVal);
+ }
}
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
- if (result.select_many.length != 2 || result.select_many[0] != "optionB" || result.select_many[1] != "optionC") {
- throw new Error('Expected select_many ["optionB", "optionC"], got ' + result.select_many);
- }
+func TestDynamicModelMapFieldCaching(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
- if (result.json.length != 3 || result.json[0] != 1 || result.json[1] != 2 || result.json[2] != 3) {
- throw new Error('Expected json [1, 2, 3], got ' + result.json);
+ vm := goja.New()
+ BindCore(vm)
+ BindDbx(vm)
+ vm.Set("$app", app)
+
+ _, err := vm.RunString(`
+ let m1 = new DynamicModel({
+ int: 0,
+ float: -0,
+ text: "",
+ bool: false,
+ obj: {},
+ arr: [],
+ })
+
+ let m2 = new DynamicModel({
+ int: 0,
+ float: -0,
+ text: "",
+ bool: false,
+ obj: {},
+ arr: [],
+ })
+
+ m1.int = 1
+ m1.float = 1.5
+ m1.text = "a"
+ m1.bool = true
+ m1.obj.set("a", 1)
+ m1.arr.push(1)
+
+ m2.int = 2
+ m2.float = 2.5
+ m2.text = "b"
+ m2.bool = false
+ m2.obj.set("b", 1)
+ m2.arr.push(2)
+
+ let m1Expected = '{"arr":[1],"bool":true,"float":1.5,"int":1,"obj":{"a":1},"text":"a"}';
+ let m1Serialized = JSON.stringify(m1);
+ if (m1Serialized != m1Expected) {
+ throw new Error("Expected m1 \n" + m1Expected + "\ngot\n" + m1Serialized);
}
- if (result.obj.get("test") != 1) {
- throw new Error('Expected obj.get("test") 1, got ' + JSON.stringify(result.obj));
+ let m2Expected = '{"arr":[2],"bool":false,"float":2.5,"int":2,"obj":{"b":1},"text":"b"}';
+ let m2Serialized = JSON.stringify(m2);
+ if (m2Serialized != m2Expected) {
+ throw new Error("Expected m2 \n" + m2Expected + "\ngot\n" + m2Serialized);
}
`)
if err != nil {
@@ -970,8 +1350,8 @@ func TestLoadingArrayOf(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
- baseBinds(vm)
- dbxBinds(vm)
+ BindCore(vm)
+ BindDbx(vm)
vm.Set("$app", app)
_, err := vm.RunString(`
@@ -980,7 +1360,7 @@ func TestLoadingArrayOf(t *testing.T) {
text: "",
}))
- $app.dao().db()
+ $app.db()
.select("id", "text")
.from("demo1")
.where($dbx.exp("id='84nmscqy84lsi1t' OR id='al1h9ijdeojtsjy'"))
@@ -1011,21 +1391,24 @@ func TestLoadingArrayOf(t *testing.T) {
}
}
-func TestHttpClientBindsCount(t *testing.T) {
+func TestBindHTTPCount(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
- httpClientBinds(vm)
+ BindHTTP(vm)
+ testBindsCount(vm, "this", 2, t) // + FormData
testBindsCount(vm, "$http", 1, t)
}
-func TestHttpClientBindsSend(t *testing.T) {
+func TestBindHTTPSend(t *testing.T) {
+ t.Parallel()
+
// start a test server
- server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+ server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if req.URL.Query().Get("testError") != "" {
- rw.WriteHeader(400)
+ res.WriteHeader(400)
return
}
@@ -1052,17 +1435,21 @@ func TestHttpClientBindsSend(t *testing.T) {
"body": string(bodyRaw),
}
- infoRaw, _ := json.Marshal(info)
+ // add custom headers and cookies
+ res.Header().Add("X-Custom", "custom_header")
+ res.Header().Add("Set-Cookie", "sessionId=123456")
+
+ infoRaw, _ := json.Marshal(info, json.Deterministic(true))
// write back the submitted request
- rw.Write(infoRaw)
+ res.Write(infoRaw)
}))
defer server.Close()
vm := goja.New()
- baseBinds(vm)
- httpClientBinds(vm)
- vm.Set("testUrl", server.URL)
+ BindCore(vm)
+ BindHTTP(vm)
+ vm.Set("testURL", server.URL)
_, err := vm.RunString(`
function getNestedVal(data, path) {
@@ -1087,7 +1474,7 @@ func TestHttpClientBindsSend(t *testing.T) {
let testTimeout;
try {
$http.send({
- url: testUrl + "?testTimeout=3",
+ url: testURL + "?testTimeout=3",
timeout: 1
})
} catch (err) {
@@ -1099,40 +1486,78 @@ func TestHttpClientBindsSend(t *testing.T) {
// error response check
const test0 = $http.send({
- url: testUrl + "?testError=1",
+ url: testURL + "?testError=1",
})
// basic fields check
const test1 = $http.send({
method: "post",
- url: testUrl,
+ url: testURL,
headers: {"header1": "123", "header2": "456"},
body: '789',
})
// with custom content-type header
const test2 = $http.send({
- url: testUrl,
+ url: testURL,
headers: {"content-type": "text/plain"},
})
+ // with FormData
+ const formData = new FormData()
+ formData.append("title", "123")
+ const test3 = $http.send({
+ url: testURL,
+ body: formData,
+ headers: {"content-type": "text/plain"}, // should be ignored
+ })
+
+ // raw body response field check
+ const test4 = $http.send({
+ method: "post",
+ url: testURL,
+ body: 'test',
+ })
+
const scenarios = [
[test0, {
"statusCode": "400",
}],
[test1, {
"statusCode": "200",
+ "headers.X-Custom.0": "custom_header",
+ "cookies.sessionId.value": "123456",
"json.method": "POST",
"json.headers.header1": "123",
"json.headers.header2": "456",
- "json.headers.content_type": "application/json", // default
"json.body": "789",
}],
[test2, {
"statusCode": "200",
+ "headers.X-Custom.0": "custom_header",
+ "cookies.sessionId.value": "123456",
"json.method": "GET",
"json.headers.content_type": "text/plain",
}],
+ [test3, {
+ "statusCode": "200",
+ "headers.X-Custom.0": "custom_header",
+ "cookies.sessionId.value": "123456",
+ "json.method": "GET",
+ "json.body": [
+ "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\n123\r\n--",
+ ],
+ "json.headers.content_type": [
+ "multipart/form-data; boundary="
+ ],
+ }],
+ [test4, {
+ "statusCode": "200",
+ "headers.X-Custom.0": "custom_header",
+ "cookies.sessionId.value": "123456",
+ // {"body":"test","headers":{"accept_encoding":"gzip","content_length":"4","user_agent":"Go-http-client/1.1"},"method":"POST"}
+ "body": [123,34,98,111,100,121,34,58,34,116,101,115,116,34,44,34,104,101,97,100,101,114,115,34,58,123,34,97,99,99,101,112,116,95,101,110,99,111,100,105,110,103,34,58,34,103,122,105,112,34,44,34,99,111,110,116,101,110,116,95,108,101,110,103,116,104,34,58,34,52,34,44,34,117,115,101,114,95,97,103,101,110,116,34,58,34,71,111,45,104,116,116,112,45,99,108,105,101,110,116,47,49,46,49,34,125,44,34,109,101,116,104,111,100,34,58,34,80,79,83,84,34,125],
+ }],
]
for (let scenario of scenarios) {
@@ -1140,8 +1565,20 @@ func TestHttpClientBindsSend(t *testing.T) {
const expectations = scenario[1];
for (let key in expectations) {
- if (getNestedVal(result, key) != expectations[key]) {
- throw new Error('Expected ' + key + ' ' + expectations[key] + ', got: ' + result.raw);
+ const value = getNestedVal(result, key);
+ const expectation = expectations[key]
+ if (Array.isArray(expectation)) {
+ // check for partial match(es)
+ for (let exp of expectation) {
+ if (!value.includes(exp)) {
+ throw new Error('Expected ' + key + ' to contain ' + exp + ', got: ' + toString(result.body));
+ }
+ }
+ } else {
+ // check for direct match
+ if (value != expectation) {
+ throw new Error('Expected ' + key + ' ' + expectation + ', got: ' + toString(result.body));
+ }
}
}
}
@@ -1156,9 +1593,17 @@ func TestCronBindsCount(t *testing.T) {
defer app.Cleanup()
vm := goja.New()
- cronBinds(app, vm, nil)
+
+ pool := newPool(1, func() *goja.Runtime { return goja.New() })
+
+ cronBinds(app, vm, pool)
testBindsCount(vm, "this", 2, t)
+
+ pool.run(func(poolVM *goja.Runtime) error {
+ testBindsCount(poolVM, "this", 2, t)
+ return nil
+ })
}
func TestHooksBindsCount(t *testing.T) {
@@ -1168,7 +1613,7 @@ func TestHooksBindsCount(t *testing.T) {
vm := goja.New()
hooksBinds(app, vm, nil)
- testBindsCount(vm, "this", 88, t)
+ testBindsCount(vm, "this", 83, t)
}
func TestHooksBinds(t *testing.T) {
@@ -1181,7 +1626,7 @@ func TestHooksBinds(t *testing.T) {
vmFactory := func() *goja.Runtime {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1193,35 +1638,41 @@ func TestHooksBinds(t *testing.T) {
hooksBinds(app, vm, pool)
_, err := vm.RunString(`
- onModelBeforeUpdate((e) => {
+ onModelUpdate((e) => {
result.called++;
+ e.next()
}, "demo1")
- onModelBeforeUpdate((e) => {
+ onModelUpdate((e) => {
throw new Error("example");
}, "demo1")
- onModelBeforeUpdate((e) => {
+ onModelUpdate((e) => {
result.called++;
+ e.next();
}, "demo2")
- onModelBeforeUpdate((e) => {
+ onModelUpdate((e) => {
result.called++;
+ e.next()
}, "demo2")
- onModelBeforeUpdate((e) => {
- return false
+ onModelUpdate((e) => {
+ // stop propagation
}, "demo2")
- onModelBeforeUpdate((e) => {
+ onModelUpdate((e) => {
result.called++;
+ e.next();
}, "demo2")
- onAfterBootstrap(() => {
+ onBootstrap((e) => {
+ e.next()
+
// check hooks propagation and tags filtering
- const recordA = $app.dao().findFirstRecordByFilter("demo2", "1=1")
+ const recordA = $app.findFirstRecordByFilter("demo2", "1=1")
recordA.set("title", "update")
- $app.dao().saveRecord(recordA)
+ $app.save(recordA)
if (result.called != 2) {
throw new Error("Expected result.called to be 2, got " + result.called)
}
@@ -1232,9 +1683,9 @@ func TestHooksBinds(t *testing.T) {
// check error handling
let hasErr = false
try {
- const recordB = $app.dao().findFirstRecordByFilter("demo1", "1=1")
+ const recordB = $app.findFirstRecordByFilter("demo1", "1=1")
recordB.set("text", "update")
- $app.dao().saveRecord(recordB)
+ $app.save(recordB)
} catch (err) {
hasErr = true
}
@@ -1253,6 +1704,47 @@ func TestHooksBinds(t *testing.T) {
}
}
+func TestHooksExceptionUnwrapping(t *testing.T) {
+ app, _ := tests.NewTestApp()
+ defer app.Cleanup()
+
+ goErr := errors.New("test")
+
+ vmFactory := func() *goja.Runtime {
+ vm := goja.New()
+ BindCore(vm)
+ vm.Set("$app", app)
+ vm.Set("goErr", goErr)
+ return vm
+ }
+
+ pool := newPool(1, vmFactory)
+
+ vm := vmFactory()
+ hooksBinds(app, vm, pool)
+
+ _, err := vm.RunString(`
+ onModelUpdate((e) => {
+ throw goErr
+ }, "demo1")
+ `)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record, err := app.FindFirstRecordByFilter("demo1", "1=1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ record.Set("text", "update")
+
+ err = app.Save(record)
+ if !errors.Is(err, goErr) {
+ t.Fatalf("Expected goError, got %v", err)
+ }
+}
+
func TestRouterBindsCount(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1260,7 +1752,7 @@ func TestRouterBindsCount(t *testing.T) {
vm := goja.New()
routerBinds(app, vm, nil)
- testBindsCount(vm, "this", 3, t)
+ testBindsCount(vm, "this", 2, t)
}
func TestRouterBinds(t *testing.T) {
@@ -1268,14 +1760,14 @@ func TestRouterBinds(t *testing.T) {
defer app.Cleanup()
result := &struct {
- AddCount int
- UseCount int
- PreCount int
+ RouteMiddlewareCalls int
+ GlobalMiddlewareCalls int
}{}
vmFactory := func() *goja.Runtime {
vm := goja.New()
- baseBinds(vm)
+ BindCore(vm)
+ BindApis(vm)
vm.Set("$app", app)
vm.Set("result", result)
return vm
@@ -1288,81 +1780,91 @@ func TestRouterBinds(t *testing.T) {
_, err := vm.RunString(`
routerAdd("GET", "/test", (e) => {
- result.addCount++;
- }, (next) => {
- return (c) => {
- result.addCount++;
-
- return next(c);
- }
+ result.routeMiddlewareCalls++;
+ }, (e) => {
+ result.routeMiddlewareCalls++;
+ return e.next();
})
- routerUse((next) => {
- return (c) => {
- result.useCount++;
-
- return next(c)
- }
+ // Promise is not technically supported as return result
+ // but we try to resolve it at least for thrown errors
+ routerAdd("GET", "/error", async (e) => {
+ throw new ApiError(456, 'test', null)
})
- routerPre((next) => {
- return (c) => {
- result.preCount++;
+ routerUse((e) => {
+ result.globalMiddlewareCalls++;
- return next(c)
- }
+ return e.next();
})
`)
if err != nil {
t.Fatal(err)
}
- e, err := apis.InitApi(app)
+ pbRouter, err := apis.NewRouter(app)
if err != nil {
t.Fatal(err)
}
- serveEvent := &core.ServeEvent{
- App: app,
- Router: e,
- }
- if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
+ serveEvent := new(core.ServeEvent)
+ serveEvent.App = app
+ serveEvent.Router = pbRouter
+ if err = app.OnServe().Trigger(serveEvent); err != nil {
t.Fatal(err)
}
- rec := httptest.NewRecorder()
- req := httptest.NewRequest("GET", "/test", nil)
- e.ServeHTTP(rec, req)
-
- if result.AddCount != 2 {
- t.Fatalf("Expected AddCount %d, got %d", 2, result.AddCount)
+ mux, err := serveEvent.Router.BuildMux()
+ if err != nil {
+ t.Fatalf("Failed to build router mux: %v", err)
}
- if result.UseCount != 1 {
- t.Fatalf("Expected UseCount %d, got %d", 1, result.UseCount)
+ scenarios := []struct {
+ method string
+ path string
+ expectedRouteMiddlewareCalls int
+ expectedGlobalMiddlewareCalls int
+ expectedCode int
+ }{
+ {"GET", "/test", 2, 1, 200},
+ {"GET", "/error", 0, 1, 456},
}
- if result.PreCount != 1 {
- t.Fatalf("Expected PreCount %d, got %d", 1, result.PreCount)
+ for _, s := range scenarios {
+ t.Run(s.method+" "+s.path, func(t *testing.T) {
+ // reset
+ result.RouteMiddlewareCalls = 0
+ result.GlobalMiddlewareCalls = 0
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(s.method, s.path, nil)
+ mux.ServeHTTP(rec, req)
+
+ if result.RouteMiddlewareCalls != s.expectedRouteMiddlewareCalls {
+ t.Fatalf("Expected RouteMiddlewareCalls %d, got %d", s.expectedRouteMiddlewareCalls, result.RouteMiddlewareCalls)
+ }
+
+ if result.GlobalMiddlewareCalls != s.expectedGlobalMiddlewareCalls {
+ t.Fatalf("Expected GlobalMiddlewareCalls %d, got %d", s.expectedGlobalMiddlewareCalls, result.GlobalMiddlewareCalls)
+ }
+
+ if rec.Code != s.expectedCode {
+ t.Fatalf("Expected status code %d, got %d", s.expectedCode, rec.Code)
+ }
+ })
}
}
-func TestFilepathBindsCount(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
+func TestBindFilepathCount(t *testing.T) {
vm := goja.New()
- filepathBinds(vm)
+ BindFilepath(vm)
testBindsCount(vm, "$filepath", 15, t)
}
-func TestOsBindsCount(t *testing.T) {
- app, _ := tests.NewTestApp()
- defer app.Cleanup()
-
+func TestBindOSCount(t *testing.T) {
vm := goja.New()
- osBinds(vm)
+ BindOS(vm)
- testBindsCount(vm, "$os", 16, t)
+ testBindsCount(vm, "$os", 20, t)
}
diff --git a/plugins/jsvm/form_data.go b/plugins/jsvm/form_data.go
new file mode 100644
index 0000000000..4419c68420
--- /dev/null
+++ b/plugins/jsvm/form_data.go
@@ -0,0 +1,149 @@
+package jsvm
+
+import (
+ "bytes"
+ "io"
+ "mime/multipart"
+
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/spf13/cast"
+)
+
+// FormData represents an interface similar to the browser's [FormData].
+//
+// The value of each FormData entry must be a string or [*filesystem.File] instance.
+//
+// It is intended to be used together with the JSVM `$http.send` when
+// sending multipart/form-data requests.
+//
+// [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData.
+type FormData map[string][]any
+
+// Append appends a new value onto an existing key inside the current FormData,
+// or adds the key if it does not already exist.
+func (data FormData) Append(key string, value any) {
+ data[key] = append(data[key], value)
+}
+
+// Set sets a new value for an existing key inside the current FormData,
+// or adds the key/value if it does not already exist.
+func (data FormData) Set(key string, value any) {
+ data[key] = []any{value}
+}
+
+// Delete deletes a key and its value(s) from the current FormData.
+func (data FormData) Delete(key string) {
+ delete(data, key)
+}
+
+// Get returns the first value associated with a given key from
+// within the current FormData.
+//
+// If you expect multiple values and want all of them,
+// use the [FormData.GetAll] method instead.
+func (data FormData) Get(key string) any {
+ values, ok := data[key]
+ if !ok || len(values) == 0 {
+ return nil
+ }
+
+ return values[0]
+}
+
+// GetAll returns all the values associated with a given key
+// from within the current FormData.
+func (data FormData) GetAll(key string) []any {
+ values, ok := data[key]
+ if !ok {
+ return nil
+ }
+
+ return values
+}
+
+// Has returns whether a FormData object contains a certain key.
+func (data FormData) Has(key string) bool {
+ values, ok := data[key]
+
+ return ok && len(values) > 0
+}
+
+// Keys returns all keys contained in the current FormData.
+func (data FormData) Keys() []string {
+ result := make([]string, 0, len(data))
+
+ for k := range data {
+ result = append(result, k)
+ }
+
+ return result
+}
+
+// Keys returns all values contained in the current FormData.
+func (data FormData) Values() []any {
+ result := make([]any, 0, len(data))
+
+ for _, values := range data {
+ result = append(result, values...)
+ }
+
+ return result
+}
+
+// Entries returns a [key, value] slice pair for each FormData entry.
+func (data FormData) Entries() [][]any {
+ result := make([][]any, 0, len(data))
+
+ for k, values := range data {
+ for _, v := range values {
+ result = append(result, []any{k, v})
+ }
+ }
+
+ return result
+}
+
+// toMultipart converts the current FormData entries into multipart encoded data.
+func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
+ body := new(bytes.Buffer)
+
+ mp := multipart.NewWriter(body)
+ defer mp.Close()
+
+ for k, values := range data {
+ for _, rawValue := range values {
+ switch v := rawValue.(type) {
+ case *filesystem.File:
+ err := func() error {
+ mpw, err := mp.CreateFormFile(k, v.OriginalName)
+ if err != nil {
+ return err
+ }
+
+ file, err := v.Reader.Open()
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ _, err = io.Copy(mpw, file)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }()
+ if err != nil {
+ return nil, nil, err
+ }
+ default:
+ err := mp.WriteField(k, cast.ToString(v))
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ }
+ }
+
+ return body, mp, nil
+}
diff --git a/plugins/jsvm/form_data_test.go b/plugins/jsvm/form_data_test.go
new file mode 100644
index 0000000000..6c8c46074a
--- /dev/null
+++ b/plugins/jsvm/form_data_test.go
@@ -0,0 +1,225 @@
+package jsvm
+
+import (
+ "bytes"
+ "encoding/json/v2"
+ "strings"
+ "testing"
+
+ "github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/list"
+)
+
+func TestFormDataAppendAndSet(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+
+ data.Append("a", 1)
+ data.Append("a", 2)
+
+ data.Append("b", 3)
+ data.Append("b", 4)
+ data.Set("b", 5) // should overwrite the previous 2 calls
+
+ data.Set("c", 6)
+ data.Set("c", 7)
+
+ if len(data["a"]) != 2 {
+ t.Fatalf("Expected 2 'a' values, got %v", data["a"])
+ }
+ if data["a"][0] != 1 || data["a"][1] != 2 {
+ t.Fatalf("Expected 1 and 2 'a' key values, got %v", data["a"])
+ }
+
+ if len(data["b"]) != 1 {
+ t.Fatalf("Expected 1 'b' values, got %v", data["b"])
+ }
+ if data["b"][0] != 5 {
+ t.Fatalf("Expected 5 as 'b' key value, got %v", data["b"])
+ }
+
+ if len(data["c"]) != 1 {
+ t.Fatalf("Expected 1 'c' values, got %v", data["c"])
+ }
+ if data["c"][0] != 7 {
+ t.Fatalf("Expected 7 as 'c' key value, got %v", data["c"])
+ }
+}
+
+func TestFormDataDelete(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("a", 2)
+ data.Append("b", 3)
+
+ data.Delete("missing") // should do nothing
+ data.Delete("a")
+
+ if len(data) != 1 {
+ t.Fatalf("Expected exactly 1 data remaining key, got %v", data)
+ }
+
+ if data["b"][0] != 3 {
+ t.Fatalf("Expected 3 as 'b' key value, got %v", data["b"])
+ }
+}
+
+func TestFormDataGet(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("a", 2)
+
+ if v := data.Get("missing"); v != nil {
+ t.Fatalf("Expected %v for key 'missing', got %v", nil, v)
+ }
+
+ if v := data.Get("a"); v != 1 {
+ t.Fatalf("Expected %v for key 'a', got %v", 1, v)
+ }
+}
+
+func TestFormDataGetAll(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("a", 2)
+
+ if v := data.GetAll("missing"); v != nil {
+ t.Fatalf("Expected %v for key 'a', got %v", nil, v)
+ }
+
+ values := data.GetAll("a")
+ if len(values) != 2 || values[0] != 1 || values[1] != 2 {
+ t.Fatalf("Expected 1 and 2 values, got %v", values)
+ }
+}
+
+func TestFormDataHas(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+
+ if v := data.Has("missing"); v {
+ t.Fatalf("Expected key 'missing' to not exist: %v", v)
+ }
+
+ if v := data.Has("a"); !v {
+ t.Fatalf("Expected key 'a' to exist: %v", v)
+ }
+}
+
+func TestFormDataKeys(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("b", 1)
+ data.Append("c", 1)
+ data.Append("a", 1)
+
+ keys := data.Keys()
+
+ expectedKeys := []string{"a", "b", "c"}
+
+ for _, expected := range expectedKeys {
+ if !list.ExistInSlice(expected, keys) {
+ t.Fatalf("Expected key %s to exists in %v", expected, keys)
+ }
+ }
+}
+
+func TestFormDataValues(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("b", 2)
+ data.Append("c", 3)
+ data.Append("a", 4)
+
+ values := data.Values()
+
+ expectedKeys := []any{1, 2, 3, 4}
+
+ for _, expected := range expectedKeys {
+ if !list.ExistInSlice(expected, values) {
+ t.Fatalf("Expected key %s to exists in %v", expected, values)
+ }
+ }
+}
+
+func TestFormDataEntries(t *testing.T) {
+ t.Parallel()
+
+ data := FormData{}
+ data.Append("a", 1)
+ data.Append("b", 2)
+ data.Append("c", 3)
+ data.Append("a", 4)
+
+ entries := data.Entries()
+
+ rawEntries, err := json.Marshal(entries, json.Deterministic(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(entries) != 4 {
+ t.Fatalf("Expected 4 entries")
+ }
+
+ expectedEntries := []string{`["a",1]`, `["a",4]`, `["b",2]`, `["c",3]`}
+ for _, expected := range expectedEntries {
+ if !bytes.Contains(rawEntries, []byte(expected)) {
+ t.Fatalf("Expected entry %s to exists in %s", expected, rawEntries)
+ }
+ }
+}
+
+func TestFormDataToMultipart(t *testing.T) {
+ t.Parallel()
+
+ f, err := filesystem.NewFileFromBytes([]byte("abc"), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ data := FormData{}
+ data.Append("a", 1) // should be casted
+ data.Append("b", "test1")
+ data.Append("b", "test2")
+ data.Append("c", f)
+
+ body, mp, err := data.toMultipart()
+ if err != nil {
+ t.Fatal(err)
+ }
+ bodyStr := body.String()
+
+ // content type checks
+ contentType := mp.FormDataContentType()
+ expectedContentType := "multipart/form-data; boundary="
+ if !strings.Contains(contentType, expectedContentType) {
+ t.Fatalf("Expected to find content-type %s in %s", expectedContentType, contentType)
+ }
+
+ // body checks
+ expectedBodyParts := []string{
+ "Content-Disposition: form-data; name=\"a\"\r\n\r\n1",
+ "Content-Disposition: form-data; name=\"b\"\r\n\r\ntest1",
+ "Content-Disposition: form-data; name=\"b\"\r\n\r\ntest2",
+ "Content-Disposition: form-data; name=\"c\"; filename=\"test\"\r\nContent-Type: application/octet-stream\r\n\r\nabc",
+ }
+ for _, part := range expectedBodyParts {
+ if !strings.Contains(bodyStr, part) {
+ t.Fatalf("Expected to find %s in body\n%s", part, bodyStr)
+ }
+ }
+}
diff --git a/plugins/jsvm/internal/types/generated/types.d.ts b/plugins/jsvm/internal/types/generated/types.d.ts
index 06e444ccca..82cd437228 100644
--- a/plugins/jsvm/internal/types/generated/types.d.ts
+++ b/plugins/jsvm/internal/types/generated/types.d.ts
@@ -1,3 +1,4 @@
+// 1790407359
// GENERATED CODE - DO NOT MODIFY BY HAND
// -------------------------------------------------------------------
@@ -54,9 +55,9 @@ declare function cronRemove(jobId: string): void;
* Example:
*
* ```js
- * routerAdd("GET", "/hello", (c) => {
- * return c.json(200, {"message": "Hello!"})
- * }, $apis.requireAdminOrRecordAuth())
+ * routerAdd("GET", "/hello", (e) => {
+ * return e.json(200, {"message": "Hello!"})
+ * }, $apis.requireAuth())
* ```
*
* _Note that this method is available only in pb_hooks context._
@@ -66,8 +67,8 @@ declare function cronRemove(jobId: string): void;
declare function routerAdd(
method: string,
path: string,
- handler: echo.HandlerFunc,
- ...middlewares: Array,
+ handler: (e: core.RequestEvent) => void,
+ ...middlewares: Array void)|Middleware>,
): void;
/**
@@ -77,11 +78,9 @@ declare function routerAdd(
* Example:
*
* ```js
- * routerUse((next) => {
- * return (c) => {
- * console.log(c.Path())
- * return next(c)
- * }
+ * routerUse((e) => {
+ * console.log(e.request.url.path)
+ * return e.next()
* })
* ```
*
@@ -89,34 +88,7 @@ declare function routerAdd(
*
* @group PocketBase
*/
-declare function routerUse(...middlewares: Array): void;
-
-/**
- * RouterPre registers one or more global middlewares that are executed
- * BEFORE the router processes the request. It is usually used for making
- * changes to the request properties, for example, adding or removing
- * a trailing slash or adding segments to a path so it matches a route.
- *
- * NB! Since the router will not have processed the request yet,
- * middlewares registered at this level won't have access to any path
- * related APIs from echo.Context.
- *
- * Example:
- *
- * ```js
- * routerPre((next) => {
- * return (c) => {
- * console.log(c.request().url)
- * return next(c)
- * }
- * })
- * ```
- *
- * _Note that this method is available only in pb_hooks context._
- *
- * @group PocketBase
- */
-declare function routerPre(...middlewares: Array): void;
+declare function routerUse(...middlewares: Array void)|Middleware>): void;
// -------------------------------------------------------------------
// baseBinds
@@ -129,8 +101,19 @@ declare function routerPre(...middlewares: Array): v
*/
declare var __hooks: string
-// skip on* hook methods as they are registered via the global on* method
-type appWithoutHooks = Omit
+// Utility type to exclude the on* hook methods from a type
+// (hooks are separately generated as global methods).
+//
+// See https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
+type excludeHooks = {
+ [Property in keyof Type as Exclude]: Type[Property]
+};
+
+// CoreApp without the on* hook methods
+type CoreApp = excludeHooks
+
+// PocketBase without the on* hook methods
+interface PocketBase extends excludeHooks{}
/**
* `$app` is the current running PocketBase instance that is globally
@@ -141,7 +124,7 @@ type appWithoutHooks = Omit
* @namespace
* @group PocketBase
*/
-declare var $app: appWithoutHooks
+declare var $app: PocketBase
/**
* `$template` is a global helper to load and cache HTML templates on the fly.
@@ -158,30 +141,86 @@ declare var $app: appWithoutHooks
* ).render({"name": "John"})
* ```
*
- * _Note that this method is available only in pb_hooks context._
- *
* @namespace
* @group PocketBase
*/
declare var $template: template.Registry
/**
- * readerToString reads the content of the specified io.Reader until
- * EOF or maxBytes are reached.
+ * This method is superseded by toString.
+ *
+ * @deprecated
+ * @group PocketBase
+ */
+declare function readerToString(reader: any, maxBytes?: number): string;
+
+/**
+ * toString stringifies the specified value.
*
- * If maxBytes is not specified it will read up to 32MB.
+ * Support optional second maxBytes argument to limit the max read bytes
+ * when the value is a io.Reader (default to 32MB).
*
- * Note that after this call the reader can't be used anymore.
+ * Types that don't have explicit string representation are json serialized.
*
* Example:
*
* ```js
- * const rawBody = readerToString(c.request().body)
+ * // io.Reader
+ * const ex1 = toString(e.request.body)
+ *
+ * // slice of bytes
+ * const ex2 = toString([104 101 108 108 111]) // "hello"
+ *
+ * // null
+ * const ex3 = toString(null) // ""
* ```
*
* @group PocketBase
*/
-declare function readerToString(reader: any, maxBytes?: number): string;
+declare function toString(val: any, maxBytes?: number): string;
+
+/**
+ * toBytes converts the specified value into a bytes slice.
+ *
+ * Support optional second maxBytes argument to limit the max read bytes
+ * when the value is a io.Reader (default to 32MB).
+ *
+ * Types that don't have Go slice representation (bool, objects, etc.)
+ * are serialized to UTF8 string and its bytes slice is returned.
+ *
+ * Example:
+ *
+ * ```js
+ * // io.Reader
+ * const ex1 = toBytes(e.request.body)
+ *
+ * // string
+ * const ex2 = toBytes("hello") // [104 101 108 108 111]
+ *
+ * // object (the same as the string '{"test":1}')
+ * const ex3 = toBytes({"test":1}) // [123 34 116 101 115 116 34 58 49 125]
+ *
+ * // null
+ * const ex4 = toBytes(null) // []
+ * ```
+ *
+ * @group PocketBase
+ */
+declare function toBytes(val: any, maxBytes?: number): Array;
+
+/**
+ * sleep pauses the current goroutine for at least the specified user duration (in ms).
+ * A zero or negative duration returns immediately.
+ *
+ * Example:
+ *
+ * ```js
+ * sleep(250) // sleeps for 250ms
+ * ```
+ *
+ * @group PocketBase
+ */
+declare function sleep(milliseconds: number): void;
/**
* arrayOf creates a placeholder array of the specified models.
@@ -192,39 +231,145 @@ declare function readerToString(reader: any, maxBytes?: number): string;
* ```js
* const records = arrayOf(new Record)
*
- * $app.dao().recordQuery("articles").limit(10).all(records)
+ * $app.recordQuery("articles").limit(10).all(records)
* ```
*
* @group PocketBase
*/
declare function arrayOf(model: T): Array;
+/**
+ * unmarshal clones and merges the data argument on top of dst.
+ *
+ * This method is rarely used directly by the users and it is most
+ * commonly used in the autogenerated migrations.
+ *
+ * To an extent it is similar to the JS native `Object.assign`
+ * but the arguments are reversed and it invokes the Go standard
+ * `json.Marshal/Unmarshal` methods under the hood.
+ *
+ * The data argument could be anything serializable, usually a plain object (map).
+ * The dst argument could be any pointer value, usually a model instance.
+ *
+ * Example:
+ *
+ * ```js
+ * unmarshal({ authAlert: { enabled: true } }, collection)
+ * ```
+ *
+ * @group PocketBase
+ */
+declare function unmarshal(data: any, dst: any): void;
+
/**
* DynamicModel creates a new dynamic model with fields from the provided data shape.
*
+ * Caveats:
+ * - In order to use 0 as double/float initialization number you have to negate it (`-0`).
+ * - You need to use lowerCamelCase when accessing the model fields (e.g. `model.roles` and not `model.Roles` even if in the model shape and in the DB table the column is capitalized).
+ * - Objects are loaded into types.JSONMap, meaning that they need to be accessed with `get(key)` (e.g. `model.meta.get('something')`).
+ * - For describing nullable types you can use the `null*()` helpers - `nullString()`, `nullInt()`, `nullFloat()`, `nullBool()`, `nullArray()`, `nullObject()`.
+ *
* Example:
*
* ```js
* const model = new DynamicModel({
- * name: ""
- * age: 0,
- * active: false,
- * roles: [],
- * meta: {}
+ * name: "" // or nullString() if nullable
+ * age: 0, // or nullInt() if nullable
+ * totalSpent: -0, // or nullFloat() if nullable
+ * active: false, // or nullBool() if nullable
+ * Roles: [], // or nullArray() if nullable; maps to "Roles" in the DB/JSON but the prop would be accessible via "model.roles"
+ * meta: {}, // or nullObject() if nullable
* })
* ```
*
* @group PocketBase
*/
declare class DynamicModel {
+ [key: string]: any;
constructor(shape?: { [key:string]: any })
}
+/**
+ * nullString creates an empty Go string pointer usually used for
+ * describing a **nullable** `DynamicModel` string value.
+ *
+ * @group PocketBase
+ */
+declare function nullString(): string;
+
+/**
+ * nullInt creates an empty Go int64 pointer usually used for
+ * describing a **nullable** `DynamicModel` int value.
+ *
+ * @group PocketBase
+ */
+declare function nullInt(): number;
+
+/**
+ * nullFloat creates an empty Go float64 pointer usually used for
+ * describing a **nullable** `DynamicModel` float value.
+ *
+ * @group PocketBase
+ */
+declare function nullFloat(): number;
+
+/**
+ * nullBool creates an empty Go bool pointer usually used for
+ * describing a **nullable** `DynamicModel` bool value.
+ *
+ * @group PocketBase
+ */
+declare function nullBool(): boolean;
+
+/**
+ * nullArray creates an empty Go types.JSONArray pointer usually used for
+ * describing a **nullable** `DynamicModel` JSON array value.
+ *
+ * @group PocketBase
+ */
+declare function nullArray(): Array;
+
+/**
+ * nullObject creates an empty Go types.JSONMap pointer usually used for
+ * describing a **nullable** `DynamicModel` JSON object value.
+ *
+ * @group PocketBase
+ */
+declare function nullObject(): { get(key:string):any; set(key:string,value:any):void };
+
+interface Context extends context.Context{} // merge
+/**
+ * Context creates a new empty Go context.Context.
+ *
+ * This is usually used as part of some Go transitive bindings.
+ *
+ * Example:
+ *
+ * ```js
+ * const blank = new Context()
+ *
+ * // with single key-value pair
+ * const base = new Context(null, "a", 123)
+ * console.log(base.value("a")) // 123
+ *
+ * // extend with additional key-value pair
+ * const sub = new Context(base, "b", 456)
+ * console.log(sub.value("a")) // 123
+ * console.log(sub.value("b")) // 456
+ * ```
+ *
+ * @group PocketBase
+ */
+declare class Context implements context.Context {
+ constructor(parentCtx?: Context, key?: any, value?: any)
+}
+
/**
* Record model class.
*
* ```js
- * const collection = $app.dao().findCollectionByNameOrId("article")
+ * const collection = $app.findCollectionByNameOrId("article")
*
* const record = new Record(collection, {
* title: "Lorem ipsum"
@@ -237,28 +382,31 @@ declare class DynamicModel {
* @group PocketBase
*/
declare const Record: {
- new(collection?: models.Collection, data?: { [key:string]: any }): models.Record
+ new(collection?: core.Collection, data?: { [key:string]: any }): core.Record
// note: declare as "newable" const due to conflict with the Record TS utility type
}
-interface Collection extends models.Collection{} // merge
+interface Collection extends core.Collection{
+ type: "base" | "view" | "auth"
+} // merge
/**
* Collection model class.
*
* ```js
* const collection = new Collection({
- * name: "article",
* type: "base",
+ * name: "article",
* listRule: "@request.auth.id != '' || status = 'public'",
* viewRule: "@request.auth.id != '' || status = 'public'",
* deleteRule: "@request.auth.id != ''",
- * schema: [
+ * fields: [
* {
* name: "title",
* type: "text",
* required: true,
- * options: { min: 6, max: 100 },
+ * min: 6,
+ * max: 100,
* },
* {
* name: "description",
@@ -270,44 +418,168 @@ interface Collection extends models.Collection{} // merge
*
* @group PocketBase
*/
-declare class Collection implements models.Collection {
- constructor(data?: Partial)
+declare class Collection implements core.Collection {
+ constructor(data?: Partial)
}
-interface Admin extends models.Admin{} // merge
+interface FieldsList extends core.FieldsList{} // merge
/**
- * Admin model class.
+ * FieldsList model class, usually used to define the Collection.fields.
*
- * ```js
- * const admin = new Admin()
- * admin.email = "test@example.com"
- * admin.setPassword(1234567890)
- * ```
+ * @group PocketBase
+ */
+declare class FieldsList implements core.FieldsList {
+ constructor(data?: Partial)
+}
+
+interface Field extends core.Field{} // merge
+/**
+ * Field model class, usually used as part of the FieldsList model.
+ *
+ * @group PocketBase
+ */
+declare class Field implements core.Field {
+ constructor(data?: Partial)
+}
+
+interface NumberField extends core.NumberField{} // merge
+/**
+ * {@inheritDoc core.NumberField}
+ *
+ * @group PocketBase
+ */
+declare class NumberField implements core.NumberField {
+ constructor(data?: Partial)
+}
+
+interface BoolField extends core.BoolField{} // merge
+/**
+ * {@inheritDoc core.BoolField}
+ *
+ * @group PocketBase
+ */
+declare class BoolField implements core.BoolField {
+ constructor(data?: Partial)
+}
+
+interface TextField extends core.TextField{} // merge
+/**
+ * {@inheritDoc core.TextField}
+ *
+ * @group PocketBase
+ */
+declare class TextField implements core.TextField {
+ constructor(data?: Partial)
+}
+
+interface URLField extends core.URLField{} // merge
+/**
+ * {@inheritDoc core.URLField}
+ *
+ * @group PocketBase
+ */
+declare class URLField implements core.URLField {
+ constructor(data?: Partial)
+}
+
+interface EmailField extends core.EmailField{} // merge
+/**
+ * {@inheritDoc core.EmailField}
+ *
+ * @group PocketBase
+ */
+declare class EmailField implements core.EmailField {
+ constructor(data?: Partial)
+}
+
+interface EditorField extends core.EditorField{} // merge
+/**
+ * {@inheritDoc core.EditorField}
+ *
+ * @group PocketBase
+ */
+declare class EditorField implements core.EditorField {
+ constructor(data?: Partial)
+}
+
+interface PasswordField extends core.PasswordField{} // merge
+/**
+ * {@inheritDoc core.PasswordField}
+ *
+ * @group PocketBase
+ */
+declare class PasswordField implements core.PasswordField {
+ constructor(data?: Partial)
+}
+
+interface DateField extends core.DateField{} // merge
+/**
+ * {@inheritDoc core.DateField}
+ *
+ * @group PocketBase
+ */
+declare class DateField implements core.DateField {
+ constructor(data?: Partial)
+}
+
+interface AutodateField extends core.AutodateField{} // merge
+/**
+ * {@inheritDoc core.AutodateField}
+ *
+ * @group PocketBase
+ */
+declare class AutodateField implements core.AutodateField {
+ constructor(data?: Partial)
+}
+
+interface JSONField extends core.JSONField{} // merge
+/**
+ * {@inheritDoc core.JSONField}
+ *
+ * @group PocketBase
+ */
+declare class JSONField implements core.JSONField {
+ constructor(data?: Partial)
+}
+
+interface RelationField extends core.RelationField{} // merge
+/**
+ * {@inheritDoc core.RelationField}
+ *
+ * @group PocketBase
+ */
+declare class RelationField implements core.RelationField {
+ constructor(data?: Partial)
+}
+
+interface SelectField extends core.SelectField{} // merge
+/**
+ * {@inheritDoc core.SelectField}
*
* @group PocketBase
*/
-declare class Admin implements models.Admin {
- constructor(data?: Partial)
+declare class SelectField implements core.SelectField {
+ constructor(data?: Partial)
}
-interface Schema extends schema.Schema{} // merge
+interface FileField extends core.FileField{} // merge
/**
- * Schema model class, usually used to define the Collection.schema field.
+ * {@inheritDoc core.FileField}
*
* @group PocketBase
*/
-declare class Schema implements schema.Schema {
- constructor(data?: Partial)
+declare class FileField implements core.FileField {
+ constructor(data?: Partial)
}
-interface SchemaField extends schema.SchemaField{} // merge
+interface GeoPointField extends core.GeoPointField{} // merge
/**
- * SchemaField model class, usually used as part of the Schema model.
+ * {@inheritDoc core.GeoPointField}
*
* @group PocketBase
*/
-declare class SchemaField implements schema.SchemaField {
- constructor(data?: Partial)
+declare class GeoPointField implements core.GeoPointField {
+ constructor(data?: Partial)
}
interface MailerMessage extends mailer.Message{} // merge
@@ -355,55 +627,114 @@ declare class Command implements cobra.Command {
constructor(cmd?: Partial)
}
-interface RequestInfo extends models.RequestInfo{} // merge
/**
- * RequestInfo defines a single models.RequestInfo instance, usually used
+ * RequestInfo defines a single core.RequestInfo instance, usually used
* as part of various filter checks.
*
* Example:
*
* ```js
- * const authRecord = $app.dao().findAuthRecordByEmail("users", "test@example.com")
+ * const authRecord = $app.findAuthRecordByEmail("users", "test@example.com")
*
* const info = new RequestInfo({
- * authRecord: authRecord,
- * data: {"name": 123},
- * headers: {"x-token": "..."},
+ * auth: authRecord,
+ * body: {"name": 123},
+ * headers: {"x-token": "..."},
* })
*
- * const record = $app.dao().findFirstRecordByData("articles", "slug", "hello")
+ * const record = $app.findFirstRecordByData("articles", "slug", "hello")
+ *
+ * const canAccess = $app.canAccessRecord(record, info, "@request.auth.id != '' && @request.body.name = 123")
+ * ```
+ *
+ * @group PocketBase
+ */
+declare const RequestInfo: {
+ new(info?: Partial): core.RequestInfo
+
+ // note: declare as "newable" const due to conflict with the RequestInfo TS node type
+}
+
+/**
+ * Middleware defines a single request middleware handler.
+ *
+ * This class is usually used when you want to explicitly specify a priority to your custom route middleware.
+ *
+ * Example:
+ *
+ * ```js
+ * routerUse(new Middleware((e) => {
+ * console.log(e.request.url.path)
+ * return e.next()
+ * }, -10))
+ * ```
+ *
+ * @group PocketBase
+ */
+declare class Middleware {
+ constructor(
+ func: string|((e: core.RequestEvent) => void),
+ priority?: number,
+ id?: string,
+ )
+}
+
+interface Timezone extends time.Location{} // merge
+/**
+ * Timezone returns the timezone location with the given name.
+ *
+ * The name is expected to be a location name corresponding to a file
+ * in the IANA Time Zone database, such as "America/New_York".
+ *
+ * If the name is "Local", LoadLocation returns Local.
+ *
+ * If the name is "", invalid or "UTC", returns UTC.
+ *
+ * The constructor is equivalent to calling the Go `time.LoadLocation(name)` method.
*
- * const canAccess = $app.dao().canAccessRecord(record, info, "@request.auth.id != '' && @request.data.name = 123")
+ * Example:
+ *
+ * ```js
+ * const zone = new Timezone("America/New_York")
+ * $app.cron().setTimezone(zone)
* ```
*
* @group PocketBase
*/
-declare class RequestInfo implements models.RequestInfo {
- constructor(date?: Partial)
+declare class Timezone implements time.Location {
+ constructor(name?: string)
}
interface DateTime extends types.DateTime{} // merge
/**
* DateTime defines a single DateTime type instance.
+ * The returned date is always represented in UTC.
*
* Example:
*
* ```js
* const dt0 = new DateTime() // now
*
+ * // full datetime string
* const dt1 = new DateTime('2023-07-01 00:00:00.000Z')
+ *
+ * // datetime string with default "parse in" timezone location
+ * //
+ * // similar to new DateTime('2023-07-01 00:00:00 +01:00') or new DateTime('2023-07-01 00:00:00 +02:00')
+ * // but accounts for the daylight saving time (DST)
+ * const dt2 = new DateTime('2023-07-01 00:00:00', 'Europe/Amsterdam')
* ```
*
* @group PocketBase
*/
declare class DateTime implements types.DateTime {
- constructor(date?: string)
+ constructor(date?: string, defaultParseInLocation?: string)
}
interface ValidationError extends ozzo_validation.Error{} // merge
/**
* ValidationError defines a single formatted data validation error,
- * usually used as part of a error response.
+ * usually used as part of an error response.
*
* ```js
* new ValidationError("invalid_title", "Title is not valid")
@@ -415,13 +746,55 @@ declare class ValidationError implements ozzo_validation.Error {
constructor(code?: string, message?: string)
}
-interface Dao extends daos.Dao{} // merge
+interface Cookie extends http.Cookie{} // merge
/**
- * @inheritDoc
+ * A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
+ * HTTP response.
+ *
+ * Example:
+ *
+ * ```js
+ * routerAdd("POST", "/example", (c) => {
+ * c.setCookie(new Cookie({
+ * name: "example_name",
+ * value: "example_value",
+ * path: "/",
+ * domain: "example.com",
+ * maxAge: 10,
+ * secure: true,
+ * httpOnly: true,
+ * sameSite: 3,
+ * }))
+ *
+ * return c.redirect(200, "/");
+ * })
+ * ```
+ *
+ * @group PocketBase
+ */
+declare class Cookie implements http.Cookie {
+ constructor(options?: Partial)
+}
+
+interface SubscriptionMessage extends subscriptions.Message{} // merge
+/**
+ * SubscriptionMessage defines a realtime subscription payload.
+ *
+ * Example:
+ *
+ * ```js
+ * onRealtimeConnectRequest((e) => {
+ * e.client.send(new SubscriptionMessage({
+ * name: "example",
+ * data: '{"greeting": "Hello world"}'
+ * }))
+ * })
+ * ```
+ *
* @group PocketBase
*/
-declare class Dao implements daos.Dao {
- constructor(concurrentDB?: dbx.Builder, nonconcurrentDB?: dbx.Builder)
+declare class SubscriptionMessage implements subscriptions.Message {
+ constructor(options?: Partial)
}
// -------------------------------------------------------------------
@@ -459,26 +832,21 @@ declare namespace $dbx {
}
// -------------------------------------------------------------------
-// tokensBinds
+// mailsBinds
// -------------------------------------------------------------------
/**
- * `$tokens` defines high level helpers to generate
- * various admins and auth records tokens (auth, forgotten password, etc.).
- *
- * For more control over the generated token, you can check `$security`.
+ * `$mails` defines helpers to send common
+ * auth records emails like verification, password reset, etc.
*
* @group PocketBase
*/
-declare namespace $tokens {
- let adminAuthToken: tokens.newAdminAuthToken
- let adminResetPasswordToken: tokens.newAdminResetPasswordToken
- let adminFileToken: tokens.newAdminFileToken
- let recordAuthToken: tokens.newRecordAuthToken
- let recordVerifyToken: tokens.newRecordVerifyToken
- let recordResetPasswordToken: tokens.newRecordResetPasswordToken
- let recordChangeEmailToken: tokens.newRecordChangeEmailToken
- let recordFileToken: tokens.newRecordFileToken
+declare namespace $mails {
+ let sendRecordPasswordReset: mails.sendRecordPasswordReset
+ let sendRecordVerification: mails.sendRecordVerification
+ let sendRecordChangeEmail: mails.sendRecordChangeEmail
+ let sendRecordOTP: mails.sendRecordOTP
+ let sendRecordAuthAlert: mails.sendRecordAuthAlert
}
// -------------------------------------------------------------------
@@ -494,11 +862,9 @@ declare namespace $tokens {
declare namespace $security {
let randomString: security.randomString
let randomStringWithAlphabet: security.randomStringWithAlphabet
+ let randomStringByRegex: security.randomStringByRegex
let pseudorandomString: security.pseudorandomString
let pseudorandomStringWithAlphabet: security.pseudorandomStringWithAlphabet
- let parseUnverifiedJWT: security.parseUnverifiedJWT
- let parseJWT: security.parseJWT
- let createJWT: security.newJWT
let encrypt: security.encrypt
let decrypt: security.decrypt
let hs256: security.hs256
@@ -507,6 +873,21 @@ declare namespace $security {
let md5: security.md5
let sha256: security.sha256
let sha512: security.sha512
+
+ /**
+ * {@inheritDoc security.newJWT}
+ */
+ function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
+
+ /**
+ * {@inheritDoc security.parseUnverifiedJWT}
+ */
+ function parseUnverifiedJWT(token: string): _TygojaDict
+
+ /**
+ * {@inheritDoc security.parseJWT}
+ */
+ function parseJWT(token: string, verificationKey: string): _TygojaDict
}
// -------------------------------------------------------------------
@@ -523,11 +904,45 @@ declare namespace $filesystem {
let fileFromPath: filesystem.newFileFromPath
let fileFromBytes: filesystem.newFileFromBytes
let fileFromMultipart: filesystem.newFileFromMultipart
-}
-
-// -------------------------------------------------------------------
-// filepathBinds
-// -------------------------------------------------------------------
+
+ /**
+ * Initializes a new S3-only filesystem instance
+ * (make sure to call `close()` after you are done working with it).
+ *
+ * Most users should prefer `$app.newFilesystem()` which will
+ * construct a local or S3 filesystem based on the configured application settings.
+ */
+ let s3: filesystem.newS3
+
+ /**
+ * Initializes a new local-only filesystem instance
+ * (make sure to call `close()` after you are done working with it).
+ *
+ * Most users should prefer `$app.newFilesystem()` which will
+ * construct a local or S3 filesystem based on the configured application settings.
+ */
+ let local: filesystem.newLocal
+
+ /**
+ * fileFromURL creates a new File from the provided url by
+ * downloading the resource and creating a BytesReader.
+ *
+ * Example:
+ *
+ * ```js
+ * // with default max timeout of 120sec
+ * const file1 = $filesystem.fileFromURL("https://...")
+ *
+ * // with custom timeout of 15sec
+ * const file2 = $filesystem.fileFromURL("https://...", 15)
+ * ```
+ */
+ function fileFromURL(url: string, secTimeout?: number): filesystem.File
+}
+
+// -------------------------------------------------------------------
+// filepathBinds
+// -------------------------------------------------------------------
/**
* `$filepath` defines common helpers for manipulating filename
@@ -536,21 +951,21 @@ declare namespace $filesystem {
* @group PocketBase
*/
declare namespace $filepath {
- export let base: filepath.base
- export let clean: filepath.clean
- export let dir: filepath.dir
- export let ext: filepath.ext
- export let fromSlash: filepath.fromSlash
- export let glob: filepath.glob
- export let isAbs: filepath.isAbs
- export let join: filepath.join
- export let match: filepath.match
- export let rel: filepath.rel
- export let split: filepath.split
- export let splitList: filepath.splitList
- export let toSlash: filepath.toSlash
- export let walk: filepath.walk
- export let walkDir: filepath.walkDir
+ let base: filepath.base
+ let clean: filepath.clean
+ let dir: filepath.dir
+ let ext: filepath.ext
+ let fromSlash: filepath.fromSlash
+ let glob: filepath.glob
+ let isAbs: filepath.isAbs
+ let join: filepath.join
+ let match: filepath.match
+ let rel: filepath.rel
+ let split: filepath.split
+ let splitList: filepath.splitList
+ let toSlash: filepath.toSlash
+ let walk: filepath.walk
+ let walkDir: filepath.walkDir
}
// -------------------------------------------------------------------
@@ -564,63 +979,53 @@ declare namespace $filepath {
* @group PocketBase
*/
declare namespace $os {
- export let exec: exec.command
- export let args: os.args
- export let exit: os.exit
- export let getenv: os.getenv
- export let dirFS: os.dirFS
- export let readFile: os.readFile
- export let writeFile: os.writeFile
- export let readDir: os.readDir
- export let tempDir: os.tempDir
- export let truncate: os.truncate
- export let getwd: os.getwd
- export let mkdir: os.mkdir
- export let mkdirAll: os.mkdirAll
- export let rename: os.rename
- export let remove: os.remove
- export let removeAll: os.removeAll
-}
-
-// -------------------------------------------------------------------
-// formsBinds
-// -------------------------------------------------------------------
+ /**
+ * Legacy alias for $os.cmd().
+ */
+ let exec: exec.command
-interface AdminLoginForm extends forms.AdminLogin{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class AdminLoginForm implements forms.AdminLogin {
- constructor(app: core.App)
-}
+ /**
+ * Prepares an external OS command.
+ *
+ * Example:
+ *
+ * ```js
+ * // prepare the command to execute
+ * const cmd = $os.cmd('ls', '-sl')
+ *
+ * // execute the command and return its standard output as string
+ * const output = toString(cmd.output());
+ * ```
+ */
+ let cmd: exec.command
-interface AdminPasswordResetConfirmForm extends forms.AdminPasswordResetConfirm{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class AdminPasswordResetConfirmForm implements forms.AdminPasswordResetConfirm {
- constructor(app: core.App)
-}
+ /**
+ * Args hold the command-line arguments, starting with the program name.
+ */
+ let args: Array
-interface AdminPasswordResetRequestForm extends forms.AdminPasswordResetRequest{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class AdminPasswordResetRequestForm implements forms.AdminPasswordResetRequest {
- constructor(app: core.App)
+ let exit: os.exit
+ let getenv: os.getenv
+ let dirFS: os.dirFS
+ let readFile: os.readFile
+ let writeFile: os.writeFile
+ let stat: os.stat
+ let readDir: os.readDir
+ let tempDir: os.tempDir
+ let truncate: os.truncate
+ let getwd: os.getwd
+ let mkdir: os.mkdir
+ let mkdirAll: os.mkdirAll
+ let rename: os.rename
+ let remove: os.remove
+ let removeAll: os.removeAll
+ let openRoot: os.openRoot
+ let openInRoot: os.openInRoot
}
-interface AdminUpsertForm extends forms.AdminUpsert{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class AdminUpsertForm implements forms.AdminUpsert {
- constructor(app: core.App, admin: models.Admin)
-}
+// -------------------------------------------------------------------
+// formsBinds
+// -------------------------------------------------------------------
interface AppleClientSecretCreateForm extends forms.AppleClientSecretCreate{} // merge
/**
@@ -628,86 +1033,7 @@ interface AppleClientSecretCreateForm extends forms.AppleClientSecretCreate{} //
* @group PocketBase
*/
declare class AppleClientSecretCreateForm implements forms.AppleClientSecretCreate {
- constructor(app: core.App)
-}
-
-interface CollectionUpsertForm extends forms.CollectionUpsert{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class CollectionUpsertForm implements forms.CollectionUpsert {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface CollectionsImportForm extends forms.CollectionsImport{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class CollectionsImportForm implements forms.CollectionsImport {
- constructor(app: core.App)
-}
-
-interface RealtimeSubscribeForm extends forms.RealtimeSubscribe{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RealtimeSubscribeForm implements forms.RealtimeSubscribe {}
-
-interface RecordEmailChangeConfirmForm extends forms.RecordEmailChangeConfirm{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordEmailChangeConfirmForm implements forms.RecordEmailChangeConfirm {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface RecordEmailChangeRequestForm extends forms.RecordEmailChangeRequest{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordEmailChangeRequestForm implements forms.RecordEmailChangeRequest {
- constructor(app: core.App, record: models.Record)
-}
-
-interface RecordOAuth2LoginForm extends forms.RecordOAuth2Login{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordOAuth2LoginForm implements forms.RecordOAuth2Login {
- constructor(app: core.App, collection: models.Collection, optAuthRecord?: models.Record)
-}
-
-interface RecordPasswordLoginForm extends forms.RecordPasswordLogin{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordPasswordLoginForm implements forms.RecordPasswordLogin {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface RecordPasswordResetConfirmForm extends forms.RecordPasswordResetConfirm{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordPasswordResetConfirmForm implements forms.RecordPasswordResetConfirm {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface RecordPasswordResetRequestForm extends forms.RecordPasswordResetRequest{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordPasswordResetRequestForm implements forms.RecordPasswordResetRequest {
- constructor(app: core.App, collection: models.Collection)
+ constructor(app: CoreApp)
}
interface RecordUpsertForm extends forms.RecordUpsert{} // merge
@@ -716,34 +1042,7 @@ interface RecordUpsertForm extends forms.RecordUpsert{} // merge
* @group PocketBase
*/
declare class RecordUpsertForm implements forms.RecordUpsert {
- constructor(app: core.App, record: models.Record)
-}
-
-interface RecordVerificationConfirmForm extends forms.RecordVerificationConfirm{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordVerificationConfirmForm implements forms.RecordVerificationConfirm {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface RecordVerificationRequestForm extends forms.RecordVerificationRequest{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class RecordVerificationRequestForm implements forms.RecordVerificationRequest {
- constructor(app: core.App, collection: models.Collection)
-}
-
-interface SettingsUpsertForm extends forms.SettingsUpsert{} // merge
-/**
- * @inheritDoc
- * @group PocketBase
- */
-declare class SettingsUpsertForm implements forms.SettingsUpsert {
- constructor(app: core.App)
+ constructor(app: CoreApp, record: core.Record)
}
interface TestEmailSendForm extends forms.TestEmailSend{} // merge
@@ -752,7 +1051,7 @@ interface TestEmailSendForm extends forms.TestEmailSend{} // merge
* @group PocketBase
*/
declare class TestEmailSendForm implements forms.TestEmailSend {
- constructor(app: core.App)
+ constructor(app: CoreApp)
}
interface TestS3FilesystemForm extends forms.TestS3Filesystem{} // merge
@@ -761,60 +1060,80 @@ interface TestS3FilesystemForm extends forms.TestS3Filesystem{} // merge
* @group PocketBase
*/
declare class TestS3FilesystemForm implements forms.TestS3Filesystem {
- constructor(app: core.App)
+ constructor(app: CoreApp)
}
// -------------------------------------------------------------------
// apisBinds
// -------------------------------------------------------------------
-interface ApiError extends apis.ApiError{} // merge
+interface ApiError extends router.ApiError{} // merge
/**
* @inheritDoc
*
* @group PocketBase
*/
-declare class ApiError implements apis.ApiError {
+declare class ApiError implements router.ApiError {
constructor(status?: number, message?: string, data?: any)
}
-interface NotFoundError extends apis.ApiError{} // merge
+interface NotFoundError extends router.ApiError{} // merge
/**
* NotFounderor returns 404 ApiError.
*
* @group PocketBase
*/
-declare class NotFoundError implements apis.ApiError {
+declare class NotFoundError implements router.ApiError {
constructor(message?: string, data?: any)
}
-interface BadRequestError extends apis.ApiError{} // merge
+interface BadRequestError extends router.ApiError{} // merge
/**
* BadRequestError returns 400 ApiError.
*
* @group PocketBase
*/
-declare class BadRequestError implements apis.ApiError {
+declare class BadRequestError implements router.ApiError {
constructor(message?: string, data?: any)
}
-interface ForbiddenError extends apis.ApiError{} // merge
+interface ForbiddenError extends router.ApiError{} // merge
/**
* ForbiddenError returns 403 ApiError.
*
* @group PocketBase
*/
-declare class ForbiddenError implements apis.ApiError {
+declare class ForbiddenError implements router.ApiError {
constructor(message?: string, data?: any)
}
-interface UnauthorizedError extends apis.ApiError{} // merge
+interface UnauthorizedError extends router.ApiError{} // merge
/**
* UnauthorizedError returns 401 ApiError.
*
* @group PocketBase
*/
-declare class UnauthorizedError implements apis.ApiError {
+declare class UnauthorizedError implements router.ApiError {
+ constructor(message?: string, data?: any)
+}
+
+interface TooManyRequestsError extends router.ApiError{} // merge
+/**
+ * TooManyRequestsError returns 429 ApiError.
+ *
+ * @group PocketBase
+ */
+declare class TooManyRequestsError implements router.ApiError {
+ constructor(message?: string, data?: any)
+}
+
+interface InternalServerError extends router.ApiError{} // merge
+/**
+ * InternalServerError returns 429 ApiError.
+ *
+ * @group PocketBase
+ */
+declare class InternalServerError implements router.ApiError {
constructor(message?: string, data?: any)
}
@@ -827,27 +1146,62 @@ declare namespace $apis {
/**
* Route handler to serve static directory content (html, js, css, etc.).
*
- * If a file resource is missing and indexFallback is set, the request
- * will be forwarded to the base index.html (useful for SPA).
+ * If a file resource is missing and indexFallback is true, the request
+ * will be forwarded to the base index.html (useful for SPA with pretty urls).
+ *
+ * NB! Expects the route to have a "{path...}" wildcard parameter.
+ *
+ * Special redirects:
+ *
+ * - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
+ * - if "path" is a directory that has index.html, the index.html file is rendered,
+ * otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
+ *
+ * Example:
+ *
+ * ```js
+ * // serves static files from the provided dir string path (it will be wrapped in $os.dirFS())
+ * routerAdd("GET", "/{path...}", $apis.static("/path/to/public", false))
+ *
+ * // serves static files from the explicit fs.FS value ($os.dirFS(), $os.openRoot().fs(), etc.)
+ * routerAdd("GET", "/{path...}", $apis.static($os.dirFS("/path/to/public"), false))
+ * ```
*/
- export function staticDirectoryHandler(dir: string, indexFallback: boolean): echo.HandlerFunc
+ function static(dirOrFS: string|fs.FS, indexFallback: boolean): (e: core.RequestEvent) => void
+
+ let requireGuestOnly: apis.requireGuestOnly
+ let requireAuth: apis.requireAuth
+ let requireSuperuserAuth: apis.requireSuperuserAuth
+ let requireSuperuserOrOwnerAuth: apis.requireSuperuserOrOwnerAuth
+ let skipSuccessActivityLog: apis.skipSuccessActivityLog
+ let gzip: apis.gzip
+ let bodyLimit: apis.bodyLimit
+ let enrichRecord: apis.enrichRecord
+ let enrichRecords: apis.enrichRecords
- let requireRecordAuth: apis.requireRecordAuth
- let requireAdminAuth: apis.requireAdminAuth
- let requireAdminAuthOnlyIfAny: apis.requireAdminAuthOnlyIfAny
- let requireAdminOrRecordAuth: apis.requireAdminOrRecordAuth
- let requireAdminOrOwnerAuth: apis.requireAdminOrOwnerAuth
- let activityLogger: apis.activityLogger
- let requestInfo: apis.requestInfo
- let recordAuthResponse: apis.recordAuthResponse
- let enrichRecord: apis.enrichRecord
- let enrichRecords: apis.enrichRecords
+ /**
+ * RecordAuthResponse writes standardized json record auth response
+ * into the specified request event.
+ *
+ * The authMethod argument specify the name of the current authentication method (eg. password, oauth2, etc.)
+ * that it is used primarily as an auth identifier during MFA and for login alerts.
+ *
+ * Set authMethod to empty string if you want to ignore the MFA checks and the login alerts
+ * (can be also adjusted additionally via the onRecordAuthRequest hook).
+ */
+ function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
}
// -------------------------------------------------------------------
// httpClientBinds
// -------------------------------------------------------------------
+// extra FormData overload to prevent TS warnings when used with non File/Blob value.
+interface FormData {
+ append(key:string, value:any): void
+ set(key:string, value:any): void
+}
+
/**
* `$http` defines common methods for working with HTTP requests.
*
@@ -861,29 +1215,37 @@ declare namespace $http {
*
* ```js
* const res = $http.send({
- * url: "https://example.com",
- * data: {"title": "test"}
- * method: "post",
+ * method: "POST",
+ * url: "https://example.com",
+ * body: JSON.stringify({"title": "test"}),
+ * headers: { 'Content-Type': 'application/json' }
* })
*
- * console.log(res.statusCode)
- * console.log(res.raw)
- * console.log(res.json)
+ * console.log(res.statusCode) // the response HTTP status code
+ * console.log(res.headers) // the response headers (eg. res.headers['X-Custom'][0])
+ * console.log(res.cookies) // the response cookies (eg. res.cookies.sessionId.value)
+ * console.log(res.body) // the response body as raw bytes slice
+ * console.log(res.json) // the response body as parsed json array or map
* ```
*/
function send(config: {
url: string,
- body?: string,
+ body?: string|FormData,
method?: string, // default to "GET"
headers?: { [key:string]: string },
timeout?: number, // default to 120
- // deprecated, please use body instead
+ // @deprecated please use body instead
data?: { [key:string]: any },
}): {
- statusCode: number
- raw: string
- json: any
+ statusCode: number,
+ headers: { [key:string]: Array },
+ cookies: { [key:string]: http.Cookie },
+ json: any,
+ body: Array,
+
+ // @deprecated please use toString(result.body) instead
+ raw: string,
};
}
@@ -899,96 +1261,91 @@ declare namespace $http {
* @group PocketBase
*/
declare function migrate(
- up: (db: dbx.Builder) => void,
- down?: (db: dbx.Builder) => void
+ up: (txApp: CoreApp) => void,
+ down?: (txApp: CoreApp) => void
): void;
-/** @group PocketBase */declare function onAdminAfterAuthRefreshRequest(handler: (e: core.AdminAuthRefreshEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterAuthWithPasswordRequest(handler: (e: core.AdminAuthWithPasswordEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterConfirmPasswordResetRequest(handler: (e: core.AdminConfirmPasswordResetEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterCreateRequest(handler: (e: core.AdminCreateEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterDeleteRequest(handler: (e: core.AdminDeleteEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterRequestPasswordResetRequest(handler: (e: core.AdminRequestPasswordResetEvent) => void): void
-/** @group PocketBase */declare function onAdminAfterUpdateRequest(handler: (e: core.AdminUpdateEvent) => void): void
-/** @group PocketBase */declare function onAdminAuthRequest(handler: (e: core.AdminAuthEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeAuthRefreshRequest(handler: (e: core.AdminAuthRefreshEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeAuthWithPasswordRequest(handler: (e: core.AdminAuthWithPasswordEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeConfirmPasswordResetRequest(handler: (e: core.AdminConfirmPasswordResetEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeCreateRequest(handler: (e: core.AdminCreateEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeDeleteRequest(handler: (e: core.AdminDeleteEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeRequestPasswordResetRequest(handler: (e: core.AdminRequestPasswordResetEvent) => void): void
-/** @group PocketBase */declare function onAdminBeforeUpdateRequest(handler: (e: core.AdminUpdateEvent) => void): void
-/** @group PocketBase */declare function onAdminViewRequest(handler: (e: core.AdminViewEvent) => void): void
-/** @group PocketBase */declare function onAdminsListRequest(handler: (e: core.AdminsListEvent) => void): void
-/** @group PocketBase */declare function onAfterApiError(handler: (e: core.ApiErrorEvent) => void): void
-/** @group PocketBase */declare function onAfterBootstrap(handler: (e: core.BootstrapEvent) => void): void
-/** @group PocketBase */declare function onBeforeApiError(handler: (e: core.ApiErrorEvent) => void): void
-/** @group PocketBase */declare function onBeforeBootstrap(handler: (e: core.BootstrapEvent) => void): void
-/** @group PocketBase */declare function onCollectionAfterCreateRequest(handler: (e: core.CollectionCreateEvent) => void): void
-/** @group PocketBase */declare function onCollectionAfterDeleteRequest(handler: (e: core.CollectionDeleteEvent) => void): void
-/** @group PocketBase */declare function onCollectionAfterUpdateRequest(handler: (e: core.CollectionUpdateEvent) => void): void
-/** @group PocketBase */declare function onCollectionBeforeCreateRequest(handler: (e: core.CollectionCreateEvent) => void): void
-/** @group PocketBase */declare function onCollectionBeforeDeleteRequest(handler: (e: core.CollectionDeleteEvent) => void): void
-/** @group PocketBase */declare function onCollectionBeforeUpdateRequest(handler: (e: core.CollectionUpdateEvent) => void): void
-/** @group PocketBase */declare function onCollectionViewRequest(handler: (e: core.CollectionViewEvent) => void): void
-/** @group PocketBase */declare function onCollectionsAfterImportRequest(handler: (e: core.CollectionsImportEvent) => void): void
-/** @group PocketBase */declare function onCollectionsBeforeImportRequest(handler: (e: core.CollectionsImportEvent) => void): void
-/** @group PocketBase */declare function onCollectionsListRequest(handler: (e: core.CollectionsListEvent) => void): void
-/** @group PocketBase */declare function onFileAfterTokenRequest(handler: (e: core.FileTokenEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onFileBeforeTokenRequest(handler: (e: core.FileTokenEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onFileDownloadRequest(handler: (e: core.FileDownloadEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerAfterAdminResetPasswordSend(handler: (e: core.MailerAdminEvent) => void): void
-/** @group PocketBase */declare function onMailerAfterRecordChangeEmailSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerAfterRecordResetPasswordSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerAfterRecordVerificationSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerBeforeAdminResetPasswordSend(handler: (e: core.MailerAdminEvent) => void): void
-/** @group PocketBase */declare function onMailerBeforeRecordChangeEmailSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerBeforeRecordResetPasswordSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onMailerBeforeRecordVerificationSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelAfterCreate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelAfterDelete(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelAfterUpdate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelBeforeCreate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelBeforeDelete(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onModelBeforeUpdate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRealtimeAfterMessageSend(handler: (e: core.RealtimeMessageEvent) => void): void
-/** @group PocketBase */declare function onRealtimeAfterSubscribeRequest(handler: (e: core.RealtimeSubscribeEvent) => void): void
-/** @group PocketBase */declare function onRealtimeBeforeMessageSend(handler: (e: core.RealtimeMessageEvent) => void): void
-/** @group PocketBase */declare function onRealtimeBeforeSubscribeRequest(handler: (e: core.RealtimeSubscribeEvent) => void): void
-/** @group PocketBase */declare function onRealtimeConnectRequest(handler: (e: core.RealtimeConnectEvent) => void): void
-/** @group PocketBase */declare function onRealtimeDisconnectRequest(handler: (e: core.RealtimeDisconnectEvent) => void): void
-/** @group PocketBase */declare function onRecordAfterAuthRefreshRequest(handler: (e: core.RecordAuthRefreshEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterAuthWithOAuth2Request(handler: (e: core.RecordAuthWithOAuth2Event) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterAuthWithPasswordRequest(handler: (e: core.RecordAuthWithPasswordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterConfirmEmailChangeRequest(handler: (e: core.RecordConfirmEmailChangeEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterConfirmPasswordResetRequest(handler: (e: core.RecordConfirmPasswordResetEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterConfirmVerificationRequest(handler: (e: core.RecordConfirmVerificationEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterCreateRequest(handler: (e: core.RecordCreateEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterDeleteRequest(handler: (e: core.RecordDeleteEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterRequestEmailChangeRequest(handler: (e: core.RecordRequestEmailChangeEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterRequestPasswordResetRequest(handler: (e: core.RecordRequestPasswordResetEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterRequestVerificationRequest(handler: (e: core.RecordRequestVerificationEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterUnlinkExternalAuthRequest(handler: (e: core.RecordUnlinkExternalAuthEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAfterUpdateRequest(handler: (e: core.RecordUpdateEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordAuthRequest(handler: (e: core.RecordAuthEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeAuthRefreshRequest(handler: (e: core.RecordAuthRefreshEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeAuthWithOAuth2Request(handler: (e: core.RecordAuthWithOAuth2Event) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeAuthWithPasswordRequest(handler: (e: core.RecordAuthWithPasswordEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeConfirmEmailChangeRequest(handler: (e: core.RecordConfirmEmailChangeEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeConfirmPasswordResetRequest(handler: (e: core.RecordConfirmPasswordResetEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeConfirmVerificationRequest(handler: (e: core.RecordConfirmVerificationEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeCreateRequest(handler: (e: core.RecordCreateEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeDeleteRequest(handler: (e: core.RecordDeleteEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeRequestEmailChangeRequest(handler: (e: core.RecordRequestEmailChangeEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeRequestPasswordResetRequest(handler: (e: core.RecordRequestPasswordResetEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeRequestVerificationRequest(handler: (e: core.RecordRequestVerificationEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeUnlinkExternalAuthRequest(handler: (e: core.RecordUnlinkExternalAuthEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordBeforeUpdateRequest(handler: (e: core.RecordUpdateEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordListExternalAuthsRequest(handler: (e: core.RecordListExternalAuthsEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordViewRequest(handler: (e: core.RecordViewEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onRecordsListRequest(handler: (e: core.RecordsListEvent) => void, ...tags: string[]): void
-/** @group PocketBase */declare function onSettingsAfterUpdateRequest(handler: (e: core.SettingsUpdateEvent) => void): void
-/** @group PocketBase */declare function onSettingsBeforeUpdateRequest(handler: (e: core.SettingsUpdateEvent) => void): void
-/** @group PocketBase */declare function onSettingsListRequest(handler: (e: core.SettingsListEvent) => void): void
+/** @group PocketBase */declare function onBackupCreate(handler: (e: core.BackupEvent) => void): void
+/** @group PocketBase */declare function onBackupRestore(handler: (e: core.BackupEvent) => void): void
+/** @group PocketBase */declare function onBatchRequest(handler: (e: core.BatchRequestEvent) => void): void
+/** @group PocketBase */declare function onBootstrap(handler: (e: core.BootstrapEvent) => void): void
+/** @group PocketBase */declare function onBootstrapClear(handler: (e: core.BootstrapEvent) => void): void
+/** @group PocketBase */declare function onCollectionAfterCreateError(handler: (e: core.CollectionErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionAfterCreateSuccess(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionAfterDeleteError(handler: (e: core.CollectionErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionAfterDeleteSuccess(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionAfterUpdateError(handler: (e: core.CollectionErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionAfterUpdateSuccess(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionCreate(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionCreateExecute(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionCreateRequest(handler: (e: core.CollectionRequestEvent) => void): void
+/** @group PocketBase */declare function onCollectionDelete(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionDeleteExecute(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionDeleteRequest(handler: (e: core.CollectionRequestEvent) => void): void
+/** @group PocketBase */declare function onCollectionUpdate(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionUpdateExecute(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionUpdateRequest(handler: (e: core.CollectionRequestEvent) => void): void
+/** @group PocketBase */declare function onCollectionValidate(handler: (e: core.CollectionEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onCollectionViewRequest(handler: (e: core.CollectionRequestEvent) => void): void
+/** @group PocketBase */declare function onCollectionsImportRequest(handler: (e: core.CollectionsImportRequestEvent) => void): void
+/** @group PocketBase */declare function onCollectionsListRequest(handler: (e: core.CollectionsListRequestEvent) => void): void
+/** @group PocketBase */declare function onFileDownloadRequest(handler: (e: core.FileDownloadRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onFileTokenRequest(handler: (e: core.FileTokenRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerRecordAuthAlertSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerRecordEmailChangeSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerRecordOTPSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerRecordPasswordResetSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerRecordVerificationSend(handler: (e: core.MailerRecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onMailerSend(handler: (e: core.MailerEvent) => void): void
+/** @group PocketBase */declare function onModelAfterCreateError(handler: (e: core.ModelErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelAfterCreateSuccess(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelAfterDeleteError(handler: (e: core.ModelErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelAfterDeleteSuccess(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelAfterUpdateError(handler: (e: core.ModelErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelAfterUpdateSuccess(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelCreate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelCreateExecute(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelDelete(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelDeleteExecute(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelUpdate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelUpdateExecute(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onModelValidate(handler: (e: core.ModelEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRealtimeConnectRequest(handler: (e: core.RealtimeConnectRequestEvent) => void): void
+/** @group PocketBase */declare function onRealtimeMessageSend(handler: (e: core.RealtimeMessageEvent) => void): void
+/** @group PocketBase */declare function onRealtimeSubscribeRequest(handler: (e: core.RealtimeSubscribeRequestEvent) => void): void
+/** @group PocketBase */declare function onRecordAfterCreateError(handler: (e: core.RecordErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAfterCreateSuccess(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAfterDeleteError(handler: (e: core.RecordErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAfterDeleteSuccess(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAfterUpdateError(handler: (e: core.RecordErrorEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAfterUpdateSuccess(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAuthRefreshRequest(handler: (e: core.RecordAuthRefreshRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAuthRequest(handler: (e: core.RecordAuthRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAuthWithOAuth2Request(handler: (e: core.RecordAuthWithOAuth2RequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAuthWithOTPRequest(handler: (e: core.RecordAuthWithOTPRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordAuthWithPasswordRequest(handler: (e: core.RecordAuthWithPasswordRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordConfirmEmailChangeRequest(handler: (e: core.RecordConfirmEmailChangeRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordConfirmPasswordResetRequest(handler: (e: core.RecordConfirmPasswordResetRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordConfirmVerificationRequest(handler: (e: core.RecordConfirmVerificationRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordCreate(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordCreateExecute(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordCreateRequest(handler: (e: core.RecordRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordDelete(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordDeleteExecute(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordDeleteRequest(handler: (e: core.RecordRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordEnrich(handler: (e: core.RecordEnrichEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordRequestEmailChangeRequest(handler: (e: core.RecordRequestEmailChangeRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordRequestOTPRequest(handler: (e: core.RecordCreateOTPRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordRequestPasswordResetRequest(handler: (e: core.RecordRequestPasswordResetRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordRequestVerificationRequest(handler: (e: core.RecordRequestVerificationRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordUpdate(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordUpdateExecute(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordUpdateRequest(handler: (e: core.RecordRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordValidate(handler: (e: core.RecordEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordViewRequest(handler: (e: core.RecordRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onRecordsListRequest(handler: (e: core.RecordsListRequestEvent) => void, ...tags: string[]): void
+/** @group PocketBase */declare function onSettingsListRequest(handler: (e: core.SettingsListRequestEvent) => void): void
+/** @group PocketBase */declare function onSettingsReload(handler: (e: core.SettingsReloadEvent) => void): void
+/** @group PocketBase */declare function onSettingsUpdateRequest(handler: (e: core.SettingsUpdateRequestEvent) => void): void
/** @group PocketBase */declare function onTerminate(handler: (e: core.TerminateEvent) => void): void
type _TygojaDict = { [key:string | number | symbol]: any; }
type _TygojaAny = any
@@ -998,9 +1355,9 @@ type _TygojaAny = any
* functionality. The design is Unix-like, although the error handling is
* Go-like; failing calls return values of type error rather than error numbers.
* Often, more information is available within the error. For example,
- * if a call that takes a file name fails, such as Open or Stat, the error
+ * if a call that takes a file name fails, such as [Open] or [Stat], the error
* will include the failing file name when printed and will be of type
- * *PathError, which may be unpacked for more information.
+ * [*PathError], which may be unpacked for more information.
*
* The os interface is intended to be uniform across all operating systems.
* Features not generally available appear in the system-specific package syscall.
@@ -1032,22 +1389,26 @@ type _TygojaAny = any
* fmt.Printf("read %d bytes: %q\n", count, data[:count])
* ```
*
- * Note: The maximum number of concurrent operations on a File may be limited by
- * the OS or the system. The number should be high, but exceeding it may degrade
- * performance or cause other issues.
+ * # Concurrency
+ *
+ * The methods of [File] correspond to file system operations. All are
+ * safe for concurrent use. The maximum number of concurrent
+ * operations on a File may be limited by the OS or the system. The
+ * number should be high, but exceeding it may degrade performance or
+ * cause other issues.
*/
namespace os {
interface readdirMode extends Number{}
interface File {
/**
* Readdir reads the contents of the directory associated with file and
- * returns a slice of up to n FileInfo values, as would be returned
- * by Lstat, in directory order. Subsequent calls on the same file will yield
+ * returns a slice of up to n [FileInfo] values, as would be returned
+ * by [Lstat], in directory order. Subsequent calls on the same file will yield
* further FileInfos.
*
* If n > 0, Readdir returns at most n FileInfo structures. In this case, if
* Readdir returns an empty slice, it will return a non-nil error
- * explaining why. At the end of a directory, the error is io.EOF.
+ * explaining why. At the end of a directory, the error is [io.EOF].
*
* If n <= 0, Readdir returns all the FileInfo from the directory in
* a single slice. In this case, if Readdir succeeds (reads all
@@ -1069,7 +1430,7 @@ namespace os {
*
* If n > 0, Readdirnames returns at most n names. In this case, if
* Readdirnames returns an empty slice, it will return a non-nil error
- * explaining why. At the end of a directory, the error is io.EOF.
+ * explaining why. At the end of a directory, the error is [io.EOF].
*
* If n <= 0, Readdirnames returns all the names from the directory in
* a single slice. In this case, if Readdirnames succeeds (reads all
@@ -1082,18 +1443,18 @@ namespace os {
}
/**
* A DirEntry is an entry read from a directory
- * (using the ReadDir function or a File's ReadDir method).
+ * (using the [ReadDir] function or a [File.ReadDir] method).
*/
interface DirEntry extends fs.DirEntry{}
interface File {
/**
* ReadDir reads the contents of the directory associated with the file f
- * and returns a slice of DirEntry values in directory order.
+ * and returns a slice of [DirEntry] values in directory order.
* Subsequent calls on the same file will yield later DirEntry records in the directory.
*
* If n > 0, ReadDir returns at most n DirEntry records.
* In this case, if ReadDir returns an empty slice, it will return an error explaining why.
- * At the end of a directory, the error is io.EOF.
+ * At the end of a directory, the error is [io.EOF].
*
* If n <= 0, ReadDir returns all the DirEntry records remaining in the directory.
* When it succeeds, it returns a nil error (not io.EOF).
@@ -1110,6 +1471,28 @@ namespace os {
*/
(name: string): Array
}
+ interface copyFS {
+ /**
+ * CopyFS copies the file system fsys into the directory dir,
+ * creating dir if necessary.
+ *
+ * Files are created with mode 0o666 plus any execute permissions
+ * from the source, and directories are created with mode 0o777
+ * (before umask).
+ *
+ * CopyFS will not overwrite existing files. If a file name in fsys
+ * already exists in the destination, CopyFS will return an error
+ * such that errors.Is(err, fs.ErrExist) will be true.
+ *
+ * Symbolic links in dir are followed.
+ *
+ * New files added to fsys (including if dir is a subdirectory of fsys)
+ * while CopyFS is running are not guaranteed to be copied.
+ *
+ * Copying stops at and returns the first error encountered.
+ */
+ (dir: string, fsys: fs.FS): void
+ }
/**
* Auxiliary information if the File describes a directory
*/
@@ -1118,7 +1501,7 @@ namespace os {
interface expand {
/**
* Expand replaces ${var} or $var in the string based on the mapping function.
- * For example, os.ExpandEnv(s) is equivalent to os.Expand(s, os.Getenv).
+ * For example, [os.ExpandEnv](s) is equivalent to [os.Expand](s, [os.Getenv]).
*/
(s: string, mapping: (_arg0: string) => string): string
}
@@ -1134,7 +1517,7 @@ namespace os {
/**
* Getenv retrieves the value of the environment variable named by the key.
* It returns the value, which will be empty if the variable is not present.
- * To distinguish between an empty value and an unset value, use LookupEnv.
+ * To distinguish between an empty value and an unset value, use [LookupEnv].
*/
(key: string): string
}
@@ -1153,7 +1536,7 @@ namespace os {
* Setenv sets the value of the environment variable named by the key.
* It returns an error, if any.
*/
- (key: string): void
+ (key: string, value: string): void
}
interface unsetenv {
/**
@@ -1175,6 +1558,7 @@ namespace os {
(): Array
}
interface timeout {
+ [key:string]: any;
timeout(): boolean
}
/**
@@ -1202,7 +1586,7 @@ namespace os {
}
interface newSyscallError {
/**
- * NewSyscallError returns, as an error, a new SyscallError
+ * NewSyscallError returns, as an error, a new [SyscallError]
* with the given system call name and error details.
* As a convenience, if err is nil, NewSyscallError returns nil.
*/
@@ -1210,57 +1594,73 @@ namespace os {
}
interface isExist {
/**
- * IsExist returns a boolean indicating whether the error is known to report
- * that a file or directory already exists. It is satisfied by ErrExist as
+ * IsExist returns a boolean indicating whether its argument is known to report
+ * that a file or directory already exists. It is satisfied by [ErrExist] as
* well as some syscall errors.
*
- * This function predates errors.Is. It only supports errors returned by
+ * This function predates [errors.Is]. It only supports errors returned by
* the os package. New code should use errors.Is(err, fs.ErrExist).
*/
(err: Error): boolean
}
interface isNotExist {
/**
- * IsNotExist returns a boolean indicating whether the error is known to
+ * IsNotExist returns a boolean indicating whether its argument is known to
* report that a file or directory does not exist. It is satisfied by
- * ErrNotExist as well as some syscall errors.
+ * [ErrNotExist] as well as some syscall errors.
*
- * This function predates errors.Is. It only supports errors returned by
+ * This function predates [errors.Is]. It only supports errors returned by
* the os package. New code should use errors.Is(err, fs.ErrNotExist).
*/
(err: Error): boolean
}
interface isPermission {
/**
- * IsPermission returns a boolean indicating whether the error is known to
- * report that permission is denied. It is satisfied by ErrPermission as well
+ * IsPermission returns a boolean indicating whether its argument is known to
+ * report that permission is denied. It is satisfied by [ErrPermission] as well
* as some syscall errors.
*
- * This function predates errors.Is. It only supports errors returned by
+ * This function predates [errors.Is]. It only supports errors returned by
* the os package. New code should use errors.Is(err, fs.ErrPermission).
*/
(err: Error): boolean
}
interface isTimeout {
/**
- * IsTimeout returns a boolean indicating whether the error is known
+ * IsTimeout returns a boolean indicating whether its argument is known
* to report that a timeout occurred.
*
- * This function predates errors.Is, and the notion of whether an
+ * This function predates [errors.Is], and the notion of whether an
* error indicates a timeout can be ambiguous. For example, the Unix
* error EWOULDBLOCK sometimes indicates a timeout and sometimes does not.
* New code should use errors.Is with a value appropriate to the call
- * returning the error, such as os.ErrDeadlineExceeded.
+ * returning the error, such as [os.ErrDeadlineExceeded].
*/
(err: Error): boolean
}
interface syscallErrorType extends syscall.Errno{}
/**
- * Process stores the information about a process created by StartProcess.
+ * processStatus describes the status of a [Process].
+ */
+ interface processStatus extends Number{}
+ /**
+ * Process stores the information about a process created by [StartProcess].
*/
interface Process {
+ /**
+ * Pid is the operating system process ID.
+ */
pid: number
}
+ /**
+ * processHandle holds an operating system handle to a process.
+ * This is only used on systems that support that concept,
+ * currently Linux and Windows.
+ * This maintains a reference count to the handle,
+ * and closes the handle when the reference drops to zero.
+ */
+ interface processHandle {
+ }
/**
* ProcAttr holds the attributes that will be applied to a new process
* started by StartProcess.
@@ -1302,6 +1702,7 @@ namespace os {
* on Unix it is syscall.Signal.
*/
interface Signal {
+ [key:string]: any;
string(): string
signal(): void // to distinguish from other Stringers
}
@@ -1321,43 +1722,45 @@ namespace os {
/**
* FindProcess looks for a running process by its pid.
*
- * The Process it returns can be used to obtain information
+ * The [Process] it returns can be used to obtain information
* about the underlying operating system process.
*
* On Unix systems, FindProcess always succeeds and returns a Process
- * for the given pid, regardless of whether the process exists.
+ * for the given pid, regardless of whether the process exists. To test whether
+ * the process actually exists, see whether p.Signal(syscall.Signal(0)) reports
+ * an error.
*/
- (pid: number): (Process | undefined)
+ (pid: number): (Process)
}
interface startProcess {
/**
* StartProcess starts a new process with the program, arguments and attributes
- * specified by name, argv and attr. The argv slice will become os.Args in the
+ * specified by name, argv and attr. The argv slice will become [os.Args] in the
* new process, so it normally starts with the program name.
*
* If the calling goroutine has locked the operating system thread
- * with runtime.LockOSThread and modified any inheritable OS-level
+ * with [runtime.LockOSThread] and modified any inheritable OS-level
* thread state (for example, Linux or Plan 9 name spaces), the new
* process will inherit the caller's thread state.
*
- * StartProcess is a low-level interface. The os/exec package provides
+ * StartProcess is a low-level interface. The [os/exec] package provides
* higher-level interfaces.
*
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
- (name: string, argv: Array, attr: ProcAttr): (Process | undefined)
+ (name: string, argv: Array, attr: ProcAttr): (Process)
}
interface Process {
/**
- * Release releases any resources associated with the Process p,
+ * Release releases any resources associated with the [Process] p,
* rendering it unusable in the future.
- * Release only needs to be called if Wait is not.
+ * Release only needs to be called if [Process.Wait] is not.
*/
release(): void
}
interface Process {
/**
- * Kill causes the Process to exit immediately. Kill does not wait until
+ * Kill causes the [Process] to exit immediately. Kill does not wait until
* the Process has actually exited. This only kills the Process itself,
* not any other processes it may have started.
*/
@@ -1365,21 +1768,34 @@ namespace os {
}
interface Process {
/**
- * Wait waits for the Process to exit, and then returns a
+ * Wait waits for the [Process] to exit, and then returns a
* ProcessState describing its status and an error, if any.
* Wait releases any resources associated with the Process.
* On most operating systems, the Process must be a child
* of the current process or an error will be returned.
*/
- wait(): (ProcessState | undefined)
+ wait(): (ProcessState)
}
interface Process {
/**
- * Signal sends a signal to the Process.
- * Sending Interrupt on Windows is not implemented.
+ * Signal sends a signal to the [Process].
+ * Sending [Interrupt] on Windows is not implemented.
*/
signal(sig: Signal): void
}
+ interface Process {
+ /**
+ * WithHandle calls a supplied function f with a valid process handle
+ * as an argument. The handle is guaranteed to refer to process p
+ * until f returns, even if p terminates. This function cannot be used
+ * after [Process.Release] or [Process.Wait].
+ *
+ * If process handles are not supported or a handle is not available,
+ * it returns [ErrNoHandle]. Currently, process handles are supported
+ * on Linux 5.4 or later (pidfd) and Windows.
+ */
+ withHandle(f: (handle: number) => void): void
+ }
interface ProcessState {
/**
* UserTime returns the user CPU time of the exited process and its children.
@@ -1411,7 +1827,7 @@ namespace os {
/**
* Sys returns system-dependent exit information about
* the process. Convert it to the appropriate underlying
- * type, such as syscall.WaitStatus on Unix, to access its contents.
+ * type, such as [syscall.WaitStatus] on Unix, to access its contents.
*/
sys(): any
}
@@ -1419,7 +1835,7 @@ namespace os {
/**
* SysUsage returns system-dependent resource usage information about
* the exited process. Convert it to the appropriate underlying
- * type, such as *syscall.Rusage on Unix, to access its contents.
+ * type, such as [*syscall.Rusage] on Unix, to access its contents.
* (On Unix, *syscall.Rusage matches struct rusage as defined in the
* getrusage(2) manual page.)
*/
@@ -1453,7 +1869,7 @@ namespace os {
* pointing to the correct executable. If a symlink was used to start
* the process, depending on the operating system, the result might
* be the symlink or the path it pointed to. If a stable result is
- * needed, path/filepath.EvalSymlinks might help.
+ * needed, [path/filepath.EvalSymlinks] might help.
*
* Executable returns an absolute path unless an error occurred.
*
@@ -1465,6 +1881,8 @@ namespace os {
interface File {
/**
* Name returns the name of the file as presented to Open.
+ *
+ * It is safe to call Name after [Close].
*/
name(): string
}
@@ -1484,13 +1902,35 @@ namespace os {
interface LinkError {
unwrap(): void
}
+ interface newFile {
+ /**
+ * NewFile returns a new [File] with the given file descriptor and name.
+ * The returned value will be nil if fd is not a valid file descriptor.
+ *
+ * NewFile's behavior differs on some platforms:
+ *
+ * ```
+ * - On Unix, if fd is in non-blocking mode, NewFile will attempt to return a pollable file.
+ * - On Windows, if fd is opened for asynchronous I/O (that is, [syscall.FILE_FLAG_OVERLAPPED]
+ * has been specified in the [syscall.CreateFile] call), NewFile will attempt to return a pollable
+ * file by associating fd with the Go runtime I/O completion port.
+ * The I/O operations will be performed synchronously if the association fails.
+ * ```
+ *
+ * Only pollable files support [File.SetDeadline], [File.SetReadDeadline], and [File.SetWriteDeadline].
+ *
+ * After passing it to NewFile, fd may become invalid under the same conditions described
+ * in the comments of [File.Fd], and the same constraints apply.
+ */
+ (fd: number, name: string): (File)
+ }
interface File {
/**
* Read reads up to len(b) bytes from the File and stores them in b.
* It returns the number of bytes read and any error encountered.
* At end of file, Read returns 0, io.EOF.
*/
- read(b: string): number
+ read(b: string|Array): number
}
interface File {
/**
@@ -1499,7 +1939,7 @@ namespace os {
* ReadAt always returns a non-nil error when n < len(b).
* At end of file, that error is io.EOF.
*/
- readAt(b: string, off: number): number
+ readAt(b: string|Array, off: number): number
}
interface File {
/**
@@ -1507,8 +1947,26 @@ namespace os {
*/
readFrom(r: io.Reader): number
}
- type _subsedpf = io.Writer
- interface onlyWriter extends _subsedpf {
+ /**
+ * noReadFrom can be embedded alongside another type to
+ * hide the ReadFrom method of that other type.
+ */
+ interface noReadFrom {
+ }
+ interface noReadFrom {
+ /**
+ * ReadFrom hides another ReadFrom method.
+ * It should never be called.
+ */
+ readFrom(_arg0: io.Reader): number
+ }
+ /**
+ * fileWithoutReadFrom implements all the methods of *File other
+ * than ReadFrom. This is used to permit ReadFrom to call io.Copy
+ * without leading to a recursive call to ReadFrom.
+ */
+ type _sscNYoE = noReadFrom&File
+ interface fileWithoutReadFrom extends _sscNYoE {
}
interface File {
/**
@@ -1516,7 +1974,7 @@ namespace os {
* It returns the number of bytes written and an error, if any.
* Write returns a non-nil error when n != len(b).
*/
- write(b: string): number
+ write(b: string|Array): number
}
interface File {
/**
@@ -1524,9 +1982,36 @@ namespace os {
* It returns the number of bytes written and an error, if any.
* WriteAt returns a non-nil error when n != len(b).
*
- * If file was opened with the O_APPEND flag, WriteAt returns an error.
+ * If file was opened with the [O_APPEND] flag, WriteAt returns an error.
+ */
+ writeAt(b: string|Array, off: number): number
+ }
+ interface File {
+ /**
+ * WriteTo implements io.WriterTo.
+ */
+ writeTo(w: io.Writer): number
+ }
+ /**
+ * noWriteTo can be embedded alongside another type to
+ * hide the WriteTo method of that other type.
+ */
+ interface noWriteTo {
+ }
+ interface noWriteTo {
+ /**
+ * WriteTo hides another WriteTo method.
+ * It should never be called.
*/
- writeAt(b: string, off: number): number
+ writeTo(_arg0: io.Writer): number
+ }
+ /**
+ * fileWithoutWriteTo implements all the methods of *File other
+ * than WriteTo. This is used to permit WriteTo to call io.Copy
+ * without leading to a recursive call to WriteTo.
+ */
+ type _sLVozOv = noWriteTo&File
+ interface fileWithoutWriteTo extends _sLVozOv {
}
interface File {
/**
@@ -1534,11 +2019,7 @@ namespace os {
* according to whence: 0 means relative to the origin of the file, 1 means
* relative to the current offset, and 2 means relative to the end.
* It returns the new offset and an error, if any.
- * The behavior of Seek on a file opened with O_APPEND is not specified.
- *
- * If f is a directory, the behavior of Seek varies by operating
- * system; you can seek to the beginning of the directory on Unix-like
- * operating systems, but not on Windows.
+ * The behavior of Seek on a file opened with [O_APPEND] is not specified.
*/
seek(offset: number, whence: number): number
}
@@ -1553,14 +2034,14 @@ namespace os {
/**
* Mkdir creates a new directory with the specified name and permission
* bits (before umask).
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(name: string, perm: FileMode): void
}
interface chdir {
/**
* Chdir changes the current working directory to the named directory.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(dir: string): void
}
@@ -1568,40 +2049,54 @@ namespace os {
/**
* Open opens the named file for reading. If successful, methods on
* the returned file can be used for reading; the associated file
- * descriptor has mode O_RDONLY.
- * If there is an error, it will be of type *PathError.
+ * descriptor has mode [O_RDONLY].
+ * If there is an error, it will be of type [*PathError].
*/
- (name: string): (File | undefined)
+ (name: string): (File)
}
interface create {
/**
* Create creates or truncates the named file. If the file already exists,
- * it is truncated. If the file does not exist, it is created with mode 0666
+ * it is truncated. If the file does not exist, it is created with mode 0o666
* (before umask). If successful, methods on the returned File can
- * be used for I/O; the associated file descriptor has mode O_RDWR.
- * If there is an error, it will be of type *PathError.
+ * be used for I/O; the associated file descriptor has mode [O_RDWR].
+ * The directory containing the file must already exist.
+ * If there is an error, it will be of type [*PathError].
*/
- (name: string): (File | undefined)
+ (name: string): (File)
}
interface openFile {
/**
* OpenFile is the generalized open call; most users will use Open
* or Create instead. It opens the named file with specified flag
- * (O_RDONLY etc.). If the file does not exist, and the O_CREATE flag
- * is passed, it is created with mode perm (before umask). If successful,
+ * ([O_RDONLY] etc.). If the file does not exist, and the [O_CREATE] flag
+ * is passed, it is created with mode perm (before umask);
+ * the containing directory must exist. If successful,
* methods on the returned File can be used for I/O.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
- (name: string, flag: number, perm: FileMode): (File | undefined)
+ (name: string, flag: number, perm: FileMode): (File)
}
interface rename {
/**
* Rename renames (moves) oldpath to newpath.
* If newpath already exists and is not a directory, Rename replaces it.
+ * If newpath already exists and is a directory, Rename returns an error.
* OS-specific restrictions may apply when oldpath and newpath are in different directories.
+ * Even within the same directory, on non-Unix platforms Rename is not an atomic operation.
* If there is an error, it will be of type *LinkError.
*/
- (oldpath: string): void
+ (oldpath: string, newpath: string): void
+ }
+ interface readlink {
+ /**
+ * Readlink returns the destination of the named symbolic link.
+ * If there is an error, it will be of type [*PathError].
+ *
+ * If the link destination is relative, Readlink returns the relative path
+ * without resolving it to an absolute one.
+ */
+ (name: string): string
}
interface tempDir {
/**
@@ -1630,8 +2125,8 @@ namespace os {
* On Windows, it returns %LocalAppData%.
* On Plan 9, it returns $home/lib/cache.
*
- * If the location cannot be determined (for example, $HOME is not defined),
- * then it will return an error.
+ * If the location cannot be determined (for example, $HOME is not defined) or
+ * the path in $XDG_CACHE_HOME is relative, then it will return an error.
*/
(): string
}
@@ -1648,8 +2143,8 @@ namespace os {
* On Windows, it returns %AppData%.
* On Plan 9, it returns $home/lib.
*
- * If the location cannot be determined (for example, $HOME is not defined),
- * then it will return an error.
+ * If the location cannot be determined (for example, $HOME is not defined) or
+ * the path in $XDG_CONFIG_HOME is relative, then it will return an error.
*/
(): string
}
@@ -1660,6 +2155,9 @@ namespace os {
* On Unix, including macOS, it returns the $HOME environment variable.
* On Windows, it returns %USERPROFILE%.
* On Plan 9, it returns the $home environment variable.
+ *
+ * If the expected variable is not set in the environment, UserHomeDir
+ * returns either a platform-specific default value or a non-nil error.
*/
(): string
}
@@ -1667,29 +2165,29 @@ namespace os {
/**
* Chmod changes the mode of the named file to mode.
* If the file is a symbolic link, it changes the mode of the link's target.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*
* A different subset of the mode bits are used, depending on the
* operating system.
*
- * On Unix, the mode's permission bits, ModeSetuid, ModeSetgid, and
- * ModeSticky are used.
+ * On Unix, the mode's permission bits, [ModeSetuid], [ModeSetgid], and
+ * [ModeSticky] are used.
*
- * On Windows, only the 0200 bit (owner writable) of mode is used; it
+ * On Windows, only the 0o200 bit (owner writable) of mode is used; it
* controls whether the file's read-only attribute is set or cleared.
* The other bits are currently unused. For compatibility with Go 1.12
- * and earlier, use a non-zero mode. Use mode 0400 for a read-only
- * file and 0600 for a readable+writable file.
+ * and earlier, use a non-zero mode. Use mode 0o400 for a read-only
+ * file and 0o600 for a readable+writable file.
*
- * On Plan 9, the mode's permission bits, ModeAppend, ModeExclusive,
- * and ModeTemporary are used.
+ * On Plan 9, the mode's permission bits, [ModeAppend], [ModeExclusive],
+ * and [ModeTemporary] are used.
*/
(name: string, mode: FileMode): void
}
interface File {
/**
* Chmod changes the mode of the file to mode.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
chmod(mode: FileMode): void
}
@@ -1749,6 +2247,30 @@ namespace os {
*/
syscallConn(): syscall.RawConn
}
+ interface File {
+ /**
+ * Fd returns the system file descriptor or handle referencing the open file.
+ * If f is closed, the descriptor becomes invalid.
+ * If f is garbage collected, a finalizer may close the descriptor,
+ * making it invalid; see [runtime.SetFinalizer] for more information on when
+ * a finalizer might be run.
+ *
+ * Do not close the returned descriptor; that could cause a later
+ * close of f to close an unrelated descriptor.
+ *
+ * Fd's behavior differs on some platforms:
+ *
+ * ```
+ * - On Unix and Windows, [File.SetDeadline] methods will stop working.
+ * - On Windows, the file descriptor will be disassociated from the
+ * Go runtime I/O completion port if there are no concurrent I/O
+ * operations on the file.
+ * ```
+ *
+ * For most uses prefer the f.SyscallConn method.
+ */
+ fd(): number
+ }
interface dirFS {
/**
* DirFS returns a file system (an fs.FS) for the tree of files rooted at the directory dir.
@@ -1757,10 +2279,17 @@ namespace os {
* operating system will begin with "/prefix": DirFS("/prefix").Open("file") is the
* same as os.Open("/prefix/file"). So if /prefix/file is a symbolic link pointing outside
* the /prefix tree, then using DirFS does not stop the access any more than using
- * os.Open does. DirFS is therefore not a general substitute for a chroot-style security
- * mechanism when the directory tree contains arbitrary content.
+ * os.Open does. Additionally, the root of the fs.FS returned for a relative path,
+ * DirFS("prefix"), will be affected by later calls to Chdir. DirFS is therefore not
+ * a general substitute for a chroot-style security mechanism when the directory tree
+ * contains arbitrary content.
+ *
+ * Use [Root.FS] to obtain a fs.FS that prevents escapes from the tree via symbolic links.
*
* The directory dir must not be "".
+ *
+ * The result implements [io/fs.StatFS], [io/fs.ReadFileFS], [io/fs.ReadDirFS], and
+ * [io/fs.ReadLinkFS].
*/
(dir: string): fs.FS
}
@@ -1768,31 +2297,56 @@ namespace os {
interface dirFS {
open(name: string): fs.File
}
+ interface dirFS {
+ /**
+ * The ReadFile method calls the [ReadFile] function for the file
+ * with the given name in the directory. The function provides
+ * robust handling for small files and special file systems.
+ * Through this method, dirFS implements [io/fs.ReadFileFS].
+ */
+ readFile(name: string): string|Array
+ }
+ interface dirFS {
+ /**
+ * ReadDir reads the named directory, returning all its directory entries sorted
+ * by filename. Through this method, dirFS implements [io/fs.ReadDirFS].
+ */
+ readDir(name: string): Array
+ }
interface dirFS {
stat(name: string): fs.FileInfo
}
+ interface dirFS {
+ lstat(name: string): fs.FileInfo
+ }
+ interface dirFS {
+ readLink(name: string): string
+ }
interface readFile {
/**
* ReadFile reads the named file and returns the contents.
* A successful call returns err == nil, not err == EOF.
* Because ReadFile reads the whole file, it does not treat an EOF from Read
* as an error to be reported.
+ * If there is an error, it will be of type [*PathError].
*/
- (name: string): string
+ (name: string): string|Array
}
interface writeFile {
/**
* WriteFile writes data to the named file, creating it if necessary.
* If the file does not exist, WriteFile creates it with permissions perm (before umask);
* otherwise WriteFile truncates it before writing, without changing permissions.
+ * Since WriteFile requires multiple system calls to complete, a failure mid-operation
+ * can leave the file in a partially written state.
*/
- (name: string, data: string, perm: FileMode): void
+ (name: string, data: string|Array, perm: FileMode): void
}
interface File {
/**
- * Close closes the File, rendering it unusable for I/O.
- * On files that support SetDeadline, any pending I/O operations will
- * be canceled and return immediately with an ErrClosed error.
+ * Close closes the [File], rendering it unusable for I/O.
+ * On files that support [File.SetDeadline], any pending I/O operations will
+ * be canceled and return immediately with an [ErrClosed] error.
* Close will return an error if it has already been called.
*/
close(): void
@@ -1802,39 +2356,39 @@ namespace os {
* Chown changes the numeric uid and gid of the named file.
* If the file is a symbolic link, it changes the uid and gid of the link's target.
* A uid or gid of -1 means to not change that value.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*
- * On Windows or Plan 9, Chown always returns the syscall.EWINDOWS or
- * EPLAN9 error, wrapped in *PathError.
+ * On Windows or Plan 9, Chown always returns the [syscall.EWINDOWS] or
+ * [syscall.EPLAN9] error, wrapped in [*PathError].
*/
- (name: string, uid: number): void
+ (name: string, uid: number, gid: number): void
}
interface lchown {
/**
* Lchown changes the numeric uid and gid of the named file.
* If the file is a symbolic link, it changes the uid and gid of the link itself.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*
- * On Windows, it always returns the syscall.EWINDOWS error, wrapped
- * in *PathError.
+ * On Windows, it always returns the [syscall.EWINDOWS] error, wrapped
+ * in [*PathError].
*/
- (name: string, uid: number): void
+ (name: string, uid: number, gid: number): void
}
interface File {
/**
* Chown changes the numeric uid and gid of the named file.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*
- * On Windows, it always returns the syscall.EWINDOWS error, wrapped
- * in *PathError.
+ * On Windows, it always returns the [syscall.EWINDOWS] error, wrapped
+ * in [*PathError].
*/
- chown(uid: number): void
+ chown(uid: number, gid: number): void
}
interface File {
/**
* Truncate changes the size of the file.
* It does not change the I/O offset.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
truncate(size: number): void
}
@@ -1850,10 +2404,11 @@ namespace os {
/**
* Chtimes changes the access and modification times of the named
* file, similar to the Unix utime() or utimes() functions.
+ * A zero [time.Time] value will leave the corresponding file time unchanged.
*
* The underlying filesystem may truncate or round the values to a
* less precise time unit.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(name: string, atime: time.Time, mtime: time.Time): void
}
@@ -1861,7 +2416,7 @@ namespace os {
/**
* Chdir changes the current working directory to the file,
* which must be a directory.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
chdir(): void
}
@@ -1873,37 +2428,6 @@ namespace os {
*/
interface file {
}
- interface File {
- /**
- * Fd returns the integer Unix file descriptor referencing the open file.
- * If f is closed, the file descriptor becomes invalid.
- * If f is garbage collected, a finalizer may close the file descriptor,
- * making it invalid; see runtime.SetFinalizer for more information on when
- * a finalizer might be run. On Unix systems this will cause the SetDeadline
- * methods to stop working.
- * Because file descriptors can be reused, the returned file descriptor may
- * only be closed through the Close method of f, or by its finalizer during
- * garbage collection. Otherwise, during garbage collection the finalizer
- * may close an unrelated file descriptor with the same (reused) number.
- *
- * As an alternative, see the f.SyscallConn method.
- */
- fd(): number
- }
- interface newFile {
- /**
- * NewFile returns a new File with the given file descriptor and
- * name. The returned value will be nil if fd is not a valid file
- * descriptor. On Unix systems, if the file descriptor is in
- * non-blocking mode, NewFile will attempt to return a pollable File
- * (one for which the SetDeadline methods work).
- *
- * After passing it to NewFile, fd may become invalid under the same
- * conditions described in the comments of the Fd method, and the same
- * constraints apply.
- */
- (fd: number, name: string): (File | undefined)
- }
/**
* newFileKind describes the kind of file to newFile.
*/
@@ -1912,14 +2436,14 @@ namespace os {
/**
* Truncate changes the size of the named file.
* If the file is a symbolic link, it changes the size of the link's target.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(name: string, size: number): void
}
interface remove {
/**
* Remove removes the named file or (empty) directory.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(name: string): void
}
@@ -1928,7 +2452,7 @@ namespace os {
* Link creates newname as a hard link to the oldname file.
* If there is an error, it will be of type *LinkError.
*/
- (oldname: string): void
+ (oldname: string, newname: string): void
}
interface symlink {
/**
@@ -1937,14 +2461,7 @@ namespace os {
* if oldname is later created as a directory the symlink will not work.
* If there is an error, it will be of type *LinkError.
*/
- (oldname: string): void
- }
- interface readlink {
- /**
- * Readlink returns the destination of the named symbolic link.
- * If there is an error, it will be of type *PathError.
- */
- (name: string): string
+ (oldname: string, newname: string): void
}
interface unixDirent {
}
@@ -1960,12 +2477,19 @@ namespace os {
interface unixDirent {
info(): FileInfo
}
+ interface unixDirent {
+ string(): string
+ }
interface getwd {
/**
- * Getwd returns a rooted path name corresponding to the
+ * Getwd returns an absolute path name corresponding to the
* current directory. If the current directory can be
* reached via multiple paths (due to symbolic links),
* Getwd may return any one of them.
+ *
+ * On Unix platforms, if the environment variable PWD
+ * provides an absolute name, and it is a name of the
+ * current directory, it is returned.
*/
(): string
}
@@ -1987,7 +2511,7 @@ namespace os {
* It removes everything it can but returns the first error
* it encounters. If the path does not exist, RemoveAll
* returns nil (no error).
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
*/
(path: string): void
}
@@ -2002,7 +2526,7 @@ namespace os {
* Pipe returns a connected pair of Files; reads from r return bytes written to w.
* It returns the files and an error, if any.
*/
- (): [(File | undefined), (File | undefined)]
+ (): [(File), (File)]
}
interface getuid {
/**
@@ -2040,7 +2564,7 @@ namespace os {
/**
* Getgroups returns a list of the numeric ids of groups that the caller belongs to.
*
- * On Windows, it returns syscall.EWINDOWS. See the os/user package
+ * On Windows, it returns [syscall.EWINDOWS]. See the [os/user] package
* for a possible alternative.
*/
(): Array
@@ -2069,26 +2593,324 @@ namespace os {
interface rawConn {
write(f: (_arg0: number) => boolean): void
}
+ interface openInRoot {
+ /**
+ * OpenInRoot opens the file name in the directory dir.
+ * It is equivalent to OpenRoot(dir) followed by opening the file in the root.
+ *
+ * OpenInRoot returns an error if any component of the name
+ * references a location outside of dir.
+ *
+ * See [Root] for details and limitations.
+ */
+ (dir: string, name: string): (File)
+ }
+ /**
+ * Root may be used to only access files within a single directory tree.
+ *
+ * Methods on Root can only access files and directories beneath a root directory.
+ * If any component of a file name passed to a method of Root references a location
+ * outside the root, the method returns an error.
+ * File names may reference the directory itself (.).
+ *
+ * Methods on Root will follow symbolic links, but symbolic links may not
+ * reference a location outside the root.
+ * Symbolic links must not be absolute.
+ *
+ * Methods on Root do not prohibit traversal of filesystem boundaries,
+ * Linux bind mounts, /proc special files, or access to Unix device files.
+ *
+ * Methods on Root are safe to be used from multiple goroutines simultaneously.
+ *
+ * On most platforms, creating a Root opens a file descriptor or handle referencing
+ * the directory. If the directory is moved, methods on Root reference the original
+ * directory in its new location.
+ *
+ * Root's behavior differs on some platforms:
+ *
+ * ```
+ * - When GOOS=windows, file names may not reference Windows reserved device names
+ * such as NUL and COM1.
+ * - On Unix, [Root.Chmod], [Root.Chown], and [Root.Chtimes] are vulnerable to a race condition.
+ * If the target of the operation is changed from a regular file to a symlink
+ * while the operation is in progress, the operation may be performed on the link
+ * rather than the link target.
+ * - When GOOS=js, Root is vulnerable to TOCTOU (time-of-check-time-of-use)
+ * attacks in symlink validation, and cannot ensure that operations will not
+ * escape the root.
+ * - When GOOS=plan9 or GOOS=js, Root does not track directories across renames.
+ * On these platforms, a Root references a directory name, not a file descriptor.
+ * - WASI preview 1 (GOOS=wasip1) does not support [Root.Chmod].
+ * ```
+ */
+ interface Root {
+ }
+ interface openRoot {
+ /**
+ * OpenRoot opens the named directory.
+ * It follows symbolic links in the directory name.
+ * If there is an error, it will be of type [*PathError].
+ */
+ (name: string): (Root)
+ }
+ interface Root {
+ /**
+ * Name returns the name of the directory presented to OpenRoot.
+ *
+ * It is safe to call Name after [Close].
+ */
+ name(): string
+ }
+ interface Root {
+ /**
+ * Close closes the Root.
+ * After Close is called, methods on Root return errors.
+ */
+ close(): void
+ }
+ interface Root {
+ /**
+ * Open opens the named file in the root for reading.
+ * See [Open] for more details.
+ */
+ open(name: string): (File)
+ }
+ interface Root {
+ /**
+ * Create creates or truncates the named file in the root.
+ * See [Create] for more details.
+ */
+ create(name: string): (File)
+ }
+ interface Root {
+ /**
+ * OpenFile opens the named file in the root.
+ * See [OpenFile] for more details.
+ *
+ * If perm contains bits other than the nine least-significant bits (0o777),
+ * OpenFile returns an error.
+ */
+ openFile(name: string, flag: number, perm: FileMode): (File)
+ }
+ interface Root {
+ /**
+ * OpenRoot opens the named directory in the root.
+ * If there is an error, it will be of type [*PathError].
+ */
+ openRoot(name: string): (Root)
+ }
+ interface Root {
+ /**
+ * Chmod changes the mode of the named file in the root to mode.
+ * See [Chmod] for more details.
+ */
+ chmod(name: string, mode: FileMode): void
+ }
+ interface Root {
+ /**
+ * Mkdir creates a new directory in the root
+ * with the specified name and permission bits (before umask).
+ * See [Mkdir] for more details.
+ *
+ * If perm contains bits other than the nine least-significant bits (0o777),
+ * Mkdir returns an error.
+ */
+ mkdir(name: string, perm: FileMode): void
+ }
+ interface Root {
+ /**
+ * MkdirAll creates a new directory in the root, along with any necessary parents.
+ * See [MkdirAll] for more details.
+ *
+ * If perm contains bits other than the nine least-significant bits (0o777),
+ * MkdirAll returns an error.
+ */
+ mkdirAll(name: string, perm: FileMode): void
+ }
+ interface Root {
+ /**
+ * Chown changes the numeric uid and gid of the named file in the root.
+ * See [Chown] for more details.
+ */
+ chown(name: string, uid: number, gid: number): void
+ }
+ interface Root {
+ /**
+ * Lchown changes the numeric uid and gid of the named file in the root.
+ * See [Lchown] for more details.
+ */
+ lchown(name: string, uid: number, gid: number): void
+ }
+ interface Root {
+ /**
+ * Chtimes changes the access and modification times of the named file in the root.
+ * See [Chtimes] for more details.
+ */
+ chtimes(name: string, atime: time.Time, mtime: time.Time): void
+ }
+ interface Root {
+ /**
+ * Remove removes the named file or (empty) directory in the root.
+ * See [Remove] for more details.
+ */
+ remove(name: string): void
+ }
+ interface Root {
+ /**
+ * RemoveAll removes the named file or directory and any children that it contains.
+ * See [RemoveAll] for more details.
+ */
+ removeAll(name: string): void
+ }
+ interface Root {
+ /**
+ * Stat returns a [FileInfo] describing the named file in the root.
+ * See [Stat] for more details.
+ */
+ stat(name: string): FileInfo
+ }
+ interface Root {
+ /**
+ * Lstat returns a [FileInfo] describing the named file in the root.
+ * If the file is a symbolic link, the returned FileInfo
+ * describes the symbolic link.
+ * See [Lstat] for more details.
+ */
+ lstat(name: string): FileInfo
+ }
+ interface Root {
+ /**
+ * Readlink returns the destination of the named symbolic link in the root.
+ * See [Readlink] for more details.
+ */
+ readlink(name: string): string
+ }
+ interface Root {
+ /**
+ * Rename renames (moves) oldname to newname.
+ * Both paths are relative to the root.
+ * See [Rename] for more details.
+ */
+ rename(oldname: string, newname: string): void
+ }
+ interface Root {
+ /**
+ * Link creates newname as a hard link to the oldname file.
+ * Both paths are relative to the root.
+ * See [Link] for more details.
+ *
+ * If oldname is a symbolic link, Link creates new link to oldname and not its target.
+ * This behavior may differ from that of [Link] on some platforms.
+ *
+ * When GOOS=js, Link returns an error if oldname is a symbolic link.
+ */
+ link(oldname: string, newname: string): void
+ }
+ interface Root {
+ /**
+ * Symlink creates newname as a symbolic link to oldname.
+ * See [Symlink] for more details.
+ *
+ * Symlink does not validate oldname,
+ * which may reference a location outside the root.
+ *
+ * On Windows, a directory link is created if oldname references
+ * a directory within the root. Otherwise a file link is created.
+ */
+ symlink(oldname: string, newname: string): void
+ }
+ interface Root {
+ /**
+ * ReadFile reads the named file in the root and returns its contents.
+ * See [ReadFile] for more details.
+ */
+ readFile(name: string): string|Array
+ }
+ interface Root {
+ /**
+ * WriteFile writes data to the named file in the root, creating it if necessary.
+ * See [WriteFile] for more details.
+ */
+ writeFile(name: string, data: string|Array, perm: FileMode): void
+ }
+ interface Root {
+ /**
+ * FS returns a file system (an fs.FS) for the tree of files in the root.
+ *
+ * The result implements [io/fs.StatFS], [io/fs.ReadFileFS],
+ * [io/fs.ReadDirFS], and [io/fs.ReadLinkFS].
+ */
+ fs(): fs.FS
+ }
+ interface rootFS extends Root{}
+ interface rootFS {
+ open(name: string): fs.File
+ }
+ interface rootFS {
+ readDir(name: string): Array
+ }
+ interface rootFS {
+ readFile(name: string): string|Array
+ }
+ interface rootFS {
+ readLink(name: string): string
+ }
+ interface rootFS {
+ stat(name: string): FileInfo
+ }
+ interface rootFS {
+ lstat(name: string): FileInfo
+ }
+ /**
+ * root implementation for platforms with a function to open a file
+ * relative to a directory.
+ */
+ interface root {
+ }
+ interface root {
+ close(): void
+ }
+ interface root {
+ name(): string
+ }
+ /**
+ * errSymlink reports that a file being operated on is actually a symlink,
+ * and the target of that symlink.
+ */
+ interface errSymlink extends String{}
+ interface errSymlink {
+ error(): string
+ }
+ /**
+ * sysfdType is the native type of a file handle
+ * (int on Unix, syscall.Handle on Windows),
+ * permitting helper functions to be written portably.
+ */
+ interface sysfdType extends Number{}
interface stat {
/**
- * Stat returns a FileInfo describing the named file.
- * If there is an error, it will be of type *PathError.
+ * Stat returns a [FileInfo] describing the named file.
+ * If there is an error, it will be of type [*PathError].
*/
(name: string): FileInfo
}
interface lstat {
/**
- * Lstat returns a FileInfo describing the named file.
+ * Lstat returns a [FileInfo] describing the named file.
* If the file is a symbolic link, the returned FileInfo
* describes the symbolic link. Lstat makes no attempt to follow the link.
- * If there is an error, it will be of type *PathError.
+ * If there is an error, it will be of type [*PathError].
+ *
+ * On Windows, if the file is a reparse point that is a surrogate for another
+ * named entity (such as a symbolic link or mounted folder), the returned
+ * FileInfo describes the reparse point, and makes no attempt to resolve it.
*/
(name: string): FileInfo
}
interface File {
/**
- * Stat returns the FileInfo structure describing file.
- * If there is an error, it will be of type *PathError.
+ * Stat returns the [FileInfo] structure describing file.
+ * If there is an error, it will be of type [*PathError].
*/
stat(): FileInfo
}
@@ -2104,12 +2926,13 @@ namespace os {
* opens the file for reading and writing, and returns the resulting file.
* The filename is generated by taking pattern and adding a random string to the end.
* If pattern includes a "*", the random string replaces the last "*".
- * If dir is the empty string, CreateTemp uses the default directory for temporary files, as returned by TempDir.
+ * The file is created with mode 0o600 (before umask).
+ * If dir is the empty string, CreateTemp uses the default directory for temporary files, as returned by [TempDir].
* Multiple programs or goroutines calling CreateTemp simultaneously will not choose the same file.
* The caller can use the file's Name method to find the pathname of the file.
* It is the caller's responsibility to remove the file when it is no longer needed.
*/
- (dir: string): (File | undefined)
+ (dir: string, pattern: string): (File)
}
interface mkdirTemp {
/**
@@ -2117,11 +2940,12 @@ namespace os {
* and returns the pathname of the new directory.
* The new directory's name is generated by adding a random string to the end of pattern.
* If pattern includes a "*", the random string replaces the last "*" instead.
+ * The directory is created with mode 0o700 (before umask).
* If dir is the empty string, MkdirTemp uses the default directory for temporary files, as returned by TempDir.
* Multiple programs or goroutines calling MkdirTemp simultaneously will not choose the same directory.
* It is the caller's responsibility to remove the directory when it is no longer needed.
*/
- (dir: string): string
+ (dir: string, pattern: string): string
}
interface getpagesize {
/**
@@ -2131,12 +2955,14 @@ namespace os {
}
/**
* File represents an open file descriptor.
+ *
+ * The methods of File are safe for concurrent use.
*/
- type _subvkfPa = file
- interface File extends _subvkfPa {
+ type _sSgswON = file
+ interface File extends _sSgswON {
}
/**
- * A FileInfo describes a file and is returned by Stat and Lstat.
+ * A FileInfo describes a file and is returned by [Stat] and [Lstat].
*/
interface FileInfo extends fs.FileInfo{}
/**
@@ -2144,7 +2970,7 @@ namespace os {
* The bits have the same definition on all systems, so that
* information about files can be moved from one system
* to another portably. Not all bits apply to all systems.
- * The only required bit is ModeDir for directories.
+ * The only required bit is [ModeDir] for directories.
*/
interface FileMode extends fs.FileMode{}
interface fileStat {
@@ -2159,10 +2985,10 @@ namespace os {
* For example, on Unix this means that the device and inode fields
* of the two underlying structures are identical; on other systems
* the decision may be based on the path names.
- * SameFile only applies to results returned by this package's Stat.
+ * SameFile only applies to results returned by this package's [Stat].
* It returns false in other cases.
*/
- (fi1: FileInfo): boolean
+ (fi1: FileInfo, fi2: FileInfo): boolean
}
/**
* A fileStat is the implementation of FileInfo returned by Stat and Lstat.
@@ -2190,7 +3016,7 @@ namespace os {
* The filepath package uses either forward slashes or backslashes,
* depending on the operating system. To process paths such as URLs
* that always use forward slashes regardless of the operating
- * system, see the path package.
+ * system, see the [path] package.
*/
namespace filepath {
interface match {
@@ -2207,58 +3033,54 @@ namespace filepath {
* '[' [ '^' ] { character-range } ']'
* character class (must be non-empty)
* c matches character c (c != '*', '?', '\\', '[')
- * '\\' c matches character c
+ * '\\' c matches character c (except on Windows)
*
* character-range:
* c matches character c (c != '\\', '-', ']')
- * '\\' c matches character c
+ * '\\' c matches character c (except on Windows)
* lo '-' hi matches character c for lo <= c <= hi
* ```
*
+ * Path segments in the pattern must be separated by [Separator].
+ *
* Match requires pattern to match all of name, not just a substring.
- * The only possible returned error is ErrBadPattern, when pattern
+ * The only possible returned error is [ErrBadPattern], when pattern
* is malformed.
*
* On Windows, escaping is disabled. Instead, '\\' is treated as
* path separator.
*/
- (pattern: string): boolean
+ (pattern: string, name: string): boolean
}
interface glob {
/**
* Glob returns the names of all files matching pattern or nil
* if there is no matching file. The syntax of patterns is the same
- * as in Match. The pattern may describe hierarchical names such as
- * /usr/*\/bin/ed (assuming the Separator is '/').
+ * as in [Match]. The pattern may describe hierarchical names such as
+ * /usr/*\/bin/ed (assuming the [Separator] is '/').
*
* Glob ignores file system errors such as I/O errors reading directories.
- * The only possible returned error is ErrBadPattern, when pattern
+ * The only possible returned error is [ErrBadPattern], when pattern
* is malformed.
*/
(pattern: string): Array
}
- /**
- * A lazybuf is a lazily constructed path buffer.
- * It supports append, reading previously appended bytes,
- * and retrieving the final string. It does not allocate a buffer
- * to hold the output until that output diverges from s.
- */
- interface lazybuf {
- }
interface clean {
/**
* Clean returns the shortest path name equivalent to path
* by purely lexical processing. It applies the following rules
* iteratively until no further processing can be done:
*
+ * 1. Replace multiple [Separator] elements with a single one.
+ * 2. Eliminate each . path name element (the current directory).
+ * 3. Eliminate each inner .. path name element (the parent directory)
* ```
- * 1. Replace multiple Separator elements with a single one.
- * 2. Eliminate each . path name element (the current directory).
- * 3. Eliminate each inner .. path name element (the parent directory)
- * along with the non-.. element that precedes it.
- * 4. Eliminate .. elements that begin a rooted path:
- * that is, replace "/.." by "/" at the beginning of a path,
- * assuming Separator is '/'.
+ * along with the non-.. element that precedes it.
+ * ```
+ * 4. Eliminate .. elements that begin a rooted path:
+ * ```
+ * that is, replace "/.." by "/" at the beginning of a path,
+ * assuming Separator is '/'.
* ```
*
* The returned path ends in a slash only if it represents a root directory,
@@ -2269,12 +3091,50 @@ namespace filepath {
* If the result of this process is an empty string, Clean
* returns the string ".".
*
- * See also Rob Pike, ``Lexical File Names in Plan 9 or
- * Getting Dot-Dot Right,''
+ * On Windows, Clean does not modify the volume name other than to replace
+ * occurrences of "/" with `\`.
+ * For example, Clean("//host/share/../x") returns `\\host\share\x`.
+ *
+ * See also Rob Pike, “Lexical File Names in Plan 9 or
+ * Getting Dot-Dot Right,”
* https://9p.io/sys/doc/lexnames.html
*/
(path: string): string
}
+ interface isLocal {
+ /**
+ * IsLocal reports whether path, using lexical analysis only, has all of these properties:
+ *
+ * ```
+ * - is within the subtree rooted at the directory in which path is evaluated
+ * - is not an absolute path
+ * - is not empty
+ * - on Windows, is not a reserved name such as "NUL"
+ * ```
+ *
+ * If IsLocal(path) returns true, then
+ * Join(base, path) will always produce a path contained within base and
+ * Clean(path) will always produce an unrooted path with no ".." path elements.
+ *
+ * IsLocal is a purely lexical operation.
+ * In particular, it does not account for the effect of any symbolic links
+ * that may exist in the filesystem.
+ */
+ (path: string): boolean
+ }
+ interface localize {
+ /**
+ * Localize converts a slash-separated path into an operating system path.
+ * The input path must be a valid path as reported by [io/fs.ValidPath].
+ *
+ * Localize returns an error if the path cannot be represented by the operating system.
+ * For example, the path a\b is rejected on Windows, on which \ is a separator
+ * character and cannot be part of a filename.
+ *
+ * The path returned by Localize will always be local, as reported by IsLocal.
+ */
+ (path: string): string
+ }
interface toSlash {
/**
* ToSlash returns the result of replacing each separator character
@@ -2288,12 +3148,15 @@ namespace filepath {
* FromSlash returns the result of replacing each slash ('/') character
* in path with a separator character. Multiple slashes are replaced
* by multiple separators.
+ *
+ * See also the Localize function, which converts a slash-separated path
+ * as used by the io/fs package to an operating system path.
*/
(path: string): string
}
interface splitList {
/**
- * SplitList splits a list of paths joined by the OS-specific ListSeparator,
+ * SplitList splits a list of paths joined by the OS-specific [ListSeparator],
* usually found in PATH or GOPATH environment variables.
* Unlike strings.Split, SplitList returns an empty slice when passed an empty
* string.
@@ -2302,18 +3165,18 @@ namespace filepath {
}
interface split {
/**
- * Split splits path immediately following the final Separator,
+ * Split splits path immediately following the final [Separator],
* separating it into a directory and file name component.
* If there is no Separator in path, Split returns an empty dir
* and file set to path.
* The returned values have the property that path = dir+file.
*/
- (path: string): string
+ (path: string): [string, string]
}
interface join {
/**
* Join joins any number of path elements into a single path,
- * separating them with an OS specific Separator. Empty elements
+ * separating them with an OS specific [Separator]. Empty elements
* are ignored. The result is Cleaned. However, if the argument
* list is empty or all its elements are empty, Join returns
* an empty string.
@@ -2337,35 +3200,42 @@ namespace filepath {
* links.
* If path is relative the result will be relative to the current directory,
* unless one of the components is an absolute symbolic link.
- * EvalSymlinks calls Clean on the result.
+ * EvalSymlinks calls [Clean] on the result.
*/
(path: string): string
}
+ interface isAbs {
+ /**
+ * IsAbs reports whether the path is absolute.
+ */
+ (path: string): boolean
+ }
interface abs {
/**
* Abs returns an absolute representation of path.
* If the path is not absolute it will be joined with the current
* working directory to turn it into an absolute path. The absolute
* path name for a given file is not guaranteed to be unique.
- * Abs calls Clean on the result.
+ * Abs calls [Clean] on the result.
*/
(path: string): string
}
interface rel {
/**
- * Rel returns a relative path that is lexically equivalent to targpath when
- * joined to basepath with an intervening separator. That is,
- * Join(basepath, Rel(basepath, targpath)) is equivalent to targpath itself.
- * On success, the returned path will always be relative to basepath,
- * even if basepath and targpath share no elements.
- * An error is returned if targpath can't be made relative to basepath or if
- * knowing the current working directory would be necessary to compute it.
- * Rel calls Clean on the result.
+ * Rel returns a relative path that is lexically equivalent to targPath when
+ * joined to basePath with an intervening separator. That is,
+ * [Join](basePath, Rel(basePath, targPath)) is equivalent to targPath itself.
+ *
+ * The returned path will always be relative to basePath, even if basePath and
+ * targPath share no elements. Rel calls [Clean] on the result.
+ *
+ * An error is returned if targPath can't be made relative to basePath
+ * or if knowing the current working directory would be necessary to compute it.
*/
- (basepath: string): string
+ (basePath: string, targPath: string): string
}
/**
- * WalkFunc is the type of the function called by Walk to visit each
+ * WalkFunc is the type of the function called by [Walk] to visit each
* file or directory.
*
* The path argument contains the argument to Walk as a prefix.
@@ -2381,10 +3251,11 @@ namespace filepath {
* The info argument is the fs.FileInfo for the named path.
*
* The error result returned by the function controls how Walk continues.
- * If the function returns the special value SkipDir, Walk skips the
+ * If the function returns the special value [SkipDir], Walk skips the
* current directory (path if info.IsDir() is true, otherwise path's
- * parent directory). Otherwise, if the function returns a non-nil error,
- * Walk stops entirely and returns that error.
+ * parent directory). If the function returns the special value [SkipAll],
+ * Walk skips all remaining files and directories. Otherwise, if the function
+ * returns a non-nil error, Walk stops entirely and returns that error.
*
* The err argument reports an error related to path, signaling that Walk
* will not walk into that directory. The function can decide how to
@@ -2393,14 +3264,14 @@ namespace filepath {
*
* Walk calls the function with a non-nil err argument in two cases.
*
- * First, if an os.Lstat on the root directory or any directory or file
+ * First, if an [os.Lstat] on the root directory or any directory or file
* in the tree fails, Walk calls the function with path set to that
* directory or file's path, info set to nil, and err set to the error
* from os.Lstat.
*
* Second, if a directory's Readdirnames method fails, Walk calls the
* function with path set to the directory's path, info, set to an
- * fs.FileInfo describing the directory, and err set to the error from
+ * [fs.FileInfo] describing the directory, and err set to the error from
* Readdirnames.
*/
interface WalkFunc {(path: string, info: fs.FileInfo, err: Error): void }
@@ -2410,37 +3281,27 @@ namespace filepath {
* directory in the tree, including root.
*
* All errors that arise visiting files and directories are filtered by fn:
- * see the fs.WalkDirFunc documentation for details.
+ * see the [fs.WalkDirFunc] documentation for details.
*
* The files are walked in lexical order, which makes the output deterministic
* but requires WalkDir to read an entire directory into memory before proceeding
* to walk that directory.
*
* WalkDir does not follow symbolic links.
+ *
+ * WalkDir calls fn with paths that use the separator character appropriate
+ * for the operating system. This is unlike [io/fs.WalkDir], which always
+ * uses slash separated paths.
*/
(root: string, fn: fs.WalkDirFunc): void
}
- interface statDirEntry {
- }
- interface statDirEntry {
- name(): string
- }
- interface statDirEntry {
- isDir(): boolean
- }
- interface statDirEntry {
- type(): fs.FileMode
- }
- interface statDirEntry {
- info(): fs.FileInfo
- }
interface walk {
/**
* Walk walks the file tree rooted at root, calling fn for each file or
* directory in the tree, including root.
*
* All errors that arise visiting files and directories are filtered by fn:
- * see the WalkFunc documentation for details.
+ * see the [WalkFunc] documentation for details.
*
* The files are walked in lexical order, which makes the output deterministic
* but requires Walk to read an entire directory into memory before proceeding
@@ -2448,7 +3309,7 @@ namespace filepath {
*
* Walk does not follow symbolic links.
*
- * Walk is less efficient than WalkDir, introduced in Go 1.16,
+ * Walk is less efficient than [WalkDir], introduced in Go 1.16,
* which avoids calling os.Lstat on every visited file or directory.
*/
(root: string, fn: WalkFunc): void
@@ -2465,11 +3326,15 @@ namespace filepath {
interface dir {
/**
* Dir returns all but the last element of path, typically the path's directory.
- * After dropping the final element, Dir calls Clean on the path and trailing
+ * After dropping the final element, Dir calls [Clean] on the path and trailing
* slashes are removed.
* If the path is empty, Dir returns ".".
* If the path consists entirely of separators, Dir returns a single separator.
* The returned path does not end in a separator unless it is the root directory.
+ *
+ * On Windows, given a volume-only name such as "C:", Dir returns "C:.",
+ * the current directory on drive C. To obtain the drive's root "C:\",
+ * use [VolumeName] combined with a separator.
*/
(path: string): string
}
@@ -2482,12 +3347,6 @@ namespace filepath {
*/
(path: string): string
}
- interface isAbs {
- /**
- * IsAbs reports whether the path is absolute.
- */
- (path: string): boolean
- }
interface hasPrefix {
/**
* HasPrefix exists for historical compatibility and should not be used.
@@ -2495,591 +3354,219 @@ namespace filepath {
* Deprecated: HasPrefix does not respect path boundaries and
* does not ignore case when required.
*/
- (p: string): boolean
+ (p: string, prefix: string): boolean
}
}
-namespace security {
- interface s256Challenge {
+/**
+ * Package dbx provides a set of DB-agnostic and easy-to-use query building methods for relational databases.
+ */
+namespace dbx {
+ /**
+ * Builder supports building SQL statements in a DB-agnostic way.
+ * Builder mainly provides two sets of query building methods: those building SELECT statements
+ * and those manipulating DB data or schema (e.g. INSERT statements, CREATE TABLE statements).
+ */
+ interface Builder {
+ [key:string]: any;
/**
- * S256Challenge creates base64 encoded sha256 challenge string derived from code.
- * The padding of the result base64 string is stripped per [RFC 7636].
- *
- * [RFC 7636]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
+ * NewQuery creates a new Query object with the given SQL statement.
+ * The SQL statement may contain parameter placeholders which can be bound with actual parameter
+ * values before the statement is executed.
*/
- (code: string): string
- }
- interface md5 {
+ newQuery(_arg0: string): (Query)
/**
- * MD5 creates md5 hash from the provided plain text.
+ * Select returns a new SelectQuery object that can be used to build a SELECT statement.
+ * The parameters to this method should be the list column names to be selected.
+ * A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- (text: string): string
- }
- interface sha256 {
+ select(..._arg0: string[]): (SelectQuery)
/**
- * SHA256 creates sha256 hash as defined in FIPS 180-4 from the provided text.
+ * ModelQuery returns a new ModelQuery object that can be used to perform model insertion, update, and deletion.
+ * The parameter to this method should be a pointer to the model struct that needs to be inserted, updated, or deleted.
*/
- (text: string): string
- }
- interface sha512 {
+ model(_arg0: {
+ }): (ModelQuery)
/**
- * SHA512 creates sha512 hash as defined in FIPS 180-4 from the provided text.
+ * GeneratePlaceholder generates an anonymous parameter placeholder with the given parameter ID.
*/
- (text: string): string
- }
- interface hs256 {
+ generatePlaceholder(_arg0: number): string
/**
- * HS256 creates a HMAC hash with sha256 digest algorithm.
+ * Quote quotes a string so that it can be embedded in a SQL statement as a string value.
*/
- (text: string, secret: string): string
- }
- interface hs512 {
+ quote(_arg0: string): string
/**
- * HS512 creates a HMAC hash with sha512 digest algorithm.
+ * QuoteSimpleTableName quotes a simple table name.
+ * A simple table name does not contain any schema prefix.
*/
- (text: string, secret: string): string
- }
- interface equal {
+ quoteSimpleTableName(_arg0: string): string
/**
- * Equal compares two hash strings for equality without leaking timing information.
+ * QuoteSimpleColumnName quotes a simple column name.
+ * A simple column name does not contain any table prefix.
*/
- (hash1: string, hash2: string): boolean
- }
- // @ts-ignore
- import crand = rand
- interface encrypt {
+ quoteSimpleColumnName(_arg0: string): string
/**
- * Encrypt encrypts data with key (must be valid 32 char aes key).
+ * QueryBuilder returns the query builder supporting the current DB.
*/
- (data: string, key: string): string
- }
- interface decrypt {
+ queryBuilder(): QueryBuilder
/**
- * Decrypt decrypts encrypted text with key (must be valid 32 chars aes key).
+ * Insert creates a Query that represents an INSERT SQL statement.
+ * The keys of cols are the column names, while the values of cols are the corresponding column
+ * values to be inserted.
*/
- (cipherText: string, key: string): string
- }
- interface parseUnverifiedJWT {
+ insert(table: string, cols: Params): (Query)
/**
- * ParseUnverifiedJWT parses JWT token and returns its claims
- * but DOES NOT verify the signature.
- *
- * It verifies only the exp, iat and nbf claims.
+ * Upsert creates a Query that represents an UPSERT SQL statement.
+ * Upsert inserts a row into the table if the primary key or unique index is not found.
+ * Otherwise it will update the row with the new values.
+ * The keys of cols are the column names, while the values of cols are the corresponding column
+ * values to be inserted.
*/
- (token: string): jwt.MapClaims
- }
- interface parseJWT {
+ upsert(table: string, cols: Params, ...constraints: string[]): (Query)
/**
- * ParseJWT verifies and parses JWT token and returns its claims.
+ * Update creates a Query that represents an UPDATE SQL statement.
+ * The keys of cols are the column names, while the values of cols are the corresponding new column
+ * values. If the "where" expression is nil, the UPDATE SQL statement will have no WHERE clause
+ * (be careful in this case as the SQL statement will update ALL rows in the table).
*/
- (token: string, verificationKey: string): jwt.MapClaims
- }
- interface newJWT {
+ update(table: string, cols: Params, where: Expression): (Query)
/**
- * NewJWT generates and returns new HS256 signed JWT token.
+ * Delete creates a Query that represents a DELETE SQL statement.
+ * If the "where" expression is nil, the DELETE SQL statement will have no WHERE clause
+ * (be careful in this case as the SQL statement will delete ALL rows in the table).
*/
- (payload: jwt.MapClaims, signingKey: string, secondsDuration: number): string
- }
- interface newToken {
+ delete(table: string, where: Expression): (Query)
/**
- * Deprecated:
- * Consider replacing with NewJWT().
- *
- * NewToken is a legacy alias for NewJWT that generates a HS256 signed JWT token.
+ * CreateTable creates a Query that represents a CREATE TABLE SQL statement.
+ * The keys of cols are the column names, while the values of cols are the corresponding column types.
+ * The optional "options" parameters will be appended to the generated SQL statement.
*/
- (payload: jwt.MapClaims, signingKey: string, secondsDuration: number): string
- }
- // @ts-ignore
- import cryptoRand = rand
- // @ts-ignore
- import mathRand = rand
- interface randomString {
+ createTable(table: string, cols: _TygojaDict, ...options: string[]): (Query)
/**
- * RandomString generates a cryptographically random string with the specified length.
- *
- * The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding.
+ * RenameTable creates a Query that can be used to rename a table.
*/
- (length: number): string
- }
- interface randomStringWithAlphabet {
+ renameTable(oldName: string, newName: string): (Query)
/**
- * RandomStringWithAlphabet generates a cryptographically random string
- * with the specified length and characters set.
- *
- * It panics if for some reason rand.Int returns a non-nil error.
+ * DropTable creates a Query that can be used to drop a table.
*/
- (length: number, alphabet: string): string
- }
- interface pseudorandomString {
+ dropTable(table: string): (Query)
/**
- * PseudorandomString generates a pseudorandom string with the specified length.
- *
- * The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding.
- *
- * For a cryptographically random string (but a little bit slower) use RandomString instead.
+ * TruncateTable creates a Query that can be used to truncate a table.
*/
- (length: number): string
- }
- interface pseudorandomStringWithAlphabet {
+ truncateTable(table: string): (Query)
/**
- * PseudorandomStringWithAlphabet generates a pseudorandom string
- * with the specified length and characters set.
- *
- * For a cryptographically random (but a little bit slower) use RandomStringWithAlphabet instead.
+ * AddColumn creates a Query that can be used to add a column to a table.
*/
- (length: number, alphabet: string): string
- }
-}
-
-/**
- * Package exec runs external commands. It wraps os.StartProcess to make it
- * easier to remap stdin and stdout, connect I/O with pipes, and do other
- * adjustments.
- *
- * Unlike the "system" library call from C and other languages, the
- * os/exec package intentionally does not invoke the system shell and
- * does not expand any glob patterns or handle other expansions,
- * pipelines, or redirections typically done by shells. The package
- * behaves more like C's "exec" family of functions. To expand glob
- * patterns, either call the shell directly, taking care to escape any
- * dangerous input, or use the path/filepath package's Glob function.
- * To expand environment variables, use package os's ExpandEnv.
- *
- * Note that the examples in this package assume a Unix system.
- * They may not run on Windows, and they do not run in the Go Playground
- * used by golang.org and godoc.org.
- */
-namespace exec {
- interface command {
+ addColumn(table: string, col: string, typ: string): (Query)
/**
- * Command returns the Cmd struct to execute the named program with
- * the given arguments.
- *
- * It sets only the Path and Args in the returned structure.
- *
- * If name contains no path separators, Command uses LookPath to
- * resolve name to a complete path if possible. Otherwise it uses name
- * directly as Path.
- *
- * The returned Cmd's Args field is constructed from the command name
- * followed by the elements of arg, so arg should not include the
- * command name itself. For example, Command("echo", "hello").
- * Args[0] is always name, not the possibly resolved Path.
- *
- * On Windows, processes receive the whole command line as a single string
- * and do their own parsing. Command combines and quotes Args into a command
- * line string with an algorithm compatible with applications using
- * CommandLineToArgvW (which is the most common way). Notable exceptions are
- * msiexec.exe and cmd.exe (and thus, all batch files), which have a different
- * unquoting algorithm. In these or other similar cases, you can do the
- * quoting yourself and provide the full command line in SysProcAttr.CmdLine,
- * leaving Args empty.
+ * DropColumn creates a Query that can be used to drop a column from a table.
*/
- (name: string, ...arg: string[]): (Cmd | undefined)
- }
-}
-
-namespace filesystem {
- /**
- * FileReader defines an interface for a file resource reader.
- */
- interface FileReader {
- open(): io.ReadSeekCloser
- }
- /**
- * File defines a single file [io.ReadSeekCloser] resource.
- *
- * The file could be from a local path, multipipart/formdata header, etc.
- */
- interface File {
- name: string
- originalName: string
- size: number
- reader: FileReader
- }
- interface newFileFromPath {
+ dropColumn(table: string, col: string): (Query)
/**
- * NewFileFromPath creates a new File instance from the provided local file path.
+ * RenameColumn creates a Query that can be used to rename a column in a table.
*/
- (path: string): (File | undefined)
- }
- interface newFileFromBytes {
+ renameColumn(table: string, oldName: string, newName: string): (Query)
/**
- * NewFileFromBytes creates a new File instance from the provided byte slice.
+ * AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- (b: string, name: string): (File | undefined)
- }
- interface newFileFromMultipart {
+ alterColumn(table: string, col: string, typ: string): (Query)
/**
- * NewFileFromMultipart creates a new File instace from the provided multipart header.
+ * AddPrimaryKey creates a Query that can be used to specify primary key(s) for a table.
+ * The "name" parameter specifies the name of the primary key constraint.
*/
- (mh: multipart.FileHeader): (File | undefined)
- }
- /**
- * MultipartReader defines a FileReader from [multipart.FileHeader].
- */
- interface MultipartReader {
- header?: multipart.FileHeader
- }
- interface MultipartReader {
+ addPrimaryKey(table: string, name: string, ...cols: string[]): (Query)
/**
- * Open implements the [filesystem.FileReader] interface.
+ * DropPrimaryKey creates a Query that can be used to remove the named primary key constraint from a table.
*/
- open(): io.ReadSeekCloser
- }
- /**
- * PathReader defines a FileReader from a local file path.
- */
- interface PathReader {
- path: string
- }
- interface PathReader {
+ dropPrimaryKey(table: string, name: string): (Query)
/**
- * Open implements the [filesystem.FileReader] interface.
+ * AddForeignKey creates a Query that can be used to add a foreign key constraint to a table.
+ * The length of cols and refCols must be the same as they refer to the primary and referential columns.
+ * The optional "options" parameters will be appended to the SQL statement. They can be used to
+ * specify options such as "ON DELETE CASCADE".
*/
- open(): io.ReadSeekCloser
- }
- /**
- * BytesReader defines a FileReader from bytes content.
- */
- interface BytesReader {
- bytes: string
- }
- interface BytesReader {
+ addForeignKey(table: string, name: string, cols: Array, refCols: Array, refTable: string, ...options: string[]): (Query)
/**
- * Open implements the [filesystem.FileReader] interface.
+ * DropForeignKey creates a Query that can be used to remove the named foreign key constraint from a table.
*/
- open(): io.ReadSeekCloser
- }
- type _subkOZzn = bytes.Reader
- interface bytesReadSeekCloser extends _subkOZzn {
- }
- interface bytesReadSeekCloser {
+ dropForeignKey(table: string, name: string): (Query)
/**
- * Close implements the [io.ReadSeekCloser] interface.
+ * CreateIndex creates a Query that can be used to create an index for a table.
*/
- close(): void
- }
- interface System {
- }
- interface newS3 {
+ createIndex(table: string, name: string, ...cols: string[]): (Query)
/**
- * NewS3 initializes an S3 filesystem instance.
- *
- * NB! Make sure to call `Close()` after you are done working with it.
+ * CreateUniqueIndex creates a Query that can be used to create a unique index for a table.
*/
- (bucketName: string, region: string, endpoint: string, accessKey: string, secretKey: string, s3ForcePathStyle: boolean): (System | undefined)
- }
- interface newLocal {
+ createUniqueIndex(table: string, name: string, ...cols: string[]): (Query)
/**
- * NewLocal initializes a new local filesystem instance.
- *
- * NB! Make sure to call `Close()` after you are done working with it.
+ * DropIndex creates a Query that can be used to remove the named index from a table.
*/
- (dirPath: string): (System | undefined)
+ dropIndex(table: string, name: string): (Query)
}
- interface System {
+ /**
+ * BaseBuilder provides a basic implementation of the Builder interface.
+ */
+ interface BaseBuilder {
+ }
+ interface newBaseBuilder {
/**
- * SetContext assigns the specified context to the current filesystem.
+ * NewBaseBuilder creates a new BaseBuilder instance.
*/
- setContext(ctx: context.Context): void
+ (db: DB, executor: Executor): (BaseBuilder)
}
- interface System {
+ interface BaseBuilder {
/**
- * Close releases any resources used for the related filesystem.
+ * DB returns the DB instance that this builder is associated with.
*/
- close(): void
+ db(): (DB)
}
- interface System {
+ interface BaseBuilder {
/**
- * Exists checks if file with fileKey path exists or not.
+ * Executor returns the executor object (a DB instance or a transaction) for executing SQL statements.
*/
- exists(fileKey: string): boolean
+ executor(): Executor
}
- interface System {
+ interface BaseBuilder {
/**
- * Attributes returns the attributes for the file with fileKey path.
+ * NewQuery creates a new Query object with the given SQL statement.
+ * The SQL statement may contain parameter placeholders which can be bound with actual parameter
+ * values before the statement is executed.
*/
- attributes(fileKey: string): (blob.Attributes | undefined)
+ newQuery(sql: string): (Query)
}
- interface System {
+ interface BaseBuilder {
/**
- * GetFile returns a file content reader for the given fileKey.
- *
- * NB! Make sure to call `Close()` after you are done working with it.
+ * GeneratePlaceholder generates an anonymous parameter placeholder with the given parameter ID.
*/
- getFile(fileKey: string): (blob.Reader | undefined)
+ generatePlaceholder(_arg0: number): string
}
- interface System {
+ interface BaseBuilder {
/**
- * List returns a flat list with info for all files under the specified prefix.
+ * Quote quotes a string so that it can be embedded in a SQL statement as a string value.
*/
- list(prefix: string): Array<(blob.ListObject | undefined)>
+ quote(s: string): string
}
- interface System {
+ interface BaseBuilder {
/**
- * Upload writes content into the fileKey location.
+ * QuoteSimpleTableName quotes a simple table name.
+ * A simple table name does not contain any schema prefix.
*/
- upload(content: string, fileKey: string): void
+ quoteSimpleTableName(s: string): string
}
- interface System {
+ interface BaseBuilder {
/**
- * UploadFile uploads the provided multipart file to the fileKey location.
+ * QuoteSimpleColumnName quotes a simple column name.
+ * A simple column name does not contain any table prefix.
*/
- uploadFile(file: File, fileKey: string): void
+ quoteSimpleColumnName(s: string): string
}
- interface System {
+ interface BaseBuilder {
/**
- * UploadMultipart uploads the provided multipart file to the fileKey location.
+ * Insert creates a Query that represents an INSERT SQL statement.
+ * The keys of cols are the column names, while the values of cols are the corresponding column
+ * values to be inserted.
*/
- uploadMultipart(fh: multipart.FileHeader, fileKey: string): void
- }
- interface System {
- /**
- * Delete deletes stored file at fileKey location.
- */
- delete(fileKey: string): void
- }
- interface System {
- /**
- * DeletePrefix deletes everything starting with the specified prefix.
- */
- deletePrefix(prefix: string): Array
- }
- interface System {
- /**
- * Serve serves the file at fileKey location to an HTTP response.
- *
- * If the `download` query parameter is used the file will be always served for
- * download no matter of its type (aka. with "Content-Disposition: attachment").
- */
- serve(res: http.ResponseWriter, req: http.Request, fileKey: string, name: string): void
- }
- interface System {
- /**
- * CreateThumb creates a new thumb image for the file at originalKey location.
- * The new thumb file is stored at thumbKey location.
- *
- * thumbSize is in the format:
- * - 0xH (eg. 0x100) - resize to H height preserving the aspect ratio
- * - Wx0 (eg. 300x0) - resize to W width preserving the aspect ratio
- * - WxH (eg. 300x100) - resize and crop to WxH viewbox (from center)
- * - WxHt (eg. 300x100t) - resize and crop to WxH viewbox (from top)
- * - WxHb (eg. 300x100b) - resize and crop to WxH viewbox (from bottom)
- * - WxHf (eg. 300x100f) - fit inside a WxH viewbox (without cropping)
- */
- createThumb(originalKey: string, thumbKey: string): void
- }
-}
-
-/**
- * Package dbx provides a set of DB-agnostic and easy-to-use query building methods for relational databases.
- */
-namespace dbx {
- /**
- * Builder supports building SQL statements in a DB-agnostic way.
- * Builder mainly provides two sets of query building methods: those building SELECT statements
- * and those manipulating DB data or schema (e.g. INSERT statements, CREATE TABLE statements).
- */
- interface Builder {
- /**
- * NewQuery creates a new Query object with the given SQL statement.
- * The SQL statement may contain parameter placeholders which can be bound with actual parameter
- * values before the statement is executed.
- */
- newQuery(_arg0: string): (Query | undefined)
- /**
- * Select returns a new SelectQuery object that can be used to build a SELECT statement.
- * The parameters to this method should be the list column names to be selected.
- * A column name may have an optional alias name. For example, Select("id", "my_name AS name").
- */
- select(..._arg0: string[]): (SelectQuery | undefined)
- /**
- * ModelQuery returns a new ModelQuery object that can be used to perform model insertion, update, and deletion.
- * The parameter to this method should be a pointer to the model struct that needs to be inserted, updated, or deleted.
- */
- model(_arg0: {
- }): (ModelQuery | undefined)
- /**
- * GeneratePlaceholder generates an anonymous parameter placeholder with the given parameter ID.
- */
- generatePlaceholder(_arg0: number): string
- /**
- * Quote quotes a string so that it can be embedded in a SQL statement as a string value.
- */
- quote(_arg0: string): string
- /**
- * QuoteSimpleTableName quotes a simple table name.
- * A simple table name does not contain any schema prefix.
- */
- quoteSimpleTableName(_arg0: string): string
- /**
- * QuoteSimpleColumnName quotes a simple column name.
- * A simple column name does not contain any table prefix.
- */
- quoteSimpleColumnName(_arg0: string): string
- /**
- * QueryBuilder returns the query builder supporting the current DB.
- */
- queryBuilder(): QueryBuilder
- /**
- * Insert creates a Query that represents an INSERT SQL statement.
- * The keys of cols are the column names, while the values of cols are the corresponding column
- * values to be inserted.
- */
- insert(table: string, cols: Params): (Query | undefined)
- /**
- * Upsert creates a Query that represents an UPSERT SQL statement.
- * Upsert inserts a row into the table if the primary key or unique index is not found.
- * Otherwise it will update the row with the new values.
- * The keys of cols are the column names, while the values of cols are the corresponding column
- * values to be inserted.
- */
- upsert(table: string, cols: Params, ...constraints: string[]): (Query | undefined)
- /**
- * Update creates a Query that represents an UPDATE SQL statement.
- * The keys of cols are the column names, while the values of cols are the corresponding new column
- * values. If the "where" expression is nil, the UPDATE SQL statement will have no WHERE clause
- * (be careful in this case as the SQL statement will update ALL rows in the table).
- */
- update(table: string, cols: Params, where: Expression): (Query | undefined)
- /**
- * Delete creates a Query that represents a DELETE SQL statement.
- * If the "where" expression is nil, the DELETE SQL statement will have no WHERE clause
- * (be careful in this case as the SQL statement will delete ALL rows in the table).
- */
- delete(table: string, where: Expression): (Query | undefined)
- /**
- * CreateTable creates a Query that represents a CREATE TABLE SQL statement.
- * The keys of cols are the column names, while the values of cols are the corresponding column types.
- * The optional "options" parameters will be appended to the generated SQL statement.
- */
- createTable(table: string, cols: _TygojaDict, ...options: string[]): (Query | undefined)
- /**
- * RenameTable creates a Query that can be used to rename a table.
- */
- renameTable(oldName: string): (Query | undefined)
- /**
- * DropTable creates a Query that can be used to drop a table.
- */
- dropTable(table: string): (Query | undefined)
- /**
- * TruncateTable creates a Query that can be used to truncate a table.
- */
- truncateTable(table: string): (Query | undefined)
- /**
- * AddColumn creates a Query that can be used to add a column to a table.
- */
- addColumn(table: string): (Query | undefined)
- /**
- * DropColumn creates a Query that can be used to drop a column from a table.
- */
- dropColumn(table: string): (Query | undefined)
- /**
- * RenameColumn creates a Query that can be used to rename a column in a table.
- */
- renameColumn(table: string): (Query | undefined)
- /**
- * AlterColumn creates a Query that can be used to change the definition of a table column.
- */
- alterColumn(table: string): (Query | undefined)
- /**
- * AddPrimaryKey creates a Query that can be used to specify primary key(s) for a table.
- * The "name" parameter specifies the name of the primary key constraint.
- */
- addPrimaryKey(table: string, ...cols: string[]): (Query | undefined)
- /**
- * DropPrimaryKey creates a Query that can be used to remove the named primary key constraint from a table.
- */
- dropPrimaryKey(table: string): (Query | undefined)
- /**
- * AddForeignKey creates a Query that can be used to add a foreign key constraint to a table.
- * The length of cols and refCols must be the same as they refer to the primary and referential columns.
- * The optional "options" parameters will be appended to the SQL statement. They can be used to
- * specify options such as "ON DELETE CASCADE".
- */
- addForeignKey(table: string, cols: Array, refTable: string, ...options: string[]): (Query | undefined)
- /**
- * DropForeignKey creates a Query that can be used to remove the named foreign key constraint from a table.
- */
- dropForeignKey(table: string): (Query | undefined)
- /**
- * CreateIndex creates a Query that can be used to create an index for a table.
- */
- createIndex(table: string, ...cols: string[]): (Query | undefined)
- /**
- * CreateUniqueIndex creates a Query that can be used to create a unique index for a table.
- */
- createUniqueIndex(table: string, ...cols: string[]): (Query | undefined)
- /**
- * DropIndex creates a Query that can be used to remove the named index from a table.
- */
- dropIndex(table: string): (Query | undefined)
- }
- /**
- * BaseBuilder provides a basic implementation of the Builder interface.
- */
- interface BaseBuilder {
- }
- interface newBaseBuilder {
- /**
- * NewBaseBuilder creates a new BaseBuilder instance.
- */
- (db: DB, executor: Executor): (BaseBuilder | undefined)
- }
- interface BaseBuilder {
- /**
- * DB returns the DB instance that this builder is associated with.
- */
- db(): (DB | undefined)
- }
- interface BaseBuilder {
- /**
- * Executor returns the executor object (a DB instance or a transaction) for executing SQL statements.
- */
- executor(): Executor
- }
- interface BaseBuilder {
- /**
- * NewQuery creates a new Query object with the given SQL statement.
- * The SQL statement may contain parameter placeholders which can be bound with actual parameter
- * values before the statement is executed.
- */
- newQuery(sql: string): (Query | undefined)
- }
- interface BaseBuilder {
- /**
- * GeneratePlaceholder generates an anonymous parameter placeholder with the given parameter ID.
- */
- generatePlaceholder(_arg0: number): string
- }
- interface BaseBuilder {
- /**
- * Quote quotes a string so that it can be embedded in a SQL statement as a string value.
- */
- quote(s: string): string
- }
- interface BaseBuilder {
- /**
- * QuoteSimpleTableName quotes a simple table name.
- * A simple table name does not contain any schema prefix.
- */
- quoteSimpleTableName(s: string): string
- }
- interface BaseBuilder {
- /**
- * QuoteSimpleColumnName quotes a simple column name.
- * A simple column name does not contain any table prefix.
- */
- quoteSimpleColumnName(s: string): string
- }
- interface BaseBuilder {
- /**
- * Insert creates a Query that represents an INSERT SQL statement.
- * The keys of cols are the column names, while the values of cols are the corresponding column
- * values to be inserted.
- */
- insert(table: string, cols: Params): (Query | undefined)
+ insert(table: string, cols: Params): (Query)
}
interface BaseBuilder {
/**
@@ -3089,7 +3576,7 @@ namespace dbx {
* The keys of cols are the column names, while the values of cols are the corresponding column
* values to be inserted.
*/
- upsert(table: string, cols: Params, ...constraints: string[]): (Query | undefined)
+ upsert(table: string, cols: Params, ...constraints: string[]): (Query)
}
interface BaseBuilder {
/**
@@ -3098,7 +3585,7 @@ namespace dbx {
* values. If the "where" expression is nil, the UPDATE SQL statement will have no WHERE clause
* (be careful in this case as the SQL statement will update ALL rows in the table).
*/
- update(table: string, cols: Params, where: Expression): (Query | undefined)
+ update(table: string, cols: Params, where: Expression): (Query)
}
interface BaseBuilder {
/**
@@ -3106,7 +3593,7 @@ namespace dbx {
* If the "where" expression is nil, the DELETE SQL statement will have no WHERE clause
* (be careful in this case as the SQL statement will delete ALL rows in the table).
*/
- delete(table: string, where: Expression): (Query | undefined)
+ delete(table: string, where: Expression): (Query)
}
interface BaseBuilder {
/**
@@ -3114,62 +3601,62 @@ namespace dbx {
* The keys of cols are the column names, while the values of cols are the corresponding column types.
* The optional "options" parameters will be appended to the generated SQL statement.
*/
- createTable(table: string, cols: _TygojaDict, ...options: string[]): (Query | undefined)
+ createTable(table: string, cols: _TygojaDict, ...options: string[]): (Query)
}
interface BaseBuilder {
/**
* RenameTable creates a Query that can be used to rename a table.
*/
- renameTable(oldName: string): (Query | undefined)
+ renameTable(oldName: string, newName: string): (Query)
}
interface BaseBuilder {
/**
* DropTable creates a Query that can be used to drop a table.
*/
- dropTable(table: string): (Query | undefined)
+ dropTable(table: string): (Query)
}
interface BaseBuilder {
/**
* TruncateTable creates a Query that can be used to truncate a table.
*/
- truncateTable(table: string): (Query | undefined)
+ truncateTable(table: string): (Query)
}
interface BaseBuilder {
/**
* AddColumn creates a Query that can be used to add a column to a table.
*/
- addColumn(table: string): (Query | undefined)
+ addColumn(table: string, col: string, typ: string): (Query)
}
interface BaseBuilder {
/**
* DropColumn creates a Query that can be used to drop a column from a table.
*/
- dropColumn(table: string): (Query | undefined)
+ dropColumn(table: string, col: string): (Query)
}
interface BaseBuilder {
/**
* RenameColumn creates a Query that can be used to rename a column in a table.
*/
- renameColumn(table: string): (Query | undefined)
+ renameColumn(table: string, oldName: string, newName: string): (Query)
}
interface BaseBuilder {
/**
* AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- alterColumn(table: string): (Query | undefined)
+ alterColumn(table: string, col: string, typ: string): (Query)
}
interface BaseBuilder {
/**
* AddPrimaryKey creates a Query that can be used to specify primary key(s) for a table.
* The "name" parameter specifies the name of the primary key constraint.
*/
- addPrimaryKey(table: string, ...cols: string[]): (Query | undefined)
+ addPrimaryKey(table: string, name: string, ...cols: string[]): (Query)
}
interface BaseBuilder {
/**
* DropPrimaryKey creates a Query that can be used to remove the named primary key constraint from a table.
*/
- dropPrimaryKey(table: string): (Query | undefined)
+ dropPrimaryKey(table: string, name: string): (Query)
}
interface BaseBuilder {
/**
@@ -3178,43 +3665,43 @@ namespace dbx {
* The optional "options" parameters will be appended to the SQL statement. They can be used to
* specify options such as "ON DELETE CASCADE".
*/
- addForeignKey(table: string, cols: Array, refTable: string, ...options: string[]): (Query | undefined)
+ addForeignKey(table: string, name: string, cols: Array, refCols: Array, refTable: string, ...options: string[]): (Query)
}
interface BaseBuilder {
/**
* DropForeignKey creates a Query that can be used to remove the named foreign key constraint from a table.
*/
- dropForeignKey(table: string): (Query | undefined)
+ dropForeignKey(table: string, name: string): (Query)
}
interface BaseBuilder {
/**
* CreateIndex creates a Query that can be used to create an index for a table.
*/
- createIndex(table: string, ...cols: string[]): (Query | undefined)
+ createIndex(table: string, name: string, ...cols: string[]): (Query)
}
interface BaseBuilder {
/**
* CreateUniqueIndex creates a Query that can be used to create a unique index for a table.
*/
- createUniqueIndex(table: string, ...cols: string[]): (Query | undefined)
+ createUniqueIndex(table: string, name: string, ...cols: string[]): (Query)
}
interface BaseBuilder {
/**
* DropIndex creates a Query that can be used to remove the named index from a table.
*/
- dropIndex(table: string): (Query | undefined)
+ dropIndex(table: string, name: string): (Query)
}
/**
* MssqlBuilder is the builder for SQL Server databases.
*/
- type _subabrGp = BaseBuilder
- interface MssqlBuilder extends _subabrGp {
+ type _sCitONk = BaseBuilder
+ interface MssqlBuilder extends _sCitONk {
}
/**
* MssqlQueryBuilder is the query builder for SQL Server databases.
*/
- type _subPMzLj = BaseQueryBuilder
- interface MssqlQueryBuilder extends _subPMzLj {
+ type _shtmbZx = BaseQueryBuilder
+ interface MssqlQueryBuilder extends _shtmbZx {
}
interface newMssqlBuilder {
/**
@@ -3234,7 +3721,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface MssqlBuilder {
/**
@@ -3242,7 +3729,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
interface MssqlBuilder {
/**
@@ -3262,19 +3749,19 @@ namespace dbx {
/**
* RenameTable creates a Query that can be used to rename a table.
*/
- renameTable(oldName: string): (Query | undefined)
+ renameTable(oldName: string, newName: string): (Query)
}
interface MssqlBuilder {
/**
* RenameColumn creates a Query that can be used to rename a column in a table.
*/
- renameColumn(table: string): (Query | undefined)
+ renameColumn(table: string, oldName: string, newName: string): (Query)
}
interface MssqlBuilder {
/**
* AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- alterColumn(table: string): (Query | undefined)
+ alterColumn(table: string, col: string, typ: string): (Query)
}
interface MssqlQueryBuilder {
/**
@@ -3285,8 +3772,8 @@ namespace dbx {
/**
* MysqlBuilder is the builder for MySQL databases.
*/
- type _subIBWll = BaseBuilder
- interface MysqlBuilder extends _subIBWll {
+ type _sCYmyzY = BaseBuilder
+ interface MysqlBuilder extends _sCYmyzY {
}
interface newMysqlBuilder {
/**
@@ -3306,7 +3793,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface MysqlBuilder {
/**
@@ -3314,7 +3801,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
interface MysqlBuilder {
/**
@@ -3338,37 +3825,37 @@ namespace dbx {
* The keys of cols are the column names, while the values of cols are the corresponding column
* values to be inserted.
*/
- upsert(table: string, cols: Params, ...constraints: string[]): (Query | undefined)
+ upsert(table: string, cols: Params, ...constraints: string[]): (Query)
}
interface MysqlBuilder {
/**
* RenameColumn creates a Query that can be used to rename a column in a table.
*/
- renameColumn(table: string): (Query | undefined)
+ renameColumn(table: string, oldName: string, newName: string): (Query)
}
interface MysqlBuilder {
/**
* DropPrimaryKey creates a Query that can be used to remove the named primary key constraint from a table.
*/
- dropPrimaryKey(table: string): (Query | undefined)
+ dropPrimaryKey(table: string, name: string): (Query)
}
interface MysqlBuilder {
/**
* DropForeignKey creates a Query that can be used to remove the named foreign key constraint from a table.
*/
- dropForeignKey(table: string): (Query | undefined)
+ dropForeignKey(table: string, name: string): (Query)
}
/**
* OciBuilder is the builder for Oracle databases.
*/
- type _subCkUus = BaseBuilder
- interface OciBuilder extends _subCkUus {
+ type _skEimbP = BaseBuilder
+ interface OciBuilder extends _skEimbP {
}
/**
* OciQueryBuilder is the query builder for Oracle databases.
*/
- type _subeXatv = BaseQueryBuilder
- interface OciQueryBuilder extends _subeXatv {
+ type _sFWNwfu = BaseQueryBuilder
+ interface OciQueryBuilder extends _sFWNwfu {
}
interface newOciBuilder {
/**
@@ -3382,7 +3869,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface OciBuilder {
/**
@@ -3390,7 +3877,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
interface OciBuilder {
/**
@@ -3408,19 +3895,19 @@ namespace dbx {
/**
* DropIndex creates a Query that can be used to remove the named index from a table.
*/
- dropIndex(table: string): (Query | undefined)
+ dropIndex(table: string, name: string): (Query)
}
interface OciBuilder {
/**
* RenameTable creates a Query that can be used to rename a table.
*/
- renameTable(oldName: string): (Query | undefined)
+ renameTable(oldName: string, newName: string): (Query)
}
interface OciBuilder {
/**
* AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- alterColumn(table: string): (Query | undefined)
+ alterColumn(table: string, col: string, typ: string): (Query)
}
interface OciQueryBuilder {
/**
@@ -3431,8 +3918,8 @@ namespace dbx {
/**
* PgsqlBuilder is the builder for PostgreSQL databases.
*/
- type _subVnxQF = BaseBuilder
- interface PgsqlBuilder extends _subVnxQF {
+ type _smALnvM = BaseBuilder
+ interface PgsqlBuilder extends _smALnvM {
}
interface newPgsqlBuilder {
/**
@@ -3446,7 +3933,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface PgsqlBuilder {
/**
@@ -3454,7 +3941,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
interface PgsqlBuilder {
/**
@@ -3476,31 +3963,53 @@ namespace dbx {
* The keys of cols are the column names, while the values of cols are the corresponding column
* values to be inserted.
*/
- upsert(table: string, cols: Params, ...constraints: string[]): (Query | undefined)
+ upsert(table: string, cols: Params, ...constraints: string[]): (Query)
}
interface PgsqlBuilder {
/**
* DropIndex creates a Query that can be used to remove the named index from a table.
*/
- dropIndex(table: string): (Query | undefined)
+ dropIndex(table: string, name: string): (Query)
}
interface PgsqlBuilder {
/**
* RenameTable creates a Query that can be used to rename a table.
*/
- renameTable(oldName: string): (Query | undefined)
+ renameTable(oldName: string, newName: string): (Query)
}
interface PgsqlBuilder {
/**
* AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- alterColumn(table: string): (Query | undefined)
+ alterColumn(table: string, col: string, typ: string): (Query)
+ }
+ /**
+ * SqliteQueryBuilder is the query builder for SQLite databases.
+ */
+ type _sYukUOF = BaseQueryBuilder
+ interface SqliteQueryBuilder extends _sYukUOF {
+ }
+ interface SqliteQueryBuilder {
+ /**
+ * BuildUnion generates a UNION clause from the given union information.
+ *
+ * This is similar to BaseQueryBuilder.BuildUnion but without the parenthesis wrapping.
+ */
+ buildUnion(unions: Array, params: Params): string
+ }
+ interface SqliteQueryBuilder {
+ /**
+ * CombineUnion combines the nonempty unionClause with the provided sql string.
+ *
+ * This is similar to BaseQueryBuilder.CombineUnion but without the parenthesis wrapping.
+ */
+ combineUnion(sql: string, unionClause: string): string
}
/**
* SqliteBuilder is the builder for SQLite databases.
*/
- type _subKdVlm = BaseBuilder
- interface SqliteBuilder extends _subKdVlm {
+ type _sLMFNlZ = BaseBuilder
+ interface SqliteBuilder extends _sLMFNlZ {
}
interface newSqliteBuilder {
/**
@@ -3520,7 +4029,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface SqliteBuilder {
/**
@@ -3528,7 +4037,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
interface SqliteBuilder {
/**
@@ -3548,38 +4057,38 @@ namespace dbx {
/**
* DropIndex creates a Query that can be used to remove the named index from a table.
*/
- dropIndex(table: string): (Query | undefined)
+ dropIndex(table: string, name: string): (Query)
}
interface SqliteBuilder {
/**
* TruncateTable creates a Query that can be used to truncate a table.
*/
- truncateTable(table: string): (Query | undefined)
+ truncateTable(table: string): (Query)
}
interface SqliteBuilder {
/**
* RenameTable creates a Query that can be used to rename a table.
*/
- renameTable(oldName: string): (Query | undefined)
+ renameTable(oldName: string, newName: string): (Query)
}
interface SqliteBuilder {
/**
* AlterColumn creates a Query that can be used to change the definition of a table column.
*/
- alterColumn(table: string): (Query | undefined)
+ alterColumn(table: string, col: string, typ: string): (Query)
}
interface SqliteBuilder {
/**
* AddPrimaryKey creates a Query that can be used to specify primary key(s) for a table.
* The "name" parameter specifies the name of the primary key constraint.
*/
- addPrimaryKey(table: string, ...cols: string[]): (Query | undefined)
+ addPrimaryKey(table: string, name: string, ...cols: string[]): (Query)
}
interface SqliteBuilder {
/**
* DropPrimaryKey creates a Query that can be used to remove the named primary key constraint from a table.
*/
- dropPrimaryKey(table: string): (Query | undefined)
+ dropPrimaryKey(table: string, name: string): (Query)
}
interface SqliteBuilder {
/**
@@ -3588,19 +4097,19 @@ namespace dbx {
* The optional "options" parameters will be appended to the SQL statement. They can be used to
* specify options such as "ON DELETE CASCADE".
*/
- addForeignKey(table: string, cols: Array, refTable: string, ...options: string[]): (Query | undefined)
+ addForeignKey(table: string, name: string, cols: Array, refCols: Array, refTable: string, ...options: string[]): (Query)
}
interface SqliteBuilder {
/**
* DropForeignKey creates a Query that can be used to remove the named foreign key constraint from a table.
*/
- dropForeignKey(table: string): (Query | undefined)
+ dropForeignKey(table: string, name: string): (Query)
}
/**
* StandardBuilder is the builder that is used by DB for an unknown driver.
*/
- type _subtgSCf = BaseBuilder
- interface StandardBuilder extends _subtgSCf {
+ type _sytUlYb = BaseBuilder
+ interface StandardBuilder extends _sytUlYb {
}
interface newStandardBuilder {
/**
@@ -3620,7 +4129,7 @@ namespace dbx {
* The parameters to this method should be the list column names to be selected.
* A column name may have an optional alias name. For example, Select("id", "my_name AS name").
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface StandardBuilder {
/**
@@ -3628,7 +4137,7 @@ namespace dbx {
* The model passed to this method should be a pointer to a model struct.
*/
model(model: {
- }): (ModelQuery | undefined)
+ }): (ModelQuery)
}
/**
* LogFunc logs a message for each SQL statement being executed.
@@ -3666,8 +4175,8 @@ namespace dbx {
* DB enhances sql.DB by providing a set of DB-agnostic query building methods.
* DB allows easier query building and population of data into Go variables.
*/
- type _subRxORM = Builder
- interface DB extends _subRxORM {
+ type _sjvwbBW = Builder
+ interface DB extends _sjvwbBW {
/**
* FieldMapper maps struct fields to DB columns. Defaults to DefaultFieldMapFunc.
*/
@@ -3702,7 +4211,7 @@ namespace dbx {
/**
* NewFromDB encapsulates an existing database connection.
*/
- (sqlDB: sql.DB, driverName: string): (DB | undefined)
+ (sqlDB: sql.DB, driverName: string): (DB)
}
interface open {
/**
@@ -3710,26 +4219,26 @@ namespace dbx {
* Note that Open does not check if DSN is specified correctly. It doesn't try to establish a DB connection either.
* Please refer to sql.Open() for more information.
*/
- (driverName: string): (DB | undefined)
+ (driverName: string, dsn: string): (DB)
}
interface mustOpen {
/**
* MustOpen opens a database and establishes a connection to it.
* Please refer to sql.Open() and sql.Ping() for more information.
*/
- (driverName: string): (DB | undefined)
+ (driverName: string, dsn: string): (DB)
}
interface DB {
/**
* Clone makes a shallow copy of DB.
*/
- clone(): (DB | undefined)
+ clone(): (DB)
}
interface DB {
/**
* WithContext returns a new instance of DB associated with the given context.
*/
- withContext(ctx: context.Context): (DB | undefined)
+ withContext(ctx: context.Context): (DB)
}
interface DB {
/**
@@ -3742,7 +4251,7 @@ namespace dbx {
/**
* DB returns the sql.DB instance encapsulated by dbx.DB.
*/
- db(): (sql.DB | undefined)
+ db(): (sql.DB)
}
interface DB {
/**
@@ -3756,19 +4265,19 @@ namespace dbx {
/**
* Begin starts a transaction.
*/
- begin(): (Tx | undefined)
+ begin(): (Tx)
}
interface DB {
/**
* BeginTx starts a transaction with the given context and transaction options.
*/
- beginTx(ctx: context.Context, opts: sql.TxOptions): (Tx | undefined)
+ beginTx(ctx: context.Context, opts: sql.TxOptions): (Tx)
}
interface DB {
/**
* Wrap encapsulates an existing transaction.
*/
- wrap(sqlTx: sql.Tx): (Tx | undefined)
+ wrap(sqlTx: sql.Tx): (Tx)
}
interface DB {
/**
@@ -3818,6 +4327,7 @@ namespace dbx {
* Expression represents a DB expression that can be embedded in a SQL statement.
*/
interface Expression {
+ [key:string]: any;
/**
* Build converts an expression into a SQL fragment.
* If the expression contains binding parameters, they will be added to the given Params.
@@ -3887,7 +4397,7 @@ namespace dbx {
* You may call Escape() and/or Match() to change the default behavior. For example, Like("name", "key").Match(false, true)
* generates "name" LIKE "key%".
*/
- (col: string, ...values: string[]): (LikeExp | undefined)
+ (col: string, ...values: string[]): (LikeExp)
}
interface notLike {
/**
@@ -3895,7 +4405,7 @@ namespace dbx {
* For example, NotLike("name", "key", "word") will generate a SQL expression:
* "name" NOT LIKE "%key%" AND "name" NOT LIKE "%word%". Please see Like() for more details.
*/
- (col: string, ...values: string[]): (LikeExp | undefined)
+ (col: string, ...values: string[]): (LikeExp)
}
interface orLike {
/**
@@ -3904,7 +4414,7 @@ namespace dbx {
* For example, OrLike("name", "key", "word") will generate a SQL expression:
* "name" LIKE "%key%" OR "name" LIKE "%word%". Please see Like() for more details.
*/
- (col: string, ...values: string[]): (LikeExp | undefined)
+ (col: string, ...values: string[]): (LikeExp)
}
interface orNotLike {
/**
@@ -3912,7 +4422,7 @@ namespace dbx {
* For example, OrNotLike("name", "key", "word") will generate a SQL expression:
* "name" NOT LIKE "%key%" OR "name" NOT LIKE "%word%". Please see Like() for more details.
*/
- (col: string, ...values: string[]): (LikeExp | undefined)
+ (col: string, ...values: string[]): (LikeExp)
}
interface exists {
/**
@@ -3932,6 +4442,7 @@ namespace dbx {
* For example, Between("age", 10, 30) generates: "age" BETWEEN 10 AND 30
*/
(col: string, from: {
+ }, to: {
}): Expression
}
interface notBetween {
@@ -3940,6 +4451,7 @@ namespace dbx {
* For example, NotBetween("age", 10, 30) generates: "age" NOT BETWEEN 10 AND 30
*/
(col: string, from: {
+ }, to: {
}): Expression
}
/**
@@ -4008,13 +4520,13 @@ namespace dbx {
* Each string at position 2i represents a special character and the string at position 2i+1 is
* the corresponding escaped version.
*/
- escape(...chars: string[]): (LikeExp | undefined)
+ escape(...chars: string[]): (LikeExp)
}
interface LikeExp {
/**
* Match specifies whether to do wildcard matching on the left and/or right of given strings.
*/
- match(left: boolean): (LikeExp | undefined)
+ match(left: boolean, right: boolean): (LikeExp)
}
interface LikeExp {
/**
@@ -4065,6 +4577,7 @@ namespace dbx {
* TableModel is the interface that should be implemented by models which have unconventional table names.
*/
interface TableModel {
+ [key:string]: any;
tableName(): string
}
/**
@@ -4074,7 +4587,7 @@ namespace dbx {
}
interface newModelQuery {
(model: {
- }, fieldMapFunc: FieldMapFunc, db: DB, builder: Builder): (ModelQuery | undefined)
+ }, fieldMapFunc: FieldMapFunc, db: DB, builder: Builder): (ModelQuery)
}
interface ModelQuery {
/**
@@ -4086,13 +4599,13 @@ namespace dbx {
/**
* WithContext associates a context with the query.
*/
- withContext(ctx: context.Context): (ModelQuery | undefined)
+ withContext(ctx: context.Context): (ModelQuery)
}
interface ModelQuery {
/**
* Exclude excludes the specified struct fields from being inserted/updated into the DB table.
*/
- exclude(...attrs: string[]): (ModelQuery | undefined)
+ exclude(...attrs: string[]): (ModelQuery)
}
interface ModelQuery {
/**
@@ -4149,6 +4662,7 @@ namespace dbx {
* Executor prepares, executes, or queries a SQL statement.
*/
interface Executor {
+ [key:string]: any;
/**
* Exec executes a SQL statement
*/
@@ -4163,16 +4677,16 @@ namespace dbx {
* Query queries a SQL statement
*/
query(query: string, ...args: {
- }[]): (sql.Rows | undefined)
+ }[]): (sql.Rows)
/**
* QueryContext queries a SQL statement with the given context
*/
queryContext(ctx: context.Context, query: string, ...args: {
- }[]): (sql.Rows | undefined)
+ }[]): (sql.Rows)
/**
* Prepare creates a prepared statement
*/
- prepare(query: string): (sql.Stmt | undefined)
+ prepare(query: string): (sql.Stmt)
}
/**
* Query represents a SQL statement to be executed.
@@ -4209,7 +4723,7 @@ namespace dbx {
/**
* NewQuery creates a new Query with the given SQL statement.
*/
- (db: DB, executor: Executor, sql: string): (Query | undefined)
+ (db: DB, executor: Executor, sql: string): (Query)
}
interface Query {
/**
@@ -4229,7 +4743,7 @@ namespace dbx {
/**
* WithContext associates a context with the query.
*/
- withContext(ctx: context.Context): (Query | undefined)
+ withContext(ctx: context.Context): (Query)
}
interface Query {
/**
@@ -4238,7 +4752,7 @@ namespace dbx {
* It is called for every Query resolver (Execute(), One(), All(), Row(), Column()),
* allowing you to implement auto fail/retry or any other additional handling.
*/
- withExecHook(fn: ExecHookFunc): (Query | undefined)
+ withExecHook(fn: ExecHookFunc): (Query)
}
interface Query {
/**
@@ -4246,7 +4760,7 @@ namespace dbx {
* called on q.One(), allowing you to implement custom struct scan based
* on the One() argument and/or result.
*/
- withOneHook(fn: OneHookFunc): (Query | undefined)
+ withOneHook(fn: OneHookFunc): (Query)
}
interface Query {
/**
@@ -4254,7 +4768,7 @@ namespace dbx {
* called on q.All(), allowing you to implement custom slice scan based
* on the All() argument and/or result.
*/
- withAllHook(fn: AllHookFunc): (Query | undefined)
+ withAllHook(fn: AllHookFunc): (Query)
}
interface Query {
/**
@@ -4267,7 +4781,7 @@ namespace dbx {
* Prepare creates a prepared statement for later queries or executions.
* Close() should be called after finishing all queries.
*/
- prepare(): (Query | undefined)
+ prepare(): (Query)
}
interface Query {
/**
@@ -4281,7 +4795,7 @@ namespace dbx {
* Bind sets the parameters that should be bound to the SQL statement.
* The parameter placeholders in the SQL statement are in the format of "{:ParamName}".
*/
- bind(params: Params): (Query | undefined)
+ bind(params: Params): (Query)
}
interface Query {
/**
@@ -4330,12 +4844,13 @@ namespace dbx {
/**
* Rows executes the SQL statement and returns a Rows object to allow retrieving data row by row.
*/
- rows(): (Rows | undefined)
+ rows(): (Rows)
}
/**
* QueryBuilder builds different clauses for a SELECT SQL statement.
*/
interface QueryBuilder {
+ [key:string]: any;
/**
* BuildSelect generates a SELECT clause from the given selected column names.
*/
@@ -4368,6 +4883,18 @@ namespace dbx {
* BuildUnion generates a UNION clause from the given union information.
*/
buildUnion(_arg0: Array, _arg1: Params): string
+ /**
+ * CombineUnion combines the nonempty unionClause with the provided sql string.
+ *
+ * The unionClause is expected to be the result of BuildUnion.
+ * If the unionClause is an empty string it returns the sql argument unmodified.
+ *
+ * This method exists as a workaround to minimize breaking changes
+ * and to allow different SQL builders to specify for example whether
+ * they support parenthesis around the UNION SQL queries
+ * (SQLite for example will throw a SyntaxError if the UNION parts are wrapped in parenthesis).
+ */
+ combineUnion(sql: string, unionClause: string): string
}
/**
* BaseQueryBuilder provides a basic implementation of QueryBuilder.
@@ -4378,13 +4905,13 @@ namespace dbx {
/**
* NewBaseQueryBuilder creates a new BaseQueryBuilder instance.
*/
- (db: DB): (BaseQueryBuilder | undefined)
+ (db: DB): (BaseQueryBuilder)
}
interface BaseQueryBuilder {
/**
* DB returns the DB instance associated with the query builder.
*/
- db(): (DB | undefined)
+ db(): (DB)
}
interface BaseQueryBuilder {
/**
@@ -4434,6 +4961,20 @@ namespace dbx {
*/
buildUnion(unions: Array, params: Params): string
}
+ interface BaseQueryBuilder {
+ /**
+ * CombineUnion combines the nonempty unionClause with the provided sql string.
+ *
+ * The unionClause is expected to be the result of BuildUnion.
+ * If the unionClause is an empty string it returns the sql argument unmodified.
+ *
+ * This method exists as a workaround to minimize breaking changes
+ * and to allow different SQL builders to specify for example whether
+ * they support parenthesis around the UNION SQL queries
+ * (SQLite for example will throw a SyntaxError if the UNION parts are wrapped in parenthesis).
+ */
+ combineUnion(sql: string, unionClause: string): string
+ }
interface BaseQueryBuilder {
/**
* BuildOrderBy generates the ORDER BY clause.
@@ -4465,8 +5006,8 @@ namespace dbx {
* Rows enhances sql.Rows by providing additional data query methods.
* Rows can be obtained by calling Query.Rows(). It is mainly used to populate data row by row.
*/
- type _subQfXwS = sql.Rows
- interface Rows extends _subQfXwS {
+ type _szCOkDy = sql.Rows
+ interface Rows extends _szCOkDy {
}
interface Rows {
/**
@@ -4532,13 +5073,13 @@ namespace dbx {
/**
* NewSelectQuery creates a new SelectQuery instance.
*/
- (builder: Builder, db: DB): (SelectQuery | undefined)
+ (builder: Builder, db: DB): (SelectQuery)
}
interface SelectQuery {
/**
* WithBuildHook runs the provided hook function with the query created on Build().
*/
- withBuildHook(fn: BuildHookFunc): (SelectQuery | undefined)
+ withBuildHook(fn: BuildHookFunc): (SelectQuery)
}
interface SelectQuery {
/**
@@ -4550,177 +5091,192 @@ namespace dbx {
/**
* WithContext associates a context with the query.
*/
- withContext(ctx: context.Context): (SelectQuery | undefined)
+ withContext(ctx: context.Context): (SelectQuery)
+ }
+ interface SelectQuery {
+ /**
+ * PreFragment sets SQL fragment that should be prepended before the select query (e.g. WITH clause).
+ */
+ preFragment(fragment: string): (SelectQuery)
+ }
+ interface SelectQuery {
+ /**
+ * PostFragment sets SQL fragment that should be appended at the end of the main select query.
+ *
+ * If there is "UNION"/"UNION ALL" clause then the extra UNION queries
+ * are appended after the PostFragment.
+ */
+ postFragment(fragment: string): (SelectQuery)
}
interface SelectQuery {
/**
* Select specifies the columns to be selected.
* Column names will be automatically quoted.
*/
- select(...cols: string[]): (SelectQuery | undefined)
+ select(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* AndSelect adds additional columns to be selected.
* Column names will be automatically quoted.
*/
- andSelect(...cols: string[]): (SelectQuery | undefined)
+ andSelect(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* Distinct specifies whether to select columns distinctively.
* By default, distinct is false.
*/
- distinct(v: boolean): (SelectQuery | undefined)
+ distinct(v: boolean): (SelectQuery)
}
interface SelectQuery {
/**
* SelectOption specifies additional option that should be append to "SELECT".
*/
- selectOption(option: string): (SelectQuery | undefined)
+ selectOption(option: string): (SelectQuery)
}
interface SelectQuery {
/**
* From specifies which tables to select from.
* Table names will be automatically quoted.
*/
- from(...tables: string[]): (SelectQuery | undefined)
+ from(...tables: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* Where specifies the WHERE condition.
*/
- where(e: Expression): (SelectQuery | undefined)
+ where(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* AndWhere concatenates a new WHERE condition with the existing one (if any) using "AND".
*/
- andWhere(e: Expression): (SelectQuery | undefined)
+ andWhere(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* OrWhere concatenates a new WHERE condition with the existing one (if any) using "OR".
*/
- orWhere(e: Expression): (SelectQuery | undefined)
+ orWhere(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* Join specifies a JOIN clause.
* The "typ" parameter specifies the JOIN type (e.g. "INNER JOIN", "LEFT JOIN").
*/
- join(typ: string, table: string, on: Expression): (SelectQuery | undefined)
+ join(typ: string, table: string, on: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* InnerJoin specifies an INNER JOIN clause.
* This is a shortcut method for Join.
*/
- innerJoin(table: string, on: Expression): (SelectQuery | undefined)
+ innerJoin(table: string, on: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* LeftJoin specifies a LEFT JOIN clause.
* This is a shortcut method for Join.
*/
- leftJoin(table: string, on: Expression): (SelectQuery | undefined)
+ leftJoin(table: string, on: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* RightJoin specifies a RIGHT JOIN clause.
* This is a shortcut method for Join.
*/
- rightJoin(table: string, on: Expression): (SelectQuery | undefined)
+ rightJoin(table: string, on: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* OrderBy specifies the ORDER BY clause.
* Column names will be properly quoted. A column name can contain "ASC" or "DESC" to indicate its ordering direction.
*/
- orderBy(...cols: string[]): (SelectQuery | undefined)
+ orderBy(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* AndOrderBy appends additional columns to the existing ORDER BY clause.
* Column names will be properly quoted. A column name can contain "ASC" or "DESC" to indicate its ordering direction.
*/
- andOrderBy(...cols: string[]): (SelectQuery | undefined)
+ andOrderBy(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* GroupBy specifies the GROUP BY clause.
* Column names will be properly quoted.
*/
- groupBy(...cols: string[]): (SelectQuery | undefined)
+ groupBy(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* AndGroupBy appends additional columns to the existing GROUP BY clause.
* Column names will be properly quoted.
*/
- andGroupBy(...cols: string[]): (SelectQuery | undefined)
+ andGroupBy(...cols: string[]): (SelectQuery)
}
interface SelectQuery {
/**
* Having specifies the HAVING clause.
*/
- having(e: Expression): (SelectQuery | undefined)
+ having(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* AndHaving concatenates a new HAVING condition with the existing one (if any) using "AND".
*/
- andHaving(e: Expression): (SelectQuery | undefined)
+ andHaving(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* OrHaving concatenates a new HAVING condition with the existing one (if any) using "OR".
*/
- orHaving(e: Expression): (SelectQuery | undefined)
+ orHaving(e: Expression): (SelectQuery)
}
interface SelectQuery {
/**
* Union specifies a UNION clause.
*/
- union(q: Query): (SelectQuery | undefined)
+ union(q: Query): (SelectQuery)
}
interface SelectQuery {
/**
* UnionAll specifies a UNION ALL clause.
*/
- unionAll(q: Query): (SelectQuery | undefined)
+ unionAll(q: Query): (SelectQuery)
}
interface SelectQuery {
/**
* Limit specifies the LIMIT clause.
* A negative limit means no limit.
*/
- limit(limit: number): (SelectQuery | undefined)
+ limit(limit: number): (SelectQuery)
}
interface SelectQuery {
/**
* Offset specifies the OFFSET clause.
* A negative offset means no offset.
*/
- offset(offset: number): (SelectQuery | undefined)
+ offset(offset: number): (SelectQuery)
}
interface SelectQuery {
/**
* Bind specifies the parameter values to be bound to the query.
*/
- bind(params: Params): (SelectQuery | undefined)
+ bind(params: Params): (SelectQuery)
}
interface SelectQuery {
/**
* AndBind appends additional parameters to be bound to the query.
*/
- andBind(params: Params): (SelectQuery | undefined)
+ andBind(params: Params): (SelectQuery)
}
interface SelectQuery {
/**
* Build builds the SELECT query and returns an executable Query object.
*/
- build(): (Query | undefined)
+ build(): (Query)
}
interface SelectQuery {
/**
@@ -4745,6 +5301,7 @@ namespace dbx {
* please use Where() to specify the filtering condition.
*/
model(pk: {
+ }, model: {
}): void
}
interface SelectQuery {
@@ -4765,7 +5322,7 @@ namespace dbx {
* Rows builds and executes the SELECT query and returns a Rows object for data retrieval purpose.
* This is a shortcut to SelectQuery.Build().Rows()
*/
- rows(): (Rows | undefined)
+ rows(): (Rows)
}
interface SelectQuery {
/**
@@ -4788,6 +5345,8 @@ namespace dbx {
* QueryInfo represents a debug/info struct with exported SelectQuery fields.
*/
interface QueryInfo {
+ preFragment: string
+ postFragment: string
builder: Builder
selects: Array
distinct: boolean
@@ -4810,7 +5369,7 @@ namespace dbx {
* Info exports common SelectQuery fields allowing to inspect the
* current select query options.
*/
- info(): (QueryInfo | undefined)
+ info(): (QueryInfo)
}
/**
* FieldMapFunc converts a struct field name into a DB column name.
@@ -4823,8 +5382,8 @@ namespace dbx {
}): string }
interface structInfo {
}
- type _subGdTwL = structInfo
- interface structValue extends _subGdTwL {
+ type _sxbSYmJ = structInfo
+ interface structValue extends _sxbSYmJ {
}
interface fieldInfo {
}
@@ -4834,6 +5393,7 @@ namespace dbx {
* PostScanner is an optional interface used by ScanStruct.
*/
interface PostScanner {
+ [key:string]: any;
/**
* PostScan executes right after the struct has been populated
* with the DB values, allowing you to further normalize or validate
@@ -4862,8 +5422,8 @@ namespace dbx {
/**
* Tx enhances sql.Tx with additional querying methods.
*/
- type _subUwOGp = Builder
- interface Tx extends _subUwOGp {
+ type _sVwsJDL = Builder
+ interface Tx extends _sVwsJDL {
}
interface Tx {
/**
@@ -4879,87 +5439,6 @@ namespace dbx {
}
}
-/**
- * Package template is a thin wrapper around the standard html/template
- * and text/template packages that implements a convenient registry to
- * load and cache templates on the fly concurrently.
- *
- * It was created to assist the JSVM plugin HTML rendering, but could be used in other Go code.
- *
- * Example:
- *
- * ```
- * registry := template.NewRegistry()
- *
- * html1, err := registry.LoadFiles(
- * // the files set wil be parsed only once and then cached
- * "layout.html",
- * "content.html",
- * ).Render(map[string]any{"name": "John"})
- *
- * html2, err := registry.LoadFiles(
- * // reuse the already parsed and cached files set
- * "layout.html",
- * "content.html",
- * ).Render(map[string]any{"name": "Jane"})
- * ```
- */
-namespace template {
- interface newRegistry {
- /**
- * NewRegistry creates and initializes a new blank templates registry.
- *
- * Use the Registry.Load* methods to load templates into the registry.
- */
- (): (Registry | undefined)
- }
- /**
- * Registry defines a templates registry that is safe to be used by multiple goroutines.
- *
- * Use the Registry.Load* methods to load templates into the registry.
- */
- interface Registry {
- }
- interface Registry {
- /**
- * LoadFiles caches (if not already) the specified filenames set as a
- * single template and returns a ready to use Renderer instance.
- *
- * There must be at least 1 filename specified.
- */
- loadFiles(...filenames: string[]): (Renderer | undefined)
- }
- interface Registry {
- /**
- * LoadString caches (if not already) the specified inline string as a
- * single template and returns a ready to use Renderer instance.
- */
- loadString(text: string): (Renderer | undefined)
- }
- interface Registry {
- /**
- * LoadString caches (if not already) the specified fs and globPatterns
- * pair as single template and returns a ready to use Renderer instance.
- *
- * There must be at least 1 file matching the provided globPattern(s)
- * (note that most file names serves as glob patterns matching themselves).
- */
- loadFS(fs: fs.FS, ...globPatterns: string[]): (Renderer | undefined)
- }
- /**
- * Renderer defines a single parsed template.
- */
- interface Renderer {
- }
- interface Renderer {
- /**
- * Render executes the template with the specified data as the dot object
- * and returns the result as plain string.
- */
- render(data: any): string
- }
-}
-
/**
* Package validation provides configurable and extensible rules for validating data of various types.
*/
@@ -4968,6 +5447,7 @@ namespace ozzo_validation {
* Error interface represents an validation error
*/
interface Error {
+ [key:string]: any;
error(): string
code(): string
message(): string
@@ -4977,12668 +5457,19394 @@ namespace ozzo_validation {
}
}
-/**
- * Package tokens implements various user and admin tokens generation methods.
- */
-namespace tokens {
- interface newAdminAuthToken {
+namespace security {
+ interface s256Challenge {
/**
- * NewAdminAuthToken generates and returns a new admin authentication token.
+ * S256Challenge creates base64 encoded sha256 challenge string derived from code.
+ * The padding of the result base64 string is stripped per [RFC 7636].
+ *
+ * [RFC 7636]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
*/
- (app: core.App, admin: models.Admin): string
+ (code: string): string
}
- interface newAdminResetPasswordToken {
+ interface md5 {
/**
- * NewAdminResetPasswordToken generates and returns a new admin password reset request token.
+ * MD5 creates md5 hash from the provided plain text.
*/
- (app: core.App, admin: models.Admin): string
+ (text: string): string
}
- interface newAdminFileToken {
+ interface sha256 {
/**
- * NewAdminFileToken generates and returns a new admin private file access token.
+ * SHA256 creates sha256 hash as defined in FIPS 180-4 from the provided text.
*/
- (app: core.App, admin: models.Admin): string
+ (text: string): string
}
- interface newRecordAuthToken {
+ interface sha512 {
/**
- * NewRecordAuthToken generates and returns a new auth record authentication token.
+ * SHA512 creates sha512 hash as defined in FIPS 180-4 from the provided text.
*/
- (app: core.App, record: models.Record): string
+ (text: string): string
}
- interface newRecordVerifyToken {
+ interface hs256 {
/**
- * NewRecordVerifyToken generates and returns a new record verification token.
+ * HS256 creates a HMAC hash with sha256 digest algorithm.
*/
- (app: core.App, record: models.Record): string
+ (text: string, secret: string): string
}
- interface newRecordResetPasswordToken {
+ interface hs512 {
/**
- * NewRecordResetPasswordToken generates and returns a new auth record password reset request token.
+ * HS512 creates a HMAC hash with sha512 digest algorithm.
*/
- (app: core.App, record: models.Record): string
+ (text: string, secret: string): string
}
- interface newRecordChangeEmailToken {
+ interface equal {
/**
- * NewRecordChangeEmailToken generates and returns a new auth record change email request token.
+ * Equal compares two hash strings for equality without leaking timing information.
*/
- (app: core.App, record: models.Record, newEmail: string): string
+ (hash1: string, hash2: string): boolean
}
- interface newRecordFileToken {
+ // @ts-ignore
+ import crand = rand
+ interface encrypt {
/**
- * NewRecordFileToken generates and returns a new record private file access token.
+ * Encrypt encrypts "data" with the specified "key" (must be valid 32 char AES key).
+ *
+ * This method uses AES-256-GCM block cypher mode.
*/
- (app: core.App, record: models.Record): string
+ (data: string|Array, key: string): string
}
-}
-
-/**
- * Package models implements various services used for request data
- * validation and applying changes to existing DB models through the app Dao.
- */
-namespace forms {
- // @ts-ignore
- import validation = ozzo_validation
- /**
- * AdminLogin is an admin email/pass login form.
- */
- interface AdminLogin {
- identity: string
- password: string
+ interface decrypt {
+ /**
+ * Decrypt decrypts encrypted text with key (must be valid 32 chars AES key).
+ *
+ * This method uses AES-256-GCM block cypher mode.
+ */
+ (cipherText: string, key: string): string|Array
}
- interface newAdminLogin {
+ interface parseUnverifiedJWT {
/**
- * NewAdminLogin creates a new [AdminLogin] form initialized with
- * the provided [core.App] instance.
+ * ParseUnverifiedJWT parses JWT and returns its claims
+ * but DOES NOT verify the signature.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * It verifies only the exp, iat and nbf claims.
*/
- (app: core.App): (AdminLogin | undefined)
+ (token: string): jwt.MapClaims
}
- interface AdminLogin {
+ interface parseJWT {
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * ParseJWT verifies and parses JWT and returns its claims.
*/
- setDao(dao: daos.Dao): void
+ (token: string, verificationKey: string): jwt.MapClaims
}
- interface AdminLogin {
+ interface newJWT {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * NewJWT generates and returns new HS256 signed JWT.
*/
- validate(): void
+ (payload: jwt.MapClaims, signingKey: string, duration: time.Duration): string
}
- interface AdminLogin {
+ // @ts-ignore
+ import cryptoRand = rand
+ // @ts-ignore
+ import mathRand = rand
+ interface randomString {
/**
- * Submit validates and submits the admin form.
- * On success returns the authorized admin model.
+ * RandomString generates a cryptographically random string with the specified length.
*
- * You can optionally provide a list of InterceptorFunc to
- * further modify the form behavior before persisting it.
+ * The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding.
*/
- submit(...interceptors: InterceptorFunc[]): (models.Admin | undefined)
- }
- /**
- * AdminPasswordResetConfirm is an admin password reset confirmation form.
- */
- interface AdminPasswordResetConfirm {
- token: string
- password: string
- passwordConfirm: string
+ (length: number): string
}
- interface newAdminPasswordResetConfirm {
+ interface randomStringWithAlphabet {
/**
- * NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
- * form initialized with from the provided [core.App] instance.
+ * RandomStringWithAlphabet generates a cryptographically random string
+ * with the specified length and characters set.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * It panics if for some reason rand.Int returns a non-nil error.
*/
- (app: core.App): (AdminPasswordResetConfirm | undefined)
+ (length: number, alphabet: string): string
}
- interface AdminPasswordResetConfirm {
+ interface pseudorandomString {
/**
- * SetDao replaces the form Dao instance with the provided one.
+ * PseudorandomString generates a pseudorandom string with the specified length.
+ *
+ * The generated string matches [A-Za-z0-9]+ and it's transparent to URL-encoding.
*
- * This is useful if you want to use a specific transaction Dao instance
- * instead of the default app.Dao().
+ * For a cryptographically random string (but a little bit slower) use RandomString instead.
*/
- setDao(dao: daos.Dao): void
+ (length: number): string
}
- interface AdminPasswordResetConfirm {
+ interface pseudorandomStringWithAlphabet {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * PseudorandomStringWithAlphabet generates a pseudorandom string
+ * with the specified length and characters set.
+ *
+ * For a cryptographically random (but a little bit slower) use RandomStringWithAlphabet instead.
*/
- validate(): void
+ (length: number, alphabet: string): string
}
- interface AdminPasswordResetConfirm {
+ interface randomStringByRegex {
/**
- * Submit validates and submits the admin password reset confirmation form.
- * On success returns the updated admin model associated to `form.Token`.
+ * RandomStringByRegex generates a random string matching the regex pattern.
+ * If optFlags is not set, fallbacks to [syntax.Perl].
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * NB! While the source of the randomness comes from [crypto/rand] this method
+ * is not recommended to be used on its own in critical secure contexts because
+ * the generated length could vary too much on the used pattern and may not be
+ * as secure as simply calling [security.RandomString].
+ * If you still insist on using it for such purposes, consider at least
+ * a large enough minimum length for the generated string, e.g. `[a-z0-9]{30}`.
+ *
+ * This function is inspired by github.com/pipe01/revregexp, github.com/lucasjones/reggen and other similar packages.
*/
- submit(...interceptors: InterceptorFunc[]): (models.Admin | undefined)
+ (pattern: string, ...optFlags: syntax.Flags[]): string
+ }
+}
+
+namespace filesystem {
+ /**
+ * FileReader defines an interface for a file resource reader.
+ */
+ interface FileReader {
+ [key:string]: any;
+ open(): io.ReadSeekCloser
}
/**
- * AdminPasswordResetRequest is an admin password reset request form.
+ * File defines a single file [io.ReadSeekCloser] resource.
+ *
+ * The file could be from a local path, multipart/form-data header, etc.
*/
- interface AdminPasswordResetRequest {
- email: string
+ interface File {
+ reader: FileReader
+ name: string
+ originalName: string
+ size: number
}
- interface newAdminPasswordResetRequest {
+ interface File {
/**
- * NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
- * form initialized with from the provided [core.App] instance.
- *
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * AsMap implements [core.mapExtractor] and returns a value suitable
+ * to be used in an API rule expression.
*/
- (app: core.App): (AdminPasswordResetRequest | undefined)
+ asMap(): _TygojaDict
}
- interface AdminPasswordResetRequest {
+ interface newFileFromPath {
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * NewFileFromPath creates a new File instance from the provided local file path.
*/
- setDao(dao: daos.Dao): void
+ (path: string): (File)
}
- interface AdminPasswordResetRequest {
+ interface newFileFromBytes {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
- *
- * This method doesn't verify that admin with `form.Email` exists (this is done on Submit).
+ * NewFileFromBytes creates a new File instance from the provided byte slice.
*/
- validate(): void
+ (b: string|Array, name: string): (File)
+ }
+ interface newFileFromMultipart {
+ /**
+ * NewFileFromMultipart creates a new File from the provided multipart header.
+ */
+ (mh: multipart.FileHeader): (File)
}
- interface AdminPasswordResetRequest {
+ interface newFileFromURL {
/**
- * Submit validates and submits the form.
- * On success sends a password reset email to the `form.Email` admin.
+ * NewFileFromURL creates a new File from the provided url by
+ * downloading the resource and load it as BytesReader.
+ *
+ * Example
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * ```
+ * ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ * defer cancel()
+ *
+ * file, err := filesystem.NewFileFromURL(ctx, "https://example.com/image.png")
+ * ```
*/
- submit(...interceptors: InterceptorFunc[]): void
+ (ctx: context.Context, url: string): (File)
}
/**
- * AdminUpsert is a [models.Admin] upsert (create/update) form.
+ * MultipartReader defines a FileReader from [multipart.FileHeader].
*/
- interface AdminUpsert {
- id: string
- avatar: number
- email: string
- password: string
- passwordConfirm: string
+ interface MultipartReader {
+ header?: multipart.FileHeader
}
- interface newAdminUpsert {
+ interface MultipartReader {
/**
- * NewAdminUpsert creates a new [AdminUpsert] form with initializer
- * config created from the provided [core.App] and [models.Admin] instances
- * (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
- *
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * Open implements the [filesystem.FileReader] interface.
*/
- (app: core.App, admin: models.Admin): (AdminUpsert | undefined)
+ open(): io.ReadSeekCloser
+ }
+ /**
+ * PathReader defines a FileReader from a local file path.
+ */
+ interface PathReader {
+ path: string
}
- interface AdminUpsert {
+ interface PathReader {
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * Open implements the [filesystem.FileReader] interface.
*/
- setDao(dao: daos.Dao): void
+ open(): io.ReadSeekCloser
+ }
+ /**
+ * BytesReader defines a FileReader from bytes content.
+ */
+ interface BytesReader {
+ bytes: string|Array
}
- interface AdminUpsert {
+ interface BytesReader {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Open implements the [filesystem.FileReader] interface.
*/
- validate(): void
+ open(): io.ReadSeekCloser
+ }
+ type _sjJYyPS = bytes.Reader
+ interface bytesReadSeekCloser extends _sjJYyPS {
}
- interface AdminUpsert {
+ interface bytesReadSeekCloser {
/**
- * Submit validates the form and upserts the form admin model.
- *
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * Close implements the [io.ReadSeekCloser] interface.
*/
- submit(...interceptors: InterceptorFunc[]): void
+ close(): void
}
/**
- * AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
- *
- * Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
+ * openFuncAsReader defines a FileReader from a bare Open function.
*/
- interface AppleClientSecretCreate {
+ interface openFuncAsReader {(): io.ReadSeekCloser }
+ interface openFuncAsReader {
/**
- * ClientId is the identifier of your app (aka. Service ID).
+ * Open implements the [filesystem.FileReader] interface.
*/
- clientId: string
+ open(): io.ReadSeekCloser
+ }
+ type _sEQBohj = hook.Event
+ interface DeleteEvent extends _sEQBohj {
+ filesystem?: System
+ fileKey: string
+ }
+ type _sMErova = hook.Event
+ interface NewWriterEvent extends _sMErova {
+ filesystem?: System
+ fileKey: string
+ options?: blob.WriterOptions
+ writer?: blob.Writer // filled only after e.Next()
+ }
+ interface System {
+ }
+ interface newS3 {
/**
- * TeamId is a 10-character string associated with your developer account
- * (usually could be found next to your name in the Apple Developer site).
+ * NewS3 initializes a new S3 filesystem instance.
+ *
+ * NB! Make sure to call `Close()` after you are done working with it.
*/
- teamId: string
+ (bucketName: string, region: string, endpoint: string, accessKey: string, secretKey: string, s3ForcePathStyle: boolean): (System)
+ }
+ interface newLocal {
/**
- * KeyId is a 10-character key identifier generated for the "Sign in with Apple"
- * private key associated with your developer account.
+ * NewLocal initializes a new local filesystem instance.
+ *
+ * NB! Make sure to call `Close()` after you are done working with it.
*/
- keyId: string
- /**
- * PrivateKey is the private key associated to your app.
- * Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
- */
- privateKey: string
- /**
- * Duration specifies how long the generated JWT token should be considered valid.
- * The specified value must be in seconds and max 15777000 (~6months).
- */
- duration: number
+ (dirPath: string): (System)
}
- interface newAppleClientSecretCreate {
+ interface System {
/**
- * NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
- * config created from the provided [core.App] instances.
+ * SetContext assigns the specified context to the current filesystem.
*/
- (app: core.App): (AppleClientSecretCreate | undefined)
+ setContext(ctx: context.Context): void
}
- interface AppleClientSecretCreate {
+ interface System {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Close releases any resources used for the related filesystem.
*/
- validate(): void
+ close(): void
}
- interface AppleClientSecretCreate {
+ interface System {
/**
- * Submit validates the form and returns a new Apple Client Secret JWT.
+ * OnNewWriter is a low level hook that is triggered on every new writer initialization
+ * (aka. when attempting to create a new file with [system.NewWriter] or [system.Upload]).
+ *
+ * Note that currently it doesn't trigger on [System.Copy] but this may change in future releases.
*/
- submit(): string
- }
- /**
- * BackupCreate is a request form for creating a new app backup.
- */
- interface BackupCreate {
- name: string
+ onNewWriter(): (hook.Hook)
}
- interface newBackupCreate {
+ interface System {
/**
- * NewBackupCreate creates new BackupCreate request form.
+ * OnDelete is a low level hook that is triggered on every [System.Delete] call.
+ *
+ * Note that the hook doesn't fire when a file is being overwritten
+ * by a new one, because in that case [System.Delete] is not invoked.
*/
- (app: core.App): (BackupCreate | undefined)
+ onDelete(): (hook.Hook)
}
- interface BackupCreate {
+ interface System {
/**
- * SetContext replaces the default form context with the provided one.
+ * Exists checks if file with fileKey path exists or not.
*/
- setContext(ctx: context.Context): void
+ exists(fileKey: string): boolean
}
- interface BackupCreate {
+ interface System {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Attributes returns the attributes for the file with fileKey path.
+ *
+ * If the file doesn't exist it returns ErrNotFound.
*/
- validate(): void
+ attributes(fileKey: string): (blob.Attributes)
}
- interface BackupCreate {
+ interface System {
/**
- * Submit validates the form and creates the app backup.
+ * GetReader returns a file content reader for the given fileKey.
+ *
+ * NB! Make sure to call Close() on the file after you are done working with it.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before creating the backup.
+ * If the file doesn't exist returns ErrNotFound.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * BackupUpload is a request form for uploading a new app backup.
- */
- interface BackupUpload {
- file?: filesystem.File
+ getReader(fileKey: string): (blob.Reader)
}
- interface newBackupUpload {
+ interface System {
/**
- * NewBackupUpload creates new BackupUpload request form.
+ * Deprecated: Please use GetReader(fileKey) instead.
*/
- (app: core.App): (BackupUpload | undefined)
+ getFile(fileKey: string): (blob.Reader)
}
- interface BackupUpload {
+ interface System {
/**
- * SetContext replaces the default form upload context with the provided one.
+ * GetReuploadableFile constructs a new reuploadable File value
+ * from the associated fileKey blob.Reader.
+ *
+ * If preserveName is false then the returned File.Name will have
+ * a new randomly generated suffix, otherwise it will reuse the original one.
+ *
+ * This method could be useful in case you want to clone an existing
+ * Record file and assign it to a new Record (e.g. in a Record duplicate action).
+ *
+ * If you simply want to copy an existing file to a new location you
+ * could check the Copy(srcKey, dstKey) method.
*/
- setContext(ctx: context.Context): void
+ getReuploadableFile(fileKey: string, preserveName: boolean): (File)
}
- interface BackupUpload {
+ interface System {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Copy copies the file stored at srcKey to dstKey.
+ *
+ * If srcKey file doesn't exist, it returns ErrNotFound.
+ *
+ * If dstKey file already exists, it is overwritten.
*/
- validate(): void
+ copy(srcKey: string, dstKey: string): void
}
- interface BackupUpload {
+ interface System {
/**
- * Submit validates the form and upload the backup file.
- *
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before uploading the backup.
+ * List returns a flat list with info for all files under the specified prefix.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * InterceptorNextFunc is a interceptor handler function.
- * Usually used in combination with InterceptorFunc.
- */
- interface InterceptorNextFunc {(t: T): void }
- /**
- * InterceptorFunc defines a single interceptor function that
- * will execute the provided next func handler.
- */
- interface InterceptorFunc {(next: InterceptorNextFunc): InterceptorNextFunc }
- /**
- * CollectionUpsert is a [models.Collection] upsert (create/update) form.
- */
- interface CollectionUpsert {
- id: string
- type: string
- name: string
- system: boolean
- schema: schema.Schema
- indexes: types.JsonArray
- listRule?: string
- viewRule?: string
- createRule?: string
- updateRule?: string
- deleteRule?: string
- options: types.JsonMap
+ list(prefix: string): Array<(blob.ListObject | undefined)>
}
- interface newCollectionUpsert {
+ interface System {
/**
- * NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
- * config created from the provided [core.App] and [models.Collection] instances
- * (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
- *
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * Upload writes content into the fileKey location.
*/
- (app: core.App, collection: models.Collection): (CollectionUpsert | undefined)
+ upload(content: string|Array, fileKey: string): void
}
- interface CollectionUpsert {
+ interface System {
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * UploadFile uploads the provided File to the fileKey location.
*/
- setDao(dao: daos.Dao): void
+ uploadFile(file: File, fileKey: string): void
}
- interface CollectionUpsert {
+ interface System {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * UploadMultipart uploads the provided multipart file to the fileKey location.
*/
- validate(): void
+ uploadMultipart(fh: multipart.FileHeader, fileKey: string): void
}
- interface CollectionUpsert {
+ interface System {
/**
- * Submit validates the form and upserts the form's Collection model.
+ * NewWriter returns a new blob.Writer instance allowing direct file
+ * create from an io.Reader value.
*
- * On success the related record table schema will be auto updated.
+ * If a file with the specified fileKey already exists, it will be replaced.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * NB! Make sure to call `Close()` on the resulting writer after you are done working with it.
+ *
+ * Note: If you have a bytes slice, [filesystem.File], or a multipart header value,
+ * you can check also the Upload* related methods as they are more user-friendly.
+ *
+ * Example:
+ *
+ * ```
+ * content := strings.NewReader("Lorem ipsum dolor sit amet...")
+ *
+ * fsys, _ := filesystem.NewLocal("dir")
+ * defer fsys.Close()
+ *
+ * w, _ := fsys.NewWriter("example/file/key", nil)
+ * w.ReadFrom(content)
+ * w.Close()
+ * ```
*/
- submit(...interceptors: InterceptorFunc[]): void
+ newWriter(fileKey: string, opts: blob.WriterOptions): (blob.Writer)
}
- /**
- * CollectionsImport is a form model to bulk import
- * (create, replace and delete) collections from a user provided list.
- */
- interface CollectionsImport {
- collections: Array<(models.Collection | undefined)>
- deleteMissing: boolean
- }
- interface newCollectionsImport {
+ interface System {
/**
- * NewCollectionsImport creates a new [CollectionsImport] form with
- * initialized with from the provided [core.App] instance.
+ * Delete deletes stored file at fileKey location.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * If the file doesn't exist returns ErrNotFound.
*/
- (app: core.App): (CollectionsImport | undefined)
+ delete(fileKey: string): void
}
- interface CollectionsImport {
+ interface System {
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * DeletePrefix deletes everything starting with the specified prefix.
+ *
+ * The prefix could be subpath (ex. "/a/b/") or filename prefix (ex. "/a/b/file_").
*/
- setDao(dao: daos.Dao): void
+ deletePrefix(prefix: string): Array
}
- interface CollectionsImport {
+ interface System {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Checks if the provided dir prefix doesn't have any files.
+ *
+ * A trailing slash will be appended to a non-empty dir string argument
+ * to ensure that the checked prefix is a "directory".
+ *
+ * Returns "false" in case it has at least one file, otherwise - "true".
*/
- validate(): void
+ isEmptyDir(dir: string): boolean
}
- interface CollectionsImport {
+ interface System {
/**
- * Submit applies the import, aka.:
- * - imports the form collections (create or replace)
- * - sync the collection changes with their related records table
- * - ensures the integrity of the imported structure (aka. run validations for each collection)
- * - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list
+ * Serve serves the file at fileKey location to an HTTP response.
*
- * All operations are wrapped in a single transaction that are
- * rollbacked on the first encountered error.
+ * If the `download` query parameter is used the file will be always served for
+ * download no matter of its type (aka. with "Content-Disposition: attachment").
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * Internally this method uses [http.ServeContent] so Range requests,
+ * If-Match, If-Unmodified-Since, etc. headers are handled transparently.
*/
- submit(...interceptors: InterceptorFunc>[]): void
- }
- /**
- * RealtimeSubscribe is a realtime subscriptions request form.
- */
- interface RealtimeSubscribe {
- clientId: string
- subscriptions: Array
+ serve(res: http.ResponseWriter, req: http.Request, fileKey: string, name: string): void
}
- interface newRealtimeSubscribe {
+ interface System {
/**
- * NewRealtimeSubscribe creates new RealtimeSubscribe request form.
+ * CreateThumb creates a new thumb image for the file at originalKey location.
+ * The new thumb file is stored at thumbKey location.
+ *
+ * thumbSize is in the format:
+ * - 0xH (eg. 0x100) - resize to H height preserving the aspect ratio
+ * - Wx0 (eg. 300x0) - resize to W width preserving the aspect ratio
+ * - WxH (eg. 300x100) - resize and crop to WxH viewbox (from center)
+ * - WxHt (eg. 300x100t) - resize and crop to WxH viewbox (from top)
+ * - WxHb (eg. 300x100b) - resize and crop to WxH viewbox (from bottom)
+ * - WxHf (eg. 300x100f) - fit inside a WxH viewbox (without cropping)
*/
- (): (RealtimeSubscribe | undefined)
+ createThumb(originalKey: string, thumbKey: string, thumbSize: string): void
}
- interface RealtimeSubscribe {
+}
+
+/**
+ * Package exec runs external commands. It wraps os.StartProcess to make it
+ * easier to remap stdin and stdout, connect I/O with pipes, and do other
+ * adjustments.
+ *
+ * Unlike the "system" library call from C and other languages, the
+ * os/exec package intentionally does not invoke the system shell and
+ * does not expand any glob patterns or handle other expansions,
+ * pipelines, or redirections typically done by shells. The package
+ * behaves more like C's "exec" family of functions. To expand glob
+ * patterns, either call the shell directly, taking care to escape any
+ * dangerous input, or use the [path/filepath] package's Glob function.
+ * To expand environment variables, use package os's ExpandEnv.
+ *
+ * Note that the examples in this package assume a Unix system.
+ * They may not run on Windows, and they do not run in the Go Playground
+ * used by go.dev and pkg.go.dev.
+ *
+ * # Executables in the current directory
+ *
+ * The functions [Command] and [LookPath] look for a program
+ * in the directories listed in the current path, following the
+ * conventions of the host operating system.
+ * Operating systems have for decades included the current
+ * directory in this search, sometimes implicitly and sometimes
+ * configured explicitly that way by default.
+ * Modern practice is that including the current directory
+ * is usually unexpected and often leads to security problems.
+ *
+ * To avoid those security problems, as of Go 1.19, this package will not resolve a program
+ * using an implicit or explicit path entry relative to the current directory.
+ * That is, if you run [LookPath]("go"), it will not successfully return
+ * ./go on Unix nor .\go.exe on Windows, no matter how the path is configured.
+ * Instead, if the usual path algorithms would result in that answer,
+ * these functions return an error err satisfying [errors.Is](err, [ErrDot]).
+ *
+ * For example, consider these two program snippets:
+ *
+ * ```
+ * path, err := exec.LookPath("prog")
+ * if err != nil {
+ * log.Fatal(err)
+ * }
+ * use(path)
+ * ```
+ *
+ * and
+ *
+ * ```
+ * cmd := exec.Command("prog")
+ * if err := cmd.Run(); err != nil {
+ * log.Fatal(err)
+ * }
+ * ```
+ *
+ * These will not find and run ./prog or .\prog.exe,
+ * no matter how the current path is configured.
+ *
+ * Code that always wants to run a program from the current directory
+ * can be rewritten to say "./prog" instead of "prog".
+ *
+ * Code that insists on including results from relative path entries
+ * can instead override the error using an errors.Is check:
+ *
+ * ```
+ * path, err := exec.LookPath("prog")
+ * if errors.Is(err, exec.ErrDot) {
+ * err = nil
+ * }
+ * if err != nil {
+ * log.Fatal(err)
+ * }
+ * use(path)
+ * ```
+ *
+ * and
+ *
+ * ```
+ * cmd := exec.Command("prog")
+ * if errors.Is(cmd.Err, exec.ErrDot) {
+ * cmd.Err = nil
+ * }
+ * if err := cmd.Run(); err != nil {
+ * log.Fatal(err)
+ * }
+ * ```
+ *
+ * Setting the environment variable GODEBUG=execerrdot=0
+ * disables generation of ErrDot entirely, temporarily restoring the pre-Go 1.19
+ * behavior for programs that are unable to apply more targeted fixes.
+ * A future version of Go may remove support for this variable.
+ *
+ * Before adding such overrides, make sure you understand the
+ * security implications of doing so.
+ * See https://go.dev/blog/path-security for more information.
+ */
+namespace exec {
+ interface command {
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Command returns the [Cmd] struct to execute the named program with
+ * the given arguments.
+ *
+ * It sets only the Path and Args in the returned structure.
+ *
+ * If name contains no path separators, Command uses [LookPath] to
+ * resolve name to a complete path if possible. Otherwise it uses name
+ * directly as Path.
+ *
+ * The returned Cmd's Args field is constructed from the command name
+ * followed by the elements of arg, so arg should not include the
+ * command name itself. For example, Command("echo", "hello").
+ * Args[0] is always name, not the possibly resolved Path.
+ *
+ * On Windows, processes receive the whole command line as a single string
+ * and do their own parsing. Command combines and quotes Args into a command
+ * line string with an algorithm compatible with applications using
+ * CommandLineToArgvW (which is the most common way). Notable exceptions are
+ * msiexec.exe and cmd.exe (and thus, all batch files), which have a different
+ * unquoting algorithm. In these or other similar cases, you can do the
+ * quoting yourself and provide the full command line in SysProcAttr.CmdLine,
+ * leaving Args empty.
*/
- validate(): void
+ (name: string, ...arg: string[]): (Cmd)
}
+}
+
+/**
+ * Package core is the backbone of PocketBase.
+ *
+ * It defines the main PocketBase App interface and its base implementation.
+ */
+namespace core {
/**
- * RecordEmailChangeConfirm is an auth record email change confirmation form.
+ * App defines the main PocketBase app interface.
+ *
+ * Note that the interface is not intended to be implemented manually by users
+ * and instead they should use core.BaseApp (either directly or as embedded field in a custom struct).
+ *
+ * This interface exists to make testing easier and to allow users to
+ * create common and pluggable helpers and methods that doesn't rely
+ * on a specific wrapped app struct (hence the large interface size).
*/
- interface RecordEmailChangeConfirm {
- token: string
- password: string
- }
- interface newRecordEmailChangeConfirm {
+ interface App {
+ [key:string]: any;
/**
- * NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
- * initialized with from the provided [core.App] and [models.Collection] instances.
+ * UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * NB! Note that using the returned app instance may cause data integrity errors
+ * since the Record validations and data normalizations (including files uploads)
+ * rely on the app hooks to work.
*/
- (app: core.App, collection: models.Collection): (RecordEmailChangeConfirm | undefined)
- }
- interface RecordEmailChangeConfirm {
+ unsafeWithoutHooks(): App
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * Logger returns the default app logger.
+ *
+ * If the application is not bootstrapped yet, fallbacks to slog.Default().
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordEmailChangeConfirm {
+ logger(): (slog.Logger)
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * IsBootstrapped checks if the application was initialized
+ * (aka. whether Bootstrap() was called).
*/
- validate(): void
- }
- interface RecordEmailChangeConfirm {
+ isBootstrapped(): boolean
+ /**
+ * IsTransactional checks if the current app instance is part of a transaction.
+ */
+ isTransactional(): boolean
/**
- * Submit validates and submits the auth record email change confirmation form.
- * On success returns the updated auth record associated to `form.Token`.
+ * TxInfo returns the transaction associated with the current app instance (if any).
*
- * You can optionally provide a list of InterceptorFunc to
- * further modify the form behavior before persisting it.
+ * Could be used if you want to execute indirectly a function after
+ * the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
*/
- submit(...interceptors: InterceptorFunc[]): (models.Record | undefined)
- }
- /**
- * RecordEmailChangeRequest is an auth record email change request form.
- */
- interface RecordEmailChangeRequest {
- newEmail: string
- }
- interface newRecordEmailChangeRequest {
+ txInfo(): (TxAppInfo)
/**
- * NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
- * initialized with from the provided [core.App] and [models.Record] instances.
+ * Bootstrap initializes the application
+ * (aka. create data dir, open db connections, load settings, etc.).
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * It calls ClearBootstrap() if the application was already bootstrapped.
*/
- (app: core.App, record: models.Record): (RecordEmailChangeRequest | undefined)
- }
- interface RecordEmailChangeRequest {
+ bootstrap(): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * ClearBootstrap releases the initialized core app resources
+ * (closing db connections, stopping cron ticker, etc.).
+ *
+ * This method is no-op if the application is not bootstrapped yet.
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordEmailChangeRequest {
+ clearBootstrap(): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Deprecated: use ClearBootstrap().
*/
- validate(): void
- }
- interface RecordEmailChangeRequest {
+ resetBootstrapState(): void
/**
- * Submit validates and sends the change email request.
- *
- * You can optionally provide a list of InterceptorFunc to
- * further modify the form behavior before persisting it.
+ * DataDir returns the app data directory path.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * RecordOAuth2LoginData defines the OA
- */
- interface RecordOAuth2LoginData {
- externalAuth?: models.ExternalAuth
- record?: models.Record
- oAuth2User?: auth.AuthUser
- providerClient: auth.Provider
- }
- /**
- * BeforeOAuth2RecordCreateFunc defines a callback function that will
- * be called before OAuth2 new Record creation.
- */
- interface BeforeOAuth2RecordCreateFunc {(createForm: RecordUpsert, authRecord: models.Record, authUser: auth.AuthUser): void }
- /**
- * RecordOAuth2Login is an auth record OAuth2 login form.
- */
- interface RecordOAuth2Login {
+ dataDir(): string
/**
- * The name of the OAuth2 client provider (eg. "google")
+ * EncryptionEnv returns the name of the app secret env key
+ * (currently used primarily for optional settings encryption but this may change in the future).
*/
- provider: string
+ encryptionEnv(): string
/**
- * The authorization code returned from the initial request.
+ * IsDev returns whether the app is in dev mode.
+ *
+ * When enabled logs, executed sql statements, etc. are printed to the stderr.
*/
- code: string
+ isDev(): boolean
/**
- * The code verifier sent with the initial request as part of the code_challenge.
+ * Settings returns the loaded app settings.
*/
- codeVerifier: string
+ settings(): (Settings)
/**
- * The redirect url sent with the initial request.
+ * Store returns the app runtime store.
*/
- redirectUrl: string
+ store(): (store.Store)
/**
- * Additional data that will be used for creating a new auth record
- * if an existing OAuth2 account doesn't exist.
+ * Cron returns the app cron instance.
*/
- createData: _TygojaDict
- }
- interface newRecordOAuth2Login {
+ cron(): (cron.Cron)
+ /**
+ * SubscriptionsBroker returns the app realtime subscriptions broker instance.
+ */
+ subscriptionsBroker(): (subscriptions.Broker)
/**
- * NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
- * initialized with from the provided [core.App] instance.
+ * NewMailClient creates and returns a new SMTP or Sendmail client
+ * based on the current app settings.
+ */
+ newMailClient(): mailer.Mailer
+ /**
+ * NewFilesystem creates a new local or S3 filesystem instance
+ * for managing regular app files (ex. record uploads)
+ * based on the current app settings.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * NB! Make sure to call Close() on the returned result
+ * after you are done working with it.
*/
- (app: core.App, collection: models.Collection, optAuthRecord: models.Record): (RecordOAuth2Login | undefined)
- }
- interface RecordOAuth2Login {
+ newFilesystem(): (filesystem.System)
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * NewBackupsFilesystem creates a new local or S3 filesystem instance
+ * for managing app backups based on the current app settings.
+ *
+ * NB! Make sure to call Close() on the returned result
+ * after you are done working with it.
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordOAuth2Login {
+ newBackupsFilesystem(): (filesystem.System)
/**
- * SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
+ * ReloadSettings reinitializes and reloads the stored application settings.
*/
- setBeforeNewRecordCreateFunc(f: BeforeOAuth2RecordCreateFunc): void
- }
- interface RecordOAuth2Login {
+ reloadSettings(): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * CreateBackup creates a new backup of the current app pb_data directory.
+ *
+ * Backups can be stored on S3 if it is configured in app.Settings().Backups.
+ *
+ * Please refer to the godoc of the specific CoreApp implementation
+ * for details on the backup procedures.
*/
- validate(): void
- }
- interface RecordOAuth2Login {
+ createBackup(ctx: context.Context, name: string): void
/**
- * Submit validates and submits the form.
+ * RestoreBackup restores the backup with the specified name and restarts
+ * the current running application process.
*
- * If an auth record doesn't exist, it will make an attempt to create it
- * based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
- * You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
+ * The safely perform the restore it is recommended to have free disk space
+ * for at least 2x the size of the restored pb_data backup.
*
- * You can also optionally provide a list of InterceptorFunc to
- * further modify the form behavior before persisting it.
+ * Please refer to the godoc of the specific CoreApp implementation
+ * for details on the restore procedures.
*
- * On success returns the authorized record model and the fetched provider's data.
+ * NB! This feature is experimental and currently is expected to work only on UNIX based systems.
*/
- submit(...interceptors: InterceptorFunc[]): [(models.Record | undefined), (auth.AuthUser | undefined)]
- }
- /**
- * RecordPasswordLogin is record username/email + password login form.
- */
- interface RecordPasswordLogin {
- identity: string
- password: string
- }
- interface newRecordPasswordLogin {
+ restoreBackup(ctx: context.Context, name: string): void
/**
- * NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
- * with from the provided [core.App] and [models.Collection] instance.
+ * Restart restarts (aka. replaces) the current running application process.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * NB! It relies on execve which is supported only on UNIX based systems.
*/
- (app: core.App, collection: models.Collection): (RecordPasswordLogin | undefined)
- }
- interface RecordPasswordLogin {
+ restart(): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordPasswordLogin {
+ runSystemMigrations(): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * RunAppMigrations applies all new migrations registered in the [CoreAppMigrations] list.
*/
- validate(): void
- }
- interface RecordPasswordLogin {
+ runAppMigrations(): void
+ /**
+ * RunAllMigrations applies all system and app migrations
+ * (aka. from both [core.SystemMigrations] and [CoreAppMigrations]).
+ */
+ runAllMigrations(): void
/**
- * Submit validates and submits the form.
- * On success returns the authorized record model.
+ * DB returns the default app data.db builder instance.
+ *
+ * To minimize SQLITE_BUSY errors, it automatically routes the
+ * SELECT queries to the underlying concurrent db pool and everything else
+ * to the nonconcurrent one.
*
- * You can optionally provide a list of InterceptorFunc to
- * further modify the form behavior before persisting it.
+ * For more finer control over the used connections pools you can
+ * call directly ConcurrentDB() or NonconcurrentDB().
*/
- submit(...interceptors: InterceptorFunc[]): (models.Record | undefined)
- }
- /**
- * RecordPasswordResetConfirm is an auth record password reset confirmation form.
- */
- interface RecordPasswordResetConfirm {
- token: string
- password: string
- passwordConfirm: string
- }
- interface newRecordPasswordResetConfirm {
+ db(): dbx.Builder
/**
- * NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
- * form initialized with from the provided [core.App] instance.
+ * ConcurrentDB returns the concurrent app data.db builder instance.
+ *
+ * This method is used mainly internally for executing db read
+ * operations in a concurrent/non-blocking manner.
+ *
+ * Most users should use simply DB() as it will automatically
+ * route the query execution to ConcurrentDB() or NonconcurrentDB().
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
*/
- (app: core.App, collection: models.Collection): (RecordPasswordResetConfirm | undefined)
- }
- interface RecordPasswordResetConfirm {
+ concurrentDB(): dbx.Builder
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * NonconcurrentDB returns the nonconcurrent app data.db builder instance.
+ *
+ * The returned db instance is limited only to a single open connection,
+ * meaning that it can process only 1 db operation at a time (other queries queue up).
+ *
+ * This method is used mainly internally and in the tests to execute write
+ * (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ *
+ * Most users should use simply DB() as it will automatically
+ * route the query execution to ConcurrentDB() or NonconcurrentDB().
+ *
+ * In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordPasswordResetConfirm {
+ nonconcurrentDB(): dbx.Builder
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * AuxDB returns the app auxiliary.db builder instance.
+ *
+ * To minimize SQLITE_BUSY errors, it automatically routes the
+ * SELECT queries to the underlying concurrent db pool and everything else
+ * to the nonconcurrent one.
+ *
+ * For more finer control over the used connections pools you can
+ * call directly AuxConcurrentDB() or AuxNonconcurrentDB().
*/
- validate(): void
- }
- interface RecordPasswordResetConfirm {
+ auxDB(): dbx.Builder
/**
- * Submit validates and submits the form.
- * On success returns the updated auth record associated to `form.Token`.
+ * AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
+ *
+ * This method is used mainly internally for executing db read
+ * operations in a concurrent/non-blocking manner.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * Most users should use simply AuxDB() as it will automatically
+ * route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ *
+ * In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
*/
- submit(...interceptors: InterceptorFunc[]): (models.Record | undefined)
- }
- /**
- * RecordPasswordResetRequest is an auth record reset password request form.
- */
- interface RecordPasswordResetRequest {
- email: string
- }
- interface newRecordPasswordResetRequest {
+ auxConcurrentDB(): dbx.Builder
/**
- * NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
- * form initialized with from the provided [core.App] instance.
+ * AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * The returned db instance is limited only to a single open connection,
+ * meaning that it can process only 1 db operation at a time (other queries queue up).
+ *
+ * This method is used mainly internally and in the tests to execute write
+ * (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ *
+ * Most users should use simply AuxDB() as it will automatically
+ * route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ *
+ * In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
*/
- (app: core.App, collection: models.Collection): (RecordPasswordResetRequest | undefined)
- }
- interface RecordPasswordResetRequest {
+ auxNonconcurrentDB(): dbx.Builder
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * HasTable checks if a table (or view) with the provided name exists (case insensitive).
+ * in the data.db.
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordPasswordResetRequest {
+ hasTable(tableName: string): boolean
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
- *
- * This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
+ * AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
+ * in the auxiliary.db.
*/
- validate(): void
- }
- interface RecordPasswordResetRequest {
+ auxHasTable(tableName: string): boolean
/**
- * Submit validates and submits the form.
- * On success, sends a password reset email to the `form.Email` auth record.
- *
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * TableColumns returns all column names of a single table by its name.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * RecordUpsert is a [models.Record] upsert (create/update) form.
- */
- interface RecordUpsert {
+ tableColumns(tableName: string): Array
/**
- * base model fields
+ * TableInfo returns the "table_info" pragma result for the specified table.
*/
- id: string
+ tableInfo(tableName: string): Array<(TableInfoRow | undefined)>
/**
- * auth collection fields
- * ---
+ * TableIndexes returns a name grouped map with all non empty index of the specified table.
+ *
+ * Note: This method doesn't return an error on nonexisting table.
*/
- username: string
- email: string
- emailVisibility: boolean
- verified: boolean
- password: string
- passwordConfirm: string
- oldPassword: string
- }
- interface newRecordUpsert {
+ tableIndexes(tableName: string): _TygojaDict
/**
- * NewRecordUpsert creates a new [RecordUpsert] form with initializer
- * config created from the provided [core.App] and [models.Record] instances
- * (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
+ * DeleteTable drops the specified table.
+ *
+ * This method is a no-op if a table with the provided name doesn't exist.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * NB! Be aware that this method is vulnerable to SQL injection and the
+ * "dangerousTableName" argument must come only from trusted input!
*/
- (app: core.App, record: models.Record): (RecordUpsert | undefined)
- }
- interface RecordUpsert {
+ deleteTable(dangerousTableName: string): void
/**
- * Data returns the loaded form's data.
+ * DeleteView drops the specified view name.
+ *
+ * This method is a no-op if a view with the provided name doesn't exist.
+ *
+ * NB! Be aware that this method is vulnerable to SQL injection and the
+ * "dangerousViewName" argument must come only from trusted input!
*/
- data(): _TygojaDict
- }
- interface RecordUpsert {
+ deleteView(dangerousViewName: string): void
/**
- * SetFullManageAccess sets the manageAccess bool flag of the current
- * form to enable/disable directly changing some system record fields
- * (often used with auth collection records).
+ * SaveView creates (or updates already existing) persistent SQL view.
+ *
+ * NB! Be aware that this method is vulnerable to SQL injection and
+ * its arguments must come only from trusted input!
*/
- setFullManageAccess(fullManageAccess: boolean): void
- }
- interface RecordUpsert {
+ saveView(dangerousViewName: string, dangerousSelectQuery: string): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * CreateViewFields creates a new FieldsList from the provided select query.
+ *
+ * There are some caveats:
+ * - The select query must have an "id" column.
+ * - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
+ *
+ * NB! Be aware that this method is vulnerable to SQL injection and the
+ * "dangerousSelectQuery" argument must come only from trusted input!
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordUpsert {
+ createViewFields(dangerousSelectQuery: string): FieldsList
/**
- * LoadRequest extracts the json or multipart/form-data request data
- * and lods it into the form.
+ * DryRunView executes the provided query by creating a temporary view
+ * collection and returning a sample of the resulting query records (if valid).
*
- * File upload is supported only via multipart/form-data.
+ * The same caveats from CreateViewFields apply here too.
+ *
+ * NB! Be aware that this method is vulnerable to SQL injection and the
+ * "dangerousSelectQuery" argument must come only from trusted input!
*/
- loadRequest(r: http.Request, keyPrefix: string): void
- }
- interface RecordUpsert {
+ dryRunView(dangerousSelectQuery: string, sampleSize: number): (DryRunViewResult)
/**
- * FilesToUpload returns the parsed request files ready for upload.
+ * FindRecordByViewFile returns the original Record of the provided view collection file.
*/
- filesToUpload(): _TygojaDict
- }
- interface RecordUpsert {
+ findRecordByViewFile(viewCollectionModelOrIdentifier: any, fileFieldName: string, filename: string): (Record)
/**
- * FilesToUpload returns the parsed request filenames ready to be deleted.
+ * Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
*/
- filesToDelete(): Array
- }
- interface RecordUpsert {
+ vacuum(): void
/**
- * AddFiles adds the provided file(s) to the specified file field.
- *
- * If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
- * then the newly added file will REPLACE the existing one.
- * In this case if you pass more than 1 files only the first one will be assigned.
- *
- * If the file field is a MULTI-value file field (aka. "Max Select > 1"),
- * then the newly added file(s) will be APPENDED to the existing one(s).
- *
- * Example
- *
- * ```
- * f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
- * f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
- * form.AddFiles("documents", f1, f2)
- * ```
+ * AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
*/
- addFiles(key: string, ...files: (filesystem.File | undefined)[]): void
- }
- interface RecordUpsert {
+ auxVacuum(): void
/**
- * RemoveFiles removes a single or multiple file from the specified file field.
- *
- * NB! If filesToDelete is not set it will remove all existing files
- * assigned to the file field (including those assigned with AddFiles)!
- *
- * Example
- *
- * ```
- * // mark only only 2 files for removal
- * form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
- *
- * // mark all "documents" files for removal
- * form.AddFiles("documents")
- * ```
+ * ModelQuery creates a new preconfigured select data.db query with preset
+ * SELECT, FROM and other common fields based on the provided model.
*/
- removeFiles(key: string, ...toDelete: string[]): void
- }
- interface RecordUpsert {
+ modelQuery(model: Model): (dbx.SelectQuery)
/**
- * LoadData loads and normalizes the provided regular record data fields into the form.
+ * AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
+ * SELECT, FROM and other common fields based on the provided model.
*/
- loadData(requestInfo: _TygojaDict): void
- }
- interface RecordUpsert {
+ auxModelQuery(model: Model): (dbx.SelectQuery)
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * Delete deletes the specified model from the regular app database.
*/
- validate(): void
- }
- interface RecordUpsert {
- validateAndFill(): void
- }
- interface RecordUpsert {
+ delete(model: Model): void
/**
- * DrySubmit performs a form submit within a transaction and reverts it.
- * For actual record persistence, check the `form.Submit()` method.
- *
- * This method doesn't handle file uploads/deletes or trigger any app events!
+ * Delete deletes the specified model from the regular app database
+ * (the context could be used to limit the query execution).
*/
- drySubmit(callback: (txDao: daos.Dao) => void): void
- }
- interface RecordUpsert {
+ deleteWithContext(ctx: context.Context, model: Model): void
/**
- * Submit validates the form and upserts the form Record model.
- *
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * AuxDelete deletes the specified model from the auxiliary database.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * RecordVerificationConfirm is an auth record email verification confirmation form.
- */
- interface RecordVerificationConfirm {
- token: string
- }
- interface newRecordVerificationConfirm {
+ auxDelete(model: Model): void
/**
- * NewRecordVerificationConfirm creates a new [RecordVerificationConfirm]
- * form initialized with from the provided [core.App] instance.
- *
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * AuxDeleteWithContext deletes the specified model from the auxiliary database
+ * (the context could be used to limit the query execution).
*/
- (app: core.App, collection: models.Collection): (RecordVerificationConfirm | undefined)
- }
- interface RecordVerificationConfirm {
+ auxDeleteWithContext(ctx: context.Context, model: Model): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * Save validates and saves the specified model into the regular app database.
+ *
+ * If you don't want to run validations, use [App.SaveNoValidate()].
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordVerificationConfirm {
+ save(model: Model): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * SaveWithContext is the same as [App.Save()] but allows specifying a context to limit the db execution.
+ *
+ * If you don't want to run validations, use [App.SaveNoValidateWithContext()].
*/
- validate(): void
- }
- interface RecordVerificationConfirm {
+ saveWithContext(ctx: context.Context, model: Model): void
/**
- * Submit validates and submits the form.
- * On success returns the verified auth record associated to `form.Token`.
+ * SaveNoValidate saves the specified model into the regular app database without performing validations.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * If you want to also run validations before persisting, use [App.Save()].
*/
- submit(...interceptors: InterceptorFunc[]): (models.Record | undefined)
- }
- /**
- * RecordVerificationRequest is an auth record email verification request form.
- */
- interface RecordVerificationRequest {
- email: string
- }
- interface newRecordVerificationRequest {
+ saveNoValidate(model: Model): void
/**
- * NewRecordVerificationRequest creates a new [RecordVerificationRequest]
- * form initialized with from the provided [core.App] instance.
+ * SaveNoValidateWithContext is the same as [App.SaveNoValidate()]
+ * but allows specifying a context to limit the db execution.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * If you want to also run validations before persisting, use [App.SaveWithContext()].
*/
- (app: core.App, collection: models.Collection): (RecordVerificationRequest | undefined)
- }
- interface RecordVerificationRequest {
+ saveNoValidateWithContext(ctx: context.Context, model: Model): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * AuxSave validates and saves the specified model into the auxiliary app database.
+ *
+ * If you don't want to run validations, use [App.AuxSaveNoValidate()].
*/
- setDao(dao: daos.Dao): void
- }
- interface RecordVerificationRequest {
+ auxSave(model: Model): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * AuxSaveWithContext is the same as [App.AuxSave()] but allows specifying a context to limit the db execution.
*
- * // This method doesn't verify that auth record with `form.Email` exists (this is done on Submit).
+ * If you don't want to run validations, use [App.AuxSaveNoValidateWithContext()].
*/
- validate(): void
- }
- interface RecordVerificationRequest {
+ auxSaveWithContext(ctx: context.Context, model: Model): void
/**
- * Submit validates and sends a verification request email
- * to the `form.Email` auth record.
+ * AuxSaveNoValidate saves the specified model into the auxiliary app database without performing validations.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * If you want to also run validations before persisting, use [App.AuxSave()].
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * SettingsUpsert is a [settings.Settings] upsert (create/update) form.
- */
- type _subJtvqR = settings.Settings
- interface SettingsUpsert extends _subJtvqR {
- }
- interface newSettingsUpsert {
+ auxSaveNoValidate(model: Model): void
/**
- * NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
- * config created from the provided [core.App] instance.
+ * AuxSaveNoValidateWithContext is the same as [App.AuxSaveNoValidate()]
+ * but allows specifying a context to limit the db execution.
*
- * If you want to submit the form as part of a transaction,
- * you can change the default Dao via [SetDao()].
+ * If you want to also run validations before persisting, use [App.AuxSaveWithContext()].
*/
- (app: core.App): (SettingsUpsert | undefined)
- }
- interface SettingsUpsert {
+ auxSaveNoValidateWithContext(ctx: context.Context, model: Model): void
/**
- * SetDao replaces the default form Dao instance with the provided one.
+ * Validate triggers the OnModelValidate hook for the specified model.
*/
- setDao(dao: daos.Dao): void
- }
- interface SettingsUpsert {
+ validate(model: Model): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * ValidateWithContext is the same as Validate but allows specifying the ModelEvent context.
*/
- validate(): void
- }
- interface SettingsUpsert {
+ validateWithContext(ctx: context.Context, model: Model): void
/**
- * Submit validates the form and upserts the loaded settings.
- *
- * On success the app settings will be refreshed with the form ones.
+ * RunInTransaction wraps fn into a transaction for the regular app database.
*
- * You can optionally provide a list of InterceptorFunc to further
- * modify the form behavior before persisting it.
+ * It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
*/
- submit(...interceptors: InterceptorFunc[]): void
- }
- /**
- * TestEmailSend is a email template test request form.
- */
- interface TestEmailSend {
- template: string
- email: string
- }
- interface newTestEmailSend {
+ runInTransaction(fn: (txApp: App) => void): void
/**
- * NewTestEmailSend creates and initializes new TestEmailSend form.
+ * AuxRunInTransaction wraps fn into a transaction for the auxiliary app database.
+ *
+ * It is safe to nest RunInTransaction calls as long as you use the callback's txApp.
*/
- (app: core.App): (TestEmailSend | undefined)
- }
- interface TestEmailSend {
+ auxRunInTransaction(fn: (txApp: App) => void): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * LogQuery returns a new Log select query.
*/
- validate(): void
- }
- interface TestEmailSend {
+ logQuery(): (dbx.SelectQuery)
/**
- * Submit validates and sends a test email to the form.Email address.
+ * FindLogById finds a single Log entry by its id.
*/
- submit(): void
- }
- /**
- * TestS3Filesystem defines a S3 filesystem connection test.
- */
- interface TestS3Filesystem {
+ findLogById(id: string): (Log)
/**
- * The name of the filesystem - storage or backups
+ * LogsStatsItem returns hourly grouped logs statistics.
*/
- filesystem: string
- }
- interface newTestS3Filesystem {
+ logsStats(expr: dbx.Expression): Array<(LogsStatsItem | undefined)>
/**
- * NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
+ * DeleteOldLogs delete all logs that are created before createdBefore.
*/
- (app: core.App): (TestS3Filesystem | undefined)
- }
- interface TestS3Filesystem {
+ deleteOldLogs(createdBefore: time.Time): void
/**
- * Validate makes the form validatable by implementing [validation.Validatable] interface.
+ * CollectionQuery returns a new Collection select query.
*/
- validate(): void
- }
- interface TestS3Filesystem {
- /**
- * Submit validates and performs a S3 filesystem connection test.
- */
- submit(): void
- }
-}
-
-/**
- * Package apis implements the default PocketBase api services and middlewares.
- */
-namespace apis {
- interface adminApi {
- }
- // @ts-ignore
- import validation = ozzo_validation
- /**
- * ApiError defines the struct for a basic api error response.
- */
- interface ApiError {
- code: number
- message: string
- data: _TygojaDict
- }
- interface ApiError {
- /**
- * Error makes it compatible with the `error` interface.
- */
- error(): string
- }
- interface ApiError {
- /**
- * RawData returns the unformatted error data (could be an internal error, text, etc.)
- */
- rawData(): any
- }
- interface newNotFoundError {
- /**
- * NewNotFoundError creates and returns 404 `ApiError`.
- */
- (message: string, data: any): (ApiError | undefined)
- }
- interface newBadRequestError {
- /**
- * NewBadRequestError creates and returns 400 `ApiError`.
- */
- (message: string, data: any): (ApiError | undefined)
- }
- interface newForbiddenError {
- /**
- * NewForbiddenError creates and returns 403 `ApiError`.
- */
- (message: string, data: any): (ApiError | undefined)
- }
- interface newUnauthorizedError {
+ collectionQuery(): (dbx.SelectQuery)
/**
- * NewUnauthorizedError creates and returns 401 `ApiError`.
+ * FindCollections finds all collections by the given type(s).
+ *
+ * If collectionTypes is not set, it returns all collections.
+ *
+ * Example:
+ *
+ * ```
+ * app.FindAllCollections() // all collections
+ * app.FindAllCollections("auth", "view") // only auth and view collections
+ * ```
*/
- (message: string, data: any): (ApiError | undefined)
- }
- interface newApiError {
+ findAllCollections(...collectionTypes: string[]): Array<(Collection | undefined)>
/**
- * NewApiError creates and returns new normalized `ApiError` instance.
+ * ReloadCachedCollections fetches all collections and caches them into the app store.
*/
- (status: number, message: string, data: any): (ApiError | undefined)
- }
- interface backupApi {
- }
- interface initApi {
+ reloadCachedCollections(): void
/**
- * InitApi creates a configured echo instance with registered
- * system and app specific routes and middlewares.
+ * FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.s
*/
- (app: core.App): (echo.Echo | undefined)
- }
- interface staticDirectoryHandler {
+ findCollectionByNameOrId(nameOrId: string): (Collection)
/**
- * StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
- * but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
+ * FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
+ * but retrieves the Collection from the app cache instead of making a db call.
+ *
+ * NB! This method is suitable for read-only Collection operations.
+ *
+ * Returns [sql.ErrNoRows] if no Collection is found for consistency
+ * with the [App.FindCollectionByNameOrId] method.
*
- * If a file resource is missing and indexFallback is set, the request
- * will be forwarded to the base index.html (useful also for SPA).
+ * If you plan making changes to the returned Collection model,
+ * use [App.FindCollectionByNameOrId] instead.
*
- * @see https://github.com/labstack/echo/issues/2211
+ * Caveats:
+ *
+ * ```
+ * - The returned Collection should be used only for read-only operations.
+ * Avoid directly modifying the returned cached Collection as it will affect
+ * the global cached value even if you don't persist the changes in the database!
+ * - If you are updating a Collection in a transaction and then call this method before commit,
+ * it'll return the cached Collection state and not the one from the uncommitted transaction.
+ * - The cache is automatically updated on collections db change (create/update/delete).
+ * To manually reload the cache you can call [App.ReloadCachedCollections]
+ * ```
*/
- (fileSystem: fs.FS, indexFallback: boolean): echo.HandlerFunc
- }
- interface collectionApi {
- }
- interface fileApi {
- }
- interface healthApi {
- }
- interface healthCheckResponse {
- code: number
- message: string
- data: {
- canBackup: boolean
- }
- }
- interface logsApi {
- }
- interface requireGuestOnly {
+ findCachedCollectionByNameOrId(nameOrId: string): (Collection)
/**
- * RequireGuestOnly middleware requires a request to NOT have a valid
- * Authorization header.
+ * FindCollectionReferences returns information for all relation
+ * fields referencing the provided collection.
*
- * This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
+ * If the provided collection has reference to itself then it will be
+ * also included in the result. To exclude it, pass the collection id
+ * as the excludeIds argument.
*/
- (): echo.MiddlewareFunc
- }
- interface requireRecordAuth {
+ findCollectionReferences(collection: Collection, ...excludeIds: string[]): _TygojaDict
/**
- * RequireRecordAuth middleware requires a request to have
- * a valid record auth Authorization header.
- *
- * The auth record could be from any collection.
- *
- * You can further filter the allowed record auth collections by
- * specifying their names.
+ * FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
+ * but retrieves the Collection from the app cache instead of making a db call.
*
- * Example:
+ * NB! This method is suitable for read-only Collection operations.
*
- * ```
- * apis.RequireRecordAuth()
- * ```
+ * If you plan making changes to the returned Collection model,
+ * use [App.FindCollectionReferences] instead.
*
- * Or:
+ * Caveats:
*
* ```
- * apis.RequireRecordAuth("users", "supervisors")
+ * - The returned Collection should be used only for read-only operations.
+ * Avoid directly modifying the returned cached Collection as it will affect
+ * the global cached value even if you don't persist the changes in the database!
+ * - If you are updating a Collection in a transaction and then call this method before commit,
+ * it'll return the cached Collection state and not the one from the uncommitted transaction.
+ * - The cache is automatically updated on collections db change (create/update/delete).
+ * To manually reload the cache you can call [App.ReloadCachedCollections].
* ```
+ */
+ findCachedCollectionReferences(collection: Collection, ...excludeIds: string[]): _TygojaDict
+ /**
+ * IsCollectionNameUnique checks that there is no existing collection
+ * with the provided name (case insensitive!).
*
- * To restrict the auth record only to the loaded context collection,
- * use [apis.RequireSameContextRecordAuth()] instead.
+ * Note: case insensitive check because the name is used also as
+ * table name for the records.
*/
- (...optCollectionNames: string[]): echo.MiddlewareFunc
- }
- interface requireSameContextRecordAuth {
+ isCollectionNameUnique(name: string, ...excludeIds: string[]): boolean
/**
- * RequireSameContextRecordAuth middleware requires a request to have
- * a valid record Authorization header.
+ * TruncateCollection deletes all records associated with the provided collection.
+ *
+ * The truncate operation is executed in a single transaction,
+ * aka. either everything is deleted or none.
*
- * The auth record must be from the same collection already loaded in the context.
+ * Note that this method will also trigger the records related
+ * cascade and file delete actions.
*/
- (): echo.MiddlewareFunc
- }
- interface requireAdminAuth {
+ truncateCollection(collection: Collection): void
/**
- * RequireAdminAuth middleware requires a request to have
- * a valid admin Authorization header.
+ * ImportCollections imports the provided collections data in a single transaction.
+ *
+ * For existing matching collections, the imported data is unmarshaled on top of the existing model.
+ *
+ * NB! If deleteMissing is true, ALL NON-SYSTEM COLLECTIONS AND SCHEMA FIELDS,
+ * that are not present in the imported configuration, WILL BE DELETED
+ * (this includes their related records data).
*/
- (): echo.MiddlewareFunc
- }
- interface requireAdminAuthOnlyIfAny {
+ importCollections(toImport: Array<_TygojaDict>, deleteMissing: boolean): void
/**
- * RequireAdminAuthOnlyIfAny middleware requires a request to have
- * a valid admin Authorization header ONLY if the application has
- * at least 1 existing Admin model.
+ * ImportCollectionsByMarshaledJSON is the same as [ImportCollections]
+ * but accept marshaled json array as import data (usually used for the autogenerated snapshots).
*/
- (app: core.App): echo.MiddlewareFunc
- }
- interface requireAdminOrRecordAuth {
+ importCollectionsByMarshaledJSON(rawSliceOfMaps: string|Array, deleteMissing: boolean): void
/**
- * RequireAdminOrRecordAuth middleware requires a request to have
- * a valid admin or record Authorization header set.
+ * SyncRecordTableSchema compares the two provided collections
+ * and applies the necessary related record table changes.
*
- * You can further filter the allowed auth record collections by providing their names.
+ * If oldCollection is null, then only newCollection is used to create the record table.
*
- * This middleware is the opposite of [apis.RequireGuestOnly()].
+ * This method is automatically invoked as part of a collection create/update/delete operation.
*/
- (...optCollectionNames: string[]): echo.MiddlewareFunc
- }
- interface requireAdminOrOwnerAuth {
+ syncRecordTableSchema(newCollection: Collection, oldCollection: Collection): void
/**
- * RequireAdminOrOwnerAuth middleware requires a request to have
- * a valid admin or auth record owner Authorization header set.
- *
- * This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
- * for the auth record token expects to have the same id as the path
- * parameter ownerIdParam (default to "id" if empty).
+ * FindAllExternalAuthsByRecord returns all ExternalAuth models
+ * linked to the provided auth record.
*/
- (ownerIdParam: string): echo.MiddlewareFunc
- }
- interface loadAuthContext {
+ findAllExternalAuthsByRecord(authRecord: Record): Array<(ExternalAuth | undefined)>
/**
- * LoadAuthContext middleware reads the Authorization request header
- * and loads the token related record or admin instance into the
- * request's context.
- *
- * This middleware is expected to be already registered by default for all routes.
+ * FindAllExternalAuthsByCollection returns all ExternalAuth models
+ * linked to the provided auth collection.
*/
- (app: core.App): echo.MiddlewareFunc
- }
- interface loadCollectionContext {
+ findAllExternalAuthsByCollection(collection: Collection): Array<(ExternalAuth | undefined)>
/**
- * LoadCollectionContext middleware finds the collection with related
- * path identifier and loads it into the request context.
- *
- * Set optCollectionTypes to further filter the found collection by its type.
+ * FindFirstExternalAuthByExpr returns the first available (the most recent created)
+ * ExternalAuth model that satisfies the non-nil expression.
*/
- (app: core.App, ...optCollectionTypes: string[]): echo.MiddlewareFunc
- }
- interface activityLogger {
+ findFirstExternalAuthByExpr(expr: dbx.Expression): (ExternalAuth)
/**
- * ActivityLogger middleware takes care to save the request information
- * into the logs database.
+ * DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
*
- * The middleware does nothing if the app logs retention period is zero
- * (aka. app.Settings().Logs.MaxDays = 0).
+ * Returns a combined error with the failed deletes.
*/
- (app: core.App): echo.MiddlewareFunc
- }
- interface realtimeApi {
- }
- interface recordData {
- action: string
- record?: models.Record
- }
- interface getter {
- get(_arg0: string): any
- }
- interface recordAuthApi {
- }
- interface providerInfo {
- name: string
- state: string
- codeVerifier: string
- codeChallenge: string
- codeChallengeMethod: string
- authUrl: string
- }
- interface recordApi {
- }
- interface requestData {
+ deleteAllExternalAuthsByRecord(authRecord: Record): void
/**
- * Deprecated: Use RequestInfo instead.
+ * FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
*/
- (c: echo.Context): (models.RequestInfo | undefined)
- }
- interface requestInfo {
+ findAllMFAsByRecord(authRecord: Record): Array<(MFA | undefined)>
/**
- * RequestInfo exports cached common request data fields
- * (query, body, logged auth state, etc.) from the provided context.
+ * FindAllMFAsByCollection returns all MFA models linked to the provided collection.
*/
- (c: echo.Context): (models.RequestInfo | undefined)
- }
- interface recordAuthResponse {
+ findAllMFAsByCollection(collection: Collection): Array<(MFA | undefined)>
/**
- * RecordAuthResponse writes standardised json record auth response
- * into the specified request context.
+ * FindMFAById returns a single MFA model by its id.
*/
- (app: core.App, c: echo.Context, authRecord: models.Record, meta: any, ...finalizers: ((token: string) => void)[]): void
- }
- interface enrichRecord {
+ findMFAById(id: string): (MFA)
/**
- * EnrichRecord parses the request context and enrich the provided record:
- * ```
- * - expands relations (if defaultExpands and/or ?expand query param is set)
- * - ensures that the emails of the auth record and its expanded auth relations
- * are visibe only for the current logged admin, record owner or record with manage access
- * ```
+ * DeleteAllMFAsByRecord deletes all MFA models associated with the provided record.
+ *
+ * Returns a combined error with the failed deletes.
*/
- (c: echo.Context, dao: daos.Dao, record: models.Record, ...defaultExpands: string[]): void
- }
- interface enrichRecords {
+ deleteAllMFAsByRecord(authRecord: Record): void
/**
- * EnrichRecords parses the request context and enriches the provided records:
- * ```
- * - expands relations (if defaultExpands and/or ?expand query param is set)
- * - ensures that the emails of the auth records and their expanded auth relations
- * are visibe only for the current logged admin, record owner or record with manage access
- * ```
+ * DeleteExpiredMFAs deletes the expired MFAs for all auth collections.
*/
- (c: echo.Context, dao: daos.Dao, records: Array<(models.Record | undefined)>, ...defaultExpands: string[]): void
- }
- /**
- * ServeConfig defines a configuration struct for apis.Serve().
- */
- interface ServeConfig {
+ deleteExpiredMFAs(): void
/**
- * ShowStartBanner indicates whether to show or hide the server start console message.
+ * FindAllOTPsByRecord returns all OTP models linked to the provided auth record.
*/
- showStartBanner: boolean
+ findAllOTPsByRecord(authRecord: Record): Array<(OTP | undefined)>
/**
- * HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
+ * FindAllOTPsByCollection returns all OTP models linked to the provided collection.
*/
- httpAddr: string
+ findAllOTPsByCollection(collection: Collection): Array<(OTP | undefined)>
/**
- * HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
+ * FindOTPById returns a single OTP model by its id.
*/
- httpsAddr: string
+ findOTPById(id: string): (OTP)
/**
- * Optional domains list to use when issuing the TLS certificate.
+ * DeleteAllOTPsByRecord deletes all OTP models associated with the provided record.
*
- * If not set, the host from the bound server address will be used.
- *
- * For convenience, for each "non-www" domain a "www" entry and
- * redirect will be automatically added.
+ * Returns a combined error with the failed deletes.
*/
- certificateDomains: Array
+ deleteAllOTPsByRecord(authRecord: Record): void
/**
- * AllowedOrigins is an optional list of CORS origins (default to "*").
+ * DeleteExpiredOTPs deletes the expired OTPs for all auth collections.
*/
- allowedOrigins: Array
- }
- interface serve {
+ deleteExpiredOTPs(): void
/**
- * Serve starts a new app web server.
- *
- * NB! The app should be bootstrapped before starting the web server.
- *
- * Example:
- *
- * ```
- * app.Bootstrap()
- * apis.Serve(app, apis.ServeConfig{
- * HttpAddr: "127.0.0.1:8080",
- * ShowStartBanner: false,
- * })
- * ```
+ * FindAllAuthOriginsByRecord returns all AuthOrigin models linked to the provided auth record (in DESC order).
*/
- (app: core.App, config: ServeConfig): (http.Server | undefined)
- }
- interface migrationsConnection {
- db?: dbx.DB
- migrationsList: migrate.MigrationsList
- }
- interface settingsApi {
- }
-}
-
-namespace pocketbase {
- /**
- * appWrapper serves as a private core.App instance wrapper.
- */
- type _subKSPES = core.App
- interface appWrapper extends _subKSPES {
- }
- /**
- * PocketBase defines a PocketBase app launcher.
- *
- * It implements [core.App] via embedding and all of the app interface methods
- * could be accessed directly through the instance (eg. PocketBase.DataDir()).
- */
- type _subofMqD = appWrapper
- interface PocketBase extends _subofMqD {
+ findAllAuthOriginsByRecord(authRecord: Record): Array<(AuthOrigin | undefined)>
/**
- * RootCmd is the main console command
+ * FindAllAuthOriginsByCollection returns all AuthOrigin models linked to the provided collection (in DESC order).
*/
- rootCmd?: cobra.Command
- }
- /**
- * Config is the PocketBase initialization config struct.
- */
- interface Config {
+ findAllAuthOriginsByCollection(collection: Collection): Array<(AuthOrigin | undefined)>
/**
- * optional default values for the console flags
+ * FindAuthOriginById returns a single AuthOrigin model by its id.
*/
- defaultDebug: boolean
- defaultDataDir: string // if not set, it will fallback to "./pb_data"
- defaultEncryptionEnv: string
+ findAuthOriginById(id: string): (AuthOrigin)
/**
- * hide the default console server info on app startup
+ * FindAuthOriginByRecordAndFingerprint returns a single AuthOrigin model
+ * by its authRecord relation and fingerprint.
*/
- hideStartBanner: boolean
+ findAuthOriginByRecordAndFingerprint(authRecord: Record, fingerprint: string): (AuthOrigin)
/**
- * optional DB configurations
+ * DeleteAllAuthOriginsByRecord deletes all AuthOrigin models associated with the provided record.
+ *
+ * Returns a combined error with the failed deletes.
*/
- dataMaxOpenConns: number // default to core.DefaultDataMaxOpenConns
- dataMaxIdleConns: number // default to core.DefaultDataMaxIdleConns
- logsMaxOpenConns: number // default to core.DefaultLogsMaxOpenConns
- logsMaxIdleConns: number // default to core.DefaultLogsMaxIdleConns
- }
- interface _new {
+ deleteAllAuthOriginsByRecord(authRecord: Record): void
/**
- * New creates a new PocketBase instance with the default configuration.
- * Use [NewWithConfig()] if you want to provide a custom configuration.
+ * RecordQuery returns a new Record select query from a collection model, id or name.
*
- * Note that the application will not be initialized/bootstrapped yet,
- * aka. DB connections, migrations, app settings, etc. will not be accessible.
- * Everything will be initialized when [Start()] is executed.
- * If you want to initialize the application before calling [Start()],
- * then you'll have to manually call [Bootstrap()].
+ * In case a collection id or name is provided and that collection doesn't
+ * actually exists, the generated query will be created with a cancelled context
+ * and will fail once an executor (Row(), One(), All(), etc.) is called.
*/
- (): (PocketBase | undefined)
- }
- interface newWithConfig {
+ recordQuery(collectionModelOrIdentifier: any): (dbx.SelectQuery)
/**
- * NewWithConfig creates a new PocketBase instance with the provided config.
- *
- * Note that the application will not be initialized/bootstrapped yet,
- * aka. DB connections, migrations, app settings, etc. will not be accessible.
- * Everything will be initialized when [Start()] is executed.
- * If you want to initialize the application before calling [Start()],
- * then you'll have to manually call [Bootstrap()].
+ * FindRecordById finds the Record model by its id.
*/
- (config: Config): (PocketBase | undefined)
- }
- interface PocketBase {
+ findRecordById(collectionModelOrIdentifier: any, recordId: string, ...optFilters: ((q: dbx.SelectQuery) => void)[]): (Record)
/**
- * Start starts the application, aka. registers the default system
- * commands (serve, migrate, version) and executes pb.RootCmd.
+ * FindRecordsByIds finds all records by the specified ids.
+ * If no records are found, returns an empty slice.
*/
- start(): void
- }
- interface PocketBase {
+ findRecordsByIds(collectionModelOrIdentifier: any, recordIds: Array, ...optFilters: ((q: dbx.SelectQuery) => void)[]): Array<(Record | undefined)>
/**
- * Execute initializes the application (if not already) and executes
- * the pb.RootCmd with graceful shutdown support.
+ * FindAllRecords finds all records matching specified db expressions.
*
- * This method differs from pb.Start() by not registering the default
- * system commands!
+ * Returns all collection records if no expression is provided.
+ *
+ * Returns an empty slice if no records are found.
+ *
+ * Example:
+ *
+ * ```
+ * // no extra expressions
+ * app.FindAllRecords("example")
+ *
+ * // with extra expressions
+ * expr1 := dbx.HashExp{"email": "test@example.com"}
+ * expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
+ * app.FindAllRecords("example", expr1, expr2)
+ * ```
*/
- execute(): void
- }
-}
-
-/**
- * Package io provides basic interfaces to I/O primitives.
- * Its primary job is to wrap existing implementations of such primitives,
- * such as those in package os, into shared public interfaces that
- * abstract the functionality, plus some other related primitives.
- *
- * Because these interfaces and primitives wrap lower-level operations with
- * various implementations, unless otherwise informed clients should not
- * assume they are safe for parallel execution.
- */
-namespace io {
+ findAllRecords(collectionModelOrIdentifier: any, ...exprs: dbx.Expression[]): Array<(Record | undefined)>
+ /**
+ * FindFirstRecordByData returns the first found record matching
+ * the provided key-value pair.
+ */
+ findFirstRecordByData(collectionModelOrIdentifier: any, key: string, value: any): (Record)
+ /**
+ * FindRecordsByFilter returns limit number of records matching the
+ * provided string filter.
+ *
+ * NB! Use the last "params" argument to bind untrusted user variables!
+ *
+ * The filter argument is optional and can be empty string to target
+ * all available records.
+ *
+ * The sort argument is optional and can be empty string OR the same format
+ * used in the web APIs, ex. "-created,title".
+ *
+ * If the limit argument is <= 0, no limit is applied to the query and
+ * all matching records are returned.
+ *
+ * Returns an empty slice if no records are found.
+ *
+ * Example:
+ *
+ * ```
+ * app.FindRecordsByFilter(
+ * "posts",
+ * "title ~ {:title} && visible = {:visible}",
+ * "-created",
+ * 10,
+ * 0,
+ * dbx.Params{"title": "lorem ipsum", "visible": true}
+ * )
+ * ```
+ */
+ findRecordsByFilter(collectionModelOrIdentifier: any, filter: string, sort: string, limit: number, offset: number, ...params: dbx.Params[]): Array<(Record | undefined)>
+ /**
+ * FindFirstRecordByFilter returns the first available record matching the provided filter (if any).
+ *
+ * NB! Use the last params argument to bind untrusted user variables!
+ *
+ * Returns sql.ErrNoRows if no record is found.
+ *
+ * Example:
+ *
+ * ```
+ * app.FindFirstRecordByFilter("posts", "")
+ * app.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
+ * ```
+ */
+ findFirstRecordByFilter(collectionModelOrIdentifier: any, filter: string, ...params: dbx.Params[]): (Record)
+ /**
+ * CountRecords returns the total number of records in a collection.
+ */
+ countRecords(collectionModelOrIdentifier: any, ...exprs: dbx.Expression[]): number
+ /**
+ * FindAuthRecordByToken finds the auth record associated with the provided JWT
+ * (auth, file, verifyEmail, changeEmail, passwordReset types).
+ *
+ * Optionally specify a list of validTypes to check tokens only from those types.
+ *
+ * Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
+ */
+ findAuthRecordByToken(token: string, ...validTypes: string[]): (Record)
+ /**
+ * FindAuthRecordByEmail finds the auth record associated with the provided email.
+ *
+ * Returns an error if it is not an auth collection or the record is not found.
+ */
+ findAuthRecordByEmail(collectionModelOrIdentifier: any, email: string): (Record)
+ /**
+ * CanAccessRecord checks if a record is allowed to be accessed by the
+ * specified requestInfo and accessRule.
+ *
+ * Rule and db checks are ignored in case requestInfo.Auth is a superuser.
+ *
+ * The returned error indicate that something unexpected happened during
+ * the check (eg. invalid rule or db query error).
+ *
+ * The method always return false on invalid rule or db query error.
+ *
+ * Example:
+ *
+ * ```
+ * requestInfo, _ := e.RequestInfo()
+ * record, _ := app.FindRecordById("example", "RECORD_ID")
+ * rule := types.Pointer("@request.auth.id != '' || status = 'public'")
+ * // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
+ *
+ * if ok, _ := app.CanAccessRecord(record, requestInfo, rule); ok { ... }
+ * ```
+ */
+ canAccessRecord(record: Record, requestInfo: RequestInfo, accessRule: string): boolean
+ /**
+ * ExpandRecord expands the relations of a single Record model.
+ *
+ * If optFetchFunc is not set, then a default function will be used
+ * that returns all relation records.
+ *
+ * Returns a map with the failed expand parameters and their errors.
+ */
+ expandRecord(record: Record, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict
+ /**
+ * ExpandRecords expands the relations of the provided Record models list.
+ *
+ * If optFetchFunc is not set, then a default function will be used
+ * that returns all relation records.
+ *
+ * Returns a map with the failed expand parameters and their errors.
+ */
+ expandRecords(records: Array<(Record | undefined)>, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict
+ /**
+ * OnBootstrap hook is triggered when initializing the main application
+ * resources (db, app settings, etc).
+ */
+ onBootstrap(): (hook.Hook)
+ /**
+ * OnBootstrapClear hook is triggered when clearing the main application
+ * resources (db connections, cron, logger, etc.)
+ *
+ * It is usually invoked automatically right before app termination
+ * or when manually calling app.ClearBootstrap().
+ */
+ onBootstrapClear(): (hook.Hook)
+ /**
+ * OnServe hook is triggered when the app web server is started
+ * (after starting the TCP listener but before initializing the blocking serve task),
+ * allowing you to adjust its options and attach new routes or middlewares.
+ */
+ onServe(): (hook.Hook)
+ /**
+ * OnTerminate hook is triggered when the app is in the process
+ * of being terminated (ex. on SIGTERM signal).
+ *
+ * Note that the app could be terminated abruptly without awaiting the hook completion.
+ */
+ onTerminate(): (hook.Hook)
+ /**
+ * OnBackupCreate hook is triggered on each [App.CreateBackup] call.
+ */
+ onBackupCreate(): (hook.Hook)
+ /**
+ * OnBackupRestore hook is triggered before app backup restore (aka. [App.RestoreBackup] call).
+ *
+ * Note that by default on success the application is restarted and the after state of the hook is ignored.
+ */
+ onBackupRestore(): (hook.Hook)
+ /**
+ * OnModelValidate is triggered every time when a model is being validated
+ * (e.g. triggered by App.Validate() or App.Save()).
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelValidate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelCreate is triggered every time when a new model is being created
+ * (e.g. triggered by App.Save()).
+ *
+ * Operations BEFORE the e.Next() execute before the model validation
+ * and the INSERT DB statement.
+ *
+ * Operations AFTER the e.Next() execute after the model validation
+ * and the INSERT DB statement.
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is persisted in the database since its wrapping transaction may
+ * not have been committed yet.
+ * If you want to listen to only the actual persisted events, you can
+ * bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelCreate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelCreateExecute is triggered after successful Model validation
+ * and right before the model INSERT DB statement execution.
+ *
+ * Usually it is triggered as part of the App.Save() in the following firing order:
+ * OnModelCreate {
+ * ```
+ * -> OnModelValidate (skipped with App.SaveNoValidate())
+ * -> OnModelCreateExecute
+ * ```
+ * }
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is persisted in the database since its wrapping transaction may have been
+ * committed yet.
+ * If you want to listen to only the actual persisted events,
+ * you can bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelCreateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterCreateSuccess is triggered after each successful
+ * Model DB create persistence.
+ *
+ * Note that when a Model is persisted as part of a transaction,
+ * this hook is delayed and executed only AFTER the transaction has been committed.
+ * This hook is NOT triggered in case the transaction rollbacks
+ * (aka. when the model wasn't persisted).
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterCreateError is triggered after each failed
+ * Model DB create persistence.
+ *
+ * Note that the execution of this hook is either immediate or delayed
+ * depending on the error:
+ * ```
+ * - "immediate" on App.Save() failure
+ * - "delayed" on transaction rollback
+ * ```
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelUpdate is triggered every time when a new model is being updated
+ * (e.g. triggered by App.Save()).
+ *
+ * Operations BEFORE the e.Next() execute before the model validation
+ * and the UPDATE DB statement.
+ *
+ * Operations AFTER the e.Next() execute after the model validation
+ * and the UPDATE DB statement.
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is persisted in the database since its wrapping transaction may
+ * not have been committed yet.
+ * If you want to listen to only the actual persisted events, you can
+ * bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelUpdate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelUpdateExecute is triggered after successful Model validation
+ * and right before the model UPDATE DB statement execution.
+ *
+ * Usually it is triggered as part of the App.Save() in the following firing order:
+ * OnModelUpdate {
+ * ```
+ * -> OnModelValidate (skipped with App.SaveNoValidate())
+ * -> OnModelUpdateExecute
+ * ```
+ * }
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is persisted in the database since its wrapping transaction may have been
+ * committed yet.
+ * If you want to listen to only the actual persisted events,
+ * you can bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelUpdateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterUpdateSuccess is triggered after each successful
+ * Model DB update persistence.
+ *
+ * Note that when a Model is persisted as part of a transaction,
+ * this hook is delayed and executed only AFTER the transaction has been committed.
+ * This hook is NOT triggered in case the transaction rollbacks
+ * (aka. when the model changes weren't persisted).
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterUpdateError is triggered after each failed
+ * Model DB update persistence.
+ *
+ * Note that the execution of this hook is either immediate or delayed
+ * depending on the error:
+ * ```
+ * - "immediate" on App.Save() failure
+ * - "delayed" on transaction rollback
+ * ```
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterUpdateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelDelete is triggered every time when a new model is being deleted
+ * (e.g. triggered by App.Delete()).
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is deleted from the database since its wrapping transaction may
+ * not have been committed yet.
+ * If you want to listen to only the actual persisted deleted events, you can
+ * bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelDelete(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelUpdateExecute is triggered right before the model
+ * DELETE DB statement execution.
+ *
+ * Usually it is triggered as part of the App.Delete() in the following firing order:
+ * OnModelDelete {
+ * ```
+ * -> (internal delete checks)
+ * -> OnModelDeleteExecute
+ * ```
+ * }
+ *
+ * Note that successful execution doesn't guarantee that the model
+ * is deleted from the database since its wrapping transaction may
+ * not have been committed yet.
+ * If you want to listen to only the actual persisted deleted events, you can
+ * bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelDeleteExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterDeleteSuccess is triggered after each successful
+ * Model DB delete persistence.
+ *
+ * Note that when a Model is deleted as part of a transaction,
+ * this hook is delayed and executed only AFTER the transaction has been committed.
+ * This hook is NOT triggered in case the transaction rollbacks
+ * (aka. when the model delete wasn't persisted).
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnModelAfterDeleteError is triggered after each failed
+ * Model DB delete persistence.
+ *
+ * Note that the execution of this hook is either immediate or delayed
+ * depending on the error:
+ * ```
+ * - "immediate" on App.Delete() failure
+ * - "delayed" on transaction rollback
+ * ```
+ *
+ * For convenience, if you want to listen to only the Record models
+ * events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
+ *
+ * If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onModelAfterDeleteError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordEnrich is triggered every time when a record is enriched
+ * (as part of the builtin Record responses, during realtime message seriazation, or when [apis.EnrichRecord] is invoked).
+ *
+ * It could be used for example to redact/hide or add computed temporary
+ * Record model props only for the specific request info. For example:
+ *
+ * app.OnRecordEnrich("posts").BindFunc(func(e core.*RecordEnrichEvent) {
+ * ```
+ * // hide one or more fields
+ * e.Record.Hide("role")
+ *
+ * // add new custom field for registered users
+ * if e.RequestInfo.Auth != nil && e.RequestInfo.Auth.Collection().Name == "users" {
+ * e.Record.WithCustomData(true) // for security requires explicitly allowing it
+ * e.Record.Set("computedScore", e.Record.GetInt("score") * e.RequestInfo.Auth.GetInt("baseScore"))
+ * }
+ *
+ * return e.Next()
+ * ```
+ * })
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordEnrich(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordValidate is a Record proxy model hook of [OnModelValidate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordValidate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordCreate is a Record proxy model hook of [OnModelCreate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordCreate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordCreateExecute is a Record proxy model hook of [OnModelCreateExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordCreateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterCreateSuccess is a Record proxy model hook of [OnModelAfterCreateSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterCreateError is a Record proxy model hook of [OnModelAfterCreateError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordUpdate is a Record proxy model hook of [OnModelUpdate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordUpdate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordUpdateExecute is a Record proxy model hook of [OnModelUpdateExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordUpdateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterUpdateSuccess is a Record proxy model hook of [OnModelAfterUpdateSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterUpdateError is a Record proxy model hook of [OnModelAfterUpdateError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterUpdateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordDelete is a Record proxy model hook of [OnModelDelete].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordDelete(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordDeleteExecute is a Record proxy model hook of [OnModelDeleteExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordDeleteExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterDeleteSuccess is a Record proxy model hook of [OnModelAfterDeleteSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAfterDeleteError is a Record proxy model hook of [OnModelAfterDeleteError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAfterDeleteError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionValidate is a Collection proxy model hook of [OnModelValidate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionValidate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionCreate is a Collection proxy model hook of [OnModelCreate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionCreate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionCreateExecute is a Collection proxy model hook of [OnModelCreateExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionCreateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterCreateSuccess is a Collection proxy model hook of [OnModelAfterCreateSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterCreateError is a Collection proxy model hook of [OnModelAfterCreateError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionUpdate is a Collection proxy model hook of [OnModelUpdate].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionUpdate(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionUpdateExecute is a Collection proxy model hook of [OnModelUpdateExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionUpdateExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterUpdateSuccess is a Collection proxy model hook of [OnModelAfterUpdateSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterUpdateError is a Collection proxy model hook of [OnModelAfterUpdateError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterUpdateError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionDelete is a Collection proxy model hook of [OnModelDelete].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionDelete(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionDeleteExecute is a Collection proxy model hook of [OnModelDeleteExecute].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionDeleteExecute(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterDeleteSuccess is a Collection proxy model hook of [OnModelAfterDeleteSuccess].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionAfterDeleteError is a Collection proxy model hook of [OnModelAfterDeleteError].
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onCollectionAfterDeleteError(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnMailerSend hook is triggered every time when a new email is
+ * being sent using the [App.NewMailClient()] instance.
+ *
+ * It allows intercepting the email message or to use a custom mailer client.
+ */
+ onMailerSend(): (hook.Hook)
+ /**
+ * OnMailerRecordAuthAlertSend hook is triggered when
+ * sending a new device login auth alert email, allowing you to
+ * intercept and customize the email message that is being sent.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onMailerRecordAuthAlertSend(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnMailerBeforeRecordResetPasswordSend hook is triggered when
+ * sending a password reset email to an auth record, allowing
+ * you to intercept and customize the email message that is being sent.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onMailerRecordPasswordResetSend(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnMailerBeforeRecordVerificationSend hook is triggered when
+ * sending a verification email to an auth record, allowing
+ * you to intercept and customize the email message that is being sent.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onMailerRecordVerificationSend(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnMailerRecordEmailChangeSend hook is triggered when sending a
+ * confirmation new address email to an auth record, allowing
+ * you to intercept and customize the email message that is being sent.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onMailerRecordEmailChangeSend(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnMailerRecordOTPSend hook is triggered when sending an OTP email
+ * to an auth record, allowing you to intercept and customize the
+ * email message that is being sent.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onMailerRecordOTPSend(...tags: string[]): (hook.TaggedHook)
+ /**
+ * onFilesystemNewWriter is an internal hook for app.NewFilesystem()
+ * instances that is triggered on every storage filesystem writer initialization
+ * (aka. whenever attempting to create a new file).
+ */
+ /**
+ * onFilesystemDelete is an internal hook for app.NewFilesystem()
+ * instances that is triggered for every storage file delete call.
+ */
+ /**
+ * OnRealtimeConnectRequest hook is triggered when establishing the SSE client connection.
+ *
+ * Any execution after e.Next() of a hook handler happens after the client disconnects.
+ */
+ onRealtimeConnectRequest(): (hook.Hook)
+ /**
+ * OnRealtimeMessageSend hook is triggered when sending an SSE message to a client.
+ */
+ onRealtimeMessageSend(): (hook.Hook)
+ /**
+ * OnRealtimeSubscribeRequest hook is triggered when updating the
+ * client subscriptions, allowing you to further validate and
+ * modify the submitted change.
+ */
+ onRealtimeSubscribeRequest(): (hook.Hook)
+ /**
+ * OnSettingsListRequest hook is triggered on each API Settings list request.
+ *
+ * Could be used to validate or modify the response before returning it to the client.
+ */
+ onSettingsListRequest(): (hook.Hook)
+ /**
+ * OnSettingsUpdateRequest hook is triggered on each API Settings update request.
+ *
+ * Could be used to additionally validate the request data or
+ * implement completely different persistence behavior.
+ */
+ onSettingsUpdateRequest(): (hook.Hook)
+ /**
+ * OnSettingsReload hook is triggered every time when the App.Settings()
+ * is being replaced with a new state.
+ *
+ * Calling App.Settings() after e.Next() returns the new state.
+ */
+ onSettingsReload(): (hook.Hook)
+ /**
+ * OnFileDownloadRequest hook is triggered before each API File download request.
+ *
+ * Could be used to validate or modify the file response before
+ * returning it to the client.
+ */
+ onFileDownloadRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnFileBeforeTokenRequest hook is triggered on each auth file token API request.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onFileTokenRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAuthRequest hook is triggered on each successful API
+ * record authentication request (sign-in, token refresh, etc.).
+ *
+ * Could be used to additionally validate or modify the authenticated
+ * record data and token.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAuthRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAuthWithPasswordRequest hook is triggered on each
+ * Record auth with password API request.
+ *
+ * [RecordAuthWithPasswordRequestEvent.Record] could be nil if no matching identity is found, allowing
+ * you to manually locate a different Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAuthWithPasswordRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAuthWithOAuth2Request hook is triggered on each Record
+ * OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
+ *
+ * If [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
+ * request will try to create a new auth Record.
+ *
+ * To assign or link a different existing record model you can
+ * change the [RecordAuthWithOAuth2RequestEvent.Record] field.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAuthWithOAuth2Request(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAuthRefreshRequest hook is triggered on each Record
+ * auth refresh API request (right before generating a new auth token).
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different auth refresh behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAuthRefreshRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordRequestPasswordResetRequest hook is triggered on
+ * each Record request password reset API request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different password reset behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordRequestPasswordResetRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordConfirmPasswordResetRequest hook is triggered on
+ * each Record confirm password reset API request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordConfirmPasswordResetRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordRequestVerificationRequest hook is triggered on
+ * each Record request verification API request.
+ *
+ * Could be used to additionally validate the loaded request data or implement
+ * completely different verification behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordRequestVerificationRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordConfirmVerificationRequest hook is triggered on each
+ * Record confirm verification API request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordConfirmVerificationRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordRequestEmailChangeRequest hook is triggered on each
+ * Record request email change API request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different request email change behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordRequestEmailChangeRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordConfirmEmailChangeRequest hook is triggered on each
+ * Record confirm email change API request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordConfirmEmailChangeRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordRequestOTPRequest hook is triggered on each Record
+ * request OTP API request.
+ *
+ * [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
+ * you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.Record]).
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordRequestOTPRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordAuthWithOTPRequest hook is triggered on each Record
+ * auth with OTP API request.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordAuthWithOTPRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordsListRequest hook is triggered on each API Records list request.
+ *
+ * Could be used to validate or modify the response before returning it to the client.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordsListRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordViewRequest hook is triggered on each API Record view request.
+ *
+ * Could be used to validate or modify the response before returning it to the client.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordViewRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordCreateRequest hook is triggered on each API Record create request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordCreateRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordUpdateRequest hook is triggered on each API Record update request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordUpdateRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnRecordDeleteRequest hook is triggered on each API Record delete request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different delete behavior.
+ *
+ * If the optional "tags" list (Collection ids or names) is specified,
+ * then all event handlers registered via the created hook will be
+ * triggered and called only if their event data origin matches the tags.
+ */
+ onRecordDeleteRequest(...tags: string[]): (hook.TaggedHook)
+ /**
+ * OnCollectionsListRequest hook is triggered on each API Collections list request.
+ *
+ * Could be used to validate or modify the response before returning it to the client.
+ */
+ onCollectionsListRequest(): (hook.Hook)
+ /**
+ * OnCollectionViewRequest hook is triggered on each API Collection view request.
+ *
+ * Could be used to validate or modify the response before returning it to the client.
+ */
+ onCollectionViewRequest(): (hook.Hook)
+ /**
+ * OnCollectionCreateRequest hook is triggered on each API Collection create request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ */
+ onCollectionCreateRequest(): (hook.Hook)
+ /**
+ * OnCollectionUpdateRequest hook is triggered on each API Collection update request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different persistence behavior.
+ */
+ onCollectionUpdateRequest(): (hook.Hook)
+ /**
+ * OnCollectionDeleteRequest hook is triggered on each API Collection delete request.
+ *
+ * Could be used to additionally validate the request data or implement
+ * completely different delete behavior.
+ */
+ onCollectionDeleteRequest(): (hook.Hook)
+ /**
+ * OnCollectionsBeforeImportRequest hook is triggered on each API
+ * collections import request.
+ *
+ * Could be used to additionally validate the imported collections or
+ * to implement completely different import behavior.
+ */
+ onCollectionsImportRequest(): (hook.Hook)
+ /**
+ * OnBatchRequest hook is triggered on each API batch request.
+ *
+ * Could be used to additionally validate or modify the submitted batch requests.
+ */
+ onBatchRequest(): (hook.Hook)
+ }
+ // @ts-ignore
+ import validation = ozzo_validation
+ /**
+ * AuthOrigin defines a Record proxy for working with the authOrigins collection.
+ */
+ type _svOAweF = Record
+ interface AuthOrigin extends _svOAweF {
+ }
+ interface newAuthOrigin {
+ /**
+ * NewAuthOrigin instantiates and returns a new blank *AuthOrigin model.
+ *
+ * Example usage:
+ *
+ * ```
+ * origin := core.NewOrigin(app)
+ * origin.SetRecordRef(user.Id)
+ * origin.SetCollectionRef(user.Collection().Id)
+ * origin.SetFingerprint("...")
+ * app.Save(origin)
+ * ```
+ */
+ (app: App): (AuthOrigin)
+ }
+ interface AuthOrigin {
+ /**
+ * PreValidate implements the [PreValidator] interface and checks
+ * whether the proxy is properly loaded.
+ */
+ preValidate(ctx: context.Context, app: App): void
+ }
+ interface AuthOrigin {
+ /**
+ * ProxyRecord returns the proxied Record model.
+ */
+ proxyRecord(): (Record)
+ }
+ interface AuthOrigin {
+ /**
+ * SetProxyRecord loads the specified record model into the current proxy.
+ */
+ setProxyRecord(record: Record): void
+ }
+ interface AuthOrigin {
+ /**
+ * CollectionRef returns the "collectionRef" field value.
+ */
+ collectionRef(): string
+ }
+ interface AuthOrigin {
+ /**
+ * SetCollectionRef updates the "collectionRef" record field value.
+ */
+ setCollectionRef(collectionId: string): void
+ }
+ interface AuthOrigin {
+ /**
+ * RecordRef returns the "recordRef" record field value.
+ */
+ recordRef(): string
+ }
+ interface AuthOrigin {
+ /**
+ * SetRecordRef updates the "recordRef" record field value.
+ */
+ setRecordRef(recordId: string): void
+ }
+ interface AuthOrigin {
+ /**
+ * Fingerprint returns the "fingerprint" record field value.
+ */
+ fingerprint(): string
+ }
+ interface AuthOrigin {
+ /**
+ * SetFingerprint updates the "fingerprint" record field value.
+ */
+ setFingerprint(fingerprint: string): void
+ }
+ interface AuthOrigin {
+ /**
+ * Created returns the "created" record field value.
+ */
+ created(): types.DateTime
+ }
+ interface AuthOrigin {
+ /**
+ * Updated returns the "updated" record field value.
+ */
+ updated(): types.DateTime
+ }
+ interface BaseApp {
+ /**
+ * FindAllAuthOriginsByRecord returns all AuthOrigin models linked to the provided auth record (in DESC order).
+ */
+ findAllAuthOriginsByRecord(authRecord: Record): Array<(AuthOrigin | undefined)>
+ }
+ interface BaseApp {
+ /**
+ * FindAllAuthOriginsByCollection returns all AuthOrigin models linked to the provided collection (in DESC order).
+ */
+ findAllAuthOriginsByCollection(collection: Collection): Array<(AuthOrigin | undefined)>
+ }
+ interface BaseApp {
+ /**
+ * FindAuthOriginById returns a single AuthOrigin model by its id.
+ */
+ findAuthOriginById(id: string): (AuthOrigin)
+ }
+ interface BaseApp {
+ /**
+ * FindAuthOriginByRecordAndFingerprint returns a single AuthOrigin model
+ * by its authRecord relation and fingerprint.
+ */
+ findAuthOriginByRecordAndFingerprint(authRecord: Record, fingerprint: string): (AuthOrigin)
+ }
+ interface BaseApp {
+ /**
+ * DeleteAllAuthOriginsByRecord deletes all AuthOrigin models associated with the provided record.
+ *
+ * Returns a combined error with the failed deletes.
+ */
+ deleteAllAuthOriginsByRecord(authRecord: Record): void
+ }
+ interface BaseApp {
+ /**
+ * CreateBackup creates a new backup of the current app pb_data directory.
+ *
+ * If name is empty, it will be autogenerated.
+ * If backup with the same name exists, the new backup file will replace it.
+ *
+ * To safely perform the backup, it is recommended to have free disk space
+ * for at least 2x the size of the pb_data directory.
+ *
+ * By default backups are stored in pb_data/backups
+ * (the backups directory itself is excluded from the generated backup).
+ *
+ * Backups can be stored on S3 if it is configured in app.Settings().Backups.
+ * When using S3 storage for the uploaded collection files, you have to
+ * take care manually to backup those since they are not part of the pb_data.
+ *
+ * DB write locks are minimal and isolated only for the duration of the
+ * VACUUM INTO statement that creates a live copy of the app database.
+ *
+ * The backup works as follow:
+ *
+ * 1. Start listening for DELETED storage files.
+ * ```
+ * In case a file is being deleted while the backup is still ongoing,
+ * we directly copy it in the zip before the deletion and mark it as "excluded".
+ * ```
+ *
+ * 2. Copy the main database with VACUUM INTO, write it in the zip and mark it as "excluded".
+ *
+ * 3. Stop listening for DELETED files.
+ *
+ * 4. Start listening for NEW storage files and mark all new files from this point as "excluded".
+ *
+ * 5. Copy the logs database with VACUUM INTO, write it in the zip and mark it as "excluded".
+ *
+ * 6. Copy the rest of the pb_data files in the zip while ignoring the "excluded" list (it should be concurrent safe).
+ *
+ * 7. Stop listening for NEW storage files.
+ *
+ * While there is a risk for a race condition between steps 1, 2 and 3, it is an
+ * acceptable trade-off between performance and correctness because in
+ * the worst case there will be some unused storage files in the backup that don't do any harm.
+ */
+ createBackup(ctx: context.Context, name: string): void
+ }
+ interface zipWriter {
+ }
+ interface BaseApp {
+ /**
+ * RestoreBackup restores the backup with the specified name and restarts
+ * the current running application process.
+ *
+ * NB! This feature is experimental and currently is expected to work only on UNIX based systems.
+ *
+ * To safely perform the restore it is recommended to have free disk space
+ * for at least 2x the size of the restored pb_data backup.
+ *
+ * The performed steps are:
+ *
+ * 1. Download the backup with the specified name in a temp location
+ * ```
+ * (this is in case of S3; otherwise it creates a temp copy of the zip)
+ * ```
+ *
+ * 2. Extract the backup in a temp directory inside the app "pb_data"
+ * ```
+ * (eg. "pb_data/.pb_temp_to_delete/pb_restore").
+ * ```
+ *
+ * 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
+ * ```
+ * under another temp sub dir that will be deleted on the next app start up
+ * (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
+ * This is because on some environments it may not be allowed
+ * to delete the currently open "pb_data" files.
+ * ```
+ *
+ * 4. Move the extracted dir content to the app "pb_data".
+ *
+ * 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
+ *
+ * If a failure occur during the restore process the dir changes are reverted.
+ * If for whatever reason the revert is not possible, it panics.
+ *
+ * Note that if your pb_data has custom network mounts as subdirectories, then
+ * it is possible the restore to fail during the `os.Rename` operations
+ * (see https://github.com/pocketbase/pocketbase/issues/4647).
+ */
+ restoreBackup(ctx: context.Context, name: string): void
+ }
+ /**
+ * FilesManager defines an interface with common methods that files manager models should implement.
+ */
+ interface FilesManager {
+ [key:string]: any;
+ /**
+ * BaseFilesPath returns the storage dir path used by the interface instance.
+ */
+ baseFilesPath(): string
+ }
+ /**
+ * DBConnectFunc defines a database connection initialization function.
+ */
+ interface DBConnectFunc {(dbPath: string): (dbx.DB) }
+ /**
+ * BaseAppConfig defines a BaseApp configuration option
+ */
+ interface BaseAppConfig {
+ dbConnect: DBConnectFunc
+ dataDir: string
+ encryptionEnv: string
+ queryTimeout: time.Duration
+ dataMaxOpenConns: number
+ dataMaxIdleConns: number
+ auxMaxOpenConns: number
+ auxMaxIdleConns: number
+ isDev: boolean
+ }
+ /**
+ * BaseApp implements CoreApp and defines the base PocketBase app structure.
+ */
+ interface BaseApp {
+ }
+ interface newBaseApp {
+ /**
+ * NewBaseApp creates and returns a new BaseApp instance
+ * configured with the provided arguments.
+ *
+ * To initialize the app, you need to call `app.Bootstrap()`.
+ */
+ (config: BaseAppConfig): (BaseApp)
+ }
+ interface BaseApp {
+ /**
+ * UnsafeWithoutHooks returns a shallow copy of the current app WITHOUT any registered hooks.
+ *
+ * NB! Note that using the returned app instance may cause data integrity errors
+ * since the Record validations and data normalizations (including files uploads)
+ * rely on the app hooks to work.
+ */
+ unsafeWithoutHooks(): App
+ }
+ interface BaseApp {
+ /**
+ * Logger returns the default app logger.
+ *
+ * If the application is not bootstrapped yet, fallbacks to slog.Default().
+ */
+ logger(): (slog.Logger)
+ }
+ interface BaseApp {
+ /**
+ * TxInfo returns the transaction associated with the current app instance (if any).
+ *
+ * Could be used if you want to execute indirectly a function after
+ * the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
+ */
+ txInfo(): (TxAppInfo)
+ }
+ interface BaseApp {
+ /**
+ * IsTransactional checks if the current app instance is part of a transaction.
+ */
+ isTransactional(): boolean
+ }
+ interface BaseApp {
+ /**
+ * IsBootstrapped checks if the application was initialized
+ * (aka. whether Bootstrap() was called).
+ */
+ isBootstrapped(): boolean
+ }
+ interface BaseApp {
+ /**
+ * Bootstrap initializes the application
+ * (aka. create data dir, open db connections, load settings, etc.).
+ *
+ * It calls ClearBootstrap() if the application was already bootstrapped.
+ */
+ bootstrap(): void
+ }
+ interface BaseApp {
+ /**
+ * Deprecated: use [ClearBootstrap].
+ */
+ resetBootstrapState(): void
+ }
+ interface BaseApp {
+ /**
+ * ClearBootstrap releases the initialized core app resources
+ * (closing db connections, stopping cron ticker, etc.).
+ *
+ * This method is no-op if the application is not bootstrapped yet.
+ */
+ clearBootstrap(): void
+ }
+ interface BaseApp {
+ /**
+ * DB returns the default app data.db builder instance.
+ *
+ * To minimize SQLITE_BUSY errors, it automatically routes the
+ * SELECT queries to the underlying concurrent db pool and everything
+ * else to the nonconcurrent one.
+ *
+ * For more finer control over the used connections pools you can
+ * call directly ConcurrentDB() or NonconcurrentDB().
+ */
+ db(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * ConcurrentDB returns the concurrent app data.db builder instance.
+ *
+ * This method is used mainly internally for executing db read
+ * operations in a concurrent/non-blocking manner.
+ *
+ * Most users should use simply DB() as it will automatically
+ * route the query execution to ConcurrentDB() or NonconcurrentDB().
+ *
+ * In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+ */
+ concurrentDB(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * NonconcurrentDB returns the nonconcurrent app data.db builder instance.
+ *
+ * The returned db instance is limited only to a single open connection,
+ * meaning that it can process only 1 db operation at a time (other queries queue up).
+ *
+ * This method is used mainly internally and in the tests to execute write
+ * (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ *
+ * Most users should use simply DB() as it will automatically
+ * route the query execution to ConcurrentDB() or NonconcurrentDB().
+ *
+ * In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
+ */
+ nonconcurrentDB(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * AuxDB returns the app auxiliary.db builder instance.
+ *
+ * To minimize SQLITE_BUSY errors, it automatically routes the
+ * SELECT queries to the underlying concurrent db pool and everything
+ * else to the nonconcurrent one.
+ *
+ * For more finer control over the used connections pools you can
+ * call directly AuxConcurrentDB() or AuxNonconcurrentDB().
+ */
+ auxDB(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
+ *
+ * This method is used mainly internally for executing db read
+ * operations in a concurrent/non-blocking manner.
+ *
+ * Most users should use simply AuxDB() as it will automatically
+ * route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ *
+ * In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+ */
+ auxConcurrentDB(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
+ *
+ * The returned db instance is limited only to a single open connection,
+ * meaning that it can process only 1 db operation at a time (other queries queue up).
+ *
+ * This method is used mainly internally and in the tests to execute write
+ * (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
+ *
+ * Most users should use simply AuxDB() as it will automatically
+ * route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
+ *
+ * In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
+ */
+ auxNonconcurrentDB(): dbx.Builder
+ }
+ interface BaseApp {
+ /**
+ * DataDir returns the app data directory path.
+ */
+ dataDir(): string
+ }
+ interface BaseApp {
+ /**
+ * EncryptionEnv returns the name of the app secret env key
+ * (currently used primarily for optional settings encryption but this may change in the future).
+ */
+ encryptionEnv(): string
+ }
+ interface BaseApp {
+ /**
+ * IsDev returns whether the app is in dev mode.
+ *
+ * When enabled logs, executed sql statements, etc. are printed to the stderr.
+ */
+ isDev(): boolean
+ }
+ interface BaseApp {
+ /**
+ * Settings returns the loaded app settings.
+ */
+ settings(): (Settings)
+ }
+ interface BaseApp {
+ /**
+ * Store returns the app runtime store.
+ */
+ store(): (store.Store)
+ }
+ interface BaseApp {
+ /**
+ * Cron returns the app cron instance.
+ */
+ cron(): (cron.Cron)
+ }
+ interface BaseApp {
+ /**
+ * SubscriptionsBroker returns the app realtime subscriptions broker instance.
+ */
+ subscriptionsBroker(): (subscriptions.Broker)
+ }
+ interface BaseApp {
+ /**
+ * NewMailClient creates and returns a new SMTP or Sendmail client
+ * based on the current app settings.
+ */
+ newMailClient(): mailer.Mailer
+ }
+ interface BaseApp {
+ /**
+ * NewFilesystem creates a new local or S3 filesystem instance
+ * for managing regular app files (ex. record uploads)
+ * based on the current app settings.
+ *
+ * NB! Make sure to call Close() on the returned result
+ * after you are done working with it.
+ */
+ newFilesystem(): (filesystem.System)
+ }
+ interface BaseApp {
+ /**
+ * NewBackupsFilesystem creates a new local or S3 filesystem instance
+ * for managing app backups based on the current app settings.
+ *
+ * NB! Make sure to call Close() on the returned result
+ * after you are done working with it.
+ */
+ newBackupsFilesystem(): (filesystem.System)
+ }
+ interface BaseApp {
+ /**
+ * Restart restarts (aka. replaces) the current running application process.
+ *
+ * NB! It relies on execve which is supported only on UNIX based systems.
+ */
+ restart(): void
+ }
+ interface BaseApp {
+ /**
+ * RunSystemMigrations applies all new migrations registered in the [core.SystemMigrations] list.
+ */
+ runSystemMigrations(): void
+ }
+ interface BaseApp {
+ /**
+ * RunAppMigrations applies all new migrations registered in the [CoreAppMigrations] list.
+ */
+ runAppMigrations(): void
+ }
+ interface BaseApp {
+ /**
+ * RunAllMigrations applies all system and app migrations
+ * (aka. from both [core.SystemMigrations] and [CoreAppMigrations]).
+ */
+ runAllMigrations(): void
+ }
+ interface BaseApp {
+ onBootstrap(): (hook.Hook)
+ }
+ interface BaseApp {
+ onBootstrapClear(): (hook.Hook)
+ }
+ interface BaseApp {
+ onServe(): (hook.Hook)
+ }
+ interface BaseApp {
+ onTerminate(): (hook.Hook)
+ }
+ interface BaseApp {
+ onBackupCreate(): (hook.Hook)
+ }
+ interface BaseApp {
+ onBackupRestore(): (hook.Hook)
+ }
+ interface BaseApp {
+ onModelCreate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelCreateExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelUpdate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelUpdateExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterUpdateError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelValidate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelDelete(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelDeleteExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onModelAfterDeleteError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordEnrich(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordValidate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordCreate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordCreateExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordUpdate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordUpdateExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterUpdateError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordDelete(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordDeleteExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onRecordAfterDeleteError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionValidate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionCreate(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionCreateExecute(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionAfterCreateSuccess(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionAfterCreateError(...tags: string[]): (hook.TaggedHook)
+ }
+ interface BaseApp {
+ onCollectionUpdate(...tags: string[]): (hook.TaggedHook