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: - - - - - - - - - - - - - -
OldNew
Authorization: Admin TOKENAuthorization: TOKEN
Authorization: User TOKENAuthorization: TOKEN
- -- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part: - - - - - - - - - -
OldNew
2022-01-02 03:04:05.6782022-01-02 03:04:05.678Z
- -- Removed the `@` prefix from the system record fields for easier json parsing: - - - - - - - - - - - - - - - - - -
OldNew
@collectionIdcollectionId
@collectionNamecollectionName
@expandexpand
- -- All users api handlers are moved under `/api/collections/:collection/`: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
- 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-resetPOST /api/collections/:collection/request-password-reset
POST /api/users/confirm-password-resetPOST /api/collections/:collection/confirm-password-reset
POST /api/users/request-verificationPOST /api/collections/:collection/request-verification
POST /api/users/confirm-verificationPOST /api/collections/:collection/confirm-verification
POST /api/users/request-email-changePOST /api/collections/:collection/request-email-change
POST /api/users/confirm-email-changePOST /api/collections/:collection/confirm-email-change
GET /api/usersGET /api/collections/:collection/records
GET /api/users/:idGET /api/collections/:collection/records/:id
POST /api/usersPOST /api/collections/:collection/records
PATCH /api/users/:idPATCH /api/collections/:collection/records/:id
DELETE /api/users/:idDELETE /api/collections/:collection/records/:id
GET /api/users/:id/external-authsGET /api/collections/:collection/records/:id/external-auths
DELETE /api/users/:id/external-auths/:providerDELETE /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: - - - - - - - - - - - - - -
OldNew
- 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`: - - - - - - - - - - - - - - - - - - - - - -
OldNew
userAuthTokenrecordAuthToken
userPasswordResetTokenrecordPasswordResetToken
userEmailChangeTokenrecordEmailChangeToken
userVerificationTokenrecordVerificationToken
- -#### 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: - - - - - - - - - - - - - - - - - -
OldNew
apis.RequireUserAuth()apis.RequireRecordAuth(optCollectionNames ...string)
apis.RequireAdminOrUserAuth()apis.RequireAdminOrRecordAuth(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: - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
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: - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
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/Adao.FindAuthRecordByUsername(collectionNameOrId, username)
- -- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
Import path
github.com/pocketbase/pocketbase/tools/restgithub.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: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
SetDataValueSet
GetDataValueGet
GetBoolDataValueGetBool
GetStringDataValueGetString
GetIntDataValueGetInt
GetFloatDataValueGetFloat
GetTimeDataValueGetTime
GetDateTimeDataValueGetDateTime
GetStringSliceDataValueGetStringSlice
- -- 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`. - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
User.EmailRecord.Email()
User.TokenKeyRecord.TokenKey()
User.VerifiedRecord.Verified()
User.SetPassword()Record.SetPassword()
User.RefreshTokenKey()Record.RefreshTokenKey()
etc.
- -- Replaced `User` related event hooks with their `Record` alternative: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OldNew
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
OnUsersListRequest() *hook.Hook[*UserListEvent]OnRecordsListRequest() *hook.Hook[*RecordsListEvent]
OnUserViewRequest() *hook.Hook[*UserViewEvent]OnRecordViewRequest() *hook.Hook[*RecordViewEvent]
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
- -- 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: - - - - - - - - - - - - - - - - - - - - - -
OldNew
core.Settings.UserAuthToken{}core.Settings.RecordAuthToken{}
core.Settings.UserPasswordResetToken{}core.Settings.RecordPasswordResetToken{}
core.Settings.UserEmailChangeToken{}core.Settings.RecordEmailChangeToken{}
core.Settings.UserVerificationToken{}core.Settings.RecordVerificationToken{}
- -- 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: + + + + + + + + + + + + + +
OldNew
Authorization: Admin TOKENAuthorization: TOKEN
Authorization: User TOKENAuthorization: TOKEN
+ +- All datetime strings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part: + + + + + + + + + +
OldNew
2022-01-02 03:04:05.6782022-01-02 03:04:05.678Z
+ +- Removed the `@` prefix from the system record fields for easier json parsing: + + + + + + + + + + + + + + + + + +
OldNew
@collectionIdcollectionId
@collectionNamecollectionName
@expandexpand
+ +- All users api handlers are moved under `/api/collections/:collection/`: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
+ 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-resetPOST /api/collections/:collection/request-password-reset
POST /api/users/confirm-password-resetPOST /api/collections/:collection/confirm-password-reset
POST /api/users/request-verificationPOST /api/collections/:collection/request-verification
POST /api/users/confirm-verificationPOST /api/collections/:collection/confirm-verification
POST /api/users/request-email-changePOST /api/collections/:collection/request-email-change
POST /api/users/confirm-email-changePOST /api/collections/:collection/confirm-email-change
GET /api/usersGET /api/collections/:collection/records
GET /api/users/:idGET /api/collections/:collection/records/:id
POST /api/usersPOST /api/collections/:collection/records
PATCH /api/users/:idPATCH /api/collections/:collection/records/:id
DELETE /api/users/:idDELETE /api/collections/:collection/records/:id
GET /api/users/:id/external-authsGET /api/collections/:collection/records/:id/external-auths
DELETE /api/users/:id/external-auths/:providerDELETE /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: + + + + + + + + + + + + + +
OldNew
+ 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`: + + + + + + + + + + + + + + + + + + + + + +
OldNew
userAuthTokenrecordAuthToken
userPasswordResetTokenrecordPasswordResetToken
userEmailChangeTokenrecordEmailChangeToken
userVerificationTokenrecordVerificationToken
+ +#### 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: + + + + + + + + + + + + + + + + + +
OldNew
apis.RequireUserAuth()apis.RequireRecordAuth(optCollectionNames ...string)
apis.RequireAdminOrUserAuth()apis.RequireAdminOrRecordAuth(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: + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
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: + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
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/Adao.FindAuthRecordByUsername(collectionNameOrId, username)
+ +- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
Import path
github.com/pocketbase/pocketbase/tools/restgithub.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: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
SetDataValueSet
GetDataValueGet
GetBoolDataValueGetBool
GetStringDataValueGetString
GetIntDataValueGetInt
GetFloatDataValueGetFloat
GetTimeDataValueGetTime
GetDateTimeDataValueGetDateTime
GetStringSliceDataValueGetStringSlice
+ +- 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`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
User.EmailRecord.Email()
User.TokenKeyRecord.TokenKey()
User.VerifiedRecord.Verified()
User.SetPassword()Record.SetPassword()
User.RefreshTokenKey()Record.RefreshTokenKey()
etc.
+ +- Replaced `User` related event hooks with their `Record` alternative: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OldNew
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
OnUsersListRequest() *hook.Hook[*UserListEvent]OnRecordsListRequest() *hook.Hook[*RecordsListEvent]
OnUserViewRequest() *hook.Hook[*UserViewEvent]OnRecordViewRequest() *hook.Hook[*RecordViewEvent]
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
+ +- 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: + + + + + + + + + + + + + + + + + + + + + +
OldNew
core.Settings.UserAuthToken{}core.Settings.RecordAuthToken{}
core.Settings.UserPasswordResetToken{}core.Settings.RecordPasswordResetToken{}
core.Settings.UserEmailChangeToken{}core.Settings.RecordEmailChangeToken{}
core.Settings.UserVerificationToken{}core.Settings.RecordVerificationToken{}
+ +- 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 - open source backend in 1 file + PocketBase - open source backend in 1 file

build Latest releases - Go package documentation + Go package documentation

-[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) + } + interface BaseApp { + onCollectionUpdateExecute(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionAfterUpdateSuccess(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionAfterUpdateError(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionDelete(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionDeleteExecute(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionAfterDeleteSuccess(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionAfterDeleteError(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onMailerSend(): (hook.Hook) + } + interface BaseApp { + onMailerRecordPasswordResetSend(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onMailerRecordVerificationSend(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onMailerRecordEmailChangeSend(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onMailerRecordOTPSend(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onMailerRecordAuthAlertSend(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRealtimeConnectRequest(): (hook.Hook) + } + interface BaseApp { + onRealtimeMessageSend(): (hook.Hook) + } + interface BaseApp { + onRealtimeSubscribeRequest(): (hook.Hook) + } + interface BaseApp { + onSettingsListRequest(): (hook.Hook) + } + interface BaseApp { + onSettingsUpdateRequest(): (hook.Hook) + } + interface BaseApp { + onSettingsReload(): (hook.Hook) + } + interface BaseApp { + onFileDownloadRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onFileTokenRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordAuthRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordAuthWithPasswordRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordAuthWithOAuth2Request(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordAuthRefreshRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordRequestPasswordResetRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordConfirmPasswordResetRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordRequestVerificationRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordConfirmVerificationRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordRequestEmailChangeRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordConfirmEmailChangeRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordRequestOTPRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordAuthWithOTPRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordsListRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordViewRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordCreateRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordUpdateRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onRecordDeleteRequest(...tags: string[]): (hook.TaggedHook) + } + interface BaseApp { + onCollectionsListRequest(): (hook.Hook) + } + interface BaseApp { + onCollectionViewRequest(): (hook.Hook) + } + interface BaseApp { + onCollectionCreateRequest(): (hook.Hook) + } + interface BaseApp { + onCollectionUpdateRequest(): (hook.Hook) + } + interface BaseApp { + onCollectionDeleteRequest(): (hook.Hook) + } + interface BaseApp { + onCollectionsImportRequest(): (hook.Hook) + } + interface BaseApp { + onBatchRequest(): (hook.Hook) + } + interface BaseApp { + /** + * ImportCollectionsByMarshaledJSON is the same as [ImportCollections] + * but accept marshaled json array as import data (usually used for the autogenerated snapshots). + */ + importCollectionsByMarshaledJSON(rawSliceOfMaps: string|Array, deleteMissing: boolean): void + } + interface BaseApp { + /** + * 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). + */ + importCollections(toImport: Array<_TygojaDict>, deleteMissing: boolean): void + } + /** + * @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 _swgWvhq = BaseModel + interface baseCollection extends _swgWvhq { + listRule?: string + viewRule?: string + createRule?: string + updateRule?: string + deleteRule?: string + /** + * 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 + name: string + type: string + fields: FieldsList + indexes: types.JSONArray + created: types.DateTime + updated: types.DateTime + /** + * System prevents the collection rename, deletion and rules change. + * It is used primarily for internal purposes for collections like "_superusers", "_externalAuths", etc. + */ + system: boolean + } + /** + * Collection defines the table, fields and various options related to a set of records. + */ + type _sZLgDRe = baseCollection&collectionAuthOptions&collectionViewOptions + interface Collection extends _sZLgDRe { + } + interface newCollection { + /** + * 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.). + */ + (typ: string, name: string, ...optId: string[]): (Collection) + } + interface newBaseCollection { + /** + * 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.). + */ + (name: string, ...optId: string[]): (Collection) + } + interface newViewCollection { + /** + * 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.). + */ + (name: string, ...optId: string[]): (Collection) + } + interface newAuthCollection { + /** + * 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.). + */ + (name: string, ...optId: string[]): (Collection) + } + interface Collection { + /** + * TableName returns the Collection model SQL table name. + */ + tableName(): string + } + interface Collection { + /** + * BaseFilesPath returns the storage dir path used by the collection. + */ + baseFilesPath(): string + } + interface Collection { + /** + * IsBase checks if the current collection has "base" type. + */ + isBase(): boolean + } + interface Collection { + /** + * IsAuth checks if the current collection has "auth" type. + */ + isAuth(): boolean + } + interface Collection { + /** + * IsView checks if the current collection has "view" type. + */ + isView(): boolean + } + interface Collection { + /** + * IntegrityChecks toggles the current collection integrity checks (ex. checking references on delete). + */ + integrityChecks(enable: boolean): void + } + interface Collection { + /** + * PostScan implements the [dbx.PostScanner] interface to auto unmarshal + * the raw serialized options into the concrete type specific fields. + */ + postScan(): void + } + interface Collection { + /** + * 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. + */ + unmarshalJSON(b: string|Array): void + } + interface Collection { + /** + * 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). + */ + marshalJSON(): string|Array + } + interface Collection { + /** + * String returns a string representation of the current collection. + */ + string(): string + } + interface Collection { + /** + * DBExport prepares and exports the current collection data for db persistence. + */ + dbExport(app: App): _TygojaDict + } + interface Collection { + /** + * GetIndex returns s single Collection index expression by its name. + */ + getIndex(name: string): string + } + interface Collection { + /** + * 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. + */ + addIndex(name: string, unique: boolean, columnsExpr: string, optWhereExpr: string): void + } + interface Collection { + /** + * RemoveIndex removes a single index with the specified name from the current collection. + */ + removeIndex(name: string): void + } + /** + * collectionAuthOptions defines the options for the "auth" type collection. + */ + interface collectionAuthOptions { + /** + * 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 + /** + * 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 + /** + * AuthAlert defines options related to the auth alerts on new device login. + */ + authAlert: AuthAlertConfig + /** + * OAuth2 specifies whether OAuth2 auth is enabled for the collection + * and which OAuth2 providers are allowed. + */ + oauth2: OAuth2Config + /** + * PasswordAuth defines options related to the collection password authentication. + */ + passwordAuth: PasswordAuthConfig + /** + * MFA defines options related to the Multi-factor authentication (MFA). + */ + mfa: MFAConfig + /** + * OTP defines options related to the One-time password authentication (OTP). + */ + otp: OTPConfig + /** + * Various token configurations + * --- + */ + authToken: TokenConfig + passwordResetToken: TokenConfig + emailChangeToken: TokenConfig + verificationToken: TokenConfig + fileToken: TokenConfig + /** + * Default email templates + * --- + */ + verificationTemplate: EmailTemplate + resetPasswordTemplate: EmailTemplate + confirmEmailChangeTemplate: EmailTemplate + } + interface EmailTemplate { + subject: string + body: string + } + interface EmailTemplate { + /** + * Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface EmailTemplate { + /** + * Resolve replaces the placeholder parameters in the current email + * template and returns its components as ready-to-use strings. + */ + resolve(placeholders: _TygojaDict): [string, string] + } + interface AuthAlertConfig { + enabled: boolean + emailTemplate: EmailTemplate + } + interface AuthAlertConfig { + /** + * Validate makes AuthAlertConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface TokenConfig { + secret: string + /** + * Duration specifies how long an issued token to be valid (in seconds) + */ + duration: number + } + interface TokenConfig { + /** + * Validate makes TokenConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface TokenConfig { + /** + * DurationTime returns the current Duration as [time.Duration]. + */ + durationTime(): time.Duration + } + interface OTPConfig { + enabled: boolean + /** + * Duration specifies how long the OTP to be valid (in seconds) + */ + duration: number + /** + * Length specifies the auto generated password length. + */ + length: number + /** + * 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 + } + interface OTPConfig { + /** + * Validate makes OTPConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface OTPConfig { + /** + * DurationTime returns the current Duration as [time.Duration]. + */ + durationTime(): time.Duration + } + interface MFAConfig { + enabled: boolean + /** + * Duration specifies how long an issued MFA to be valid (in seconds) + */ + duration: number + /** + * 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 + } + interface MFAConfig { + /** + * Validate makes MFAConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface MFAConfig { + /** + * DurationTime returns the current Duration as [time.Duration]. + */ + durationTime(): time.Duration + } + interface PasswordAuthConfig { + enabled: boolean + /** + * 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: Array + } + interface PasswordAuthConfig { + /** + * Validate makes PasswordAuthConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface OAuth2KnownFields { + id: string + name: string + username: string + avatarURL: string + } + interface OAuth2Config { + providers: Array + mappedFields: OAuth2KnownFields + enabled: boolean + } + interface OAuth2Config { + /** + * 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). + */ + unmarshalJSON(b: string|Array): void + } + interface OAuth2Config { + /** + * GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name. + * + * Returns false and zero config if no such provider is available in c.Providers. + */ + getProviderConfig(name: string): [OAuth2ProviderConfig, boolean] + } + interface OAuth2Config { + /** + * Validate makes OAuth2Config validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface OAuth2ProviderConfig { + /** + * 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?: boolean + name: string + clientId: string + clientSecret: string + authURL: string + tokenURL: string + userInfoURL: string + displayName: string + extra: _TygojaDict + } + interface OAuth2ProviderConfig { + /** + * Validate makes OAuth2ProviderConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface OAuth2ProviderConfig { + /** + * InitProvider returns a new auth.Provider instance loaded with the current OAuth2ProviderConfig options. + */ + initProvider(): auth.Provider + } + /** + * collectionBaseOptions defines the options for the "base" type collection. + */ + interface collectionBaseOptions { + } + /** + * collectionViewOptions defines the options for the "view" type collection. + */ + interface collectionViewOptions { + viewQuery: string + } + interface BaseApp { + /** + * CollectionQuery returns a new Collection select query. + */ + collectionQuery(): (dbx.SelectQuery) + } + interface BaseApp { + /** + * 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[]): Array<(Collection | undefined)> + } + interface BaseApp { + /** + * ReloadCachedCollections fetches all collections and caches them into the app store. + */ + reloadCachedCollections(): void + } + interface BaseApp { + /** + * FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id. + */ + findCollectionByNameOrId(nameOrId: string): (Collection) + } + interface BaseApp { + /** + * 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]. + * ``` + */ + findCachedCollectionByNameOrId(nameOrId: string): (Collection) + } + interface BaseApp { + /** + * 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[]): _TygojaDict + } + interface BaseApp { + /** + * 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]. + * ``` + */ + findCachedCollectionReferences(collection: Collection, ...excludeIds: string[]): _TygojaDict + } + interface BaseApp { + /** + * 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. + */ + isCollectionNameUnique(name: string, ...excludeIds: string[]): boolean + } + interface BaseApp { + /** + * 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. + */ + truncateCollection(collection: Collection): void + } + interface BaseApp { + /** + * 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. + */ + syncRecordTableSchema(newCollection: Collection, oldCollection: Collection): void + } + interface collectionValidator { + } + interface optionsValidator { + [key:string]: any; + } + /** + * DBExporter defines an interface for custom DB data export. + * Usually used as part of [App.Save]. + */ + interface DBExporter { + [key:string]: any; + /** + * DBExport returns a key-value map with the data to be used when saving the struct in the database. + */ + dbExport(app: App): _TygojaDict + } + /** + * PreValidator defines an optional model interface for registering a + * function that will run BEFORE firing the validation hooks (see [App.ValidateWithContext]). + */ + interface PreValidator { + [key:string]: any; + /** + * PreValidate defines a function that runs BEFORE the validation hooks. + */ + preValidate(ctx: context.Context, app: App): void + } + /** + * PostValidator defines an optional model interface for registering a + * function that will run AFTER executing the validation hooks (see [App.ValidateWithContext]). + */ + interface PostValidator { + [key:string]: any; + /** + * PostValidate defines a function that runs AFTER the successful + * execution of the validation hooks. + */ + postValidate(ctx: context.Context, app: App): void + } + interface generateDefaultRandomId { + /** + * GenerateDefaultRandomId generates a default random id string + * (note: the generated random string is not intended for security purposes). + */ + (): string + } + interface BaseApp { + /** + * ModelQuery creates a new preconfigured select data.db query with preset + * SELECT, FROM and other common fields based on the provided model. + */ + modelQuery(m: Model): (dbx.SelectQuery) + } + interface BaseApp { + /** + * AuxModelQuery creates a new preconfigured select auxiliary.db query with preset + * SELECT, FROM and other common fields based on the provided model. + */ + auxModelQuery(m: Model): (dbx.SelectQuery) + } + interface BaseApp { + /** + * Delete deletes the specified model from the regular app database. + */ + delete(model: Model): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * AuxDelete deletes the specified model from the auxiliary database. + */ + auxDelete(model: Model): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * Validate triggers the OnModelValidate hook for the specified model. + */ + validate(model: Model): void + } + interface BaseApp { + /** + * ValidateWithContext is the same as Validate but allows specifying the ModelEvent context. + */ + validateWithContext(ctx: context.Context, model: Model): void + } + /** + * note: expects both builder to use the same driver + */ + interface dualDBBuilder { + } + interface dualDBBuilder { + /** + * Select implements the [dbx.Builder.Select] interface method. + */ + select(...cols: string[]): (dbx.SelectQuery) + } + interface dualDBBuilder { + /** + * Model implements the [dbx.Builder.Model] interface method. + */ + model(data: { + }): (dbx.ModelQuery) + } + interface dualDBBuilder { + /** + * GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method. + */ + generatePlaceholder(i: number): string + } + interface dualDBBuilder { + /** + * Quote implements the [dbx.Builder.Quote] interface method. + */ + quote(str: string): string + } + interface dualDBBuilder { + /** + * QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method. + */ + quoteSimpleTableName(table: string): string + } + interface dualDBBuilder { + /** + * QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method. + */ + quoteSimpleColumnName(col: string): string + } + interface dualDBBuilder { + /** + * QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method. + */ + queryBuilder(): dbx.QueryBuilder + } + interface dualDBBuilder { + /** + * Insert implements the [dbx.Builder.Insert] interface method. + */ + insert(table: string, cols: dbx.Params): (dbx.Query) + } + interface dualDBBuilder { + /** + * Upsert implements the [dbx.Builder.Upsert] interface method. + */ + upsert(table: string, cols: dbx.Params, ...constraints: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * Update implements the [dbx.Builder.Update] interface method. + */ + update(table: string, cols: dbx.Params, where: dbx.Expression): (dbx.Query) + } + interface dualDBBuilder { + /** + * Delete implements the [dbx.Builder.Delete] interface method. + */ + delete(table: string, where: dbx.Expression): (dbx.Query) + } + interface dualDBBuilder { + /** + * CreateTable implements the [dbx.Builder.CreateTable] interface method. + */ + createTable(table: string, cols: _TygojaDict, ...options: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * RenameTable implements the [dbx.Builder.RenameTable] interface method. + */ + renameTable(oldName: string, newName: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * DropTable implements the [dbx.Builder.DropTable] interface method. + */ + dropTable(table: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * TruncateTable implements the [dbx.Builder.TruncateTable] interface method. + */ + truncateTable(table: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * AddColumn implements the [dbx.Builder.AddColumn] interface method. + */ + addColumn(table: string, col: string, typ: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * DropColumn implements the [dbx.Builder.DropColumn] interface method. + */ + dropColumn(table: string, col: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * RenameColumn implements the [dbx.Builder.RenameColumn] interface method. + */ + renameColumn(table: string, oldName: string, newName: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * AlterColumn implements the [dbx.Builder.AlterColumn] interface method. + */ + alterColumn(table: string, col: string, typ: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method. + */ + addPrimaryKey(table: string, name: string, ...cols: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method. + */ + dropPrimaryKey(table: string, name: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method. + */ + addForeignKey(table: string, name: string, cols: Array, refCols: Array, refTable: string, ...options: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method. + */ + dropForeignKey(table: string, name: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * CreateIndex implements the [dbx.Builder.CreateIndex] interface method. + */ + createIndex(table: string, name: string, ...cols: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method. + */ + createUniqueIndex(table: string, name: string, ...cols: string[]): (dbx.Query) + } + interface dualDBBuilder { + /** + * DropIndex implements the [dbx.Builder.DropIndex] interface method. + */ + dropIndex(table: string, name: string): (dbx.Query) + } + interface dualDBBuilder { + /** + * NewQuery implements the [dbx.Builder.NewQuery] interface method by + * routing the SELECT queries to the concurrent builder instance. + */ + newQuery(str: string): (dbx.Query) + } + interface defaultDBConnect { + (dbPath: string): (dbx.DB) + } + /** + * Model defines an interface with common methods that all db models should have. + * + * Note: for simplicity composite pk are not supported. + */ + interface Model { + [key:string]: any; + tableName(): string + pk(): any + lastSavedPK(): any + isNew(): boolean + markAsNew(): void + markAsNotNew(): void + } + /** + * BaseModel defines a base struct that is intended to be embedded into other custom models. + */ + interface BaseModel { + /** + * Id is the primary key of the model. + * It is usually autogenerated by the parent model implementation. + */ + id: string + } + interface BaseModel { + /** + * LastSavedPK returns the last saved primary key of the model. + * + * Its value is updated to the latest PK value after MarkAsNotNew() or PostScan() calls. + */ + lastSavedPK(): any + } + interface BaseModel { + pk(): any + } + interface BaseModel { + /** + * IsNew indicates what type of db query (insert or update) + * should be used with the model instance. + */ + isNew(): boolean + } + interface BaseModel { + /** + * MarkAsNew clears the pk field and marks the current model as "new" + * (aka. forces m.IsNew() to be true). + */ + markAsNew(): void + } + interface BaseModel { + /** + * MarkAsNew set the pk field to the Id value and marks the current model + * as NOT "new" (aka. forces m.IsNew() to be false). + */ + markAsNotNew(): void + } + interface BaseModel { + /** + * PostScan implements the [dbx.PostScanner] interface. + * + * It is usually executed right after the model is populated with the db row values. + */ + postScan(): void + } + interface BaseApp { + /** + * TableColumns returns all column names of a single table by its name. + */ + tableColumns(tableName: string): Array + } + interface TableInfoRow { + /** + * the `db:"pk"` tag has special semantic so we cannot rename + * the original field without specifying a custom mapper + */ + pk: number + index: number + name: string + type: string + notNull: boolean + defaultValue: sql.NullString + } + interface BaseApp { + /** + * TableInfo returns the "table_info" pragma result for the specified table. + */ + tableInfo(tableName: string): Array<(TableInfoRow | undefined)> + } + interface BaseApp { + /** + * 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): _TygojaDict + } + interface BaseApp { + /** + * 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): void + } + interface BaseApp { + /** + * HasTable checks if a table (or view) with the provided name exists (case insensitive). + * in the data.db. + */ + hasTable(tableName: string): boolean + } + interface BaseApp { + /** + * AuxHasTable checks if a table (or view) with the provided name exists (case insensitive) + * in the auixiliary.db. + */ + auxHasTable(tableName: string): boolean + } + interface BaseApp { + /** + * Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space. + */ + vacuum(): void + } + interface BaseApp { + /** + * AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space. + */ + auxVacuum(): void + } + interface BaseApp { + /** + * 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: (txApp: App) => void): void + } + interface BaseApp { + /** + * 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: (txApp: App) => void): void + } + /** + * TxAppInfo represents an active transaction context associated to an existing app instance. + */ + interface TxAppInfo { + } + interface TxAppInfo { + /** + * 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. + */ + onComplete(fn: (txErr: Error) => void): void + } + /** + * RequestEvent defines the PocketBase router handler event. + */ + type _sUyRsEx = router.Event + interface RequestEvent extends _sUyRsEx { + app: App + auth?: Record + } + interface RequestEvent { + /** + * 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(). + */ + realIP(): string + } + interface RequestEvent { + /** + * HasSuperuserAuth checks whether the current RequestEvent has superuser authentication loaded. + */ + hasSuperuserAuth(): boolean + } + interface RequestEvent { + /** + * 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. + */ + requestInfo(): (RequestInfo) + } + /** + * 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. + */ + interface RequestInfo { + query: _TygojaDict + headers: _TygojaDict + body: _TygojaDict + auth?: Record + method: string + context: string + } + interface RequestInfo { + /** + * HasSuperuserAuth checks whether the current RequestInfo instance + * has superuser authentication loaded. + */ + hasSuperuserAuth(): boolean + } + interface RequestInfo { + /** + * Clone creates a new shallow copy of the current RequestInfo and its Auth record (if any). + */ + clone(): (RequestInfo) + } + type _sZJCoJr = hook.Event&RequestEvent + interface BatchRequestEvent extends _sZJCoJr { + batch: Array<(InternalRequest | undefined)> + } + interface InternalRequest { + /** + * note: for uploading files the value must be either *filesystem.File or []*filesystem.File + */ + body: _TygojaDict + headers: _TygojaDict + method: string + url: string + } + interface InternalRequest { + validate(): void + } + interface HookTagger { + [key:string]: any; + hookTags(): Array + } + interface baseModelEventData { + model: Model + } + interface baseModelEventData { + tags(): Array + } + interface baseRecordEventData { + record?: Record + } + interface baseRecordEventData { + tags(): Array + } + interface baseCollectionEventData { + collection?: Collection + } + interface baseCollectionEventData { + /** + * @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? + */ + tags(): Array + } + type _sWToFCk = hook.Event + interface BootstrapEvent extends _sWToFCk { + app: App + } + type _smZKWnr = hook.Event + interface TerminateEvent extends _smZKWnr { + app: App + isRestart: boolean + } + type _sTCjEkl = hook.Event + interface BackupEvent extends _sTCjEkl { + app: App + context: context.Context + name: string // the name of the backup to create/restore. + exclude: Array // list of dir entries to exclude from the backup create/restore. + } + type _sGxiQow = hook.Event + interface ServeEvent extends _sGxiQow { + app: App + router?: router.Router + server?: http.Server + certManager?: any + /** + * 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: (app: App, systemSuperuser: Record, baseURL: string) => void + /** + * @todo experimental + * + * UIExtensions is a list with the superuser UI extensions. + */ + uiExtensions: Array + } + interface UIExtension { + /** + * 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 + } + type _sDPyaIU = hook.Event&RequestEvent + interface SettingsListRequestEvent extends _sDPyaIU { + settings?: Settings + } + type _sxTFDvY = hook.Event&RequestEvent + interface SettingsUpdateRequestEvent extends _sxTFDvY { + oldSettings?: Settings + newSettings?: Settings + } + type _sWrGpKk = hook.Event + interface SettingsReloadEvent extends _sWrGpKk { + app: App + } + type _sfBOjLN = hook.Event + interface MailerEvent extends _sfBOjLN { + app: App + mailer: mailer.Mailer + message?: mailer.Message + } + type _sERsoqP = MailerEvent&baseRecordEventData + interface MailerRecordEvent extends _sERsoqP { + meta: _TygojaDict + } + type _sperqDW = hook.Event&filesystem.NewWriterEvent + interface FilesystemNewWriterEvent extends _sperqDW { + app: App + } + type _sCfjHZy = hook.Event&filesystem.DeleteEvent + interface FilesystemDeleteEvent extends _sCfjHZy { + app: App + } + type _scDCSHp = hook.Event&baseModelEventData + interface ModelEvent extends _scDCSHp { + app: App + context: context.Context + /** + * Could be any of the ModelEventType* constants, like: + * - create + * - update + * - delete + * - validate + */ + type: string + } + type _sALZaXI = ModelEvent + interface ModelErrorEvent extends _sALZaXI { + error: Error + } + type _stFRgZA = hook.Event&baseRecordEventData + interface RecordEvent extends _stFRgZA { + app: App + context: context.Context + /** + * Could be any of the ModelEventType* constants, like: + * - create + * - update + * - delete + * - validate + */ + type: string + } + type _speNxtN = RecordEvent + interface RecordErrorEvent extends _speNxtN { + error: Error + } + type _syZiqpC = hook.Event&baseCollectionEventData + interface CollectionEvent extends _syZiqpC { + app: App + context: context.Context + /** + * Could be any of the ModelEventType* constants, like: + * - create + * - update + * - delete + * - validate + */ + type: string + } + type _slinOLR = CollectionEvent + interface CollectionErrorEvent extends _slinOLR { + error: Error + } + type _sKMOTuP = hook.Event&RequestEvent&baseRecordEventData + interface FileTokenRequestEvent extends _sKMOTuP { + token: string + } + type _szbAeXA = hook.Event&RequestEvent&baseCollectionEventData + interface FileDownloadRequestEvent extends _szbAeXA { + record?: Record + fileField?: FileField + servedPath: string + servedName: string + /** + * ThumbError indicates the a thumb wasn't able to be generated + * (e.g. because it didn't satisfy the support image formats or it timed out). + * + * Note that PocketBase fallbacks to the original file in case of a thumb error, + * but developers can check the field and provide their own custom thumb generation if necessary. + */ + thumbError: Error + } + type _sOvVPbi = hook.Event&RequestEvent + interface CollectionsListRequestEvent extends _sOvVPbi { + collections: Array<(Collection | undefined)> + result?: search.Result + } + type _syrdJOc = hook.Event&RequestEvent + interface CollectionsImportRequestEvent extends _syrdJOc { + collectionsData: Array<_TygojaDict> + deleteMissing: boolean + } + type _szXPzbW = hook.Event&RequestEvent&baseCollectionEventData + interface CollectionRequestEvent extends _szXPzbW { + } + type _siDGoNI = hook.Event&RequestEvent + interface RealtimeConnectRequestEvent extends _siDGoNI { + client: subscriptions.Client + /** + * 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 _syfvWpH = hook.Event&RequestEvent + interface RealtimeMessageEvent extends _syfvWpH { + client: subscriptions.Client + message?: subscriptions.Message + } + type _smFBejm = hook.Event&RequestEvent + interface RealtimeSubscribeRequestEvent extends _smFBejm { + client: subscriptions.Client + subscriptions: Array + } + type _sqPQZgw = hook.Event&RequestEvent&baseCollectionEventData + interface RecordsListRequestEvent extends _sqPQZgw { + /** + * @todo consider removing and maybe add as generic to the search.Result? + */ + records: Array<(Record | undefined)> + result?: search.Result + } + type _sFxjYmX = hook.Event&RequestEvent&baseCollectionEventData + interface RecordRequestEvent extends _sFxjYmX { + record?: Record + } + type _sOBlEOz = hook.Event&baseRecordEventData + interface RecordEnrichEvent extends _sOBlEOz { + app: App + requestInfo?: RequestInfo + } + type _sYaRmCn = hook.Event&RequestEvent&baseCollectionEventData + interface RecordCreateOTPRequestEvent extends _sYaRmCn { + record?: Record + password: string + } + type _sRYgkcO = hook.Event&RequestEvent&baseCollectionEventData + interface RecordAuthWithOTPRequestEvent extends _sRYgkcO { + record?: Record + otp?: OTP + } + type _sXqklQL = hook.Event&RequestEvent&baseCollectionEventData + interface RecordAuthRequestEvent extends _sXqklQL { + record?: Record + token: string + meta: any + authMethod: string + } + type _spUzQTO = hook.Event&RequestEvent&baseCollectionEventData + interface RecordAuthWithPasswordRequestEvent extends _spUzQTO { + record?: Record + identity: string + identityField: string + password: string + } + type _sJNvYPO = hook.Event&RequestEvent&baseCollectionEventData + interface RecordAuthWithOAuth2RequestEvent extends _sJNvYPO { + providerName: string + providerClient: auth.Provider + record?: Record + oAuth2User?: auth.AuthUser + createData: _TygojaDict + isNewRecord: boolean + } + type _sYbYktr = hook.Event&RequestEvent&baseCollectionEventData + interface RecordAuthRefreshRequestEvent extends _sYbYktr { + record?: Record + } + type _sGqIlPy = hook.Event&RequestEvent&baseCollectionEventData + interface RecordRequestPasswordResetRequestEvent extends _sGqIlPy { + record?: Record + } + type _spiKNvN = hook.Event&RequestEvent&baseCollectionEventData + interface RecordConfirmPasswordResetRequestEvent extends _spiKNvN { + record?: Record + } + type _smZCoNJ = hook.Event&RequestEvent&baseCollectionEventData + interface RecordRequestVerificationRequestEvent extends _smZCoNJ { + record?: Record + } + type _suGuuwF = hook.Event&RequestEvent&baseCollectionEventData + interface RecordConfirmVerificationRequestEvent extends _suGuuwF { + record?: Record + } + type _stkfrPw = hook.Event&RequestEvent&baseCollectionEventData + interface RecordRequestEmailChangeRequestEvent extends _stkfrPw { + record?: Record + newEmail: string + } + type _svBztrp = hook.Event&RequestEvent&baseCollectionEventData + interface RecordConfirmEmailChangeRequestEvent extends _svBztrp { + record?: Record + newEmail: string + } + /** + * ExternalAuth defines a Record proxy for working with the externalAuths collection. + */ + type _sBRQHly = Record + interface ExternalAuth extends _sBRQHly { + } + interface newExternalAuth { + /** + * 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) + * ``` + */ + (app: App): (ExternalAuth) + } + interface ExternalAuth { + /** + * PreValidate implements the [PreValidator] interface and checks + * whether the proxy is properly loaded. + */ + preValidate(ctx: context.Context, app: App): void + } + interface ExternalAuth { + /** + * ProxyRecord returns the proxied Record model. + */ + proxyRecord(): (Record) + } + interface ExternalAuth { + /** + * SetProxyRecord loads the specified record model into the current proxy. + */ + setProxyRecord(record: Record): void + } + interface ExternalAuth { + /** + * CollectionRef returns the "collectionRef" field value. + */ + collectionRef(): string + } + interface ExternalAuth { + /** + * SetCollectionRef updates the "collectionRef" record field value. + */ + setCollectionRef(collectionId: string): void + } + interface ExternalAuth { + /** + * RecordRef returns the "recordRef" record field value. + */ + recordRef(): string + } + interface ExternalAuth { + /** + * SetRecordRef updates the "recordRef" record field value. + */ + setRecordRef(recordId: string): void + } + interface ExternalAuth { + /** + * Provider returns the "provider" record field value. + */ + provider(): string + } + interface ExternalAuth { + /** + * SetProvider updates the "provider" record field value. + */ + setProvider(provider: string): void + } + interface ExternalAuth { + /** + * Provider returns the "providerId" record field value. + */ + providerId(): string + } + interface ExternalAuth { + /** + * SetProvider updates the "providerId" record field value. + */ + setProviderId(providerId: string): void + } + interface ExternalAuth { + /** + * Created returns the "created" record field value. + */ + created(): types.DateTime + } + interface ExternalAuth { + /** + * Updated returns the "updated" record field value. + */ + updated(): types.DateTime + } + interface BaseApp { + /** + * FindAllExternalAuthsByRecord returns all ExternalAuth models + * linked to the provided auth record. + */ + findAllExternalAuthsByRecord(authRecord: Record): Array<(ExternalAuth | undefined)> + } + interface BaseApp { + /** + * FindAllExternalAuthsByCollection returns all ExternalAuth models + * linked to the provided auth collection. + */ + findAllExternalAuthsByCollection(collection: Collection): Array<(ExternalAuth | undefined)> + } + interface BaseApp { + /** + * FindFirstExternalAuthByExpr returns the first available (the most recent created) + * ExternalAuth model that satisfies the non-nil expression. + */ + findFirstExternalAuthByExpr(expr: dbx.Expression): (ExternalAuth) + } + interface BaseApp { + /** + * DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record. + * + * Returns a combined error with the failed deletes. + */ + deleteAllExternalAuthsByRecord(authRecord: Record): void + } + /** + * FieldFactoryFunc defines a simple function to construct a specific Field instance. + */ + interface FieldFactoryFunc {(): Field } + /** + * Field defines a common interface that all Collection fields should implement. + */ + interface Field { + [key:string]: any; + /** + * GetId returns the field id. + */ + getId(): string + /** + * SetId changes the field id. + */ + setId(id: string): void + /** + * GetName returns the field name. + */ + getName(): string + /** + * SetName changes the field name. + */ + setName(name: string): void + /** + * GetSystem returns the field system flag state. + */ + getSystem(): boolean + /** + * SetSystem changes the field system flag state. + */ + setSystem(system: boolean): void + /** + * GetHidden returns the field hidden flag state. + */ + getHidden(): boolean + /** + * SetHidden changes the field hidden flag state. + */ + setHidden(hidden: boolean): void + /** + * 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 + /** + * ValidateSettings validates the current field value associated with the provided record. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + /** + * ValidateSettings validates the current field settings. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + /** + * MaxBodySizeCalculator defines an optional field interface for + * specifying the max size of a field value. + */ + interface MaxBodySizeCalculator { + [key:string]: any; + /** + * CalculateMaxBodySize returns the approximate max body size of a field value. + */ + calculateMaxBodySize(): number + } + interface SetterFunc {(record: Record, raw: any): void } + /** + * SetterFinder defines a field interface for registering custom field value setters. + */ + interface SetterFinder { + [key:string]: any; + /** + * 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 + } + interface GetterFunc {(record: Record): any } + /** + * GetterFinder defines a field interface for registering custom field value getters. + */ + interface GetterFinder { + [key:string]: any; + /** + * 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. + */ + interface DriverValuer { + [key:string]: any; + /** + * DriverValue exports a single field value for persistence in the database. + */ + driverValue(record: Record): any + } + /** + * MultiValuer defines a field interface that every multi-valued (eg. with MaxSelect) field has. + */ + interface MultiValuer { + [key:string]: any; + /** + * IsMultiple checks whether the field is configured to support multiple or single values. + */ + isMultiple(): boolean + } + /** + * RecordInterceptor defines a field interface for reacting to various + * Record related operations (create, delete, validate, etc.). + */ + interface RecordInterceptor { + [key:string]: any; + /** + * 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: () => void): void + } + interface defaultFieldHelpValidationRule { + /** + * DefaultFieldHelpValidationRule performs base validation on a field's "help" value. + */ + (value: any): void + } + interface defaultFieldIdValidationRule { + /** + * DefaultFieldIdValidationRule performs base validation on a field id value. + */ + (value: any): void + } + interface defaultFieldNameValidationRule { + /** + * DefaultFieldIdValidationRule performs base validation on a field name value. + */ + (value: any): void + } + /** + * 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. + */ + interface AutodateField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * OnCreate auto sets the current datetime as field value on record create. + */ + onCreate: boolean + /** + * OnUpdate auto sets the current datetime as field value on record update. + */ + onUpdate: boolean + } + interface AutodateField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface AutodateField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface AutodateField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface AutodateField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface AutodateField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface AutodateField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface AutodateField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface AutodateField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface AutodateField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface AutodateField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface AutodateField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface AutodateField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface AutodateField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface AutodateField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } + interface AutodateField { + /** + * Intercept implements the [RecordInterceptor] interface. + */ + intercept(ctx: context.Context, app: App, record: Record, actionName: string, actionFunc: () => void): void + } + /** + * BoolField defines "bool" type field to store a single true/false value. + * + * The respective zero record field value is false. + */ + interface BoolField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Required will require the field value to be always "true". + */ + required: boolean + } + interface BoolField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface BoolField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface BoolField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface BoolField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface BoolField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface BoolField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface BoolField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface BoolField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface BoolField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface BoolField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface BoolField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface BoolField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface BoolField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + /** + * DateField defines "date" type field to store a single [types.DateTime] value. + * + * The respective zero record field value is the zero [types.DateTime]. + */ + interface DateField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Min specifies the min allowed field value. + * + * Leave it empty to skip the validator. + */ + min: types.DateTime + /** + * Max specifies the max allowed field value. + * + * Leave it empty to skip the validator. + */ + max: types.DateTime + /** + * Required will require the field value to be non-zero [types.DateTime]. + */ + required: boolean + } + interface DateField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface DateField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface DateField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface DateField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface DateField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface DateField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface DateField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface DateField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface DateField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface DateField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface DateField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface DateField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface DateField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + /** + * EditorField defines "editor" type field to store HTML formatted text. + * + * The respective zero record field value is empty string. + */ + interface EditorField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * 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: number + /** + * 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: boolean + /** + * Required will require the field value to be non-empty string. + */ + required: boolean + } + interface EditorField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface EditorField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface EditorField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface EditorField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface EditorField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface EditorField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface EditorField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface EditorField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface EditorField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface EditorField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface EditorField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface EditorField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface EditorField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface EditorField { + /** + * CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface. + */ + calculateMaxBodySize(): number + } + /** + * EmailField defines "email" type field for storing a single email string address. + * + * The respective zero record field value is empty string. + */ + interface EmailField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * 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: Array + /** + * OnlyDomains will require the email domain to be included in the listed ones. + * + * This validator can be set only if ExceptDomains is empty. + */ + onlyDomains: Array + /** + * Required will require the field value to be non-empty email string. + */ + required: boolean + } + interface EmailField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface EmailField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface EmailField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface EmailField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface EmailField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface EmailField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface EmailField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface EmailField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface EmailField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface EmailField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface EmailField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface EmailField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface EmailField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + /** + * 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") + * ``` + */ + interface FileField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * 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: number + /** + * MaxSelect specifies the max allowed files. + * + * For multiple files the value must be > 1, otherwise fallbacks to single (default). + */ + maxSelect: number + /** + * MimeTypes specifies an optional list of the allowed file mime types. + * + * Leave it empty to disable the validator. + */ + mimeTypes: Array + /** + * 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: Array + /** + * 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: boolean + /** + * Required will require the field value to have at least one file. + */ + required: boolean + } + interface FileField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface FileField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface FileField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface FileField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface FileField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface FileField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface FileField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface FileField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface FileField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface FileField { + /** + * IsMultiple implements MultiValuer interface and checks whether the + * current field options support multiple values. + */ + isMultiple(): boolean + } + interface FileField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface FileField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface FileField { + /** + * DriverValue implements the [DriverValuer] interface. + */ + driverValue(record: Record): any + } + interface FileField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface FileField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface FileField { + /** + * CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface. + */ + calculateMaxBodySize(): number + } + interface FileField { + /** + * Intercept implements the [RecordInterceptor] interface. + * + * note: files delete after records deletion is handled globally by the app FileManager hook + */ + intercept(ctx: context.Context, app: App, record: Record, actionName: string, actionFunc: () => void): void + } + interface FileField { + /** + * FindGetter implements the [GetterFinder] interface. + */ + findGetter(key: string): GetterFunc + } + interface FileField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } + /** + * 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}`) + * ``` + */ + interface GeoPointField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Required will require the field coordinates to be non-zero (aka. not "Null Island"). + */ + required: boolean + } + interface GeoPointField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface GeoPointField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface GeoPointField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface GeoPointField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface GeoPointField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface GeoPointField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface GeoPointField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface GeoPointField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface GeoPointField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface GeoPointField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface GeoPointField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface GeoPointField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface GeoPointField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + /** + * JSONField defines "json" type field for storing any serialized JSON value. + * + * The respective zero record field value is the zero [types.JSONRaw]. + */ + interface JSONField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * 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: number + /** + * Required will require the field value to be non-empty JSON value + * (aka. not "null", `""`, "[]", "{}"). + */ + required: boolean + } + interface JSONField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface JSONField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface JSONField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface JSONField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface JSONField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface JSONField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface JSONField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface JSONField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface JSONField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface JSONField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface JSONField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface JSONField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface JSONField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface JSONField { + /** + * CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface. + */ + calculateMaxBodySize(): number + } + /** + * 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) + * ``` + */ + interface NumberField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Min specifies the min allowed field value. + * + * Leave it nil to skip the validator. + */ + min?: number + /** + * Max specifies the max allowed field value. + * + * Leave it nil to skip the validator. + */ + max?: number + /** + * OnlyInt will require the field value to be integer. + */ + onlyInt: boolean + /** + * Required will require the field value to be non-zero. + */ + required: boolean + } + interface NumberField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface NumberField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface NumberField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface NumberField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface NumberField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface NumberField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface NumberField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface NumberField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface NumberField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface NumberField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface NumberField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface NumberField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface NumberField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface NumberField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } + /** + * 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") + * ``` + */ + interface PasswordField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * @todo remove + * + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Pattern specifies an optional regex pattern to match against the field value. + * + * Leave it empty to skip the pattern check. + */ + pattern: string + /** + * Min specifies an optional required field string length. + */ + min: number + /** + * Max specifies an optional required field string length. + * + * If zero, fallback to max 71 bytes. + */ + max: number + /** + * 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: number + /** + * Required will require the field value to be non-empty string. + */ + required: boolean + } + interface PasswordField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface PasswordField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface PasswordField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface PasswordField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface PasswordField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface PasswordField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface PasswordField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface PasswordField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface PasswordField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface PasswordField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface PasswordField { + /** + * DriverValue implements the [DriverValuer] interface. + */ + driverValue(record: Record): any + } + interface PasswordField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface PasswordField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface PasswordField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface PasswordField { + /** + * Intercept implements the [RecordInterceptor] interface. + */ + intercept(ctx: context.Context, app: App, record: Record, actionName: string, actionFunc: () => void): void + } + interface PasswordField { + /** + * FindGetter implements the [GetterFinder] interface. + */ + findGetter(key: string): GetterFunc + } + interface PasswordField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } + interface PasswordFieldValue { + lastError: Error + hash: string + plain: string + } + interface PasswordFieldValue { + validate(pass: string): boolean + } + /** + * 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"} + * ``` + */ + interface RelationField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * CollectionId is the id of the related collection. + */ + collectionId: string + /** + * CascadeDelete indicates whether the root model should be deleted + * in case of delete of all linked relations. + */ + cascadeDelete: boolean + /** + * 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: number + /** + * 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: number + /** + * Required will require the field value to be non-empty. + */ + required: boolean + } + interface RelationField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface RelationField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface RelationField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface RelationField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface RelationField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface RelationField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface RelationField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface RelationField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface RelationField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface RelationField { + /** + * IsMultiple implements [MultiValuer] interface and checks whether the + * current field options support multiple values. + */ + isMultiple(): boolean + } + interface RelationField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface RelationField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface RelationField { + /** + * DriverValue implements the [DriverValuer] interface. + */ + driverValue(record: Record): any + } + interface RelationField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface RelationField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface RelationField { + /** + * FindSetter implements [SetterFinder] interface method. + */ + findSetter(key: string): SetterFunc + } + /** + * 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"} + * ``` + */ + interface SelectField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Values specifies the list of accepted values. + */ + values: Array + /** + * MaxSelect specifies the max allowed selected values. + * + * For multiple select the value must be > 1, otherwise fallbacks to single (default). + */ + maxSelect: number + /** + * Required will require the field value to be non-empty. + */ + required: boolean + } + interface SelectField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface SelectField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface SelectField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface SelectField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface SelectField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface SelectField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface SelectField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface SelectField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface SelectField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface SelectField { + /** + * IsMultiple implements [MultiValuer] interface and checks whether the + * current field options support multiple values. + */ + isMultiple(): boolean + } + interface SelectField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface SelectField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface SelectField { + /** + * DriverValue implements the [DriverValuer] interface. + */ + driverValue(record: Record): any + } + interface SelectField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface SelectField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface SelectField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } + /** + * 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] + * ``` + */ + interface TextField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * Min specifies the minimum required string characters. + * + * if zero value, no min limit is applied. + */ + min: number + /** + * Max specifies the maximum allowed string characters. + * + * If zero, a default limit of 5000 is applied. + */ + max: number + /** + * Pattern specifies an optional regex pattern to match against the field value. + * + * Leave it empty to skip the pattern check. + */ + pattern: string + /** + * 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 + /** + * Required will require the field value to be non-empty string. + */ + required: boolean + /** + * PrimaryKey will mark the field as primary key. + * + * A single collection can have only 1 field marked as primary key. + */ + primaryKey: boolean + } + interface TextField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface TextField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface TextField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface TextField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface TextField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface TextField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface TextField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface TextField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface TextField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface TextField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface TextField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface TextField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface TextField { + /** + * ValidatePlainValue validates the provided string against the field options. + */ + validatePlainValue(value: string): void + } + interface TextField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface TextField { + /** + * Intercept implements the [RecordInterceptor] interface. + */ + intercept(ctx: context.Context, app: App, record: Record, actionName: string, actionFunc: () => void): void + } + interface TextField { + /** + * FindSetter implements the [SetterFinder] interface. + */ + findSetter(key: string): SetterFunc + } /** - * Reader is the interface that wraps the basic Read method. - * - * Read reads up to len(p) bytes into p. It returns the number of bytes - * read (0 <= n <= len(p)) and any error encountered. Even if Read - * returns n < len(p), it may use all of p as scratch space during the call. - * If some data is available but not len(p) bytes, Read conventionally - * returns what is available instead of waiting for more. - * - * When Read encounters an error or end-of-file condition after - * successfully reading n > 0 bytes, it returns the number of - * bytes read. It may return the (non-nil) error from the same call - * or return the error (and n == 0) from a subsequent call. - * An instance of this general case is that a Reader returning - * a non-zero number of bytes at the end of the input stream may - * return either err == EOF or err == nil. The next Read should - * return 0, EOF. - * - * Callers should always process the n > 0 bytes returned before - * considering the error err. Doing so correctly handles I/O errors - * that happen after reading some bytes and also both of the - * allowed EOF behaviors. - * - * Implementations of Read are discouraged from returning a - * zero byte count with a nil error, except when len(p) == 0. - * Callers should treat a return of 0 and nil as indicating that - * nothing happened; in particular it does not indicate EOF. + * URLField defines "url" type field for storing a single URL string value. * - * Implementations must not retain p. + * The respective zero record field value is empty string. */ - interface Reader { - read(p: string): number + interface URLField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Help is an extra text explaining what the field is about. + * It is usually shown in Dashboard UI under the field input. + */ + help: string + /** + * 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: Array + /** + * OnlyDomains will require the URL domain to be included in the listed ones. + * + * This validator can be set only if ExceptDomains is empty. + */ + onlyDomains: Array + /** + * Required will require the field value to be non-empty URL string. + */ + required: boolean + } + interface URLField { + /** + * Type implements [Field.Type] interface method. + */ + type(): string + } + interface URLField { + /** + * GetId implements [Field.GetId] interface method. + */ + getId(): string + } + interface URLField { + /** + * SetId implements [Field.SetId] interface method. + */ + setId(id: string): void + } + interface URLField { + /** + * GetName implements [Field.GetName] interface method. + */ + getName(): string + } + interface URLField { + /** + * SetName implements [Field.SetName] interface method. + */ + setName(name: string): void + } + interface URLField { + /** + * GetSystem implements [Field.GetSystem] interface method. + */ + getSystem(): boolean + } + interface URLField { + /** + * SetSystem implements [Field.SetSystem] interface method. + */ + setSystem(system: boolean): void + } + interface URLField { + /** + * GetHidden implements [Field.GetHidden] interface method. + */ + getHidden(): boolean + } + interface URLField { + /** + * SetHidden implements [Field.SetHidden] interface method. + */ + setHidden(hidden: boolean): void + } + interface URLField { + /** + * ColumnType implements [Field.ColumnType] interface method. + */ + columnType(app: App): string + } + interface URLField { + /** + * PrepareValue implements [Field.PrepareValue] interface method. + */ + prepareValue(record: Record, raw: any): any + } + interface URLField { + /** + * ValidateValue implements [Field.ValidateValue] interface method. + */ + validateValue(ctx: context.Context, app: App, record: Record): void + } + interface URLField { + /** + * ValidateSettings implements [Field.ValidateSettings] interface method. + */ + validateSettings(ctx: context.Context, app: App, collection: Collection): void + } + interface newFieldsList { + /** + * NewFieldsList creates a new FieldsList instance with the provided fields. + */ + (...fields: Field[]): FieldsList } /** - * Writer is the interface that wraps the basic Write method. - * - * Write writes len(p) bytes from p to the underlying data stream. - * It returns the number of bytes written from p (0 <= n <= len(p)) - * and any error encountered that caused the write to stop early. - * Write must return a non-nil error if it returns n < len(p). - * Write must not modify the slice data, even temporarily. - * - * Implementations must not retain p. + * FieldsList defines a Collection slice of fields. */ - interface Writer { - write(p: string): number + interface FieldsList extends Array{} + interface FieldsList { + /** + * Clone creates a deep clone of the current list. + */ + clone(): FieldsList + } + interface FieldsList { + /** + * FieldNames returns a slice with the name of all list fields. + */ + fieldNames(): Array + } + interface FieldsList { + /** + * AsMap returns a map with all registered list field. + * The returned map is indexed with each field name. + */ + asMap(): _TygojaDict + } + interface FieldsList { + /** + * GetById returns a single field by its id. + */ + getById(fieldId: string): Field + } + interface FieldsList { + /** + * GetByName returns a single field by its name. + */ + getByName(fieldName: string): Field + } + interface FieldsList { + /** + * RemoveById removes a single field by its id. + * + * This method does nothing if field with the specified id doesn't exist. + */ + removeById(fieldId: string): void + } + interface FieldsList { + /** + * RemoveByName removes a single field by its name. + * + * This method does nothing if field with the specified name doesn't exist. + */ + removeByName(fieldName: string): void + } + interface FieldsList { + /** + * 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). + */ + add(...fields: Field[]): void + } + interface FieldsList { + /** + * 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. + */ + addAt(pos: number, ...fields: Field[]): void + } + interface FieldsList { + /** + * 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"}]`}) + * ``` + */ + addMarshaledJSON(rawJSON: string|Array): void + } + interface FieldsList { + /** + * 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. + */ + addMarshaledJSONAt(pos: number, rawJSON: string|Array): void + } + interface FieldsList { + /** + * String returns the string representation of the current list. + */ + string(): string + } + interface onlyFieldType { + type: string + } + type _sJyylTf = Field + interface fieldWithType extends _sJyylTf { + type: string + } + interface fieldWithType { + unmarshalJSON(data: string|Array): void + } + interface FieldsList { + /** + * UnmarshalJSON implements [json.Unmarshaler] and + * loads the provided json data into the current FieldsList. + */ + unmarshalJSON(data: string|Array): void + } + interface FieldsList { + /** + * MarshalJSON implements the [json.Marshaler] interface. + */ + marshalJSON(): string|Array + } + interface FieldsList { + /** + * Value implements the [driver.Valuer] interface. + */ + value(): any + } + interface FieldsList { + /** + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current FieldsList instance. + */ + scan(value: any): void + } + type _swWdlTH = BaseModel + interface Log extends _swWdlTH { + created: types.DateTime + data: types.JSONMap + message: string + level: number + } + interface Log { + tableName(): string + } + interface Log { + /** + * 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. + */ + dbExport(app: App): _TygojaDict + } + interface BaseApp { + /** + * LogQuery returns a new Log select query. + */ + logQuery(): (dbx.SelectQuery) + } + interface BaseApp { + /** + * FindLogById finds a single Log entry by its id. + */ + findLogById(id: string): (Log) + } + /** + * LogsStatsItem defines the total number of logs for a specific time period. + */ + interface LogsStatsItem { + date: types.DateTime + total: number + } + interface BaseApp { + /** + * LogsStats returns hourly grouped logs statistics. + */ + logsStats(expr: dbx.Expression): Array<(LogsStatsItem | undefined)> + } + interface BaseApp { + /** + * 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. + */ + deleteOldLogs(createdBefore: time.Time): void } /** - * ReadSeekCloser is the interface that groups the basic Read, Seek and Close - * methods. + * MFA defines a Record proxy for working with the mfas collection. */ - interface ReadSeekCloser { + type _syuWtnG = Record + interface MFA extends _syuWtnG { } -} - -/** - * Package syscall contains an interface to the low-level operating system - * primitives. The details vary depending on the underlying system, and - * by default, godoc will display the syscall documentation for the current - * system. If you want godoc to display syscall documentation for another - * system, set $GOOS and $GOARCH to the desired system. For example, if - * you want to view documentation for freebsd/arm on linux/amd64, set $GOOS - * to freebsd and $GOARCH to arm. - * The primary use of syscall is inside other packages that provide a more - * portable interface to the system, such as "os", "time" and "net". Use - * those packages rather than this one if you can. - * For details of the functions and data types in this package consult - * the manuals for the appropriate operating system. - * These calls return err == nil to indicate success; otherwise - * err is an operating system error describing the failure. - * On most systems, that error has type syscall.Errno. - * - * Deprecated: this package is locked down. Callers should use the - * corresponding package in the golang.org/x/sys repository instead. - * That is also where updates required by new systems or versions - * should be applied. See https://golang.org/s/go1.4-syscall for more - * information. - */ -namespace syscall { - interface SysProcAttr { - chroot: string // Chroot. - credential?: Credential // Credential. + interface newMFA { /** - * Ptrace tells the child to call ptrace(PTRACE_TRACEME). - * Call runtime.LockOSThread before starting a process with this set, - * and don't call UnlockOSThread until done with PtraceSyscall calls. + * 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) + * ``` */ - ptrace: boolean - setsid: boolean // Create session. + (app: App): (MFA) + } + interface MFA { /** - * Setpgid sets the process group ID of the child to Pgid, - * or, if Pgid == 0, to the new child's process ID. + * PreValidate implements the [PreValidator] interface and checks + * whether the proxy is properly loaded. */ - setpgid: boolean + preValidate(ctx: context.Context, app: App): void + } + interface MFA { /** - * Setctty sets the controlling terminal of the child to - * file descriptor Ctty. Ctty must be a descriptor number - * in the child process: an index into ProcAttr.Files. - * This is only meaningful if Setsid is true. + * ProxyRecord returns the proxied Record model. */ - setctty: boolean - noctty: boolean // Detach fd 0 from controlling terminal - ctty: number // Controlling TTY fd + proxyRecord(): (Record) + } + interface MFA { /** - * Foreground places the child process group in the foreground. - * This implies Setpgid. The Ctty field must be set to - * the descriptor of the controlling TTY. - * Unlike Setctty, in this case Ctty must be a descriptor - * number in the parent process. + * SetProxyRecord loads the specified record model into the current proxy. */ - foreground: boolean - pgid: number // Child's process group ID if Setpgid. - pdeathsig: Signal // Signal that the process will get when its parent dies (Linux and FreeBSD only) - cloneflags: number // Flags for clone calls (Linux only) - unshareflags: number // Flags for unshare calls (Linux only) - uidMappings: Array // User ID mappings for user namespaces. - gidMappings: Array // Group ID mappings for user namespaces. + setProxyRecord(record: Record): void + } + interface MFA { /** - * GidMappingsEnableSetgroups enabling setgroups syscall. - * If false, then setgroups syscall will be disabled for the child process. - * This parameter is no-op if GidMappings == nil. Otherwise for unprivileged - * users this should be set to false for mappings work. + * CollectionRef returns the "collectionRef" field value. */ - gidMappingsEnableSetgroups: boolean - ambientCaps: Array // Ambient capabilities (Linux only) + collectionRef(): string } - // @ts-ignore - import errorspkg = errors - /** - * A RawConn is a raw network connection. - */ - interface RawConn { + interface MFA { /** - * Control invokes f on the underlying connection's file - * descriptor or handle. - * The file descriptor fd is guaranteed to remain valid while - * f executes but not after f returns. + * SetCollectionRef updates the "collectionRef" record field value. */ - control(f: (fd: number) => void): void + setCollectionRef(collectionId: string): void + } + interface MFA { /** - * Read invokes f on the underlying connection's file - * descriptor or handle; f is expected to try to read from the - * file descriptor. - * If f returns true, Read returns. Otherwise Read blocks - * waiting for the connection to be ready for reading and - * tries again repeatedly. - * The file descriptor is guaranteed to remain valid while f - * executes but not after f returns. + * RecordRef returns the "recordRef" record field value. */ - read(f: (fd: number) => boolean): void + recordRef(): string + } + interface MFA { /** - * Write is like Read but for writing. + * SetRecordRef updates the "recordRef" record field value. */ - write(f: (fd: number) => boolean): void + setRecordRef(recordId: string): void + } + interface MFA { + /** + * Method returns the "method" record field value. + */ + method(): string + } + interface MFA { + /** + * SetMethod updates the "method" record field value. + */ + setMethod(method: string): void + } + interface MFA { + /** + * Created returns the "created" record field value. + */ + created(): types.DateTime + } + interface MFA { + /** + * Updated returns the "updated" record field value. + */ + updated(): types.DateTime + } + interface MFA { + /** + * HasExpired checks if the mfa is expired, aka. whether it has been + * more than maxElapsed time since its creation. + */ + hasExpired(maxElapsed: time.Duration): boolean + } + interface BaseApp { + /** + * FindAllMFAsByRecord returns all MFA models linked to the provided auth record. + */ + findAllMFAsByRecord(authRecord: Record): Array<(MFA | undefined)> + } + interface BaseApp { + /** + * FindAllMFAsByCollection returns all MFA models linked to the provided collection. + */ + findAllMFAsByCollection(collection: Collection): Array<(MFA | undefined)> + } + interface BaseApp { + /** + * FindMFAById returns a single MFA model by its id. + */ + findMFAById(id: string): (MFA) + } + interface BaseApp { + /** + * DeleteAllMFAsByRecord deletes all MFA models associated with the provided record. + * + * Returns a combined error with the failed deletes. + */ + deleteAllMFAsByRecord(authRecord: Record): void + } + interface BaseApp { + /** + * DeleteExpiredMFAs deletes the expired MFAs for all auth collections. + */ + deleteExpiredMFAs(): void + } + interface Migration { + up: (txApp: App) => void + down: (txApp: App) => void + file: string + reapplyCondition: (txApp: App, runner: MigrationsRunner, fileName: string) => boolean } /** - * An Errno is an unsigned number describing an error condition. - * It implements the error interface. The zero Errno is by convention - * a non-error, so code to convert from Errno to error should use: - * ``` - * err = nil - * if errno != 0 { - * err = errno - * } - * ``` - * - * Errno values can be tested against error values from the os package - * using errors.Is. For example: - * - * ``` - * _, _, err := syscall.Syscall(...) - * if errors.Is(err, fs.ErrNotExist) ... - * ``` + * MigrationsList defines a list with migration definitions */ - interface Errno extends Number{} - interface Errno { - error(): string + interface MigrationsList { } - interface Errno { - is(target: Error): boolean + interface MigrationsList { + /** + * Item returns a single migration from the list by its index. + */ + item(index: number): (Migration) } - interface Errno { - temporary(): boolean + interface MigrationsList { + /** + * Items returns the internal migrations list slice. + */ + items(): Array<(Migration | undefined)> } - interface Errno { - timeout(): boolean + interface MigrationsList { + /** + * Copy copies all provided list migrations into the current one. + */ + copy(list: MigrationsList): void } -} - -/** - * Package time provides functionality for measuring and displaying time. - * - * The calendrical calculations always assume a Gregorian calendar, with - * no leap seconds. - * - * Monotonic Clocks - * - * Operating systems provide both a “wall clock,” which is subject to - * changes for clock synchronization, and a “monotonic clock,” which is - * not. The general rule is that the wall clock is for telling time and - * the monotonic clock is for measuring time. Rather than split the API, - * in this package the Time returned by time.Now contains both a wall - * clock reading and a monotonic clock reading; later time-telling - * operations use the wall clock reading, but later time-measuring - * operations, specifically comparisons and subtractions, use the - * monotonic clock reading. - * - * For example, this code always computes a positive elapsed time of - * approximately 20 milliseconds, even if the wall clock is changed during - * the operation being timed: - * - * ``` - * start := time.Now() - * ... operation that takes 20 milliseconds ... - * t := time.Now() - * elapsed := t.Sub(start) - * ``` - * - * Other idioms, such as time.Since(start), time.Until(deadline), and - * time.Now().Before(deadline), are similarly robust against wall clock - * resets. - * - * The rest of this section gives the precise details of how operations - * use monotonic clocks, but understanding those details is not required - * to use this package. - * - * The Time returned by time.Now contains a monotonic clock reading. - * If Time t has a monotonic clock reading, t.Add adds the same duration to - * both the wall clock and monotonic clock readings to compute the result. - * Because t.AddDate(y, m, d), t.Round(d), and t.Truncate(d) are wall time - * computations, they always strip any monotonic clock reading from their results. - * Because t.In, t.Local, and t.UTC are used for their effect on the interpretation - * of the wall time, they also strip any monotonic clock reading from their results. - * The canonical way to strip a monotonic clock reading is to use t = t.Round(0). - * - * If Times t and u both contain monotonic clock readings, the operations - * t.After(u), t.Before(u), t.Equal(u), and t.Sub(u) are carried out - * using the monotonic clock readings alone, ignoring the wall clock - * readings. If either t or u contains no monotonic clock reading, these - * operations fall back to using the wall clock readings. - * - * On some systems the monotonic clock will stop if the computer goes to sleep. - * On such a system, t.Sub(u) may not accurately reflect the actual - * time that passed between t and u. - * - * Because the monotonic clock reading has no meaning outside - * the current process, the serialized forms generated by t.GobEncode, - * t.MarshalBinary, t.MarshalJSON, and t.MarshalText omit the monotonic - * clock reading, and t.Format provides no format for it. Similarly, the - * constructors time.Date, time.Parse, time.ParseInLocation, and time.Unix, - * as well as the unmarshalers t.GobDecode, t.UnmarshalBinary. - * t.UnmarshalJSON, and t.UnmarshalText always create times with - * no monotonic clock reading. - * - * Note that the Go == operator compares not just the time instant but - * also the Location and the monotonic clock reading. See the - * documentation for the Time type for a discussion of equality - * testing for Time values. - * - * For debugging, the result of t.String does include the monotonic - * clock reading if present. If t != u because of different monotonic clock readings, - * that difference will be visible when printing t.String() and u.String(). - */ -namespace time { - interface Time { + interface MigrationsList { + /** + * 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. + */ + add(m: Migration): void + } + interface MigrationsList { + /** + * 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. + */ + register(up: (txApp: App) => void, down: (txApp: App) => void, ...optFilename: string[]): void + } + /** + * MigrationsRunner defines a simple struct for managing the execution of db migrations. + */ + interface MigrationsRunner { + } + interface newMigrationsRunner { /** - * String returns the time formatted using the format string + * NewMigrationsRunner creates and initializes a new db migrations MigrationsRunner instance. + */ + (app: App, migrationsList: MigrationsList): (MigrationsRunner) + } + interface MigrationsRunner { + /** + * 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 + */ + run(...args: string[]): void + } + interface MigrationsRunner { + /** + * Up executes all unapplied migrations for the provided runner. + * + * On success returns list with the applied migrations file names. + */ + up(): Array + } + interface MigrationsRunner { + /** + * Down reverts the last `toRevertCount` applied migrations + * (in the order they were applied). + * + * On success returns list with the reverted migrations file names. + */ + down(toRevertCount: number): Array + } + interface MigrationsRunner { + /** + * RemoveMissingAppliedMigrations removes the db entries of all applied migrations + * that are not listed in the runner's migrations list. + */ + removeMissingAppliedMigrations(): void + } + /** + * OTP defines a Record proxy for working with the otps collection. + */ + type _sezvJbc = Record + interface OTP extends _sezvJbc { + } + interface newOTP { + /** + * NewOTP instantiates and returns a new blank *OTP model. + * + * Example usage: + * * ``` - * "2006-01-02 15:04:05.999999999 -0700 MST" + * otp := core.NewOTP(app) + * otp.SetRecordRef(user.Id) + * otp.SetCollectionRef(user.Collection().Id) + * otp.SetPassword(security.RandomStringWithAlphabet(6, "1234567890")) + * app.Save(otp) * ``` + */ + (app: App): (OTP) + } + interface OTP { + /** + * PreValidate implements the [PreValidator] interface and checks + * whether the proxy is properly loaded. + */ + preValidate(ctx: context.Context, app: App): void + } + interface OTP { + /** + * ProxyRecord returns the proxied Record model. + */ + proxyRecord(): (Record) + } + interface OTP { + /** + * SetProxyRecord loads the specified record model into the current proxy. + */ + setProxyRecord(record: Record): void + } + interface OTP { + /** + * CollectionRef returns the "collectionRef" field value. + */ + collectionRef(): string + } + interface OTP { + /** + * SetCollectionRef updates the "collectionRef" record field value. + */ + setCollectionRef(collectionId: string): void + } + interface OTP { + /** + * RecordRef returns the "recordRef" record field value. + */ + recordRef(): string + } + interface OTP { + /** + * SetRecordRef updates the "recordRef" record field value. + */ + setRecordRef(recordId: string): void + } + interface OTP { + /** + * SentTo returns the "sentTo" record field value. * - * If the time has a monotonic clock reading, the returned string - * includes a final field "m=±", where value is the monotonic - * clock reading formatted as a decimal number of seconds. - * - * The returned string is meant for debugging; for a stable serialized - * representation, use t.MarshalText, t.MarshalBinary, or t.Format - * with an explicit format string. + * 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. */ - string(): string + sentTo(): string } - interface Time { + interface OTP { /** - * GoString implements fmt.GoStringer and formats t to be printed in Go source - * code. + * SetSentTo updates the "sentTo" record field value. */ - goString(): string + setSentTo(val: string): void } - interface Time { + interface OTP { /** - * Format returns a textual representation of the time value formatted according - * to the layout defined by the argument. See the documentation for the - * constant called Layout to see how to represent the layout format. + * Created returns the "created" record field value. + */ + created(): types.DateTime + } + interface OTP { + /** + * Updated returns the "updated" record field value. + */ + updated(): types.DateTime + } + interface OTP { + /** + * HasExpired checks if the otp is expired, aka. whether it has been + * more than maxElapsed time since its creation. + */ + hasExpired(maxElapsed: time.Duration): boolean + } + interface BaseApp { + /** + * FindAllOTPsByRecord returns all OTP models linked to the provided auth record. + */ + findAllOTPsByRecord(authRecord: Record): Array<(OTP | undefined)> + } + interface BaseApp { + /** + * FindAllOTPsByCollection returns all OTP models linked to the provided collection. + */ + findAllOTPsByCollection(collection: Collection): Array<(OTP | undefined)> + } + interface BaseApp { + /** + * FindOTPById returns a single OTP model by its id. + */ + findOTPById(id: string): (OTP) + } + interface BaseApp { + /** + * DeleteAllOTPsByRecord deletes all OTP models associated with the provided record. * - * The executable example for Time.Format demonstrates the working - * of the layout string in detail and is a good reference. + * Returns a combined error with the failed deletes. */ - format(layout: string): string + deleteAllOTPsByRecord(authRecord: Record): void } - interface Time { + interface BaseApp { /** - * AppendFormat is like Format but appends the textual - * representation to b and returns the extended buffer. + * DeleteExpiredOTPs deletes the expired OTPs for all auth collections. */ - appendFormat(b: string, layout: string): string + deleteExpiredOTPs(): void + } + interface ruleJoin { } /** - * A Time represents an instant in time with nanosecond precision. - * - * Programs using times should typically store and pass them as values, - * not pointers. That is, time variables and struct fields should be of - * type time.Time, not *time.Time. - * - * A Time value can be used by multiple goroutines simultaneously except - * that the methods GobDecode, UnmarshalBinary, UnmarshalJSON and - * UnmarshalText are not concurrency-safe. - * - * Time instants can be compared using the Before, After, and Equal methods. - * The Sub method subtracts two instants, producing a Duration. - * The Add method adds a Time and a Duration, producing a Time. - * - * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC. - * As this time is unlikely to come up in practice, the IsZero method gives - * a simple way of detecting a time that has not been initialized explicitly. - * - * Each Time has associated with it a Location, consulted when computing the - * presentation form of the time, such as in the Format, Hour, and Year methods. - * The methods Local, UTC, and In return a Time with a specific location. - * Changing the location in this way changes only the presentation; it does not - * change the instant in time being denoted and therefore does not affect the - * computations described in earlier paragraphs. - * - * Representations of a Time value saved by the GobEncode, MarshalBinary, - * MarshalJSON, and MarshalText methods store the Time.Location's offset, but not - * the location name. They therefore lose information about Daylight Saving Time. + * RecordFieldResolver defines a custom search resolver struct for + * managing Record model search fields. * - * In addition to the required “wall clock” reading, a Time may contain an optional - * reading of the current process's monotonic clock, to provide additional precision - * for comparison or subtraction. - * See the “Monotonic Clocks” section in the package documentation for details. + * Usually used together with `search.Provider`. + * Example: * - * Note that the Go == operator compares not just the time instant but also the - * Location and the monotonic clock reading. Therefore, Time values should not - * be used as map or database keys without first guaranteeing that the - * identical Location has been set for all values, which can be achieved - * through use of the UTC or Local method, and that the monotonic clock reading - * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u) - * to t == u, since t.Equal uses the most accurate comparison available and - * correctly handles the case when only one of its arguments has a monotonic - * clock reading. + * ``` + * resolver := resolvers.NewRecordFieldResolver( + * app, + * myCollection, + * &models.RequestInfo{...}, + * true, + * ) + * provider := search.NewProvider(resolver) + * ... + * ``` */ - interface Time { + interface RecordFieldResolver { } - interface Time { + interface RecordFieldResolver { /** - * After reports whether the time instant t is after u. + * AllowedFields returns a copy of the resolver's allowed fields. */ - after(u: Time): boolean + allowedFields(): Array } - interface Time { + interface RecordFieldResolver { /** - * Before reports whether the time instant t is before u. + * SetAllowedFields replaces the resolver's allowed fields with the new ones. */ - before(u: Time): boolean + setAllowedFields(newAllowedFields: Array): void } - interface Time { + interface RecordFieldResolver { /** - * Equal reports whether t and u represent the same time instant. - * Two times can be equal even if they are in different locations. - * For example, 6:00 +0200 and 4:00 UTC are Equal. - * See the documentation on the Time type for the pitfalls of using == with - * Time values; most code should use Equal instead. + * AllowHiddenFields returns whether the current resolver allows filtering hidden fields. */ - equal(u: Time): boolean + allowHiddenFields(): boolean } - interface Time { + interface RecordFieldResolver { /** - * IsZero reports whether t represents the zero time instant, - * January 1, year 1, 00:00:00 UTC. + * SetAllowHiddenFields enables or disables hidden fields filtering. */ - isZero(): boolean + setAllowHiddenFields(allowHiddenFields: boolean): void } - interface Time { + interface newRecordFieldResolver { /** - * Date returns the year, month, and day in which t occurs. + * NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. */ - date(): [number, Month, number] + (app: App, baseCollection: Collection, requestInfo: RequestInfo, allowHiddenFields: boolean): (RecordFieldResolver) } - interface Time { + interface RecordFieldResolver { /** - * Year returns the year in which t occurs. + * @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). */ - year(): number + updateQuery(query: dbx.SelectQuery): void } - interface Time { + interface RecordFieldResolver { /** - * Month returns the month of the year specified by t. + * 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 + * ``` */ - month(): Month + resolve(fieldName: string): (search.ResolverResult) } - interface Time { + interface mapExtractor { + [key:string]: any; + asMap(): _TygojaDict + } + /** + * replaceWithExpression defines a custom expression that will replace + * a placeholder identifier found in "old" with the result of "new". + */ + interface replaceWithExpression { + } + interface replaceWithExpression { /** - * Day returns the day of the month specified by t. + * Build converts the expression into a SQL fragment. + * + * Implements [dbx.Expression] interface. */ - day(): number + build(db: dbx.DB, params: dbx.Params): string } - interface Time { + interface runner { + } + type _sVGduGB = BaseModel + interface Record extends _sVGduGB { + } + interface newRecord { /** - * Weekday returns the day of the week specified by t. + * NewRecord initializes a new empty Record model. */ - weekday(): Weekday + (collection: Collection): (Record) } - interface Time { + interface Record { /** - * ISOWeek returns the ISO 8601 year and week number in which t occurs. - * Week ranges from 1 to 53. Jan 01 to Jan 03 of year n might belong to - * week 52 or 53 of year n-1, and Dec 29 to Dec 31 might belong to week 1 - * of year n+1. + * 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. */ - isoWeek(): number + collection(): (Collection) } - interface Time { + interface Record { /** - * Clock returns the hour, minute, and second within the day specified by t. + * TableName returns the table name associated with the current Record model. */ - clock(): number + tableName(): string } - interface Time { + interface Record { /** - * Hour returns the hour within the day specified by t, in the range [0, 23]. + * 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(). */ - hour(): number + postScan(): void } - interface Time { + interface Record { /** - * Minute returns the minute offset within the hour specified by t, in the range [0, 59]. + * HookTags returns the hook tags associated with the current record. */ - minute(): number + hookTags(): Array } - interface Time { + interface Record { /** - * Second returns the second offset within the minute specified by t, in the range [0, 59]. + * BaseFilesPath returns the storage dir path used by the record. */ - second(): number + baseFilesPath(): string } - interface Time { + interface Record { /** - * Nanosecond returns the nanosecond offset within the second specified by t, - * in the range [0, 999999999]. + * 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). */ - nanosecond(): number + original(): (Record) } - interface Time { + interface Record { /** - * YearDay returns the day of the year specified by t, in the range [1,365] for non-leap years, - * and [1,366] in leap years. + * 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). */ - yearDay(): number + fresh(): (Record) } - /** - * A Duration represents the elapsed time between two instants - * as an int64 nanosecond count. The representation limits the - * largest representable duration to approximately 290 years. - */ - interface Duration extends Number{} - interface Duration { + interface Record { /** - * String returns a string representing the duration in the form "72h3m0.5s". - * Leading zero units are omitted. As a special case, durations less than one - * second format use a smaller unit (milli-, micro-, or nanoseconds) to ensure - * that the leading digit is non-zero. The zero duration formats as 0s. + * 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. */ - string(): string + clone(): (Record) } - interface Duration { + interface Record { /** - * Nanoseconds returns the duration as an integer nanosecond count. + * Expand returns a shallow copy of the current Record model expand data (if any). */ - nanoseconds(): number + expand(): _TygojaDict } - interface Duration { + interface Record { /** - * Microseconds returns the duration as an integer microsecond count. + * SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied). */ - microseconds(): number + setExpand(expand: _TygojaDict): void } - interface Duration { + interface Record { /** - * Milliseconds returns the duration as an integer millisecond count. + * 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). */ - milliseconds(): number + mergeExpand(expand: _TygojaDict): void } - interface Duration { + interface Record { /** - * Seconds returns the duration as a floating point number of seconds. + * FieldsData returns a shallow copy ONLY of the collection's fields record's data. */ - seconds(): number + fieldsData(): _TygojaDict } - interface Duration { + interface Record { /** - * Minutes returns the duration as a floating point number of minutes. + * 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. */ - minutes(): number + customData(): _TygojaDict } - interface Duration { + interface Record { /** - * Hours returns the duration as a floating point number of hours. + * WithCustomData toggles the export/serialization of custom data fields + * (false by default). */ - hours(): number + withCustomData(state: boolean): (Record) } - interface Duration { + interface Record { /** - * Truncate returns the result of rounding d toward zero to a multiple of m. - * If m <= 0, Truncate returns d unchanged. + * IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check. */ - truncate(m: Duration): Duration + ignoreEmailVisibility(state: boolean): (Record) } - interface Duration { + interface Record { /** - * Round returns the result of rounding d to the nearest multiple of m. - * The rounding behavior for halfway values is to round away from zero. - * If the result exceeds the maximum (or minimum) - * value that can be stored in a Duration, - * Round returns the maximum (or minimum) duration. - * If m <= 0, Round returns d unchanged. + * 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). */ - round(m: Duration): Duration + ignoreUnchangedFields(state: boolean): (Record) } - interface Time { + interface Record { /** - * Add returns the time t+d. + * Set sets the provided key-value data pair into the current Record + * model directly as it is WITHOUT NORMALIZATIONS. + * + * See also [Record.Set]. */ - add(d: Duration): Time + setRaw(key: string, value: any): void } - interface Time { + interface Record { /** - * Sub returns the duration t-u. If the result exceeds the maximum (or minimum) - * value that can be stored in a Duration, the maximum (or minimum) duration - * will be returned. - * To compute t-d for a duration d, use t.Add(-d). + * 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. */ - sub(u: Time): Duration + setIfFieldExists(key: string, value: any): Field } - interface Time { + interface Record { /** - * AddDate returns the time corresponding to adding the - * given number of years, months, and days to t. - * For example, AddDate(-1, 2, 3) applied to January 1, 2011 - * returns March 4, 2010. + * Set sets the provided key-value data pair into the current Record model. * - * AddDate normalizes its result in the same way that Date does, - * so, for example, adding one month to October 31 yields - * December 1, the normalized form for November 31. + * If the record collection has field with name matching the provided "key", + * the value will be further normalized according to the field setter(s). */ - addDate(years: number, months: number, days: number): Time + set(key: string, value: any): void } - interface Time { + interface Record { + getRaw(key: string): any + } + interface Record { /** - * UTC returns t with the location set to UTC. + * Get returns a normalized single record model data value for "key". */ - utc(): Time + get(key: string): any } - interface Time { + interface Record { /** - * Local returns t with the location set to local time. + * Load bulk loads the provided data into the current Record model. */ - local(): Time + load(data: _TygojaDict): void } - interface Time { + interface Record { /** - * In returns a copy of t representing the same time instant, but - * with the copy's location information set to loc for display - * purposes. - * - * In panics if loc is nil. + * GetBool returns the data value for "key" as a bool. */ - in(loc: Location): Time + getBool(key: string): boolean } - interface Time { + interface Record { /** - * Location returns the time zone information associated with t. + * GetString returns the data value for "key" as a string. */ - location(): (Location | undefined) + getString(key: string): string } - interface Time { + interface Record { /** - * Zone computes the time zone in effect at time t, returning the abbreviated - * name of the zone (such as "CET") and its offset in seconds east of UTC. + * GetInt returns the data value for "key" as an int. */ - zone(): [string, number] + getInt(key: string): number } - interface Time { + interface Record { /** - * Unix returns t as a Unix time, the number of seconds elapsed - * since January 1, 1970 UTC. The result does not depend on the - * location associated with t. - * Unix-like operating systems often record time as a 32-bit - * count of seconds, but since the method here returns a 64-bit - * value it is valid for billions of years into the past or future. + * GetInt64 returns the data value for "key" as an int64. */ - unix(): number + getInt64(key: string): number } - interface Time { + interface Record { /** - * UnixMilli returns t as a Unix time, the number of milliseconds elapsed since - * January 1, 1970 UTC. The result is undefined if the Unix time in - * milliseconds cannot be represented by an int64 (a date more than 292 million - * years before or after 1970). The result does not depend on the - * location associated with t. + * GetFloat returns the data value for "key" as a float64. */ - unixMilli(): number + getFloat(key: string): number } - interface Time { + interface Record { /** - * UnixMicro returns t as a Unix time, the number of microseconds elapsed since - * January 1, 1970 UTC. The result is undefined if the Unix time in - * microseconds cannot be represented by an int64 (a date before year -290307 or - * after year 294246). The result does not depend on the location associated - * with t. + * GetDateTime returns the data value for "key" as a DateTime instance. */ - unixMicro(): number + getDateTime(key: string): types.DateTime } - interface Time { + interface Record { /** - * UnixNano returns t as a Unix time, the number of nanoseconds elapsed - * since January 1, 1970 UTC. The result is undefined if the Unix time - * in nanoseconds cannot be represented by an int64 (a date before the year - * 1678 or after 2262). Note that this means the result of calling UnixNano - * on the zero Time is undefined. The result does not depend on the - * location associated with t. + * GetGeoPoint returns the data value for "key" as a GeoPoint instance. */ - unixNano(): number + getGeoPoint(key: string): types.GeoPoint } - interface Time { + interface Record { /** - * MarshalBinary implements the encoding.BinaryMarshaler interface. + * GetStringSlice returns the data value for "key" as a slice of non-zero unique strings. */ - marshalBinary(): string + getStringSlice(key: string): Array } - interface Time { + interface Record { /** - * UnmarshalBinary implements the encoding.BinaryUnmarshaler interface. + * 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 + * ``` */ - unmarshalBinary(data: string): void + getUnsavedFiles(key: string): Array<(filesystem.File | undefined)> } - interface Time { + interface Record { /** - * GobEncode implements the gob.GobEncoder interface. + * Deprecated: replaced with GetUnsavedFiles. */ - gobEncode(): string + getUploadedFiles(key: string): Array<(filesystem.File | undefined)> } - interface Time { + interface Record { /** - * GobDecode implements the gob.GobDecoder interface. + * 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) + * ``` */ - gobDecode(data: string): void + unmarshalJSONField(key: string, result: any): void } - interface Time { + interface Record { /** - * MarshalJSON implements the json.Marshaler interface. - * The time is a quoted string in RFC 3339 format, with sub-second precision added if present. + * 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. */ - marshalJSON(): string + expandedOne(relField: string): (Record) } - interface Time { + interface Record { /** - * UnmarshalJSON implements the json.Unmarshaler interface. - * The time is expected to be a quoted string in RFC 3339 format. + * 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. */ - unmarshalJSON(data: string): void + expandedAll(relField: string): Array<(Record | undefined)> } - interface Time { + interface Record { /** - * MarshalText implements the encoding.TextMarshaler interface. - * The time is formatted in RFC 3339 format, with sub-second precision added if present. + * FindFileFieldByFile returns the first file type field for which + * any of the record's data contains the provided filename. */ - marshalText(): string + findFileFieldByFile(filename: string): (FileField) } - interface Time { + interface Record { /** - * UnmarshalText implements the encoding.TextUnmarshaler interface. - * The time is expected to be in RFC 3339 format. + * DBExport implements the [DBExporter] interface and returns a key-value + * map with the data to be persisted when saving the Record in the database. */ - unmarshalText(data: string): void + dbExport(app: App): _TygojaDict } - interface Time { + interface Record { /** - * IsDST reports whether the time in the configured location is in Daylight Savings Time. + * Hide hides the specified fields from the public safe serialization of the record. */ - isDST(): boolean + hide(...fieldNames: string[]): (Record) } - interface Time { + interface Record { /** - * Truncate returns the result of rounding t down to a multiple of d (since the zero time). - * If d <= 0, Truncate returns t stripped of any monotonic clock reading but otherwise unchanged. - * - * Truncate operates on the time as an absolute duration since the - * zero time; it does not operate on the presentation form of the - * time. Thus, Truncate(Hour) may return a time with a non-zero - * minute, depending on the time's Location. + * 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). */ - truncate(d: Duration): Time + unhide(...fieldNames: string[]): (Record) } - interface Time { + interface Record { /** - * Round returns the result of rounding t to the nearest multiple of d (since the zero time). - * The rounding behavior for halfway values is to round up. - * If d <= 0, Round returns t stripped of any monotonic clock reading but otherwise unchanged. + * PublicExport exports only the record fields that are safe to be public. * - * Round operates on the time as an absolute duration since the - * zero time; it does not operate on the presentation form of the - * time. Thus, Round(Hour) may return a time with a non-zero - * minute, depending on the time's Location. + * 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). */ - round(d: Duration): Time + publicExport(): _TygojaDict } -} - -/** - * Package fs defines basic interfaces to a file system. - * A file system can be provided by the host operating system - * but also by other packages. - */ -namespace fs { - /** - * An FS provides access to a hierarchical file system. - * - * The FS interface is the minimum implementation required of the file system. - * A file system may implement additional interfaces, - * such as ReadFileFS, to provide additional or optimized functionality. - */ - interface FS { + interface Record { /** - * Open opens the named file. - * - * When Open returns an error, it should be of type *PathError - * with the Op field set to "open", the Path field set to name, - * and the Err field describing the problem. + * MarshalJSON implements the [json.Marshaler] interface. * - * Open should reject attempts to open names that do not satisfy - * ValidPath(name), returning a *PathError with Err set to - * ErrInvalid or ErrNotExist. + * Only the data exported by `PublicExport()` will be serialized. */ - open(name: string): File + marshalJSON(): string|Array } - /** - * A File provides access to a single file. - * The File interface is the minimum implementation required of the file. - * Directory files should also implement ReadDirFile. - * A file may implement io.ReaderAt or io.Seeker as optimizations. - */ - interface File { - stat(): FileInfo - read(_arg0: string): number - close(): void + interface Record { + /** + * UnmarshalJSON implements the [json.Unmarshaler] interface. + */ + unmarshalJSON(data: string|Array): void } - /** - * A DirEntry is an entry read from a directory - * (using the ReadDir function or a ReadDirFile's ReadDir method). - */ - interface DirEntry { + interface Record { /** - * Name returns the name of the file (or subdirectory) described by the entry. - * This name is only the final element of the path (the base name), not the entire path. - * For example, Name would return "hello.go" not "home/gopher/hello.go". + * 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} + * ``` */ - name(): string + replaceModifiers(data: _TygojaDict): _TygojaDict + } + interface Record { /** - * IsDir reports whether the entry describes a directory. + * Email returns the "email" record field value (usually available with Auth collections). */ - isDir(): boolean + email(): string + } + interface Record { /** - * Type returns the type bits for the entry. - * The type bits are a subset of the usual FileMode bits, those returned by the FileMode.Type method. + * SetEmail sets the "email" record field value (usually available with Auth collections). */ - type(): FileMode + setEmail(email: string): void + } + interface Record { /** - * Info returns the FileInfo for the file or subdirectory described by the entry. - * The returned FileInfo may be from the time of the original directory read - * or from the time of the call to Info. If the file has been removed or renamed - * since the directory read, Info may return an error satisfying errors.Is(err, ErrNotExist). - * If the entry denotes a symbolic link, Info reports the information about the link itself, - * not the link's target. + * Verified returns the "emailVisibility" record field value (usually available with Auth collections). */ - info(): FileInfo + emailVisibility(): boolean } - /** - * A FileInfo describes a file and is returned by Stat. - */ - interface FileInfo { - name(): string // base name of the file - size(): number // length in bytes for regular files; system-dependent for others - mode(): FileMode // file mode bits - modTime(): time.Time // modification time - isDir(): boolean // abbreviation for Mode().IsDir() - sys(): any // underlying data source (can return nil) + interface Record { + /** + * SetEmailVisibility sets the "emailVisibility" record field value (usually available with Auth collections). + */ + setEmailVisibility(visible: boolean): void } - /** - * A FileMode represents a file's mode and permission bits. - * 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. - */ - interface FileMode extends Number{} - interface FileMode { - string(): string + interface Record { + /** + * Verified returns the "verified" record field value (usually available with Auth collections). + */ + verified(): boolean } - interface FileMode { + interface Record { /** - * IsDir reports whether m describes a directory. - * That is, it tests for the ModeDir bit being set in m. + * SetVerified sets the "verified" record field value (usually available with Auth collections). */ - isDir(): boolean + setVerified(verified: boolean): void } - interface FileMode { + interface Record { /** - * IsRegular reports whether m describes a regular file. - * That is, it tests that no mode type bits are set. + * TokenKey returns the "tokenKey" record field value (usually available with Auth collections). */ - isRegular(): boolean + tokenKey(): string } - interface FileMode { + interface Record { /** - * Perm returns the Unix permission bits in m (m & ModePerm). + * SetTokenKey sets the "tokenKey" record field value (usually available with Auth collections). */ - perm(): FileMode + setTokenKey(key: string): void } - interface FileMode { + interface Record { /** - * Type returns type bits in m (m & ModeType). + * RefreshTokenKey generates and sets a new random auth record "tokenKey". */ - type(): FileMode + refreshTokenKey(): void } - /** - * PathError records an error and the operation and file path that caused it. - */ - interface PathError { - op: string - path: string - err: Error + interface Record { + /** + * SetPassword sets the "password" record field value (usually available with Auth collections). + */ + setPassword(password: string): void } - interface PathError { - error(): string + interface Record { + /** + * 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). + */ + setRandomPassword(): string } - interface PathError { - unwrap(): void + interface Record { + /** + * ValidatePassword validates a plain password against the "password" record field. + * + * Returns false if the password is incorrect. + */ + validatePassword(password: string): boolean } - interface PathError { + interface Record { /** - * Timeout reports whether this error represents a timeout. + * IsSuperuser returns whether the current record is a superuser, aka. + * whether the record is from the _superusers collection. */ - timeout(): boolean + isSuperuser(): boolean } /** - * WalkDirFunc is the type of the function called by WalkDir to visit - * each file or directory. - * - * The path argument contains the argument to WalkDir as a prefix. - * That is, if WalkDir is called with root argument "dir" and finds a file - * named "a" in that directory, the walk function will be called with - * argument "dir/a". - * - * The d argument is the fs.DirEntry for the named path. - * - * The error result returned by the function controls how WalkDir - * continues. If the function returns the special value SkipDir, WalkDir - * skips the current directory (path if d.IsDir() is true, otherwise - * path's parent directory). Otherwise, if the function returns a non-nil - * error, WalkDir stops entirely and returns that error. + * 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. * - * The err argument reports an error related to path, signaling that - * WalkDir will not walk into that directory. The function can decide how - * to handle that error; as described earlier, returning the error will - * cause WalkDir to stop walking the entire tree. - * - * WalkDir calls the function with a non-nil err argument in two cases. - * - * First, if the initial fs.Stat on the root directory fails, WalkDir - * calls the function with path set to root, d set to nil, and err set to - * the error from fs.Stat. - * - * Second, if a directory's ReadDir method fails, WalkDir calls the - * function with path set to the directory's path, d set to an - * fs.DirEntry describing the directory, and err set to the error from - * ReadDir. In this second case, the function is called twice with the - * path of the directory: the first call is before the directory read is - * attempted and has err set to nil, giving the function a chance to - * return SkipDir and avoid the ReadDir entirely. The second call is - * after a failed ReadDir and reports the error from ReadDir. - * (If ReadDir succeeds, there is no second call.) - * - * The differences between WalkDirFunc compared to filepath.WalkFunc are: - * - * ``` - * - The second argument has type fs.DirEntry instead of fs.FileInfo. - * - The function is called before reading a directory, to allow SkipDir - * to bypass the directory read entirely. - * - If a directory read fails, the function is called a second time - * for that directory to report the error. - * ``` - */ - interface WalkDirFunc {(path: string, d: DirEntry, err: Error): void } -} - -/** - * Package bytes implements functions for the manipulation of byte slices. - * It is analogous to the facilities of the strings package. - */ -namespace bytes { - /** - * A Reader implements the io.Reader, io.ReaderAt, io.WriterTo, io.Seeker, - * io.ByteScanner, and io.RuneScanner interfaces by reading from - * a byte slice. - * Unlike a Buffer, a Reader is read-only and supports seeking. - * The zero value for Reader operates like a Reader of an empty slice. + * To implement the interface it is usually enough to embed the [BaseRecordProxy] struct. */ - interface Reader { - } - interface Reader { + interface RecordProxy { + [key:string]: any; /** - * Len returns the number of bytes of the unread portion of the - * slice. + * ProxyRecord returns the proxied Record model. */ - len(): number - } - interface Reader { + proxyRecord(): (Record) /** - * Size returns the original length of the underlying byte slice. - * Size is the number of bytes available for reading via ReadAt. - * The returned value is always the same and is not affected by calls - * to any other method. + * SetProxyRecord loads the specified record model into the current proxy. */ - size(): number + setProxyRecord(record: Record): void } - interface Reader { - /** - * Read implements the io.Reader interface. - */ - read(b: string): number + /** + * BaseRecordProxy implements the [RecordProxy] interface and it is intended + * to be used as embed to custom user provided Record proxy structs. + */ + type _sUxgYYz = Record + interface BaseRecordProxy extends _sUxgYYz { } - interface Reader { + interface BaseRecordProxy { /** - * ReadAt implements the io.ReaderAt interface. + * ProxyRecord returns the proxied Record model. */ - readAt(b: string, off: number): number + proxyRecord(): (Record) } - interface Reader { + interface BaseRecordProxy { /** - * ReadByte implements the io.ByteReader interface. + * SetProxyRecord loads the specified record model into the current proxy. */ - readByte(): string + setProxyRecord(record: Record): void } - interface Reader { + interface BaseApp { /** - * UnreadByte complements ReadByte in implementing the io.ByteScanner interface. + * 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. */ - unreadByte(): void + recordQuery(collectionModelOrIdentifier: any): (dbx.SelectQuery) } - interface Reader { + interface BaseApp { /** - * ReadRune implements the io.RuneReader interface. + * FindRecordById finds the Record model by its id. */ - readRune(): [string, number] + findRecordById(collectionModelOrIdentifier: any, recordId: string, ...optFilters: ((q: dbx.SelectQuery) => void)[]): (Record) } - interface Reader { + interface BaseApp { /** - * UnreadRune complements ReadRune in implementing the io.RuneScanner interface. + * FindRecordsByIds finds all records by the specified ids. + * If no records are found, returns an empty slice. */ - unreadRune(): void + findRecordsByIds(collectionModelOrIdentifier: any, recordIds: Array, ...optFilters: ((q: dbx.SelectQuery) => void)[]): Array<(Record | undefined)> } - interface Reader { + interface BaseApp { /** - * Seek implements the io.Seeker interface. + * 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) + * ``` */ - seek(offset: number, whence: number): number + findAllRecords(collectionModelOrIdentifier: any, ...exprs: dbx.Expression[]): Array<(Record | undefined)> } - interface Reader { + interface BaseApp { /** - * WriteTo implements the io.WriterTo interface. + * FindFirstRecordByData returns the first found record matching + * the provided key-value pair. */ - writeTo(w: io.Writer): number + findFirstRecordByData(collectionModelOrIdentifier: any, key: string, value: any): (Record) } - interface Reader { + interface BaseApp { /** - * Reset resets the Reader to be reading from b. + * 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} + * ) + * ``` */ - reset(b: string): void + findRecordsByFilter(collectionModelOrIdentifier: any, filter: string, sort: string, limit: number, offset: number, ...params: dbx.Params[]): Array<(Record | undefined)> } -} - -/** - * Package context defines the Context type, which carries deadlines, - * cancellation signals, and other request-scoped values across API boundaries - * and between processes. - * - * Incoming requests to a server should create a Context, and outgoing - * calls to servers should accept a Context. The chain of function - * calls between them must propagate the Context, optionally replacing - * it with a derived Context created using WithCancel, WithDeadline, - * WithTimeout, or WithValue. When a Context is canceled, all - * Contexts derived from it are also canceled. - * - * The WithCancel, WithDeadline, and WithTimeout functions take a - * Context (the parent) and return a derived Context (the child) and a - * CancelFunc. Calling the CancelFunc cancels the child and its - * children, removes the parent's reference to the child, and stops - * any associated timers. Failing to call the CancelFunc leaks the - * child and its children until the parent is canceled or the timer - * fires. The go vet tool checks that CancelFuncs are used on all - * control-flow paths. - * - * Programs that use Contexts should follow these rules to keep interfaces - * consistent across packages and enable static analysis tools to check context - * propagation: - * - * Do not store Contexts inside a struct type; instead, pass a Context - * explicitly to each function that needs it. The Context should be the first - * parameter, typically named ctx: - * - * ``` - * func DoSomething(ctx context.Context, arg Arg) error { - * // ... use ctx ... - * } - * ``` - * - * Do not pass a nil Context, even if a function permits it. Pass context.TODO - * if you are unsure about which Context to use. - * - * Use context Values only for request-scoped data that transits processes and - * APIs, not for passing optional parameters to functions. - * - * The same Context may be passed to functions running in different goroutines; - * Contexts are safe for simultaneous use by multiple goroutines. - * - * See https://blog.golang.org/context for example code for a server that uses - * Contexts. - */ -namespace context { - /** - * A Context carries a deadline, a cancellation signal, and other values across - * API boundaries. - * - * Context's methods may be called by multiple goroutines simultaneously. - */ - interface Context { - /** - * Deadline returns the time when work done on behalf of this context - * should be canceled. Deadline returns ok==false when no deadline is - * set. Successive calls to Deadline return the same results. - */ - deadline(): [time.Time, boolean] + interface BaseApp { /** - * Done returns a channel that's closed when work done on behalf of this - * context should be canceled. Done may return nil if this context can - * never be canceled. Successive calls to Done return the same value. - * The close of the Done channel may happen asynchronously, - * after the cancel function returns. + * FindFirstRecordByFilter returns the first available record matching the provided filter (if any). * - * WithCancel arranges for Done to be closed when cancel is called; - * WithDeadline arranges for Done to be closed when the deadline - * expires; WithTimeout arranges for Done to be closed when the timeout - * elapses. + * NB! Use the last params argument to bind untrusted user variables! * - * Done is provided for use in select statements: + * Returns sql.ErrNoRows if no record is found. * - * // Stream generates values with DoSomething and sends them to out - * // until DoSomething returns an error or ctx.Done is closed. - * func Stream(ctx context.Context, out chan<- Value) error { - * for { - * v, err := DoSomething(ctx) - * if err != nil { - * return err - * } - * select { - * case <-ctx.Done(): - * return ctx.Err() - * case out <- v: - * } - * } - * } + * Example: * - * See https://blog.golang.org/pipelines for more examples of how to use - * a Done channel for cancellation. + * ``` + * app.FindFirstRecordByFilter("posts", "") + * app.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"}) + * ``` */ - done(): undefined + findFirstRecordByFilter(collectionModelOrIdentifier: any, filter: string, ...params: dbx.Params[]): (Record) + } + interface BaseApp { /** - * If Done is not yet closed, Err returns nil. - * If Done is closed, Err returns a non-nil error explaining why: - * Canceled if the context was canceled - * or DeadlineExceeded if the context's deadline passed. - * After Err returns a non-nil error, successive calls to Err return the same error. + * CountRecords returns the total number of records in a collection. */ - err(): void + countRecords(collectionModelOrIdentifier: any, ...exprs: dbx.Expression[]): number + } + interface BaseApp { /** - * Value returns the value associated with this context for key, or nil - * if no value is associated with key. Successive calls to Value with - * the same key returns the same result. + * FindAuthRecordByToken finds the auth record associated with the provided JWT + * (auth, file, verifyEmail, changeEmail, passwordReset types). * - * Use context values only for request-scoped data that transits - * processes and API boundaries, not for passing optional parameters to - * functions. + * Optionally specify a list of validTypes to check tokens only from those types. * - * A key identifies a specific value in a Context. Functions that wish - * to store values in Context typically allocate a key in a global - * variable then use that key as the argument to context.WithValue and - * Context.Value. A key can be any type that supports equality; - * packages should define keys as an unexported type to avoid - * collisions. + * Returns an error if the JWT is invalid, expired or not associated to an auth collection record. + */ + findAuthRecordByToken(token: string, ...validTypes: string[]): (Record) + } + interface BaseApp { + /** + * FindAuthRecordByEmail finds the auth record associated with the provided email. * - * Packages that define a Context key should provide type-safe accessors - * for the values stored using that key: + * The email check would be case-insensitive if the related collection + * email unique index has COLLATE NOCASE specified for the email column. * - * ``` - * // Package user defines a User type that's stored in Contexts. - * package user + * Returns an error if it is not an auth collection or the record is not found. + */ + findAuthRecordByEmail(collectionModelOrIdentifier: any, email: string): (Record) + } + interface BaseApp { + /** + * CanAccessRecord checks if a record is allowed to be accessed by the + * specified requestInfo and accessRule. * - * import "context" + * Rule and db checks are ignored in case requestInfo.Auth is a superuser. * - * // User is the type of value stored in the Contexts. - * type User struct {...} + * The returned error indicate that something unexpected happened during + * the check (eg. invalid rule or db query error). * - * // key is an unexported type for keys defined in this package. - * // This prevents collisions with keys defined in other packages. - * type key int + * The method always return false on invalid rule or db query error. * - * // userKey is the key for user.User values in Contexts. It is - * // unexported; clients use user.NewContext and user.FromContext - * // instead of using this key directly. - * var userKey key + * Example: * - * // NewContext returns a new Context that carries value u. - * func NewContext(ctx context.Context, u *User) context.Context { - * return context.WithValue(ctx, userKey, u) - * } + * ``` + * 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 * - * // FromContext returns the User value stored in ctx, if any. - * func FromContext(ctx context.Context) (*User, bool) { - * u, ok := ctx.Value(userKey).(*User) - * return u, ok - * } + * if ok, _ := app.CanAccessRecord(record, requestInfo, rule); ok { ... } * ``` */ - value(key: any): any + canAccessRecord(record: Record, requestInfo: RequestInfo, accessRule: string): boolean } -} - -/** - * 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 { /** - * Cmd represents an external command being prepared or run. - * - * A Cmd cannot be reused after calling its Run, Output or CombinedOutput - * methods. + * ExpandFetchFunc defines the function that is used to fetch the expanded relation records. */ - interface Cmd { + interface ExpandFetchFunc {(relCollection: Collection, relIds: Array): Array<(Record | undefined)> } + interface BaseApp { /** - * Path is the path of the command to run. + * ExpandRecord expands the relations of a single Record model. * - * This is the only field that must be set to a non-zero - * value. If Path is relative, it is evaluated relative - * to Dir. + * 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. */ - path: string + expandRecord(record: Record, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict + } + interface BaseApp { /** - * Args holds command line arguments, including the command as Args[0]. - * If the Args field is empty or nil, Run uses {Path}. + * ExpandRecords expands the relations of the provided Record models list. * - * In typical use, both Path and Args are set by calling Command. + * 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. */ - args: Array + expandRecords(records: Array<(Record | undefined)>, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict + } + interface Record { /** - * Env specifies the environment of the process. - * Each entry is of the form "key=value". - * If Env is nil, the new process uses the current process's - * environment. - * If Env contains duplicate environment keys, only the last - * value in the slice for each duplicate key is used. - * As a special case on Windows, SYSTEMROOT is always added if - * missing and not explicitly set to the empty string. + * 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. */ - env: Array + newStaticAuthToken(duration: time.Duration): string + } + interface Record { /** - * Dir specifies the working directory of the command. - * If Dir is the empty string, Run runs the command in the - * calling process's current directory. + * NewAuthToken generates and returns a new record authentication token. */ - dir: string + newAuthToken(): string + } + interface Record { /** - * Stdin specifies the process's standard input. - * - * If Stdin is nil, the process reads from the null device (os.DevNull). - * - * If Stdin is an *os.File, the process's standard input is connected - * directly to that file. - * - * Otherwise, during the execution of the command a separate - * goroutine reads from Stdin and delivers that data to the command - * over a pipe. In this case, Wait does not complete until the goroutine - * stops copying, either because it has reached the end of Stdin - * (EOF or a read error) or because writing to the pipe returned an error. + * NewVerificationToken generates and returns a new record verification token. */ - stdin: io.Reader + newVerificationToken(): string + } + interface Record { /** - * Stdout and Stderr specify the process's standard output and error. - * - * If either is nil, Run connects the corresponding file descriptor - * to the null device (os.DevNull). - * - * If either is an *os.File, the corresponding output from the process - * is connected directly to that file. - * - * Otherwise, during the execution of the command a separate goroutine - * reads from the process over a pipe and delivers that data to the - * corresponding Writer. In this case, Wait does not complete until the - * goroutine reaches EOF or encounters an error. - * - * If Stdout and Stderr are the same writer, and have a type that can - * be compared with ==, at most one goroutine at a time will call Write. + * NewPasswordResetToken generates and returns a new auth record password reset request token. */ - stdout: io.Writer - stderr: io.Writer + newPasswordResetToken(): string + } + interface Record { /** - * ExtraFiles specifies additional open files to be inherited by the - * new process. It does not include standard input, standard output, or - * standard error. If non-nil, entry i becomes file descriptor 3+i. - * - * ExtraFiles is not supported on Windows. + * NewEmailChangeToken generates and returns a new auth record change email request token. */ - extraFiles: Array<(os.File | undefined)> + newEmailChangeToken(newEmail: string): string + } + interface Record { /** - * SysProcAttr holds optional, operating system-specific attributes. - * Run passes it to os.StartProcess as the os.ProcAttr's Sys field. + * NewFileToken generates and returns a new record private file access token. */ - sysProcAttr?: syscall.SysProcAttr + newFileToken(): string + } + interface settings { /** - * Process is the underlying process, once started. + * SuperuserIPs defines an optional list of the superuser allowed + * individual IPs and subnets (in CIDR notation). */ - process?: os.Process + superuserIPs: Array + smtp: SMTPConfig + backups: BackupsConfig + s3: S3Config + meta: MetaConfig + rateLimits: RateLimitsConfig + trustedProxy: TrustedProxyConfig + batch: BatchConfig + logs: LogsConfig + } + /** + * Settings defines the PocketBase app settings. + */ + type _sAPPeIK = settings + interface Settings extends _sAPPeIK { + } + interface Settings { /** - * ProcessState contains information about an exited process, - * available after a call to Wait or Run. + * TableName implements [Model.TableName] interface method. */ - processState?: os.ProcessState + tableName(): string } - interface Cmd { + interface Settings { /** - * String returns a human-readable description of c. - * It is intended only for debugging. - * In particular, it is not suitable for use as input to a shell. - * The output of String may vary across Go releases. + * PK implements [Model.LastSavedPK] interface method. + */ + lastSavedPK(): any + } + interface Settings { + /** + * PK implements [Model.PK] interface method. + */ + pk(): any + } + interface Settings { + /** + * IsNew implements [Model.IsNew] interface method. + */ + isNew(): boolean + } + interface Settings { + /** + * MarkAsNew implements [Model.MarkAsNew] interface method. + */ + markAsNew(): void + } + interface Settings { + /** + * MarkAsNew implements [Model.MarkAsNotNew] interface method. + */ + markAsNotNew(): void + } + interface Settings { + /** + * PostScan implements [Model.PostScan] interface method. + */ + postScan(): void + } + interface Settings { + /** + * String returns a serialized string representation of the current settings. */ string(): string } - interface Cmd { + interface Settings { /** - * Run starts the specified command and waits for it to complete. - * - * The returned error is nil if the command runs, has no problems - * copying stdin, stdout, and stderr, and exits with a zero exit - * status. - * - * If the command starts but does not complete successfully, the error is of - * type *ExitError. Other error types may be returned for other situations. - * - * If the calling goroutine has locked the operating system thread - * 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. + * DBExport prepares and exports the current settings for db persistence. */ - run(): void + dbExport(app: App): _TygojaDict } - interface Cmd { + interface Settings { /** - * Start starts the specified command but does not wait for it to complete. - * - * If Start returns successfully, the c.Process field will be set. - * - * The Wait method will return the exit code and release associated resources - * once the command exits. + * PostValidate implements the [PostValidator] interface and defines + * the Settings model validations. */ - start(): void + postValidate(ctx: context.Context, app: App): void } - interface Cmd { + interface Settings { /** - * Wait waits for the command to exit and waits for any copying to - * stdin or copying from stdout or stderr to complete. - * - * The command must have been started by Start. - * - * The returned error is nil if the command runs, has no problems - * copying stdin, stdout, and stderr, and exits with a zero exit - * status. - * - * If the command fails to run or doesn't complete successfully, the - * error is of type *ExitError. Other error types may be - * returned for I/O problems. - * - * If any of c.Stdin, c.Stdout or c.Stderr are not an *os.File, Wait also waits - * for the respective I/O loop copying to or from the process to complete. - * - * Wait releases any resources associated with the Cmd. + * Merge merges the "other" settings into the current one. */ - wait(): void + merge(other: Settings): void } - interface Cmd { + interface Settings { /** - * Output runs the command and returns its standard output. - * Any returned error will usually be of type *ExitError. - * If c.Stderr was nil, Output populates ExitError.Stderr. + * Clone creates a new deep copy of the current settings. */ - output(): string + clone(): (Settings) } - interface Cmd { + interface Settings { /** - * CombinedOutput runs the command and returns its combined standard - * output and standard error. + * MarshalJSON implements the [json.Marshaler] interface. + * + * Note that sensitive fields (S3 secret, SMTP password, etc.) are excluded. */ - combinedOutput(): string + marshalJSON(): string|Array } - interface Cmd { + interface SMTPConfig { + enabled: boolean + port: number + host: string + username: string + password: string /** - * StdinPipe returns a pipe that will be connected to the command's - * standard input when the command starts. - * The pipe will be closed automatically after Wait sees the command exit. - * A caller need only call Close to force the pipe to close sooner. - * For example, if the command being run will not exit until standard input - * is closed, the caller must close the pipe. + * SMTP AUTH - PLAIN (default) or LOGIN */ - stdinPipe(): io.WriteCloser - } - interface Cmd { + authMethod: string /** - * StdoutPipe returns a pipe that will be connected to the command's - * standard output when the command starts. + * Whether to enforce TLS encryption for the mail server connection. * - * Wait will close the pipe after seeing the command exit, so most callers - * need not close the pipe themselves. It is thus incorrect to call Wait - * before all reads from the pipe have completed. - * For the same reason, it is incorrect to call Run when using StdoutPipe. - * See the example for idiomatic usage. + * When set to false StartTLS command is send, leaving the server + * to decide whether to upgrade the connection or not. */ - stdoutPipe(): io.ReadCloser - } - interface Cmd { + tls: boolean /** - * StderrPipe returns a pipe that will be connected to the command's - * standard error when the command starts. + * LocalName is optional domain name or IP address used for the + * EHLO/HELO exchange (if not explicitly set, defaults to "localhost"). * - * Wait will close the pipe after seeing the command exit, so most callers - * need not close the pipe themselves. It is thus incorrect to call Wait - * before all reads from the pipe have completed. - * For the same reason, it is incorrect to use Run when using StderrPipe. - * See the StdoutPipe example for idiomatic usage. + * This is required only by some SMTP servers, such as Gmail SMTP-relay. */ - stderrPipe(): io.ReadCloser + localName: string } -} - -/** - * Package sql provides a generic interface around SQL (or SQL-like) - * databases. - * - * The sql package must be used in conjunction with a database driver. - * See https://golang.org/s/sqldrivers for a list of drivers. - * - * Drivers that do not support context cancellation will not return until - * after the query is completed. - * - * For usage examples, see the wiki page at - * https://golang.org/s/sqlwiki. - */ -namespace sql { - /** - * TxOptions holds the transaction options to be used in DB.BeginTx. - */ - interface TxOptions { + interface SMTPConfig { /** - * Isolation is the transaction isolation level. - * If zero, the driver or database's default level is used. + * Validate makes SMTPConfig validatable by implementing [validation.Validatable] interface. */ - isolation: IsolationLevel - readOnly: boolean - } - /** - * DB is a database handle representing a pool of zero or more - * underlying connections. It's safe for concurrent use by multiple - * goroutines. - * - * The sql package creates and frees connections automatically; it - * also maintains a free pool of idle connections. If the database has - * a concept of per-connection state, such state can be reliably observed - * within a transaction (Tx) or connection (Conn). Once DB.Begin is called, the - * returned Tx is bound to a single connection. Once Commit or - * Rollback is called on the transaction, that transaction's - * connection is returned to DB's idle connection pool. The pool size - * can be controlled with SetMaxIdleConns. - */ - interface DB { + validate(): void } - interface DB { + interface SMTPConfig { /** - * PingContext verifies a connection to the database is still alive, - * establishing a connection if necessary. + * MarshalJSON implements the [json.Marshaler] interface. */ - pingContext(ctx: context.Context): void + marshalJSON(): string|Array } - interface DB { + interface S3Config { + enabled: boolean + bucket: string + region: string + endpoint: string + accessKey: string + secret: string + forcePathStyle: boolean + } + interface S3Config { /** - * Ping verifies a connection to the database is still alive, - * establishing a connection if necessary. - * - * Ping uses context.Background internally; to specify the context, use - * PingContext. + * Validate makes S3Config validatable by implementing [validation.Validatable] interface. */ - ping(): void + validate(): void } - interface DB { + interface BatchConfig { + enabled: boolean /** - * Close closes the database and prevents new queries from starting. - * Close then waits for all queries that have started processing on the server - * to finish. - * - * It is rare to Close a DB, as the DB handle is meant to be - * long-lived and shared between many goroutines. + * MaxRequests is the maximum allowed batch request to execute. */ - close(): void - } - interface DB { + maxRequests: number /** - * SetMaxIdleConns sets the maximum number of connections in the idle - * connection pool. - * - * If MaxOpenConns is greater than 0 but less than the new MaxIdleConns, - * then the new MaxIdleConns will be reduced to match the MaxOpenConns limit. - * - * If n <= 0, no idle connections are retained. - * - * The default max idle connections is currently 2. This may change in - * a future release. + * Timeout is the max duration in seconds to wait before cancelling the batch transaction. */ - setMaxIdleConns(n: number): void - } - interface DB { + timeout: number /** - * SetMaxOpenConns sets the maximum number of open connections to the database. - * - * If MaxIdleConns is greater than 0 and the new MaxOpenConns is less than - * MaxIdleConns, then MaxIdleConns will be reduced to match the new - * MaxOpenConns limit. + * MaxBodySize is the maximum allowed batch request body size in bytes. * - * If n <= 0, then there is no limit on the number of open connections. - * The default is 0 (unlimited). + * If not set, fallbacks to max ~128MB. */ - setMaxOpenConns(n: number): void + maxBodySize: number } - interface DB { + interface BatchConfig { /** - * SetConnMaxLifetime sets the maximum amount of time a connection may be reused. - * - * Expired connections may be closed lazily before reuse. - * - * If d <= 0, connections are not closed due to a connection's age. + * Validate makes BatchConfig validatable by implementing [validation.Validatable] interface. */ - setConnMaxLifetime(d: time.Duration): void + validate(): void } - interface DB { + interface BackupsConfig { /** - * SetConnMaxIdleTime sets the maximum amount of time a connection may be idle. + * Cron is a cron expression to schedule auto backups, eg. "* * * * *". * - * Expired connections may be closed lazily before reuse. + * Leave it empty to disable the auto backups functionality. + */ + cron: string + /** + * CronMaxKeep is the max number of cron generated backups to + * keep before removing older entries. * - * If d <= 0, connections are not closed due to a connection's idle time. + * This field works only when the cron config has valid cron expression. */ - setConnMaxIdleTime(d: time.Duration): void - } - interface DB { + cronMaxKeep: number /** - * Stats returns database statistics. + * S3 is an optional S3 storage config specifying where to store the app backups. */ - stats(): DBStats + s3: S3Config } - interface DB { + interface BackupsConfig { /** - * PrepareContext creates a prepared statement for later queries or executions. - * Multiple queries or executions may be run concurrently from the - * returned statement. - * The caller must call the statement's Close method - * when the statement is no longer needed. - * - * The provided context is used for the preparation of the statement, not for the - * execution of the statement. + * Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface. */ - prepareContext(ctx: context.Context, query: string): (Stmt | undefined) + validate(): void } - interface DB { + interface MetaConfig { /** - * Prepare creates a prepared statement for later queries or executions. - * Multiple queries or executions may be run concurrently from the - * returned statement. - * The caller must call the statement's Close method - * when the statement is no longer needed. + * @todo experimental * - * Prepare uses context.Background internally; to specify the context, use - * PrepareContext. + * AccentColor specify the UI "accent" color (HEX). */ - prepare(query: string): (Stmt | undefined) + accentColor: string + appName: string + appURL: string + senderName: string + senderAddress: string + hideControls: boolean } - interface DB { + interface MetaConfig { /** - * ExecContext executes a query without returning any rows. - * The args are for any placeholder parameters in the query. + * Validate makes MetaConfig validatable by implementing [validation.Validatable] interface. */ - execContext(ctx: context.Context, query: string, ...args: any[]): Result + validate(): void } - interface DB { + interface LogsConfig { /** - * Exec executes a query without returning any rows. - * The args are for any placeholder parameters in the query. + * MaxDataSize specifies the maximum allowed serialized log data + * size before it gets truncated (see [Log.DBExport]). * - * Exec uses context.Background internally; to specify the context, use - * ExecContext. + * If zero, fallbacks to ~16kb by default. */ - exec(query: string, ...args: any[]): Result + maxDataSize: number + maxDays: number + minLevel: number + logIP: boolean + logAuthId: boolean } - interface DB { + interface LogsConfig { /** - * QueryContext executes a query that returns rows, typically a SELECT. - * The args are for any placeholder parameters in the query. + * Validate makes LogsConfig validatable by implementing [validation.Validatable] interface. */ - queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows | undefined) + validate(): void } - interface DB { + interface TrustedProxyConfig { /** - * Query executes a query that returns rows, typically a SELECT. - * The args are for any placeholder parameters in the query. + * Headers is a list of explicit trusted header(s) to check. + */ + headers: Array + /** + * UseLeftmostIP specifies to use the left-mostish IP from the trusted headers. * - * Query uses context.Background internally; to specify the context, use - * QueryContext. + * 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. */ - query(query: string, ...args: any[]): (Rows | undefined) + useLeftmostIP: boolean } - interface DB { + interface TrustedProxyConfig { /** - * QueryRowContext executes a query that is expected to return at most one row. - * QueryRowContext always returns a non-nil value. Errors are deferred until - * Row's Scan method is called. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. + * Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface. */ - queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row | undefined) + validate(): void } - interface DB { + interface RateLimitsConfig { + rules: Array + excludedIPs: Array + enabled: boolean + } + interface RateLimitsConfig { /** - * QueryRow executes a query that is expected to return at most one row. - * QueryRow always returns a non-nil value. Errors are deferred until - * Row's Scan method is called. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. + * FindRateLimitRule returns the first matching rule based on the provided labels. * - * QueryRow uses context.Background internally; to specify the context, use - * QueryRowContext. + * 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). */ - queryRow(query: string, ...args: any[]): (Row | undefined) + findRateLimitRule(searchLabels: Array, ...optOnlyAudience: string[]): [RateLimitRule, boolean] } - interface DB { + interface RateLimitsConfig { /** - * BeginTx starts a transaction. + * Validate makes RateLimitsConfig validatable by implementing [validation.Validatable] interface. + */ + validate(): void + } + interface RateLimitRule { + /** + * Label is the identifier of the current rule. * - * The provided context is used until the transaction is committed or rolled back. - * If the context is canceled, the sql package will roll back - * the transaction. Tx.Commit will return an error if the context provided to - * BeginTx is canceled. + * It could be a tag, complete path or path prerefix (when ends with `/`). * - * The provided TxOptions is optional and may be nil if defaults should be used. - * If a non-default isolation level is used that the driver doesn't support, - * an error will be returned. + * Example supported labels: + * ``` + * - test_a (plain text "tag") + * - users:create + * - *:create + * - / + * - /api + * - POST /api/collections/ + * ``` + */ + label: string + /** + * 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 + * ``` */ - beginTx(ctx: context.Context, opts: TxOptions): (Tx | undefined) - } - interface DB { + audience: string /** - * Begin starts a transaction. The default isolation level is dependent on - * the driver. - * - * Begin uses context.Background internally; to specify the context, use - * BeginTx. + * Duration specifies the interval (in seconds) per which to reset + * the counted/accumulated rate limiter tokens. */ - begin(): (Tx | undefined) - } - interface DB { + duration: number /** - * Driver returns the database's underlying driver. + * MaxRequests is the max allowed number of requests per Duration. */ - driver(): any + maxRequests: number } - interface DB { + interface RateLimitRule { /** - * Conn returns a single connection by either opening a new connection - * or returning an existing connection from the connection pool. Conn will - * block until either a connection is returned or ctx is canceled. - * Queries run on the same Conn will be run in the same database session. - * - * Every Conn must be returned to the database pool after use by - * calling Conn.Close. + * Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface. */ - conn(ctx: context.Context): (Conn | undefined) - } - /** - * Tx is an in-progress database transaction. - * - * A transaction must end with a call to Commit or Rollback. - * - * After a call to Commit or Rollback, all operations on the - * transaction fail with ErrTxDone. - * - * The statements prepared for a transaction by calling - * the transaction's Prepare or Stmt methods are closed - * by the call to Commit or Rollback. - */ - interface Tx { + validate(): void } - interface Tx { + interface RateLimitRule { /** - * Commit commits the transaction. + * DurationTime returns the tag's Duration as [time.Duration]. */ - commit(): void + durationTime(): time.Duration } - interface Tx { + interface RateLimitRule { /** - * Rollback aborts the transaction. + * String returns a string representation of the rule. */ - rollback(): void + string(): string } - interface Tx { + type _sXgRVmQ = BaseModel + interface Param extends _sXgRVmQ { + created: types.DateTime + updated: types.DateTime + value: types.JSONRaw + } + interface Param { + tableName(): string + } + interface BaseApp { /** - * PrepareContext creates a prepared statement for use within a transaction. - * - * The returned statement operates within the transaction and will be closed - * when the transaction has been committed or rolled back. - * - * To use an existing prepared statement on this transaction, see Tx.Stmt. + * ReloadSettings initializes and reloads the stored application settings. * - * The provided context will be used for the preparation of the context, not - * for the execution of the returned statement. The returned statement - * will run in the transaction context. + * If no settings were stored it will persist the current app ones. */ - prepareContext(ctx: context.Context, query: string): (Stmt | undefined) + reloadSettings(): void } - interface Tx { + interface BaseApp { /** - * Prepare creates a prepared statement for use within a transaction. - * - * The returned statement operates within the transaction and will be closed - * when the transaction has been committed or rolled back. + * DeleteView drops the specified view name. * - * To use an existing prepared statement on this transaction, see Tx.Stmt. + * This method is a no-op if a view with the provided name doesn't exist. * - * Prepare uses context.Background internally; to specify the context, use - * PrepareContext. + * NB! Be aware that this method is vulnerable to SQL injection and the + * "dangerousViewName" argument must come only from trusted input! */ - prepare(query: string): (Stmt | undefined) + deleteView(dangerousViewName: string): void } - interface Tx { + interface BaseApp { /** - * StmtContext returns a transaction-specific prepared statement from - * an existing statement. + * SaveView creates (or updates already existing) persistent SQL view. * - * Example: - * updateMoney, err := db.Prepare("UPDATE balance SET money=money+? WHERE id=?") - * ... - * tx, err := db.Begin() - * ... - * res, err := tx.StmtContext(ctx, updateMoney).Exec(123.45, 98293203) + * 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): void + } + interface BaseApp { + /** + * CreateViewFields creates a new FieldsList from the provided select query. * - * The provided context is used for the preparation of the statement, not for the - * execution of the statement. + * There are some caveats: + * - The select query must have an "id" column. + * - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data. * - * The returned statement operates within the transaction and will be closed - * when the transaction has been committed or rolled back. + * NB! Be aware that this method is vulnerable to SQL injection and the + * "dangerousSelectQuery" argument must come only from trusted input! */ - stmtContext(ctx: context.Context, stmt: Stmt): (Stmt | undefined) + createViewFields(dangerousSelectQuery: string): FieldsList } - interface Tx { + interface DryRunViewResult { + fields: FieldsList + sample: Array<(Record | undefined)> + } + interface BaseApp { /** - * Stmt returns a transaction-specific prepared statement from - * an existing statement. - * - * Example: - * updateMoney, err := db.Prepare("UPDATE balance SET money=money+? WHERE id=?") - * ... - * tx, err := db.Begin() - * ... - * res, err := tx.Stmt(updateMoney).Exec(123.45, 98293203) + * DryRunView executes the provided query by creating a temporary view + * collection and returning a sample of the resulting query records (if valid). * - * The returned statement operates within the transaction and will be closed - * when the transaction has been committed or rolled back. + * The same caveats from CreateViewFields apply here too. * - * Stmt uses context.Background internally; to specify the context, use - * StmtContext. + * NB! Be aware that this method is vulnerable to SQL injection and the + * "dangerousSelectQuery" argument must come only from trusted input! */ - stmt(stmt: Stmt): (Stmt | undefined) + dryRunView(dangerousSelectQuery: string, sampleSize: number): (DryRunViewResult) } - interface Tx { + interface BaseApp { /** - * ExecContext executes a query that doesn't return rows. - * For example: an INSERT and UPDATE. + * FindRecordByViewFile returns the original Record of the provided view collection file. */ - execContext(ctx: context.Context, query: string, ...args: any[]): Result + findRecordByViewFile(viewCollectionModelOrIdentifier: any, fileFieldName: string, filename: string): (Record) } - interface Tx { + interface queryField { + } + interface identifier { + } + interface identifiersParser { + } +} + +/** + * Package mails implements various helper methods for sending common + * emails like forgotten password, verification, etc. + */ +namespace mails { + interface sendRecordAuthAlert { /** - * Exec executes a query that doesn't return rows. - * For example: an INSERT and UPDATE. - * - * Exec uses context.Background internally; to specify the context, use - * ExecContext. + * SendRecordAuthAlert sends a new device login alert to the specified auth record. */ - exec(query: string, ...args: any[]): Result + (app: CoreApp, authRecord: core.Record, info: string): void } - interface Tx { + interface sendRecordOTP { /** - * QueryContext executes a query that returns rows, typically a SELECT. + * 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). */ - queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows | undefined) + (app: CoreApp, authRecord: core.Record, otpId: string, pass: string): void } - interface Tx { + interface sendRecordPasswordReset { /** - * Query executes a query that returns rows, typically a SELECT. - * - * Query uses context.Background internally; to specify the context, use - * QueryContext. + * SendRecordPasswordReset sends a password reset request email to the specified auth record. */ - query(query: string, ...args: any[]): (Rows | undefined) + (app: CoreApp, authRecord: core.Record): void } - interface Tx { + interface sendRecordVerification { /** - * QueryRowContext executes a query that is expected to return at most one row. - * QueryRowContext always returns a non-nil value. Errors are deferred until - * Row's Scan method is called. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. + * SendRecordVerification sends a verification request email to the specified auth record. */ - queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row | undefined) + (app: CoreApp, authRecord: core.Record): void } - interface Tx { + interface sendRecordChangeEmail { /** - * QueryRow executes a query that is expected to return at most one row. - * QueryRow always returns a non-nil value. Errors are deferred until - * Row's Scan method is called. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. - * - * QueryRow uses context.Background internally; to specify the context, use - * QueryRowContext. + * SendRecordChangeEmail sends a change email confirmation email to the specified auth record. */ - queryRow(query: string, ...args: any[]): (Row | undefined) + (app: CoreApp, authRecord: core.Record, newEmail: string): void } +} + +namespace forms { + // @ts-ignore + import validation = ozzo_validation /** - * Stmt is a prepared statement. - * A Stmt is safe for concurrent use by multiple goroutines. + * AppleClientSecretCreate is a form struct to generate a new Apple Client Secret. * - * If a Stmt is prepared on a Tx or Conn, it will be bound to a single - * underlying connection forever. If the Tx or Conn closes, the Stmt will - * become unusable and all operations will return an error. - * If a Stmt is prepared on a DB, it will remain usable for the lifetime of the - * DB. When the Stmt needs to execute on a new underlying connection, it will - * prepare itself on the new connection automatically. + * Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens */ - interface Stmt { - } - interface Stmt { + interface AppleClientSecretCreate { /** - * ExecContext executes a prepared statement with the given arguments and - * returns a Result summarizing the effect of the statement. + * ClientId is the identifier of your app (aka. Service ID). */ - execContext(ctx: context.Context, ...args: any[]): Result - } - interface Stmt { + clientId: string /** - * Exec executes a prepared statement with the given arguments and - * returns a Result summarizing the effect of the statement. - * - * Exec uses context.Background internally; to specify the context, use - * ExecContext. + * TeamId is a 10-character string associated with your developer account + * (usually could be found next to your name in the Apple Developer site). */ - exec(...args: any[]): Result - } - interface Stmt { + teamId: string /** - * QueryContext executes a prepared query statement with the given arguments - * and returns the query results as a *Rows. + * KeyId is a 10-character key identifier generated for the "Sign in with Apple" + * private key associated with your developer account. */ - queryContext(ctx: context.Context, ...args: any[]): (Rows | undefined) - } - interface Stmt { + keyId: string /** - * Query executes a prepared query statement with the given arguments - * and returns the query results as a *Rows. - * - * Query uses context.Background internally; to specify the context, use - * QueryContext. + * 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 should be considered valid. + * The specified value must be in seconds and max 15777000 (~6months). */ - query(...args: any[]): (Rows | undefined) + duration: number } - interface Stmt { + interface newAppleClientSecretCreate { /** - * QueryRowContext executes a prepared query statement with the given arguments. - * If an error occurs during the execution of the statement, that error will - * be returned by a call to Scan on the returned *Row, which is always non-nil. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. + * NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer + * config created from the provided [CoreApp] instances. */ - queryRowContext(ctx: context.Context, ...args: any[]): (Row | undefined) + (app: CoreApp): (AppleClientSecretCreate) } - interface Stmt { + interface AppleClientSecretCreate { /** - * QueryRow executes a prepared query statement with the given arguments. - * If an error occurs during the execution of the statement, that error will - * be returned by a call to Scan on the returned *Row, which is always non-nil. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. - * - * Example usage: - * - * var name string - * err := nameByUseridStmt.QueryRow(id).Scan(&name) - * - * QueryRow uses context.Background internally; to specify the context, use - * QueryRowContext. + * Validate makes the form validatable by implementing [validation.Validatable] interface. */ - queryRow(...args: any[]): (Row | undefined) + validate(): void } - interface Stmt { + interface AppleClientSecretCreate { /** - * Close closes the statement. + * Submit validates the form and returns a new Apple Client Secret JWT. */ - close(): void + submit(): string } - /** - * Rows is the result of a query. Its cursor starts before the first row - * of the result set. Use Next to advance from row to row. - */ - interface Rows { + interface RecordUpsert { } - interface Rows { + interface newRecordUpsert { /** - * Next prepares the next result row for reading with the Scan method. It - * returns true on success, or false if there is no next result row or an error - * happened while preparing it. Err should be consulted to distinguish between - * the two cases. - * - * Every call to Scan, even the first one, must be preceded by a call to Next. + * NewRecordUpsert creates a new [RecordUpsert] form from the provided [CoreApp] and [core.Record] instances + * (for create you could pass a pointer to an empty Record - core.NewRecord(collection)). */ - next(): boolean + (app: CoreApp, record: core.Record): (RecordUpsert) } - interface Rows { + interface RecordUpsert { /** - * NextResultSet prepares the next result set for reading. It reports whether - * there is further result sets, or false if there is no further result set - * or if there is an error advancing to it. The Err method should be consulted - * to distinguish between the two cases. + * SetContext assigns ctx as context of the current form. + */ + setContext(ctx: context.Context): void + } + interface RecordUpsert { + /** + * SetApp replaces the current form app instance. * - * After calling NextResultSet, the Next method should always be called before - * scanning. If there are further result sets they may not have rows in the result - * set. + * This could be used for example if you want to change at later stage + * before submission to change from regular -> transactional app instance. */ - nextResultSet(): boolean + setApp(app: CoreApp): void } - interface Rows { + interface RecordUpsert { /** - * Err returns the error, if any, that was encountered during iteration. - * Err may be called after an explicit or implicit Close. + * SetRecord replaces the current form record instance. */ - err(): void + setRecord(record: core.Record): void } - interface Rows { + interface RecordUpsert { /** - * Columns returns the column names. - * Columns returns an error if the rows are closed. + * ResetAccess resets the form access level to the accessLevelDefault. */ - columns(): Array + resetAccess(): void } - interface Rows { + interface RecordUpsert { /** - * ColumnTypes returns column information such as column type, length, - * and nullable. Some information may not be available from some drivers. + * GrantManagerAccess updates the form access level to "manager" allowing + * directly changing some system record fields (often used with auth collection records). */ - columnTypes(): Array<(ColumnType | undefined)> + grantManagerAccess(): void } - interface Rows { + interface RecordUpsert { /** - * Scan copies the columns in the current row into the values pointed - * at by dest. The number of values in dest must be the same as the - * number of columns in Rows. - * - * Scan converts columns read from the database into the following - * common Go types and special types provided by the sql package: - * - * ``` - * *string - * *[]byte - * *int, *int8, *int16, *int32, *int64 - * *uint, *uint8, *uint16, *uint32, *uint64 - * *bool - * *float32, *float64 - * *interface{} - * *RawBytes - * *Rows (cursor value) - * any type implementing Scanner (see Scanner docs) - * ``` - * - * In the most simple case, if the type of the value from the source - * column is an integer, bool or string type T and dest is of type *T, - * Scan simply assigns the value through the pointer. - * - * Scan also converts between string and numeric types, as long as no - * information would be lost. While Scan stringifies all numbers - * scanned from numeric database columns into *string, scans into - * numeric types are checked for overflow. For example, a float64 with - * value 300 or a string with value "300" can scan into a uint16, but - * not into a uint8, though float64(255) or "255" can scan into a - * uint8. One exception is that scans of some float64 numbers to - * strings may lose information when stringifying. In general, scan - * floating point columns into *float64. - * - * If a dest argument has type *[]byte, Scan saves in that argument a - * copy of the corresponding data. The copy is owned by the caller and - * can be modified and held indefinitely. The copy can be avoided by - * using an argument of type *RawBytes instead; see the documentation - * for RawBytes for restrictions on its use. - * - * If an argument has type *interface{}, Scan copies the value - * provided by the underlying driver without conversion. When scanning - * from a source value of type []byte to *interface{}, a copy of the - * slice is made and the caller owns the result. - * - * Source values of type time.Time may be scanned into values of type - * *time.Time, *interface{}, *string, or *[]byte. When converting to - * the latter two, time.RFC3339Nano is used. - * - * Source values of type bool may be scanned into types *bool, - * *interface{}, *string, *[]byte, or *RawBytes. - * - * For scanning into *bool, the source may be true, false, 1, 0, or - * string inputs parseable by strconv.ParseBool. + * GrantSuperuserAccess updates the form access level to "superuser" allowing + * directly changing all system record fields, including those marked as "Hidden". + */ + grantSuperuserAccess(): void + } + interface RecordUpsert { + /** + * HasManageAccess reports whether the form has "manager" or "superuser" level access. + */ + hasManageAccess(): boolean + } + interface RecordUpsert { + /** + * Load loads the provided data into the form and the related record. + */ + load(data: _TygojaDict): void + } + interface RecordUpsert { + /** + * 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. * - * Scan can also convert a cursor returned from a query, such as - * "select cursor(select * from my_table) from dual", into a - * *Rows value that can itself be scanned from. The parent - * select query will close any cursor *Rows if the parent *Rows is closed. + * DrySubmit performs a temp form submit within a transaction and reverts it at the end. + * For actual record persistence, check the [RecordUpsert.Submit()] method. * - * If any of the first arguments implementing Scanner returns an error, - * that error will be wrapped in the returned error + * This method doesn't perform validations, handle file uploads/deletes or trigger app save events! */ - scan(...dest: any[]): void + drySubmit(callback: (txApp: CoreApp, drySavedRecord: core.Record) => void): void } - interface Rows { + interface RecordUpsert { /** - * Close closes the Rows, preventing further enumeration. If Next is called - * and returns false and there are no further result sets, - * the Rows are closed automatically and it will suffice to check the - * result of Err. Close is idempotent and does not affect the result of Err. + * Submit validates the form specific validations and attempts to save the form record. */ - close(): void + submit(): void } /** - * A Result summarizes an executed SQL command. + * TestEmailSend is a email template test request form. */ - interface Result { + interface TestEmailSend { + email: string + template: string + collection: string // optional, fallbacks to _superusers + } + interface newTestEmailSend { /** - * LastInsertId returns the integer generated by the database - * in response to a command. Typically this will be from an - * "auto increment" column when inserting a new row. Not all - * databases support this feature, and the syntax of such - * statements varies. + * NewTestEmailSend creates and initializes new TestEmailSend form. */ - lastInsertId(): number + (app: CoreApp): (TestEmailSend) + } + interface TestEmailSend { /** - * RowsAffected returns the number of rows affected by an - * update, insert, or delete. Not every database or database - * driver may support this. + * Validate makes the form validatable by implementing [validation.Validatable] interface. */ - rowsAffected(): number + validate(): void + } + interface TestEmailSend { + /** + * Submit validates and sends a test email to the form.Email address. + */ + submit(): void } -} - -namespace migrate { /** - * MigrationsList defines a list with migration definitions + * TestS3Filesystem defines a S3 filesystem connection test. */ - interface MigrationsList { + interface TestS3Filesystem { + /** + * The name of the filesystem - storage or backups + */ + filesystem: string } - interface MigrationsList { + interface newTestS3Filesystem { /** - * Item returns a single migration from the list by its index. + * NewTestS3Filesystem creates and initializes new TestS3Filesystem form. */ - item(index: number): (Migration | undefined) + (app: CoreApp): (TestS3Filesystem) } - interface MigrationsList { + interface TestS3Filesystem { /** - * Items returns the internal migrations list slice. + * Validate makes the form validatable by implementing [validation.Validatable] interface. */ - items(): Array<(Migration | undefined)> + validate(): void } - interface MigrationsList { + interface TestS3Filesystem { /** - * 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. + * Submit validates and performs a S3 filesystem connection test. */ - register(up: (db: dbx.Builder) => void, down: (db: dbx.Builder) => void, ...optFilename: string[]): void + submit(): void } } -/** - * Package jwt is a Go implementation of JSON Web Tokens: http://self-issued.info/docs/draft-jones-json-web-token.html - * - * See README.md for more info. - */ -namespace jwt { - /** - * MapClaims is a claims type that uses the map[string]interface{} for JSON decoding. - * This is the default claims type if you don't supply one - */ - interface MapClaims extends _TygojaDict{} - interface MapClaims { +namespace apis { + interface toApiError { /** - * VerifyAudience Compares the aud claim against cmp. - * If required is false, this method will return true if the value matches or is unset + * ToApiError wraps err into ApiError instance (if not already). */ - verifyAudience(cmp: string, req: boolean): boolean + (err: Error): (router.ApiError) } - interface MapClaims { + interface newApiError { /** - * VerifyExpiresAt compares the exp claim against cmp (cmp <= exp). - * If req is false, it will return true, if exp is unset. + * NewApiError is an alias for [router.NewApiError]. */ - verifyExpiresAt(cmp: number, req: boolean): boolean + (status: number, message: string, errData: any): (router.ApiError) } - interface MapClaims { + interface newBadRequestError { /** - * VerifyIssuedAt compares the exp claim against cmp (cmp >= iat). - * If req is false, it will return true, if iat is unset. + * NewBadRequestError is an alias for [router.NewBadRequestError]. */ - verifyIssuedAt(cmp: number, req: boolean): boolean + (message: string, errData: any): (router.ApiError) } - interface MapClaims { + interface newNotFoundError { /** - * VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). - * If req is false, it will return true, if nbf is unset. + * NewNotFoundError is an alias for [router.NewNotFoundError]. */ - verifyNotBefore(cmp: number, req: boolean): boolean + (message: string, errData: any): (router.ApiError) } - interface MapClaims { + interface newForbiddenError { /** - * VerifyIssuer compares the iss claim against cmp. - * If required is false, this method will return true if the value matches or is unset + * NewForbiddenError is an alias for [router.NewForbiddenError]. */ - verifyIssuer(cmp: string, req: boolean): boolean + (message: string, errData: any): (router.ApiError) } - interface MapClaims { + interface newUnauthorizedError { /** - * Valid validates time based claims "exp, iat, nbf". - * There is no accounting for clock skew. - * As well, if any of the above claims are not in the token, it will still - * be considered a valid claim. + * NewUnauthorizedError is an alias for [router.NewUnauthorizedError]. */ - valid(): void + (message: string, errData: any): (router.ApiError) } -} - -/** - * Package multipart implements MIME multipart parsing, as defined in RFC - * 2046. - * - * The implementation is sufficient for HTTP (RFC 2388) and the multipart - * bodies generated by popular browsers. - */ -namespace multipart { - /** - * A FileHeader describes a file part of a multipart request. - */ - interface FileHeader { - filename: string - header: textproto.MIMEHeader - size: number + interface newTooManyRequestsError { + /** + * NewTooManyRequestsError is an alias for [router.NewTooManyRequestsError]. + */ + (message: string, errData: any): (router.ApiError) } - interface FileHeader { + interface newInternalServerError { /** - * Open opens and returns the FileHeader's associated File. + * NewInternalServerError is an alias for [router.NewInternalServerError]. */ - open(): File + (message: string, errData: any): (router.ApiError) + } + interface backupFileInfo { + modified: types.DateTime + key: string + size: number } -} - -/** - * Package http provides HTTP client and server implementations. - * - * Get, Head, Post, and PostForm make HTTP (or HTTPS) requests: - * - * ``` - * resp, err := http.Get("http://example.com/") - * ... - * resp, err := http.Post("http://example.com/upload", "image/jpeg", &buf) - * ... - * resp, err := http.PostForm("http://example.com/form", - * url.Values{"key": {"Value"}, "id": {"123"}}) - * ``` - * - * The client must close the response body when finished with it: - * - * ``` - * resp, err := http.Get("http://example.com/") - * if err != nil { - * // handle error - * } - * defer resp.Body.Close() - * body, err := io.ReadAll(resp.Body) - * // ... - * ``` - * - * For control over HTTP client headers, redirect policy, and other - * settings, create a Client: - * - * ``` - * client := &http.Client{ - * CheckRedirect: redirectPolicyFunc, - * } - * - * resp, err := client.Get("http://example.com") - * // ... - * - * req, err := http.NewRequest("GET", "http://example.com", nil) - * // ... - * req.Header.Add("If-None-Match", `W/"wyzzy"`) - * resp, err := client.Do(req) - * // ... - * ``` - * - * For control over proxies, TLS configuration, keep-alives, - * compression, and other settings, create a Transport: - * - * ``` - * tr := &http.Transport{ - * MaxIdleConns: 10, - * IdleConnTimeout: 30 * time.Second, - * DisableCompression: true, - * } - * client := &http.Client{Transport: tr} - * resp, err := client.Get("https://example.com") - * ``` - * - * Clients and Transports are safe for concurrent use by multiple - * goroutines and for efficiency should only be created once and re-used. - * - * ListenAndServe starts an HTTP server with a given address and handler. - * The handler is usually nil, which means to use DefaultServeMux. - * Handle and HandleFunc add handlers to DefaultServeMux: - * - * ``` - * http.Handle("/foo", fooHandler) - * - * http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { - * fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) - * }) - * - * log.Fatal(http.ListenAndServe(":8080", nil)) - * ``` - * - * More control over the server's behavior is available by creating a - * custom Server: - * - * ``` - * s := &http.Server{ - * Addr: ":8080", - * Handler: myHandler, - * ReadTimeout: 10 * time.Second, - * WriteTimeout: 10 * time.Second, - * MaxHeaderBytes: 1 << 20, - * } - * log.Fatal(s.ListenAndServe()) - * ``` - * - * Starting with Go 1.6, the http package has transparent support for the - * HTTP/2 protocol when using HTTPS. Programs that must disable HTTP/2 - * can do so by setting Transport.TLSNextProto (for clients) or - * Server.TLSNextProto (for servers) to a non-nil, empty - * map. Alternatively, the following GODEBUG environment variables are - * currently supported: - * - * ``` - * GODEBUG=http2client=0 # disable HTTP/2 client support - * GODEBUG=http2server=0 # disable HTTP/2 server support - * GODEBUG=http2debug=1 # enable verbose HTTP/2 debug logs - * GODEBUG=http2debug=2 # ... even more verbose, with frame dumps - * ``` - * - * The GODEBUG variables are not covered by Go's API compatibility - * promise. Please report any issues before disabling HTTP/2 - * support: https://golang.org/s/http2bug - * - * The http package's Transport and Server both automatically enable - * HTTP/2 support for simple configurations. To enable HTTP/2 for more - * complex configurations, to use lower-level HTTP/2 features, or to use - * a newer version of Go's http2 package, import "golang.org/x/net/http2" - * directly and use its ConfigureTransport and/or ConfigureServer - * functions. Manually configuring HTTP/2 via the golang.org/x/net/http2 - * package takes precedence over the net/http package's built-in HTTP/2 - * support. - */ -namespace http { - // @ts-ignore - import mathrand = rand // @ts-ignore - import urlpkg = url + import validation = ozzo_validation + interface backupCreateForm { + name: string + } + interface backupUploadForm { + file?: filesystem.File + } + interface newRouter { + /** + * NewRouter returns a new router instance loaded with the default app middlewares and routes. + */ + (app: CoreApp): (router.Router) + } + interface wrapStdHandler { + /** + * WrapStdHandler wraps Go [http.Handler] into a PocketBase handler func. + */ + (h: http.Handler): (_arg0: core.RequestEvent) => void + } + interface wrapStdMiddleware { + /** + * WrapStdMiddleware wraps Go [func(http.Handler) http.Handle] into a PocketBase middleware func. + */ + (m: (_arg0: http.Handler) => http.Handler): (_arg0: core.RequestEvent) => void + } + interface mustSubFS { + /** + * MustSubFS returns an [fs.FS] corresponding to the subtree rooted at fsys's dir. + * + * This is similar to [fs.Sub] but panics on failure. + */ + (fsys: fs.FS, dir: string): fs.FS + } + interface _static { + /** + * 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. + * + * 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: + * + * ``` + * fsys := os.DirFS("./pb_public") + * router.GET("/files/{path...}", apis.Static(fsys, false)) + * ``` + */ + (fsys: fs.FS, indexFallback: boolean): (_arg0: core.RequestEvent) => void + } + interface HandleFunc {(e: core.RequestEvent): void } + interface BatchActionHandlerFunc {(app: CoreApp, ir: core.InternalRequest, params: _TygojaDict, next: (data: any) => void): HandleFunc } + interface BatchRequestResult { + body: any + status: number + } + interface batchRequestsForm { + requests: Array<(core.InternalRequest | undefined)> + } + interface batchProcessor { + } + interface batchProcessor { + process(batch: Array<(core.InternalRequest | undefined)>, timeout: time.Duration): void + } + interface BatchResponseError { + } + interface BatchResponseError { + error(): string + } + interface BatchResponseError { + code(): string + } + interface BatchResponseError { + resolve(errData: _TygojaDict): any + } + interface BatchResponseError { + marshalJSON(): string|Array + } + interface providerListItem { + name: string + displayName: string + logo: string + } + interface dryRunViewForm { + query: string + } + interface collectionsImportForm { + collections: Array<_TygojaDict> + deleteMissing: boolean + } + interface fileApi { + } + interface defaultInstallerFunc { + /** + * 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. + */ + (app: CoreApp, systemSuperuser: core.Record, baseURL: string): void + } + interface requireGuestOnly { + /** + * RequireGuestOnly middleware requires a request to NOT have a valid + * Authorization header. + * + * This middleware is the opposite of [apis.RequireAuth()]. + */ + (): (hook.Handler) + } + interface requireAuth { + /** + * 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. + * + * Example: + * + * ``` + * apis.RequireAuth() // any auth collection + * apis.RequireAuth("_superusers", "users") // only the listed auth collections + * ``` + */ + (...optCollectionNames: string[]): (hook.Handler) + } + interface requireSuperuserAuth { + /** + * RequireSuperuserAuth middleware requires a request to have + * a valid superuser Authorization header. + */ + (): (hook.Handler) + } + interface requireSuperuserOrOwnerAuth { + /** + * RequireSuperuserOrOwnerAuth middleware requires a request to have + * a valid superuser or regular record owner Authorization header set. + * + * 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). + */ + (ownerIdPathParam: string): (hook.Handler) + } + interface requireSameCollectionContextAuth { + /** + * 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). + */ + (collectionPathParam: string): (hook.Handler) + } + interface skipSuccessActivityLog { + /** + * SkipSuccessActivityLog is a helper middleware that instructs the global + * activity logger to log only requests that have failed/returned an error. + */ + (): (hook.Handler) + } + interface bodyLimit { + /** + * 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. + */ + (limitBytes: number): (hook.Handler) + } /** - * A Request represents an HTTP request received by a server - * or to be sent by a client. - * - * The field semantics differ slightly between client and server - * usage. In addition to the notes on the fields below, see the - * documentation for Request.Write and RoundTripper. + * 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. */ - interface Request { + type _sGfKWMJ = io.ReadCloser + interface maxBytesReader extends _sGfKWMJ { + } + interface maxBytesReader { + read(b: string|Array): number + } + interface maxBytesReader { + reread(): void + } + interface maxBytesReader { + close(): void + } + /** + * CORSConfig defines the config for CORS middleware. + */ + interface CORSConfig { /** - * Method specifies the HTTP method (GET, POST, PUT, etc.). - * For client requests, an empty string means GET. + * 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 * - * Go's HTTP client does not support sending a request with - * the CONNECT method. See the documentation on Transport for - * details. + * Optional. Default value []string{"*"}. + * + * See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin */ - method: string + allowOrigins: Array /** - * URL specifies either the URI being requested (for server - * requests) or the URL to access (for client requests). + * 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. * - * For server requests, the URL is parsed from the URI - * supplied on the Request-Line as stored in RequestURI. For - * most requests, fields other than Path and RawQuery will be - * empty. (See RFC 7230, Section 5.3) + * 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 * - * For client requests, the URL's Host specifies the server to - * connect to, while the Request's Host field optionally - * specifies the Host header value to send in the HTTP - * request. + * Optional. */ - url?: url.URL + allowOriginFunc: (origin: string) => boolean /** - * The protocol version for incoming server requests. + * 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. * - * For client requests, these fields are ignored. The HTTP - * client code always uses either HTTP/1.1 or HTTP/2. - * See the docs on Transport for details. + * Optional. Default value DefaultCORSConfig.AllowMethods. + * + * See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods */ - proto: string // "HTTP/1.0" - protoMajor: number // 1 - protoMinor: number // 0 + allowMethods: Array /** - * Header contains the request header fields either received - * by the server or to be sent by the client. + * 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. * - * If a server received a request with header lines, + * Optional. Default value []string{}. * - * ``` - * Host: example.com - * accept-encoding: gzip, deflate - * Accept-Language: en-us - * fOO: Bar - * foo: two - * ``` + * See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers + */ + allowHeaders: Array + /** + * 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]. * - * then + * Optional. Default value false, in which case the header is not set. * - * ``` - * Header = map[string][]string{ - * "Accept-Encoding": {"gzip, deflate"}, - * "Accept-Language": {"en-us"}, - * "Foo": {"Bar", "two"}, - * } - * ``` + * 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 * - * For incoming requests, the Host header is promoted to the - * Request.Host field and removed from the Header map. + * See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials + */ + allowCredentials: boolean + /** + * 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. * - * HTTP defines that header names are case-insensitive. The - * request parser implements this by using CanonicalHeaderKey, - * making the first character and any characters following a - * hyphen uppercase and the rest lowercase. + * 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. * - * For client requests, certain headers such as Content-Length - * and Connection are automatically written when needed and - * values in Header may be ignored. See the documentation - * for the Request.Write method. + * Optional. Default value is false. */ - header: Header + unsafeWildcardOriginWithAllowCredentials: boolean /** - * Body is the request's body. + * ExposeHeaders determines the value of Access-Control-Expose-Headers, which + * defines a list of headers that clients are allowed to access. * - * For client requests, a nil body means the request has no - * body, such as a GET request. The HTTP Client's Transport - * is responsible for calling the Close method. + * Optional. Default value []string{}, in which case the header is not set. * - * For server requests, the Request Body is always non-nil - * but will return EOF immediately when no body is present. - * The Server will close the request body. The ServeHTTP - * Handler does not need to. + * See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Header + */ + exposeHeaders: Array + /** + * 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. * - * Body must allow Read to be called concurrently with Close. - * In particular, calling Close should unblock a Read waiting - * for input. + * 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 */ - body: io.ReadCloser + maxAge: number + } + interface cors { /** - * GetBody defines an optional func to return a new copy of - * Body. It is used for client requests when a redirect requires - * reading the body more than once. Use of GetBody still - * requires setting Body. + * CORS returns a CORS middleware. + */ + (config: CORSConfig): (hook.Handler) + } + /** + * GzipConfig defines the config for Gzip middleware. + */ + interface GzipConfig { + /** + * Gzip compression level. + * Optional. Default value -1. + */ + level: number + /** + * Length threshold before gzip compression is applied. + * Optional. Default value 0. * - * For server requests, it is unused. + * 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 */ - getBody: () => io.ReadCloser + minLength: number + } + interface gzip { + /** + * Gzip returns a middleware which compresses HTTP response using Gzip compression scheme. + */ + (): (hook.Handler) + } + interface gzipWithConfig { + /** + * GzipWithConfig returns a middleware which compresses HTTP response using gzip compression scheme. + */ + (config: GzipConfig): (hook.Handler) + } + type _suzRqEc = http.ResponseWriter&io.Writer + interface gzipResponseWriter extends _suzRqEc { + } + interface gzipResponseWriter { + writeHeader(code: number): void + } + interface gzipResponseWriter { + write(b: string|Array): number + } + interface gzipResponseWriter { + flush(): void + } + interface gzipResponseWriter { + hijack(): [net.Conn, (bufio.ReadWriter)] + } + interface gzipResponseWriter { + push(target: string, opts: http.PushOptions): void + } + interface gzipResponseWriter { + unwrap(): http.ResponseWriter + } + type _sMARIqX = sync.RWMutex + interface rateLimiter extends _sMARIqX { + } + /** + * @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare. + * + * rateClient implements fixed window rate limit strategy. + */ + type _sbpPpLz = sync.Mutex + interface rateClient extends _sbpPpLz { + } + interface realtimeSubscribeForm { + clientId: string + subscriptions: Array + } + /** + * recordData represents the broadcasted record subscrition message data. + */ + interface recordData { + record: any // map or core.Record + action: string + } + interface EmailChangeConfirmForm { + token: string + password: string + } + interface emailChangeRequestForm { + newEmail: string + } + interface impersonateForm { + /** + * Duration is the optional custom token duration in seconds. + */ + duration: number + } + interface otpResponse { + enabled: boolean + duration: number // in seconds + } + interface mfaResponse { + enabled: boolean + duration: number // in seconds + } + interface passwordResponse { + identityFields: Array + enabled: boolean + } + interface oauth2Response { + providers: Array + enabled: boolean + } + interface providerInfo { + name: string + displayName: string + logo: string + state: string + authURL: string + /** + * @todo + * deprecated: use AuthURL instead + * AuthUrl will be removed after dropping v0.22 support + */ + authUrl: string + /** + * 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 + codeChallenge: string + codeChallengeMethod: string + } + interface authMethodsResponse { + password: passwordResponse + oauth2: oauth2Response + mfa: mfaResponse + otp: otpResponse + /** + * legacy fields + * @todo remove after dropping v0.22 support + */ + authProviders: Array + usernamePassword: boolean + emailPassword: boolean + } + interface createOTPForm { + email: string + } + interface recordConfirmPasswordResetForm { + token: string + password: string + passwordConfirm: string + } + interface recordRequestPasswordResetForm { + email: string + } + interface recordConfirmVerificationForm { + token: string + } + interface recordRequestVerificationForm { + email: string + } + interface recordOAuth2LoginForm { + /** + * Additional data that will be used for creating a new auth record + * if an existing OAuth2 account doesn't exist. + */ + createData: _TygojaDict + /** + * The name of the OAuth2 client provider (eg. "google") + */ + provider: string + /** + * The authorization code returned from the initial request. + */ + code: string + /** + * The optional PKCE code verifier as part of the code_challenge sent with the initial request. + */ + codeVerifier: string + /** + * The redirect url sent with the initial request. + */ + redirectURL: string + /** + * @todo + * deprecated: use RedirectURL instead + * RedirectUrl will be removed after dropping v0.22 support + */ + redirectUrl: string + } + interface oauth2RedirectData { + state: string + code: string + error: string /** - * ContentLength records the length of the associated content. - * The value -1 indicates that the length is unknown. - * Values >= 0 indicate that the given number of bytes may - * be read from Body. - * - * For client requests, a value of 0 with a non-nil Body is - * also treated as unknown. + * returned by Apple only */ - contentLength: number + appleUser: string + } + interface authWithOTPForm { + otpId: string + password: string + } + interface authWithPasswordForm { + identity: string + password: string /** - * TransferEncoding lists the transfer encodings from outermost to - * innermost. An empty list denotes the "identity" encoding. - * TransferEncoding can usually be ignored; chunked encoding is - * automatically added and removed as necessary when sending and - * receiving requests. + * IdentityField specifies the field to use to search for the identity + * (leave it empty for "auto" detection). */ - transferEncoding: Array + identityField: string + } + // @ts-ignore + import cryptoRand = rand + interface recordAuthResponse { /** - * Close indicates whether to close the connection after - * replying to this request (for servers) or after sending this - * request and reading its response (for clients). + * RecordAuthResponse writes standardized json record auth response + * into the specified request context. * - * For server requests, the HTTP server handles this automatically - * and this field is not needed by Handlers. + * 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. * - * For client requests, setting this field prevents re-use of - * TCP connections between requests to the same hosts, as if - * Transport.DisableKeepAlives were set. + * 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). */ - close: boolean + (e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta: any): void + } + interface enrichRecord { /** - * For server requests, Host specifies the host on which the - * URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this - * is either the value of the "Host" header or the host name - * given in the URL itself. For HTTP/2, it is the value of the - * ":authority" pseudo-header field. - * It may be of the form "host:port". For international domain - * names, Host may be in Punycode or Unicode form. Use - * golang.org/x/net/idna to convert it to either format if - * needed. - * To prevent DNS rebinding attacks, server Handlers should - * validate that the Host header has a value for which the - * Handler considers itself authoritative. The included - * ServeMux supports patterns registered to particular host - * names and thus protects its registered Handlers. + * 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 visible only for the current logged superuser, record owner or record with manage access + * ``` + */ + (e: core.RequestEvent, record: core.Record, ...defaultExpands: string[]): void + } + interface enrichRecords { + /** + * 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 visible only for the current logged superuser, record owner or record with manage access + * ``` * - * For client requests, Host optionally overrides the Host - * header to send. If empty, the Request.Write method uses - * the value of URL.Host. Host may contain an international - * domain name. + * Note: Expects all records to be from the same collection! */ - host: string + (e: core.RequestEvent, records: Array<(core.Record | undefined)>, ...defaultExpands: string[]): void + } + interface iterator { + } + /** + * ServeConfig defines a configuration struct for apis.Serve(). + */ + interface ServeConfig { /** - * Form contains the parsed form data, including both the URL - * field's query parameters and the PATCH, POST, or PUT form data. - * This field is only available after ParseForm is called. - * The HTTP client ignores Form and uses Body instead. + * ShowStartBanner indicates whether to show or hide the server start console message. */ - form: url.Values + showStartBanner: boolean /** - * PostForm contains the parsed form data from PATCH, POST - * or PUT body parameters. - * - * This field is only available after ParseForm is called. - * The HTTP client ignores PostForm and uses Body instead. + * HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80"). */ - postForm: url.Values + httpAddr: string /** - * MultipartForm is the parsed multipart form, including file uploads. - * This field is only available after ParseMultipartForm is called. - * The HTTP client ignores MultipartForm and uses Body instead. + * HttpsAddr is the TCP address to listen for the HTTPS server (eg. "127.0.0.1:443"). */ - multipartForm?: multipart.Form + httpsAddr: string /** - * Trailer specifies additional headers that are sent after the request - * body. - * - * For server requests, the Trailer map initially contains only the - * trailer keys, with nil values. (The client declares which trailers it - * will later send.) While the handler is reading from Body, it must - * not reference Trailer. After reading from Body returns EOF, Trailer - * can be read again and will contain non-nil values, if they were sent - * by the client. + * Optional domains list to use when issuing the TLS certificate. * - * For client requests, Trailer must be initialized to a map containing - * the trailer keys to later send. The values may be nil or their final - * values. The ContentLength must be 0 or -1, to send a chunked request. - * After the HTTP request is sent the map values can be updated while - * the request body is read. Once the body returns EOF, the caller must - * not mutate Trailer. + * If not set, the host from the bound server address will be used. * - * Few HTTP clients, servers, or proxies support HTTP trailers. + * For convenience, for each "non-www" domain a "www" entry and + * redirect will be automatically added. */ - trailer: Header + certificateDomains: Array /** - * RemoteAddr allows HTTP servers and other software to record - * the network address that sent the request, usually for - * logging. This field is not filled in by ReadRequest and - * has no defined format. The HTTP server in this package - * sets RemoteAddr to an "IP:port" address before invoking a - * handler. - * This field is ignored by the HTTP client. + * AllowedOrigins is an optional list of CORS origins (default to "*"). */ - remoteAddr: string + allowedOrigins: Array + } + interface serve { /** - * RequestURI is the unmodified request-target of the - * Request-Line (RFC 7230, Section 3.1.1) as sent by the client - * to a server. Usually the URL field should be used instead. - * It is an error to set this field in an HTTP client request. + * 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, + * }) + * ``` */ - requestURI: string + (app: CoreApp, config: ServeConfig): void + } + interface serverErrorLogWriter { + } + interface serverErrorLogWriter { + write(p: string|Array): number + } + interface runSQLForm { + query: string + } + interface runSQLResultColumn { + name: string + type: string + nullable: boolean + } + interface runSQLResult { + execTime: number + affectedRows: number + columns: Array + rows: Array> + } +} + +/** + * 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 { /** - * TLS allows HTTP servers and other software to record - * information about the TLS connection on which the request - * was received. This field is not filled in by ReadRequest. - * The HTTP server in this package sets the field for - * TLS-enabled connections before invoking a handler; - * otherwise it leaves the field nil. - * This field is ignored by the HTTP client. + * NewRegistry creates and initializes a new templates registry with + * some defaults (eg. global "raw" template function for unescaped HTML). + * + * Use the Registry.Load* methods to load templates into the registry. */ - tls?: any + (): (Registry) + } + /** + * 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 { /** - * Cancel is an optional channel whose closure indicates that the client - * request should be regarded as canceled. Not all implementations of - * RoundTripper may support Cancel. + * AddFuncs registers new global template functions. * - * For server requests, this field is not applicable. + * The key of each map entry is the function name that will be used in the templates. + * If a function with the map entry name already exists it will be replaced with the new one. * - * Deprecated: Set the Request's context with NewRequestWithContext - * instead. If a Request's Cancel field and context are both - * set, it is undefined whether Cancel is respected. + * The value of each map entry is a function that must have either a + * single return value, or two return values of which the second has type error. + * + * Example: + * + * ``` + * r.AddFuncs(map[string]any{ + * "toUpper": func(str string) string { + * return strings.ToUppser(str) + * }, + * ... + * }) + * ``` */ - cancel: undefined + addFuncs(funcs: _TygojaDict): (Registry) + } + interface Registry { /** - * Response is the redirect response which caused this request - * to be created. This field is only populated during client - * redirects. + * 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. */ - response?: Response + loadFiles(...filenames: string[]): (Renderer) } - interface Request { + interface Registry { /** - * Context returns the request's context. To change the context, use - * WithContext. - * - * The returned context is always non-nil; it defaults to the - * background context. - * - * For outgoing client requests, the context controls cancellation. + * 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) + } + interface Registry { + /** + * LoadFS caches (if not already) the specified fs and globPatterns + * pair as single template and returns a ready to use Renderer instance. * - * For incoming server requests, the context is canceled when the - * client's connection closes, the request is canceled (with HTTP/2), - * or when the ServeHTTP method returns. + * There must be at least 1 file matching the provided globPattern(s) + * (note that most file names serves as glob patterns matching themselves). + */ + loadFS(fsys: fs.FS, ...globPatterns: string[]): (Renderer) + } + /** + * 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. */ - context(): context.Context + render(data: any): string } - interface Request { +} + +namespace pocketbase { + /** + * PocketBase defines a PocketBase app launcher. + * + * It implements [CoreApp] via embedding and all of the app interface methods + * could be accessed directly through the instance (eg. PocketBase.DataDir()). + */ + type _sCrESXQ = CoreApp + interface PocketBase extends _sCrESXQ { /** - * WithContext returns a shallow copy of r with its context changed - * to ctx. The provided ctx must be non-nil. - * - * For outgoing client request, the context controls the entire - * lifetime of a request and its response: obtaining a connection, - * sending the request, and reading the response headers and body. - * - * To create a new request with a context, use NewRequestWithContext. - * To change the context of a request, such as an incoming request you - * want to modify before sending back out, use Request.Clone. Between - * those two uses, it's rare to need WithContext. + * RootCmd is the main console command */ - withContext(ctx: context.Context): (Request | undefined) + rootCmd?: cobra.Command } - interface Request { + /** + * Config is the PocketBase initialization config struct. + */ + interface Config { /** - * Clone returns a deep copy of r with its context changed to ctx. - * The provided ctx must be non-nil. - * - * For an outgoing client request, the context controls the entire - * lifetime of a request and its response: obtaining a connection, - * sending the request, and reading the response headers and body. + * hide the default console server info on app startup */ - clone(ctx: context.Context): (Request | undefined) - } - interface Request { + hideStartBanner: boolean /** - * ProtoAtLeast reports whether the HTTP protocol used - * in the request is at least major.minor. + * optional default values for the console flags */ - protoAtLeast(major: number): boolean - } - interface Request { + defaultDev: boolean + defaultDataDir: string // if not set, it will fallback to "./pb_data" + defaultEncryptionEnv: string + defaultQueryTimeout: time.Duration // default to core.DefaultQueryTimeout (in seconds) /** - * UserAgent returns the client's User-Agent, if sent in the request. + * optional DB configurations */ - userAgent(): string + dataMaxOpenConns: number // default to core.DefaultDataMaxOpenConns + dataMaxIdleConns: number // default to core.DefaultDataMaxIdleConns + auxMaxOpenConns: number // default to core.DefaultAuxMaxOpenConns + auxMaxIdleConns: number // default to core.DefaultAuxMaxIdleConns + dbConnect: core.DBConnectFunc // default to core.dbConnect } - interface Request { + interface _new { /** - * Cookies parses and returns the HTTP cookies sent with the request. + * New creates a new PocketBase instance with the default configuration. + * Use [NewWithConfig] if you want to provide a custom configuration. + * + * 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 [PocketBase.Start] is executed. + * If you want to initialize the application before calling [PocketBase.Start], + * then you'll have to manually call [PocketBase.Bootstrap]. */ - cookies(): Array<(Cookie | undefined)> + (): (PocketBase) } - interface Request { + interface newWithConfig { /** - * Cookie returns the named cookie provided in the request or - * ErrNoCookie if not found. - * If multiple cookies match the given name, only one cookie will - * be returned. + * 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 [PocketBase.Start] is executed. + * If you want to initialize the application before calling [PocketBase.Start], + * then you'll have to manually call [PocketBase.Bootstrap]. */ - cookie(name: string): (Cookie | undefined) + (config: Config): (PocketBase) } - interface Request { + interface PocketBase { /** - * AddCookie adds a cookie to the request. Per RFC 6265 section 5.4, - * AddCookie does not attach more than one Cookie header field. That - * means all cookies, if any, are written into the same line, - * separated by semicolon. - * AddCookie only sanitizes c's name and value, and does not sanitize - * a Cookie header already present in the request. + * Start starts the application, aka. registers the default system + * commands (serve, superuser, version) and executes pb.RootCmd. */ - addCookie(c: Cookie): void + start(): void } - interface Request { + interface PocketBase { /** - * Referer returns the referring URL, if sent in the request. + * Execute initializes the application (if not already) and executes + * the pb.RootCmd with graceful shutdown support. * - * Referer is misspelled as in the request itself, a mistake from the - * earliest days of HTTP. This value can also be fetched from the - * Header map as Header["Referer"]; the benefit of making it available - * as a method is that the compiler can diagnose programs that use the - * alternate (correct English) spelling req.Referrer() but cannot - * diagnose programs that use Header["Referrer"]. + * This method differs from pb.Start() by not registering the default + * system commands! */ - referer(): string + execute(): void } - interface Request { + interface nopWrite { + } + interface nopWrite { + write(p: string|Array): number + } +} + +/** + * Package sync provides basic synchronization primitives such as mutual + * exclusion locks. Other than the [Once] and [WaitGroup] types, most are intended + * for use by low-level library routines. Higher-level synchronization is + * better done via channels and communication. + * + * Values containing the types defined in this package should not be copied. + */ +namespace sync { + // @ts-ignore + import isync = sync + /** + * A Mutex is a mutual exclusion lock. + * The zero value for a Mutex is an unlocked mutex. + * + * A Mutex must not be copied after first use. + * + * In the terminology of [the Go memory model], + * the n'th call to [Mutex.Unlock] “synchronizes before” the m'th call to [Mutex.Lock] + * for any n < m. + * A successful call to [Mutex.TryLock] is equivalent to a call to Lock. + * A failed call to TryLock does not establish any “synchronizes before” + * relation at all. + * + * [the Go memory model]: https://go.dev/ref/mem + */ + interface Mutex { + } + interface Mutex { /** - * MultipartReader returns a MIME multipart reader if this is a - * multipart/form-data or a multipart/mixed POST request, else returns nil and an error. - * Use this function instead of ParseMultipartForm to - * process the request body as a stream. + * Lock locks m. + * If the lock is already in use, the calling goroutine + * blocks until the mutex is available. */ - multipartReader(): (multipart.Reader | undefined) + lock(): void } - interface Request { + interface Mutex { /** - * Write writes an HTTP/1.1 request, which is the header and body, in wire format. - * This method consults the following fields of the request: - * ``` - * Host - * URL - * Method (defaults to "GET") - * Header - * ContentLength - * TransferEncoding - * Body - * ``` + * TryLock tries to lock m and reports whether it succeeded. * - * If Body is present, Content-Length is <= 0 and TransferEncoding - * hasn't been set to "identity", Write adds "Transfer-Encoding: - * chunked" to the header. Body is closed after it is sent. + * Note that while correct uses of TryLock do exist, they are rare, + * and use of TryLock is often a sign of a deeper problem + * in a particular use of mutexes. */ - write(w: io.Writer): void + tryLock(): boolean } - interface Request { + interface Mutex { /** - * WriteProxy is like Write but writes the request in the form - * expected by an HTTP proxy. In particular, WriteProxy writes the - * initial Request-URI line of the request with an absolute URI, per - * section 5.3 of RFC 7230, including the scheme and host. - * In either case, WriteProxy also writes a Host header, using - * either r.Host or r.URL.Host. + * Unlock unlocks m. + * It is a run-time error if m is not locked on entry to Unlock. + * + * A locked [Mutex] is not associated with a particular goroutine. + * It is allowed for one goroutine to lock a Mutex and then + * arrange for another goroutine to unlock it. */ - writeProxy(w: io.Writer): void + unlock(): void } - interface Request { + // @ts-ignore + import rtatomic = atomic + /** + * A RWMutex is a reader/writer mutual exclusion lock. + * The lock can be held by an arbitrary number of readers or a single writer. + * The zero value for a RWMutex is an unlocked mutex. + * + * A RWMutex must not be copied after first use. + * + * If any goroutine calls [RWMutex.Lock] while the lock is already held by + * one or more readers, concurrent calls to [RWMutex.RLock] will block until + * the writer has acquired (and released) the lock, to ensure that + * the lock eventually becomes available to the writer. + * Note that this prohibits recursive read-locking. + * A [RWMutex.RLock] cannot be upgraded into a [RWMutex.Lock], + * nor can a [RWMutex.Lock] be downgraded into a [RWMutex.RLock]. + * + * In the terminology of [the Go memory model], + * the n'th call to [RWMutex.Unlock] “synchronizes before” the m'th call to Lock + * for any n < m, just as for [Mutex]. + * For any call to RLock, there exists an n such that + * the n'th call to Unlock “synchronizes before” that call to RLock, + * and the corresponding call to [RWMutex.RUnlock] “synchronizes before” + * the n+1'th call to Lock. + * + * [the Go memory model]: https://go.dev/ref/mem + */ + interface RWMutex { + } + interface RWMutex { /** - * BasicAuth returns the username and password provided in the request's - * Authorization header, if the request uses HTTP Basic Authentication. - * See RFC 2617, Section 2. + * RLock locks rw for reading. + * + * It should not be used for recursive read locking; a blocked Lock + * call excludes new readers from acquiring the lock. See the + * documentation on the [RWMutex] type. */ - basicAuth(): [string, boolean] + rLock(): void } - interface Request { + interface RWMutex { /** - * SetBasicAuth sets the request's Authorization header to use HTTP - * Basic Authentication with the provided username and password. - * - * With HTTP Basic Authentication the provided username and password - * are not encrypted. + * TryRLock tries to lock rw for reading and reports whether it succeeded. * - * Some protocols may impose additional requirements on pre-escaping the - * username and password. For instance, when used with OAuth2, both arguments - * must be URL encoded first with url.QueryEscape. + * Note that while correct uses of TryRLock do exist, they are rare, + * and use of TryRLock is often a sign of a deeper problem + * in a particular use of mutexes. */ - setBasicAuth(username: string): void + tryRLock(): boolean } - interface Request { + interface RWMutex { /** - * ParseForm populates r.Form and r.PostForm. - * - * For all requests, ParseForm parses the raw query from the URL and updates - * r.Form. - * - * For POST, PUT, and PATCH requests, it also reads the request body, parses it - * as a form and puts the results into both r.PostForm and r.Form. Request body - * parameters take precedence over URL query string values in r.Form. - * - * If the request Body's size has not already been limited by MaxBytesReader, - * the size is capped at 10MB. - * - * For other HTTP methods, or when the Content-Type is not - * application/x-www-form-urlencoded, the request Body is not read, and - * r.PostForm is initialized to a non-nil, empty value. - * - * ParseMultipartForm calls ParseForm automatically. - * ParseForm is idempotent. + * RUnlock undoes a single [RWMutex.RLock] call; + * it does not affect other simultaneous readers. + * It is a run-time error if rw is not locked for reading + * on entry to RUnlock. */ - parseForm(): void + rUnlock(): void } - interface Request { + interface RWMutex { /** - * ParseMultipartForm parses a request body as multipart/form-data. - * The whole request body is parsed and up to a total of maxMemory bytes of - * its file parts are stored in memory, with the remainder stored on - * disk in temporary files. - * ParseMultipartForm calls ParseForm if necessary. - * If ParseForm returns an error, ParseMultipartForm returns it but also - * continues parsing the request body. - * After one call to ParseMultipartForm, subsequent calls have no effect. + * Lock locks rw for writing. + * If the lock is already locked for reading or writing, + * Lock blocks until the lock is available. */ - parseMultipartForm(maxMemory: number): void + lock(): void } - interface Request { + interface RWMutex { /** - * FormValue returns the first value for the named component of the query. - * POST and PUT body parameters take precedence over URL query string values. - * FormValue calls ParseMultipartForm and ParseForm if necessary and ignores - * any errors returned by these functions. - * If key is not present, FormValue returns the empty string. - * To access multiple values of the same key, call ParseForm and - * then inspect Request.Form directly. + * TryLock tries to lock rw for writing and reports whether it succeeded. + * + * Note that while correct uses of TryLock do exist, they are rare, + * and use of TryLock is often a sign of a deeper problem + * in a particular use of mutexes. */ - formValue(key: string): string + tryLock(): boolean } - interface Request { + interface RWMutex { /** - * PostFormValue returns the first value for the named component of the POST, - * PATCH, or PUT request body. URL query parameters are ignored. - * PostFormValue calls ParseMultipartForm and ParseForm if necessary and ignores - * any errors returned by these functions. - * If key is not present, PostFormValue returns the empty string. + * Unlock unlocks rw for writing. It is a run-time error if rw is + * not locked for writing on entry to Unlock. + * + * As with Mutexes, a locked [RWMutex] is not associated with a particular + * goroutine. One goroutine may [RWMutex.RLock] ([RWMutex.Lock]) a RWMutex and then + * arrange for another goroutine to [RWMutex.RUnlock] ([RWMutex.Unlock]) it. */ - postFormValue(key: string): string + unlock(): void } - interface Request { + interface RWMutex { /** - * FormFile returns the first file for the provided form key. - * FormFile calls ParseMultipartForm and ParseForm if necessary. + * RLocker returns a [Locker] interface that implements + * the [Locker.Lock] and [Locker.Unlock] methods by calling rw.RLock and rw.RUnlock. */ - formFile(key: string): [multipart.File, (multipart.FileHeader | undefined)] + rLocker(): Locker + } +} + +/** + * 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 { + /** + * Reader is the interface that wraps the basic Read method. + * + * Read reads up to len(p) bytes into p. It returns the number of bytes + * read (0 <= n <= len(p)) and any error encountered. Even if Read + * returns n < len(p), it may use all of p as scratch space during the call. + * If some data is available but not len(p) bytes, Read conventionally + * returns what is available instead of waiting for more. + * + * When Read encounters an error or end-of-file condition after + * successfully reading n > 0 bytes, it returns the number of + * bytes read. It may return the (non-nil) error from the same call + * or return the error (and n == 0) from a subsequent call. + * An instance of this general case is that a Reader returning + * a non-zero number of bytes at the end of the input stream may + * return either err == EOF or err == nil. The next Read should + * return 0, EOF. + * + * Callers should always process the n > 0 bytes returned before + * considering the error err. Doing so correctly handles I/O errors + * that happen after reading some bytes and also both of the + * allowed EOF behaviors. + * + * If len(p) == 0, Read should always return n == 0. It may return a + * non-nil error if some error condition is known, such as EOF. + * + * Implementations of Read are discouraged from returning a + * zero byte count with a nil error, except when len(p) == 0. + * Callers should treat a return of 0 and nil as indicating that + * nothing happened; in particular it does not indicate EOF. + * + * Implementations must not retain p. + */ + interface Reader { + [key:string]: any; + read(p: string|Array): number } /** - * A ResponseWriter interface is used by an HTTP handler to - * construct an HTTP response. + * Writer is the interface that wraps the basic Write method. + * + * Write writes len(p) bytes from p to the underlying data stream. + * It returns the number of bytes written from p (0 <= n <= len(p)) + * and any error encountered that caused the write to stop early. + * Write must return a non-nil error if it returns n < len(p). + * Write must not modify the slice data, even temporarily. * - * A ResponseWriter may not be used after the Handler.ServeHTTP method - * has returned. + * Implementations must not retain p. + */ + interface Writer { + [key:string]: any; + write(p: string|Array): number + } + /** + * ReadCloser is the interface that groups the basic Read and Close methods. + */ + interface ReadCloser { + [key:string]: any; + } + /** + * ReadSeekCloser is the interface that groups the basic Read, Seek and Close + * methods. */ - interface ResponseWriter { - /** - * Header returns the header map that will be sent by - * WriteHeader. The Header map also is the mechanism with which - * Handlers can set HTTP trailers. - * - * Changing the header map after a call to WriteHeader (or - * Write) has no effect unless the modified headers are - * trailers. - * - * There are two ways to set Trailers. The preferred way is to - * predeclare in the headers which trailers you will later - * send by setting the "Trailer" header to the names of the - * trailer keys which will come later. In this case, those - * keys of the Header map are treated as if they were - * trailers. See the example. The second way, for trailer - * keys not known to the Handler until after the first Write, - * is to prefix the Header map keys with the TrailerPrefix - * constant value. See TrailerPrefix. - * - * To suppress automatic response headers (such as "Date"), set - * their value to nil. - */ - header(): Header - /** - * Write writes the data to the connection as part of an HTTP reply. - * - * If WriteHeader has not yet been called, Write calls - * WriteHeader(http.StatusOK) before writing the data. If the Header - * does not contain a Content-Type line, Write adds a Content-Type set - * to the result of passing the initial 512 bytes of written data to - * DetectContentType. Additionally, if the total size of all written - * data is under a few KB and there are no Flush calls, the - * Content-Length header is added automatically. - * - * Depending on the HTTP protocol version and the client, calling - * Write or WriteHeader may prevent future reads on the - * Request.Body. For HTTP/1.x requests, handlers should read any - * needed request body data before writing the response. Once the - * headers have been flushed (due to either an explicit Flusher.Flush - * call or writing enough data to trigger a flush), the request body - * may be unavailable. For HTTP/2 requests, the Go HTTP server permits - * handlers to continue to read the request body while concurrently - * writing the response. However, such behavior may not be supported - * by all HTTP/2 clients. Handlers should read before writing if - * possible to maximize compatibility. - */ - write(_arg0: string): number - /** - * WriteHeader sends an HTTP response header with the provided - * status code. - * - * If WriteHeader is not called explicitly, the first call to Write - * will trigger an implicit WriteHeader(http.StatusOK). - * Thus explicit calls to WriteHeader are mainly used to - * send error codes. - * - * The provided code must be a valid HTTP 1xx-5xx status code. - * Only one header may be written. Go does not currently - * support sending user-defined 1xx informational headers, - * with the exception of 100-continue response header that the - * Server sends automatically when the Request.Body is read. - */ - writeHeader(statusCode: number): void + interface ReadSeekCloser { + [key:string]: any; } +} + +/** + * Package bytes implements functions for the manipulation of byte slices. + * It is analogous to the facilities of the [strings] package. + */ +namespace bytes { /** - * A Server defines parameters for running an HTTP server. - * The zero value for Server is a valid configuration. + * A Reader implements the [io.Reader], [io.ReaderAt], [io.WriterTo], [io.Seeker], + * [io.ByteScanner], and [io.RuneScanner] interfaces by reading from + * a byte slice. + * Unlike a [Buffer], a Reader is read-only and supports seeking. + * The zero value for Reader operates like a Reader of an empty slice. */ - interface Server { - /** - * Addr optionally specifies the TCP address for the server to listen on, - * in the form "host:port". If empty, ":http" (port 80) is used. - * The service names are defined in RFC 6335 and assigned by IANA. - * See net.Dial for details of the address format. - */ - addr: string - handler: Handler // handler to invoke, http.DefaultServeMux if nil - /** - * TLSConfig optionally provides a TLS configuration for use - * by ServeTLS and ListenAndServeTLS. Note that this value is - * cloned by ServeTLS and ListenAndServeTLS, so it's not - * possible to modify the configuration with methods like - * tls.Config.SetSessionTicketKeys. To use - * SetSessionTicketKeys, use Server.Serve with a TLS Listener - * instead. - */ - tlsConfig?: any + interface Reader { + } + interface Reader { /** - * ReadTimeout is the maximum duration for reading the entire - * request, including the body. A zero or negative value means - * there will be no timeout. - * - * Because ReadTimeout does not let Handlers make per-request - * decisions on each request body's acceptable deadline or - * upload rate, most users will prefer to use - * ReadHeaderTimeout. It is valid to use them both. + * Len returns the number of bytes of the unread portion of the + * slice. */ - readTimeout: time.Duration + len(): number + } + interface Reader { /** - * ReadHeaderTimeout is the amount of time allowed to read - * request headers. The connection's read deadline is reset - * after reading the headers and the Handler can decide what - * is considered too slow for the body. If ReadHeaderTimeout - * is zero, the value of ReadTimeout is used. If both are - * zero, there is no timeout. + * Size returns the original length of the underlying byte slice. + * Size is the number of bytes available for reading via [Reader.ReadAt]. + * The result is unaffected by any method calls except [Reader.Reset]. */ - readHeaderTimeout: time.Duration + size(): number + } + interface Reader { /** - * WriteTimeout is the maximum duration before timing out - * writes of the response. It is reset whenever a new - * request's header is read. Like ReadTimeout, it does not - * let Handlers make decisions on a per-request basis. - * A zero or negative value means there will be no timeout. + * Read implements the [io.Reader] interface. */ - writeTimeout: time.Duration + read(b: string|Array): number + } + interface Reader { /** - * IdleTimeout is the maximum amount of time to wait for the - * next request when keep-alives are enabled. If IdleTimeout - * is zero, the value of ReadTimeout is used. If both are - * zero, there is no timeout. + * ReadAt implements the [io.ReaderAt] interface. */ - idleTimeout: time.Duration + readAt(b: string|Array, off: number): number + } + interface Reader { /** - * MaxHeaderBytes controls the maximum number of bytes the - * server will read parsing the request header's keys and - * values, including the request line. It does not limit the - * size of the request body. - * If zero, DefaultMaxHeaderBytes is used. + * ReadByte implements the [io.ByteReader] interface. */ - maxHeaderBytes: number + readByte(): number + } + interface Reader { /** - * TLSNextProto optionally specifies a function to take over - * ownership of the provided TLS connection when an ALPN - * protocol upgrade has occurred. The map key is the protocol - * name negotiated. The Handler argument should be used to - * handle HTTP requests and will initialize the Request's TLS - * and RemoteAddr if not already set. The connection is - * automatically closed when the function returns. - * If TLSNextProto is not nil, HTTP/2 support is not enabled - * automatically. + * UnreadByte complements [Reader.ReadByte] in implementing the [io.ByteScanner] interface. */ - tlsNextProto: _TygojaDict + unreadByte(): void + } + interface Reader { /** - * ConnState specifies an optional callback function that is - * called when a client connection changes state. See the - * ConnState type and associated constants for details. + * ReadRune implements the [io.RuneReader] interface. */ - connState: (_arg0: net.Conn, _arg1: ConnState) => void + readRune(): [number, number] + } + interface Reader { /** - * ErrorLog specifies an optional logger for errors accepting - * connections, unexpected behavior from handlers, and - * underlying FileSystem errors. - * If nil, logging is done via the log package's standard logger. + * UnreadRune complements [Reader.ReadRune] in implementing the [io.RuneScanner] interface. */ - errorLog?: any + unreadRune(): void + } + interface Reader { /** - * BaseContext optionally specifies a function that returns - * the base context for incoming requests on this server. - * The provided Listener is the specific Listener that's - * about to start accepting requests. - * If BaseContext is nil, the default is context.Background(). - * If non-nil, it must return a non-nil context. + * Seek implements the [io.Seeker] interface. */ - baseContext: (_arg0: net.Listener) => context.Context + seek(offset: number, whence: number): number + } + interface Reader { /** - * ConnContext optionally specifies a function that modifies - * the context used for a new connection c. The provided ctx - * is derived from the base context and has a ServerContextKey - * value. + * WriteTo implements the [io.WriterTo] interface. */ - connContext: (ctx: context.Context, c: net.Conn) => context.Context + writeTo(w: io.Writer): number } - interface Server { + interface Reader { /** - * Close immediately closes all active net.Listeners and any - * connections in state StateNew, StateActive, or StateIdle. For a - * graceful shutdown, use Shutdown. - * - * Close does not attempt to close (and does not even know about) - * any hijacked connections, such as WebSockets. - * - * Close returns any error returned from closing the Server's - * underlying Listener(s). + * Reset resets the [Reader] to be reading from b. */ - close(): void + reset(b: string|Array): void } - interface Server { +} + +/** + * Package syscall contains an interface to the low-level operating system + * primitives. The details vary depending on the underlying system, and + * by default, godoc will display the syscall documentation for the current + * system. If you want godoc to display syscall documentation for another + * system, set $GOOS and $GOARCH to the desired system. For example, if + * you want to view documentation for freebsd/arm on linux/amd64, set $GOOS + * to freebsd and $GOARCH to arm. + * The primary use of syscall is inside other packages that provide a more + * portable interface to the system, such as "os", "time" and "net". Use + * those packages rather than this one if you can. + * For details of the functions and data types in this package consult + * the manuals for the appropriate operating system. + * These calls return err == nil to indicate success; otherwise + * err is an operating system error describing the failure. + * On most systems, that error has type [Errno]. + * + * NOTE: Most of the functions, types, and constants defined in + * this package are also available in the [golang.org/x/sys] package. + * That package has more system call support than this one, + * and most new code should prefer that package where possible. + * See https://golang.org/s/go1.4-syscall for more information. + */ +namespace syscall { + // @ts-ignore + import errpkg = errors + interface SysProcAttr { + chroot: string // Chroot. + credential?: Credential // Credential. /** - * Shutdown gracefully shuts down the server without interrupting any - * active connections. Shutdown works by first closing all open - * listeners, then closing all idle connections, and then waiting - * indefinitely for connections to return to idle and then shut down. - * If the provided context expires before the shutdown is complete, - * Shutdown returns the context's error, otherwise it returns any - * error returned from closing the Server's underlying Listener(s). - * - * When Shutdown is called, Serve, ListenAndServe, and - * ListenAndServeTLS immediately return ErrServerClosed. Make sure the - * program doesn't exit and waits instead for Shutdown to return. - * - * Shutdown does not attempt to close nor wait for hijacked - * connections such as WebSockets. The caller of Shutdown should - * separately notify such long-lived connections of shutdown and wait - * for them to close, if desired. See RegisterOnShutdown for a way to - * register shutdown notification functions. - * - * Once Shutdown has been called on a server, it may not be reused; - * future calls to methods such as Serve will return ErrServerClosed. + * Ptrace tells the child to call ptrace(PTRACE_TRACEME). + * Call runtime.LockOSThread before starting a process with this set, + * and don't call UnlockOSThread until done with PtraceSyscall calls. */ - shutdown(ctx: context.Context): void - } - interface Server { + ptrace: boolean + setsid: boolean // Create session. /** - * RegisterOnShutdown registers a function to call on Shutdown. - * This can be used to gracefully shutdown connections that have - * undergone ALPN protocol upgrade or that have been hijacked. - * This function should start protocol-specific graceful shutdown, - * but should not wait for shutdown to complete. + * Setpgid sets the process group ID of the child to Pgid, + * or, if Pgid == 0, to the new child's process ID. */ - registerOnShutdown(f: () => void): void - } - interface Server { + setpgid: boolean /** - * ListenAndServe listens on the TCP network address srv.Addr and then - * calls Serve to handle requests on incoming connections. - * Accepted connections are configured to enable TCP keep-alives. - * - * If srv.Addr is blank, ":http" is used. - * - * ListenAndServe always returns a non-nil error. After Shutdown or Close, - * the returned error is ErrServerClosed. + * Setctty sets the controlling terminal of the child to + * file descriptor Ctty. Ctty must be a descriptor number + * in the child process: an index into ProcAttr.Files. + * This is only meaningful if Setsid is true. */ - listenAndServe(): void - } - interface Server { + setctty: boolean + noctty: boolean // Detach fd 0 from controlling terminal. + ctty: number // Controlling TTY fd. /** - * Serve accepts incoming connections on the Listener l, creating a - * new service goroutine for each. The service goroutines read requests and - * then call srv.Handler to reply to them. - * - * HTTP/2 support is only enabled if the Listener returns *tls.Conn - * connections and they were configured with "h2" in the TLS - * Config.NextProtos. - * - * Serve always returns a non-nil error and closes l. - * After Shutdown or Close, the returned error is ErrServerClosed. + * Foreground places the child process group in the foreground. + * This implies Setpgid. The Ctty field must be set to + * the descriptor of the controlling TTY. + * Unlike Setctty, in this case Ctty must be a descriptor + * number in the parent process. */ - serve(l: net.Listener): void - } - interface Server { + foreground: boolean + pgid: number // Child's process group ID if Setpgid. /** - * ServeTLS accepts incoming connections on the Listener l, creating a - * new service goroutine for each. The service goroutines perform TLS - * setup and then read requests, calling srv.Handler to reply to them. - * - * Files containing a certificate and matching private key for the - * server must be provided if neither the Server's - * TLSConfig.Certificates nor TLSConfig.GetCertificate are populated. - * If the certificate is signed by a certificate authority, the - * certFile should be the concatenation of the server's certificate, - * any intermediates, and the CA's certificate. - * - * ServeTLS always returns a non-nil error. After Shutdown or Close, the - * returned error is ErrServerClosed. + * Pdeathsig, if non-zero, is a signal that the kernel will send to + * the child process when the creating thread dies. Note that the signal + * is sent on thread termination, which may happen before process termination. + * There are more details at https://go.dev/issue/27505. */ - serveTLS(l: net.Listener, certFile: string): void - } - interface Server { + pdeathsig: Signal + cloneflags: number // Flags for clone calls. + unshareflags: number // Flags for unshare calls. + uidMappings: Array // User ID mappings for user namespaces. + gidMappings: Array // Group ID mappings for user namespaces. /** - * SetKeepAlivesEnabled controls whether HTTP keep-alives are enabled. - * By default, keep-alives are always enabled. Only very - * resource-constrained environments or servers in the process of - * shutting down should disable them. + * GidMappingsEnableSetgroups enabling setgroups syscall. + * If false, then setgroups syscall will be disabled for the child process. + * This parameter is no-op if GidMappings == nil. Otherwise for unprivileged + * users this should be set to false for mappings work. */ - setKeepAlivesEnabled(v: boolean): void - } - interface Server { + gidMappingsEnableSetgroups: boolean + ambientCaps: Array // Ambient capabilities. + useCgroupFD: boolean // Whether to make use of the CgroupFD field. + cgroupFD: number // File descriptor of a cgroup to put the new process into. /** - * ListenAndServeTLS listens on the TCP network address srv.Addr and - * then calls ServeTLS to handle requests on incoming TLS connections. - * Accepted connections are configured to enable TCP keep-alives. - * - * Filenames containing a certificate and matching private key for the - * server must be provided if neither the Server's TLSConfig.Certificates - * nor TLSConfig.GetCertificate are populated. If the certificate is - * signed by a certificate authority, the certFile should be the - * concatenation of the server's certificate, any intermediates, and - * the CA's certificate. - * - * If srv.Addr is blank, ":https" is used. - * - * ListenAndServeTLS always returns a non-nil error. After Shutdown or - * Close, the returned error is ErrServerClosed. + * PidFD, if not nil, is used to store the pidfd of a child, if the + * functionality is supported by the kernel, or -1. Note *PidFD is + * changed only if the process starts successfully. */ - listenAndServeTLS(certFile: string): void - } -} - -namespace auth { - /** - * AuthUser defines a standardized oauth2 user data structure. - */ - interface AuthUser { - id: string - name: string - username: string - email: string - avatarUrl: string - rawUser: _TygojaDict - accessToken: string - refreshToken: string + pidFD?: number } + // @ts-ignore + import errorspkg = errors /** - * Provider defines a common interface for an OAuth2 client. + * A RawConn is a raw network connection. */ - interface Provider { + interface RawConn { + [key:string]: any; /** - * Scopes returns the context associated with the provider (if any). + * Control invokes f on the underlying connection's file + * descriptor or handle. + * The file descriptor fd is guaranteed to remain valid while + * f executes but not after f returns. */ - context(): context.Context + control(f: (fd: number) => void): void /** - * SetContext assigns the specified context to the current provider. + * Read invokes f on the underlying connection's file + * descriptor or handle; f is expected to try to read from the + * file descriptor. + * If f returns true, Read returns. Otherwise Read blocks + * waiting for the connection to be ready for reading and + * tries again repeatedly. + * The file descriptor is guaranteed to remain valid while f + * executes but not after f returns. */ - setContext(ctx: context.Context): void + read(f: (fd: number) => boolean): void /** - * Scopes returns the provider access permissions that will be requested. + * Write is like Read but for writing. */ - scopes(): Array + write(f: (fd: number) => boolean): void + } + /** + * An Errno is an unsigned number describing an error condition. + * It implements the error interface. The zero Errno is by convention + * a non-error, so code to convert from Errno to error should use: + * + * ``` + * err = nil + * if errno != 0 { + * err = errno + * } + * ``` + * + * Errno values can be tested against error values using [errors.Is]. + * For example: + * + * ``` + * _, _, err := syscall.Syscall(...) + * if errors.Is(err, fs.ErrNotExist) ... + * ``` + */ + interface Errno extends Number{} + interface Errno { + error(): string + } + interface Errno { + is(target: Error): boolean + } + interface Errno { + temporary(): boolean + } + interface Errno { + timeout(): boolean + } +} + +/** + * Package time provides functionality for measuring and displaying time. + * + * The calendrical calculations always assume a Gregorian calendar, with + * no leap seconds. + * + * # Monotonic Clocks + * + * Operating systems provide both a “wall clock,” which is subject to + * changes for clock synchronization, and a “monotonic clock,” which is + * not. The general rule is that the wall clock is for telling time and + * the monotonic clock is for measuring time. Rather than split the API, + * in this package the Time returned by [time.Now] contains both a wall + * clock reading and a monotonic clock reading; later time-telling + * operations use the wall clock reading, but later time-measuring + * operations, specifically comparisons and subtractions, use the + * monotonic clock reading. + * + * For example, this code always computes a positive elapsed time of + * approximately 20 milliseconds, even if the wall clock is changed during + * the operation being timed: + * + * ``` + * start := time.Now() + * ... operation that takes 20 milliseconds ... + * t := time.Now() + * elapsed := t.Sub(start) + * ``` + * + * Other idioms, such as [time.Since](start), [time.Until](deadline), and + * time.Now().Before(deadline), are similarly robust against wall clock + * resets. + * + * The rest of this section gives the precise details of how operations + * use monotonic clocks, but understanding those details is not required + * to use this package. + * + * The Time returned by time.Now contains a monotonic clock reading. + * If Time t has a monotonic clock reading, t.Add adds the same duration to + * both the wall clock and monotonic clock readings to compute the result. + * Because t.AddDate(y, m, d), t.Round(d), and t.Truncate(d) are wall time + * computations, they always strip any monotonic clock reading from their results. + * Because t.In, t.Local, and t.UTC are used for their effect on the interpretation + * of the wall time, they also strip any monotonic clock reading from their results. + * The canonical way to strip a monotonic clock reading is to use t = t.Round(0). + * + * If Times t and u both contain monotonic clock readings, the operations + * t.After(u), t.Before(u), t.Equal(u), t.Compare(u), and t.Sub(u) are carried out + * using the monotonic clock readings alone, ignoring the wall clock + * readings. If either t or u contains no monotonic clock reading, these + * operations fall back to using the wall clock readings. + * + * On some systems the monotonic clock will stop if the computer goes to sleep. + * On such a system, t.Sub(u) may not accurately reflect the actual + * time that passed between t and u. The same applies to other functions and + * methods that subtract times, such as [Since], [Until], [Time.Before], [Time.After], + * [Time.Add], [Time.Equal] and [Time.Compare]. In some cases, you may need to strip + * the monotonic clock to get accurate results. + * + * Because the monotonic clock reading has no meaning outside + * the current process, the serialized forms generated by t.GobEncode, + * t.MarshalBinary, t.MarshalJSON, and t.MarshalText omit the monotonic + * clock reading, and t.Format provides no format for it. Similarly, the + * constructors [time.Date], [time.Parse], [time.ParseInLocation], and [time.Unix], + * as well as the unmarshalers t.GobDecode, t.UnmarshalBinary. + * t.UnmarshalJSON, and t.UnmarshalText always create times with + * no monotonic clock reading. + * + * The monotonic clock reading exists only in [Time] values. It is not + * a part of [Duration] values or the Unix times returned by t.Unix and + * friends. + * + * Note that the Go == operator compares not just the time instant but + * also the [Location] and the monotonic clock reading. See the + * documentation for the Time type for a discussion of equality + * testing for Time values. + * + * For debugging, the result of t.String does include the monotonic + * clock reading if present. If t != u because of different monotonic clock readings, + * that difference will be visible when printing t.String() and u.String(). + * + * # Timer Resolution + * + * [Timer] resolution varies depending on the Go runtime, the operating system + * and the underlying hardware. + * On Unix, the resolution is ~1ms. + * On Windows version 1803 and newer, the resolution is ~0.5ms. + * On older Windows versions, the default resolution is ~16ms, but + * a higher resolution may be requested using [golang.org/x/sys/windows.TimeBeginPeriod]. + */ +namespace time { + interface Time { /** - * SetScopes sets the provider access permissions that will be requested later. + * String returns the time formatted using the format string + * + * ``` + * "2006-01-02 15:04:05.999999999 -0700 MST" + * ``` + * + * If the time has a monotonic clock reading, the returned string + * includes a final field "m=±", where value is the monotonic + * clock reading formatted as a decimal number of seconds. + * + * The returned string is meant for debugging; for a stable serialized + * representation, use t.MarshalText, t.MarshalBinary, or t.Format + * with an explicit format string. */ - setScopes(scopes: Array): void + string(): string + } + interface Time { /** - * ClientId returns the provider client's app ID. + * GoString implements [fmt.GoStringer] and formats t to be printed in Go source + * code. */ - clientId(): string + goString(): string + } + interface Time { /** - * SetClientId sets the provider client's ID. + * Format returns a textual representation of the time value formatted according + * to the layout defined by the argument. See the documentation for the + * constant called [Layout] to see how to represent the layout format. + * + * The executable example for [Time.Format] demonstrates the working + * of the layout string in detail and is a good reference. */ - setClientId(clientId: string): void + format(layout: string): string + } + interface Time { /** - * ClientSecret returns the provider client's app secret. + * AppendFormat is like [Time.Format] but appends the textual + * representation to b and returns the extended buffer. */ - clientSecret(): string + appendFormat(b: string|Array, layout: string): string|Array + } + /** + * A Time represents an instant in time with nanosecond precision. + * + * Programs using times should typically store and pass them as values, + * not pointers. That is, time variables and struct fields should be of + * type [time.Time], not *time.Time. + * + * A Time value can be used by multiple goroutines simultaneously except + * that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and + * [Time.UnmarshalText] are not concurrency-safe. + * + * Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods. + * The [Time.Sub] method subtracts two instants, producing a [Duration]. + * The [Time.Add] method adds a Time and a Duration, producing a Time. + * + * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC. + * As this time is unlikely to come up in practice, the [Time.IsZero] method gives + * a simple way of detecting a time that has not been initialized explicitly. + * + * Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a + * Time with a specific Location. Changing the Location of a Time value with + * these methods does not change the actual instant it represents, only the time + * zone in which to interpret it. + * + * Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary], + * [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset, + * but not the location name. They therefore lose information about Daylight Saving Time. + * + * In addition to the required “wall clock” reading, a Time may contain an optional + * reading of the current process's monotonic clock, to provide additional precision + * for comparison or subtraction. + * See the “Monotonic Clocks” section in the package documentation for details. + * + * Note that the Go == operator compares not just the time instant but also the + * Location and the monotonic clock reading. Therefore, Time values should not + * be used as map or database keys without first guaranteeing that the + * identical Location has been set for all values, which can be achieved + * through use of the UTC or Local method, and that the monotonic clock reading + * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u) + * to t == u, since t.Equal uses the most accurate comparison available and + * correctly handles the case when only one of its arguments has a monotonic + * clock reading. + */ + interface Time { + } + interface Time { /** - * SetClientSecret sets the provider client's app secret. + * IsZero reports whether t represents the zero time instant, + * January 1, year 1, 00:00:00 UTC. */ - setClientSecret(secret: string): void + isZero(): boolean + } + interface Time { /** - * RedirectUrl returns the end address to redirect the user - * going through the OAuth flow. + * After reports whether the time instant t is after u. */ - redirectUrl(): string + after(u: Time): boolean + } + interface Time { /** - * SetRedirectUrl sets the provider's RedirectUrl. + * Before reports whether the time instant t is before u. */ - setRedirectUrl(url: string): void + before(u: Time): boolean + } + interface Time { /** - * AuthUrl returns the provider's authorization service url. + * Compare compares the time instant t with u. If t is before u, it returns -1; + * if t is after u, it returns +1; if they're the same, it returns 0. */ - authUrl(): string + compare(u: Time): number + } + interface Time { /** - * SetAuthUrl sets the provider's AuthUrl. + * Equal reports whether t and u represent the same time instant. + * Two times can be equal even if they are in different locations. + * For example, 6:00 +0200 and 4:00 UTC are Equal. + * See the documentation on the Time type for the pitfalls of using == with + * Time values; most code should use Equal instead. */ - setAuthUrl(url: string): void + equal(u: Time): boolean + } + interface Time { /** - * TokenUrl returns the provider's token exchange service url. + * Date returns the year, month, and day in which t occurs. */ - tokenUrl(): string + date(): [number, Month, number] + } + interface Time { /** - * SetTokenUrl sets the provider's TokenUrl. + * Year returns the year in which t occurs. */ - setTokenUrl(url: string): void + year(): number + } + interface Time { /** - * UserApiUrl returns the provider's user info api url. + * Month returns the month of the year specified by t. */ - userApiUrl(): string + month(): Month + } + interface Time { /** - * SetUserApiUrl sets the provider's UserApiUrl. + * Day returns the day of the month specified by t. */ - setUserApiUrl(url: string): void + day(): number + } + interface Time { /** - * Client returns an http client using the provided token. + * Weekday returns the day of the week specified by t. */ - client(token: oauth2.Token): (any | undefined) + weekday(): Weekday + } + interface Time { /** - * BuildAuthUrl returns a URL to the provider's consent page - * that asks for permissions for the required scopes explicitly. + * ISOWeek returns the ISO 8601 year and week number in which t occurs. + * Week ranges from 1 to 53. Jan 01 to Jan 03 of year n might belong to + * week 52 or 53 of year n-1, and Dec 29 to Dec 31 might belong to week 1 + * of year n+1. */ - buildAuthUrl(state: string, ...opts: oauth2.AuthCodeOption[]): string + isoWeek(): [number, number] + } + interface Time { /** - * FetchToken converts an authorization code to token. + * Clock returns the hour, minute, and second within the day specified by t. */ - fetchToken(code: string, ...opts: oauth2.AuthCodeOption[]): (oauth2.Token | undefined) + clock(): [number, number, number] + } + interface Time { /** - * FetchRawUserData requests and marshalizes into `result` the - * the OAuth user api response. + * Hour returns the hour within the day specified by t, in the range [0, 23]. */ - fetchRawUserData(token: oauth2.Token): string + hour(): number + } + interface Time { /** - * FetchAuthUser is similar to FetchRawUserData, but normalizes and - * marshalizes the user api response into a standardized AuthUser struct. + * Minute returns the minute offset within the hour specified by t, in the range [0, 59]. */ - fetchAuthUser(token: oauth2.Token): (AuthUser | undefined) + minute(): number } -} - -/** - * Package types implements some commonly used db serializable types - * like datetime, json, etc. - */ -namespace types { - /** - * JsonArray defines a slice that is safe for json and db read/write. - */ - interface JsonArray extends Array{} - interface JsonArray { + interface Time { /** - * MarshalJSON implements the [json.Marshaler] interface. + * Second returns the second offset within the minute specified by t, in the range [0, 59]. */ - marshalJSON(): string + second(): number } - interface JsonArray { + interface Time { /** - * Value implements the [driver.Valuer] interface. + * Nanosecond returns the nanosecond offset within the second specified by t, + * in the range [0, 999999999]. */ - value(): any + nanosecond(): number } - interface JsonArray { + interface Time { /** - * Scan implements [sql.Scanner] interface to scan the provided value - * into the current JsonArray[T] instance. + * YearDay returns the day of the year specified by t, in the range [1,365] for non-leap years, + * and [1,366] in leap years. */ - scan(value: any): void + yearDay(): number } /** - * JsonMap defines a map that is safe for json and db read/write. + * A Duration represents the elapsed time between two instants + * as an int64 nanosecond count. The representation limits the + * largest representable duration to approximately 290 years. */ - interface JsonMap extends _TygojaDict{} - interface JsonMap { + interface Duration extends Number{} + interface Duration { /** - * MarshalJSON implements the [json.Marshaler] interface. + * String returns a string representing the duration in the form "72h3m0.5s". + * Leading zero units are omitted. As a special case, durations less than one + * second format use a smaller unit (milli-, micro-, or nanoseconds) to ensure + * that the leading digit is non-zero. The zero duration formats as 0s. */ - marshalJSON(): string + string(): string } - interface JsonMap { + interface Duration { /** - * Get retrieves a single value from the current JsonMap. - * - * This helper was added primarily to assist the goja integration since custom map types - * don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). + * Nanoseconds returns the duration as an integer nanosecond count. */ - get(key: string): any + nanoseconds(): number } - interface JsonMap { + interface Duration { /** - * Set sets a single value in the current JsonMap. - * - * This helper was added primarily to assist the goja integration since custom map types - * don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). + * Microseconds returns the duration as an integer microsecond count. */ - set(key: string, value: any): void + microseconds(): number } - interface JsonMap { + interface Duration { /** - * Value implements the [driver.Valuer] interface. + * Milliseconds returns the duration as an integer millisecond count. */ - value(): any + milliseconds(): number } - interface JsonMap { + interface Duration { /** - * Scan implements [sql.Scanner] interface to scan the provided value - * into the current `JsonMap` instance. + * Seconds returns the duration as a floating point number of seconds. */ - scan(value: any): void - } -} - -/** - * Package blob provides an easy and portable way to interact with blobs - * within a storage location. Subpackages contain driver implementations of - * blob for supported services. - * - * See https://gocloud.dev/howto/blob/ for a detailed how-to guide. - * - * *blob.Bucket implements io/fs.FS and io/fs.SubFS, so it can be used with - * functions in that package. - * - * # Errors - * - * The errors returned from this package can be inspected in several ways: - * - * The Code function from gocloud.dev/gcerrors will return an error code, also - * defined in that package, when invoked on an error. - * - * The Bucket.ErrorAs method can retrieve the driver error underlying the returned - * error. - * - * # OpenCensus Integration - * - * OpenCensus supports tracing and metric collection for multiple languages and - * backend providers. See https://opencensus.io. - * - * This API collects OpenCensus traces and metrics for the following methods: - * ``` - * - Attributes - * - Copy - * - Delete - * - ListPage - * - NewRangeReader, from creation until the call to Close. (NewReader and ReadAll - * are included because they call NewRangeReader.) - * - NewWriter, from creation until the call to Close. - * ``` - * - * All trace and metric names begin with the package import path. - * The traces add the method name. - * For example, "gocloud.dev/blob/Attributes". - * The metrics are "completed_calls", a count of completed method calls by driver, - * method and status (error code); and "latency", a distribution of method latency - * by driver and method. - * For example, "gocloud.dev/blob/latency". - * - * It also collects the following metrics: - * ``` - * - gocloud.dev/blob/bytes_read: the total number of bytes read, by driver. - * - gocloud.dev/blob/bytes_written: the total number of bytes written, by driver. - * ``` - * - * To enable trace collection in your application, see "Configure Exporter" at - * https://opencensus.io/quickstart/go/tracing. - * To enable metric collection in your application, see "Exporting stats" at - * https://opencensus.io/quickstart/go/metrics. - */ -namespace blob { - /** - * Reader reads bytes from a blob. - * It implements io.ReadSeekCloser, and must be closed after - * reads are finished. - */ - interface Reader { + seconds(): number } - interface Reader { + interface Duration { /** - * Read implements io.Reader (https://golang.org/pkg/io/#Reader). + * Minutes returns the duration as a floating point number of minutes. */ - read(p: string): number + minutes(): number } - interface Reader { + interface Duration { /** - * Seek implements io.Seeker (https://golang.org/pkg/io/#Seeker). + * Hours returns the duration as a floating point number of hours. */ - seek(offset: number, whence: number): number + hours(): number } - interface Reader { + interface Duration { /** - * Close implements io.Closer (https://golang.org/pkg/io/#Closer). + * Truncate returns the result of rounding d toward zero to a multiple of m. + * If m <= 0, Truncate returns d unchanged. */ - close(): void + truncate(m: Duration): Duration } - interface Reader { + interface Duration { /** - * ContentType returns the MIME type of the blob. + * Round returns the result of rounding d to the nearest multiple of m. + * The rounding behavior for halfway values is to round away from zero. + * If the result exceeds the maximum (or minimum) + * value that can be stored in a [Duration], + * Round returns the maximum (or minimum) duration. + * If m <= 0, Round returns d unchanged. */ - contentType(): string + round(m: Duration): Duration } - interface Reader { + interface Duration { /** - * ModTime returns the time the blob was last modified. + * Abs returns the absolute value of d. + * As a special case, Duration([math.MinInt64]) is converted to Duration([math.MaxInt64]), + * reducing its magnitude by 1 nanosecond. */ - modTime(): time.Time + abs(): Duration } - interface Reader { + interface Time { /** - * Size returns the size of the blob content in bytes. + * Add returns the time t+d. */ - size(): number + add(d: Duration): Time } - interface Reader { + interface Time { /** - * As converts i to driver-specific types. - * See https://gocloud.dev/concepts/as/ for background information, the "As" - * examples in this package for examples, and the driver package - * documentation for the specific types supported for that driver. + * Sub returns the duration t-u. If the result exceeds the maximum (or minimum) + * value that can be stored in a [Duration], the maximum (or minimum) duration + * will be returned. + * To compute t-d for a duration d, use t.Add(-d). */ - as(i: { - }): boolean + sub(u: Time): Duration } - interface Reader { + interface Time { /** - * WriteTo reads from r and writes to w until there's no more data or - * an error occurs. - * The return value is the number of bytes written to w. + * AddDate returns the time corresponding to adding the + * given number of years, months, and days to t. + * For example, AddDate(-1, 2, 3) applied to January 1, 2011 + * returns March 4, 2010. * - * It implements the io.WriterTo interface. + * Note that dates are fundamentally coupled to timezones, and calendrical + * periods like days don't have fixed durations. AddDate uses the Location of + * the Time value to determine these durations. That means that the same + * AddDate arguments can produce a different shift in absolute time depending on + * the base Time value and its Location. For example, AddDate(0, 0, 1) applied + * to 12:00 on March 27 always returns 12:00 on March 28. At some locations and + * in some years this is a 24 hour shift. In others it's a 23 hour shift due to + * daylight savings time transitions. + * + * AddDate normalizes its result in the same way that Date does, + * so, for example, adding one month to October 31 yields + * December 1, the normalized form for November 31. */ - writeTo(w: io.Writer): number + addDate(years: number, months: number, days: number): Time } - /** - * Attributes contains attributes about a blob. - */ - interface Attributes { - /** - * CacheControl specifies caching attributes that services may use - * when serving the blob. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - */ - cacheControl: string - /** - * ContentDisposition specifies whether the blob content is expected to be - * displayed inline or as an attachment. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition - */ - contentDisposition: string - /** - * ContentEncoding specifies the encoding used for the blob's content, if any. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding - */ - contentEncoding: string - /** - * ContentLanguage specifies the language used in the blob's content, if any. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language - */ - contentLanguage: string + interface Time { /** - * ContentType is the MIME type of the blob. It will not be empty. - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + * UTC returns t with the location set to UTC. */ - contentType: string + utc(): Time + } + interface Time { /** - * Metadata holds key/value pairs associated with the blob. - * Keys are guaranteed to be in lowercase, even if the backend service - * has case-sensitive keys (although note that Metadata written via - * this package will always be lowercased). If there are duplicate - * case-insensitive keys (e.g., "foo" and "FOO"), only one value - * will be kept, and it is undefined which one. + * Local returns t with the location set to local time. */ - metadata: _TygojaDict + local(): Time + } + interface Time { /** - * CreateTime is the time the blob was created, if available. If not available, - * CreateTime will be the zero time. + * In returns a copy of t representing the same time instant, but + * with the copy's location information set to loc for display + * purposes. + * + * In panics if loc is nil. */ - createTime: time.Time + in(loc: Location): Time + } + interface Time { /** - * ModTime is the time the blob was last modified. + * Location returns the time zone information associated with t. */ - modTime: time.Time + location(): (Location) + } + interface Time { /** - * Size is the size of the blob's content in bytes. + * Zone computes the time zone in effect at time t, returning the abbreviated + * name of the zone (such as "CET") and its offset in seconds east of UTC. */ - size: number + zone(): [string, number] + } + interface Time { /** - * MD5 is an MD5 hash of the blob contents or nil if not available. + * ZoneBounds returns the bounds of the time zone in effect at time t. + * The zone begins at start and the next zone begins at end. + * If the zone begins at the beginning of time, start will be returned as a zero Time. + * If the zone goes on forever, end will be returned as a zero Time. + * The Location of the returned times will be the same as t. */ - md5: string + zoneBounds(): [Time, Time] + } + interface Time { /** - * ETag for the blob; see https://en.wikipedia.org/wiki/HTTP_ETag. + * Unix returns t as a Unix time, the number of seconds elapsed + * since January 1, 1970 UTC. The result does not depend on the + * location associated with t. + * Unix-like operating systems often record time as a 32-bit + * count of seconds, but since the method here returns a 64-bit + * value it is valid for billions of years into the past or future. */ - eTag: string + unix(): number } - interface Attributes { + interface Time { /** - * As converts i to driver-specific types. - * See https://gocloud.dev/concepts/as/ for background information, the "As" - * examples in this package for examples, and the driver package - * documentation for the specific types supported for that driver. + * UnixMilli returns t as a Unix time, the number of milliseconds elapsed since + * January 1, 1970 UTC. The result is undefined if the Unix time in + * milliseconds cannot be represented by an int64 (a date more than 292 million + * years before or after 1970). The result does not depend on the + * location associated with t. */ - as(i: { - }): boolean + unixMilli(): number } - /** - * ListObject represents a single blob returned from List. - */ - interface ListObject { + interface Time { /** - * Key is the key for this blob. + * UnixMicro returns t as a Unix time, the number of microseconds elapsed since + * January 1, 1970 UTC. The result is undefined if the Unix time in + * microseconds cannot be represented by an int64 (a date before year -290307 or + * after year 294246). The result does not depend on the location associated + * with t. */ - key: string + unixMicro(): number + } + interface Time { /** - * ModTime is the time the blob was last modified. + * UnixNano returns t as a Unix time, the number of nanoseconds elapsed + * since January 1, 1970 UTC. The result is undefined if the Unix time + * in nanoseconds cannot be represented by an int64 (a date before the year + * 1678 or after 2262). Note that this means the result of calling UnixNano + * on the zero Time is undefined. The result does not depend on the + * location associated with t. */ - modTime: time.Time + unixNano(): number + } + interface Time { /** - * Size is the size of the blob's content in bytes. + * AppendBinary implements the [encoding.BinaryAppender] interface. */ - size: number + appendBinary(b: string|Array): string|Array + } + interface Time { /** - * MD5 is an MD5 hash of the blob contents or nil if not available. + * MarshalBinary implements the [encoding.BinaryMarshaler] interface. */ - md5: string + marshalBinary(): string|Array + } + interface Time { /** - * IsDir indicates that this result represents a "directory" in the - * hierarchical namespace, ending in ListOptions.Delimiter. Key can be - * passed as ListOptions.Prefix to list items in the "directory". - * Fields other than Key and IsDir will not be set if IsDir is true. + * UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface. */ - isDir: boolean + unmarshalBinary(data: string|Array): void } - interface ListObject { + interface Time { /** - * As converts i to driver-specific types. - * See https://gocloud.dev/concepts/as/ for background information, the "As" - * examples in this package for examples, and the driver package - * documentation for the specific types supported for that driver. + * GobEncode implements the gob.GobEncoder interface. */ - as(i: { - }): boolean + gobEncode(): string|Array } -} - -/** - * Package schema implements custom Schema and SchemaField datatypes - * for handling the Collection schema definitions. - */ -namespace schema { - // @ts-ignore - import validation = ozzo_validation - /** - * Schema defines a dynamic db schema as a slice of `SchemaField`s. - */ - interface Schema { + interface Time { + /** + * GobDecode implements the gob.GobDecoder interface. + */ + gobDecode(data: string|Array): void } - interface Schema { + interface Time { /** - * Fields returns the registered schema fields. + * MarshalJSON implements the [encoding/json.Marshaler] interface. + * The time is a quoted string in the RFC 3339 format with sub-second precision. + * If the timestamp cannot be represented as valid RFC 3339 + * (e.g., the year is out of range), then an error is reported. */ - fields(): Array<(SchemaField | undefined)> + marshalJSON(): string|Array } - interface Schema { + interface Time { /** - * InitFieldsOptions calls `InitOptions()` for all schema fields. + * UnmarshalJSON implements the [encoding/json.Unmarshaler] interface. + * The time must be a quoted string in the RFC 3339 format. */ - initFieldsOptions(): void + unmarshalJSON(data: string|Array): void } - interface Schema { + interface Time { /** - * Clone creates a deep clone of the current schema. + * AppendText implements the [encoding.TextAppender] interface. + * The time is formatted in RFC 3339 format with sub-second precision. + * If the timestamp cannot be represented as valid RFC 3339 + * (e.g., the year is out of range), then an error is returned. */ - clone(): (Schema | undefined) + appendText(b: string|Array): string|Array } - interface Schema { + interface Time { /** - * AsMap returns a map with all registered schema field. - * The returned map is indexed with each field name. + * MarshalText implements the [encoding.TextMarshaler] interface. The output + * matches that of calling the [Time.AppendText] method. + * + * See [Time.AppendText] for more information. */ - asMap(): _TygojaDict + marshalText(): string|Array } - interface Schema { + interface Time { /** - * GetFieldById returns a single field by its id. + * UnmarshalText implements the [encoding.TextUnmarshaler] interface. + * The time must be in the RFC 3339 format. */ - getFieldById(id: string): (SchemaField | undefined) + unmarshalText(data: string|Array): void } - interface Schema { + interface Time { /** - * GetFieldByName returns a single field by its name. + * IsDST reports whether the time in the configured location is in Daylight Savings Time. */ - getFieldByName(name: string): (SchemaField | undefined) + isDST(): boolean } - interface Schema { + interface Time { /** - * RemoveField removes a single schema field by its id. + * Truncate returns the result of rounding t down to a multiple of d (since the zero time). + * If d <= 0, Truncate returns t stripped of any monotonic clock reading but otherwise unchanged. * - * This method does nothing if field with `id` doesn't exist. + * Truncate operates on the time as an absolute duration since the + * zero time; it does not operate on the presentation form of the + * time. Thus, Truncate(Hour) may return a time with a non-zero + * minute, depending on the time's Location. */ - removeField(id: string): void + truncate(d: Duration): Time } - interface Schema { + interface Time { /** - * 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. + * Round returns the result of rounding t to the nearest multiple of d (since the zero time). + * The rounding behavior for halfway values is to round up. + * If d <= 0, Round returns t stripped of any monotonic clock reading but otherwise unchanged. * - * Otherwise the new field is appended to the other schema fields. + * Round operates on the time as an absolute duration since the + * zero time; it does not operate on the presentation form of the + * time. Thus, Round(Hour) may return a time with a non-zero + * minute, depending on the time's Location. */ - addField(newField: SchemaField): void + round(d: Duration): Time } - interface Schema { +} + +/** + * Package context defines the Context type, which carries deadlines, + * cancellation signals, and other request-scoped values across API boundaries + * and between processes. + * + * Incoming requests to a server should create a [Context], and outgoing + * calls to servers should accept a Context. The chain of function + * calls between them must propagate the Context, optionally replacing + * it with a derived Context created using [WithCancel], [WithDeadline], + * [WithTimeout], or [WithValue]. + * + * A Context may be canceled to indicate that work done on its behalf should stop. + * A Context with a deadline is canceled after the deadline passes. + * When a Context is canceled, all Contexts derived from it are also canceled. + * + * The [WithCancel], [WithDeadline], and [WithTimeout] functions take a + * Context (the parent) and return a derived Context (the child) and a + * [CancelFunc]. Calling the CancelFunc directly cancels the child and its + * children, removes the parent's reference to the child, and stops + * any associated timers. Failing to call the CancelFunc leaks the + * child and its children until the parent is canceled. The go vet tool + * checks that CancelFuncs are used on all control-flow paths. + * + * The [WithCancelCause] function returns a [CancelCauseFunc], which takes + * an error and records it as the cancellation cause. [WithDeadlineCause] + * and [WithTimeoutCause] take a cause to use when the deadline expires. + * Calling [Cause] on the canceled context or any of its children retrieves + * the cause. If no cause is specified, Cause(ctx) returns the same value + * as ctx.Err(). + * + * Programs that use Contexts should follow these rules to keep interfaces + * consistent across packages and enable static analysis tools to check context + * propagation: + * + * Do not store Contexts inside a struct type; instead, pass a Context + * explicitly to each function that needs it. This is discussed further in + * https://go.dev/blog/context-and-structs. The Context should be the first + * parameter, typically named ctx: + * + * ``` + * func DoSomething(ctx context.Context, arg Arg) error { + * // ... use ctx ... + * } + * ``` + * + * Do not pass a nil [Context], even if a function permits it. Pass [context.TODO] + * if you are unsure about which Context to use. + * + * Use context Values only for request-scoped data that transits processes and + * APIs, not for passing optional parameters to functions. + * + * The same Context may be passed to functions running in different goroutines; + * Contexts are safe for simultaneous use by multiple goroutines. + * + * See https://go.dev/blog/context for example code for a server that uses + * Contexts. + */ +namespace context { + /** + * A Context carries a deadline, a cancellation signal, and other values across + * API boundaries. + * + * Context's methods may be called by multiple goroutines simultaneously. + */ + interface Context { + [key:string]: any; + /** + * Deadline returns the time when work done on behalf of this context + * should be canceled. Deadline returns ok==false when no deadline is + * set. Successive calls to Deadline return the same results. + */ + deadline(): [time.Time, boolean] /** - * Validate makes Schema validatable by implementing [validation.Validatable] interface. + * Done returns a channel that's closed when work done on behalf of this + * context should be canceled. Done may return nil if this context can + * never be canceled. Successive calls to Done return the same value. + * The close of the Done channel may happen asynchronously, + * after the cancel function returns. + * + * WithCancel arranges for Done to be closed when cancel is called; + * WithDeadline arranges for Done to be closed when the deadline + * expires; WithTimeout arranges for Done to be closed when the timeout + * elapses. + * + * Done is provided for use in select statements: + * + * // Stream generates values with DoSomething and sends them to out + * // until DoSomething returns an error or ctx.Done is closed. + * func Stream(ctx context.Context, out chan<- Value) error { + * for { + * v, err := DoSomething(ctx) + * if err != nil { + * return err + * } + * select { + * case <-ctx.Done(): + * return ctx.Err() + * case out <- v: + * } + * } + * } * - * Internally calls each individual field's validator and additionally - * checks for invalid renamed fields and field name duplications. + * See https://go.dev/blog/pipelines for more examples of how to use + * a Done channel for cancellation. */ - validate(): void - } - interface Schema { + done(): undefined /** - * MarshalJSON implements the [json.Marshaler] interface. + * If Done is not yet closed, Err returns nil. + * If Done is closed, Err returns a non-nil error explaining why: + * DeadlineExceeded if the context's deadline passed, + * or Canceled if the context was canceled for some other reason. + * After Err returns a non-nil error, successive calls to Err return the same error. */ - marshalJSON(): string - } - interface Schema { + err(): void /** - * UnmarshalJSON implements the [json.Unmarshaler] interface. + * Value returns the value associated with this context for key, or nil + * if no value is associated with key. Successive calls to Value with + * the same key returns the same result. + * + * Use context values only for request-scoped data that transits + * processes and API boundaries, not for passing optional parameters to + * functions. + * + * A key identifies a specific value in a Context. Functions that wish + * to store values in Context typically allocate a key in a global + * variable then use that key as the argument to context.WithValue and + * Context.Value. A key can be any type that supports equality; + * packages should define keys as an unexported type to avoid + * collisions. + * + * Packages that define a Context key should provide type-safe accessors + * for the values stored using that key: + * + * ``` + * // Package user defines a User type that's stored in Contexts. + * package user + * + * import "context" + * + * // User is the type of value stored in the Contexts. + * type User struct {...} + * + * // key is an unexported type for keys defined in this package. + * // This prevents collisions with keys defined in other packages. + * type key int + * + * // userKey is the key for user.User values in Contexts. It is + * // unexported; clients use user.NewContext and user.FromContext + * // instead of using this key directly. + * var userKey key + * + * // NewContext returns a new Context that carries value u. + * func NewContext(ctx context.Context, u *User) context.Context { + * return context.WithValue(ctx, userKey, u) + * } * - * On success, all schema field options are auto initialized. - */ - unmarshalJSON(data: string): void - } - interface Schema { - /** - * Value implements the [driver.Valuer] interface. - */ - value(): any - } - interface Schema { - /** - * Scan implements [sql.Scanner] interface to scan the provided value - * into the current Schema instance. + * // FromContext returns the User value stored in ctx, if any. + * func FromContext(ctx context.Context) (*User, bool) { + * u, ok := ctx.Value(userKey).(*User) + * return u, ok + * } + * ``` */ - scan(value: any): void + value(key: any): any } } /** - * Package models implements all PocketBase DB models and DTOs. + * Package fs defines basic interfaces to a file system. + * A file system can be provided by the host operating system + * but also by other packages. + * + * # Path Names + * + * The interfaces in this package all operate on the same + * path name syntax, regardless of the host operating system. + * + * Path names are UTF-8-encoded, + * unrooted, slash-separated sequences of path elements, like “x/y/z”. + * Path names must not contain an element that is “.” or “..” or the empty string, + * except for the special case that the name "." may be used for the root directory. + * Paths must not start or end with a slash: “/x” and “x/” are invalid. + * + * # Testing + * + * See the [testing/fstest] package for support with testing + * implementations of file systems. */ -namespace models { - type _subTuLRn = BaseModel - interface Admin extends _subTuLRn { - avatar: number - email: string - tokenKey: string - passwordHash: string - lastResetSentAt: types.DateTime - } - interface Admin { - /** - * TableName returns the Admin model SQL table name. - */ - tableName(): string - } - interface Admin { - /** - * ValidatePassword validates a plain password against the model's password. - */ - validatePassword(password: string): boolean - } - interface Admin { +namespace fs { + /** + * An FS provides access to a hierarchical file system. + * + * The FS interface is the minimum implementation required of the file system. + * A file system may implement additional interfaces, + * such as [ReadFileFS], to provide additional or optimized functionality. + * + * [testing/fstest.TestFS] may be used to test implementations of an FS for + * correctness. + */ + interface FS { + [key:string]: any; /** - * SetPassword sets cryptographically secure string to `model.Password`. + * Open opens the named file. + * [File.Close] must be called to release any associated resources. * - * Additionally this method also resets the LastResetSentAt and the TokenKey fields. - */ - setPassword(password: string): void - } - interface Admin { - /** - * RefreshTokenKey generates and sets new random token key. - */ - refreshTokenKey(): void - } - // @ts-ignore - import validation = ozzo_validation - type _subSMNrV = BaseModel - interface Collection extends _subSMNrV { - name: string - type: string - system: boolean - schema: schema.Schema - indexes: types.JsonArray - /** - * rules - */ - listRule?: string - viewRule?: string - createRule?: string - updateRule?: string - deleteRule?: string - options: types.JsonMap - } - interface Collection { - /** - * TableName returns the Collection model SQL table name. + * When Open returns an error, it should be of type *PathError + * with the Op field set to "open", the Path field set to name, + * and the Err field describing the problem. + * + * Open should reject attempts to open names that do not satisfy + * ValidPath(name), returning a *PathError with Err set to + * ErrInvalid or ErrNotExist. */ - tableName(): string + open(name: string): File } - interface Collection { - /** - * BaseFilesPath returns the storage dir path used by the collection. - */ - baseFilesPath(): string + /** + * A File provides access to a single file. + * The File interface is the minimum implementation required of the file. + * Directory files should also implement [ReadDirFile]. + * A file may implement [io.ReaderAt] or [io.Seeker] as optimizations. + */ + interface File { + [key:string]: any; + stat(): FileInfo + read(_arg0: string|Array): number + close(): void } - interface Collection { + /** + * A DirEntry is an entry read from a directory + * (using the [ReadDir] function or a [ReadDirFile]'s ReadDir method). + */ + interface DirEntry { + [key:string]: any; /** - * IsBase checks if the current collection has "base" type. + * Name returns the name of the file (or subdirectory) described by the entry. + * This name is only the final element of the path (the base name), not the entire path. + * For example, Name would return "hello.go" not "home/gopher/hello.go". */ - isBase(): boolean - } - interface Collection { + name(): string /** - * IsAuth checks if the current collection has "auth" type. + * IsDir reports whether the entry describes a directory. */ - isAuth(): boolean - } - interface Collection { + isDir(): boolean /** - * IsView checks if the current collection has "view" type. + * Type returns the type bits for the entry. + * The type bits are a subset of the usual FileMode bits, those returned by the FileMode.Type method. */ - isView(): boolean - } - interface Collection { + type(): FileMode /** - * MarshalJSON implements the [json.Marshaler] interface. + * Info returns the FileInfo for the file or subdirectory described by the entry. + * The returned FileInfo may be from the time of the original directory read + * or from the time of the call to Info. If the file has been removed or renamed + * since the directory read, Info may return an error satisfying errors.Is(err, ErrNotExist). + * If the entry denotes a symbolic link, Info reports the information about the link itself, + * not the link's target. */ - marshalJSON(): string + info(): FileInfo } - interface Collection { - /** - * BaseOptions decodes the current collection options and returns them - * as new [CollectionBaseOptions] instance. - */ - baseOptions(): CollectionBaseOptions + /** + * A FileInfo describes a file and is returned by [Stat]. + */ + interface FileInfo { + [key:string]: any; + name(): string // base name of the file + size(): number // length in bytes for regular files; system-dependent for others + mode(): FileMode // file mode bits + modTime(): time.Time // modification time + isDir(): boolean // abbreviation for Mode().IsDir() + sys(): any // underlying data source (can return nil) } - interface Collection { - /** - * AuthOptions decodes the current collection options and returns them - * as new [CollectionAuthOptions] instance. - */ - authOptions(): CollectionAuthOptions + /** + * A FileMode represents a file's mode and permission bits. + * 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. + */ + interface FileMode extends Number{} + interface FileMode { + string(): string } - interface Collection { + interface FileMode { /** - * ViewOptions decodes the current collection options and returns them - * as new [CollectionViewOptions] instance. + * IsDir reports whether m describes a directory. + * That is, it tests for the [ModeDir] bit being set in m. */ - viewOptions(): CollectionViewOptions + isDir(): boolean } - interface Collection { + interface FileMode { /** - * NormalizeOptions updates the current collection options with a - * new normalized state based on the collection type. + * IsRegular reports whether m describes a regular file. + * That is, it tests that no mode type bits are set. */ - normalizeOptions(): void + isRegular(): boolean } - interface Collection { + interface FileMode { /** - * DecodeOptions decodes the current collection options into the - * provided "result" (must be a pointer). + * Perm returns the Unix permission bits in m (m & [ModePerm]). */ - decodeOptions(result: any): void + perm(): FileMode } - interface Collection { + interface FileMode { /** - * SetOptions normalizes and unmarshals the specified options into m.Options. + * Type returns type bits in m (m & [ModeType]). */ - setOptions(typedOptions: any): void + type(): FileMode } - type _subcrntT = BaseModel - interface ExternalAuth extends _subcrntT { - collectionId: string - recordId: string - provider: string - providerId: string + /** + * PathError records an error and the operation and file path that caused it. + */ + interface PathError { + op: string + path: string + err: Error } - interface ExternalAuth { - tableName(): string + interface PathError { + error(): string } - type _subrFaPA = BaseModel - interface Record extends _subrFaPA { + interface PathError { + unwrap(): void } - interface Record { + interface PathError { /** - * TableName returns the table name associated to the current Record model. + * Timeout reports whether this error represents a timeout. */ - tableName(): string + timeout(): boolean } - interface Record { + /** + * WalkDirFunc is the type of the function called by [WalkDir] to visit + * each file or directory. + * + * The path argument contains the argument to [WalkDir] as a prefix. + * That is, if WalkDir is called with root argument "dir" and finds a file + * named "a" in that directory, the walk function will be called with + * argument "dir/a". + * + * The d argument is the [DirEntry] for the named path. + * + * The error result returned by the function controls how [WalkDir] + * continues. If the function returns the special value [SkipDir], WalkDir + * skips the current directory (path if d.IsDir() is true, otherwise + * path's parent directory). If the function returns the special value + * [SkipAll], WalkDir skips all remaining files and directories. Otherwise, + * if the function returns a non-nil error, WalkDir stops entirely and + * returns that error. + * + * The err argument reports an error related to path, signaling that + * [WalkDir] will not walk into that directory. The function can decide how + * to handle that error; as described earlier, returning the error will + * cause WalkDir to stop walking the entire tree. + * + * [WalkDir] calls the function with a non-nil err argument in two cases. + * + * First, if the initial [Stat] on the root directory fails, WalkDir + * calls the function with path set to root, d set to nil, and err set to + * the error from [fs.Stat]. + * + * Second, if a directory's ReadDir method (see [ReadDirFile]) fails, WalkDir calls the + * function with path set to the directory's path, d set to an + * [DirEntry] describing the directory, and err set to the error from + * ReadDir. In this second case, the function is called twice with the + * path of the directory: the first call is before the directory read is + * attempted and has err set to nil, giving the function a chance to + * return [SkipDir] or [SkipAll] and avoid the ReadDir entirely. The second call + * is after a failed ReadDir and reports the error from ReadDir. + * (If ReadDir succeeds, there is no second call.) + * + * The differences between WalkDirFunc compared to [path/filepath.WalkFunc] are: + * + * ``` + * - The second argument has type [DirEntry] instead of [FileInfo]. + * - The function is called before reading a directory, to allow [SkipDir] + * or [SkipAll] to bypass the directory read entirely or skip all remaining + * files and directories respectively. + * - If a directory read fails, the function is called a second time + * for that directory to report the error. + * ``` + */ + interface WalkDirFunc {(path: string, d: DirEntry, err: Error): void } +} + +/** + * Package net provides a portable interface for network I/O, including + * TCP/IP, UDP, domain name resolution, and Unix domain sockets. + * + * Although the package provides access to low-level networking + * primitives, most clients will need only the basic interface provided + * by the [Dial], [Listen], and Accept functions and the associated + * [Conn] and [Listener] interfaces. The crypto/tls package uses + * the same interfaces and similar Dial and Listen functions. + * + * The Dial function connects to a server: + * + * ``` + * conn, err := net.Dial("tcp", "golang.org:80") + * if err != nil { + * // handle error + * } + * fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n") + * status, err := bufio.NewReader(conn).ReadString('\n') + * // ... + * ``` + * + * The Listen function creates servers: + * + * ``` + * ln, err := net.Listen("tcp", ":8080") + * if err != nil { + * // handle error + * } + * for { + * conn, err := ln.Accept() + * if err != nil { + * // handle error + * } + * go handleConnection(conn) + * } + * ``` + * + * # Name Resolution + * + * The method for resolving domain names, whether indirectly with functions like Dial + * or directly with functions like [LookupHost] and [LookupAddr], varies by operating system. + * + * On Unix systems, the resolver has two options for resolving names. + * It can use a pure Go resolver that sends DNS requests directly to the servers + * listed in /etc/resolv.conf, or it can use a cgo-based resolver that calls C + * library routines such as getaddrinfo and getnameinfo. + * + * On Unix the pure Go resolver is preferred over the cgo resolver, because a blocked DNS + * request consumes only a goroutine, while a blocked C call consumes an operating system thread. + * When cgo is available, the cgo-based resolver is used instead under a variety of + * conditions: on systems that do not let programs make direct DNS requests (OS X), + * when the LOCALDOMAIN environment variable is present (even if empty), + * when the RES_OPTIONS or HOSTALIASES environment variable is non-empty, + * when the ASR_CONFIG environment variable is non-empty (OpenBSD only), + * when /etc/resolv.conf or /etc/nsswitch.conf specify the use of features that the + * Go resolver does not implement. + * + * On all systems (except Plan 9), when the cgo resolver is being used + * this package applies a concurrent cgo lookup limit to prevent the system + * from running out of system threads. Currently, it is limited to 500 concurrent lookups. + * + * The resolver decision can be overridden by setting the netdns value of the + * GODEBUG environment variable (see package runtime) to go or cgo, as in: + * + * ``` + * export GODEBUG=netdns=go # force pure Go resolver + * export GODEBUG=netdns=cgo # force native resolver (cgo, win32) + * ``` + * + * The decision can also be forced while building the Go source tree + * by setting the netgo or netcgo build tag. + * The netgo build tag disables entirely the use of the native (CGO) resolver, + * meaning the Go resolver is the only one that can be used. + * With the netcgo build tag the native and the pure Go resolver are compiled into the binary, + * but the native (CGO) resolver is preferred over the Go resolver. + * With netcgo, the Go resolver can still be forced at runtime with GODEBUG=netdns=go. + * + * A numeric netdns setting, as in GODEBUG=netdns=1, causes the resolver + * to print debugging information about its decisions. + * To force a particular resolver while also printing debugging information, + * join the two settings by a plus sign, as in GODEBUG=netdns=go+1. + * + * The Go resolver will send an EDNS0 additional header with a DNS request, + * to signal a willingness to accept a larger DNS packet size. + * This can reportedly cause sporadic failures with the DNS server run + * by some modems and routers. Setting GODEBUG=netedns0=0 will disable + * sending the additional header. + * + * On macOS, if Go code that uses the net package is built with + * -buildmode=c-archive, linking the resulting archive into a C program + * requires passing -lresolv when linking the C code. + * + * On Plan 9, the resolver always accesses /net/cs and /net/dns. + * + * On Windows, in Go 1.18.x and earlier, the resolver always used C + * library functions, such as GetAddrInfo and DnsQuery. + */ +namespace net { + /** + * Conn is a generic stream-oriented network connection. + * + * Multiple goroutines may invoke methods on a Conn simultaneously. + */ + interface Conn { + [key:string]: any; /** - * Collection returns the Collection model associated to the current Record model. + * Read reads data from the connection. + * Read can be made to time out and return an error after a fixed + * time limit; see SetDeadline and SetReadDeadline. */ - collection(): (Collection | undefined) - } - interface Record { + read(b: string|Array): number /** - * 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. + * Write writes data to the connection. + * Write can be made to time out and return an error after a fixed + * time limit; see SetDeadline and SetWriteDeadline. */ - originalCopy(): (Record | undefined) - } - interface Record { + write(b: string|Array): number /** - * CleanCopy returns a copy of the current record model populated only - * with its LATEST data state and everything else reset to the defaults. + * Close closes the connection. + * Any blocked Read or Write operations will be unblocked and return errors. + * Close may or may not block until any buffered data is sent; + * for TCP connections see [*TCPConn.SetLinger]. */ - cleanCopy(): (Record | undefined) - } - interface Record { + close(): void /** - * Expand returns a shallow copy of the current Record model expand data. + * LocalAddr returns the local network address, if known. */ - expand(): _TygojaDict - } - interface Record { + localAddr(): Addr /** - * SetExpand shallow copies the provided data to the current Record model's expand. + * RemoteAddr returns the remote network address, if known. */ - setExpand(expand: _TygojaDict): void - } - interface Record { + remoteAddr(): Addr /** - * MergeExpand merges recursively the provided expand data into - * the current model's expand (if any). + * SetDeadline sets the read and write deadlines associated + * with the connection. It is equivalent to calling both + * SetReadDeadline and SetWriteDeadline. * - * 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). - */ - mergeExpand(expand: _TygojaDict): void - } - interface Record { - /** - * SchemaData returns a shallow copy ONLY of the defined record schema fields data. - */ - schemaData(): _TygojaDict - } - interface Record { - /** - * 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. - */ - unknownData(): _TygojaDict - } - interface Record { - /** - * IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check. + * A deadline is an absolute time after which I/O operations + * fail instead of blocking. The deadline applies to all future + * and pending I/O, not just the immediately following call to + * Read or Write. After a deadline has been exceeded, the + * connection can be refreshed by setting a deadline in the future. + * + * If the deadline is exceeded a call to Read or Write or to other + * I/O methods will return an error that wraps os.ErrDeadlineExceeded. + * This can be tested using errors.Is(err, os.ErrDeadlineExceeded). + * The error's Timeout method will return true, but note that there + * are other possible errors for which the Timeout method will + * return true even if the deadline has not been exceeded. + * + * An idle timeout can be implemented by repeatedly extending + * the deadline after successful Read or Write calls. + * + * A zero value for t means I/O operations will not time out. */ - ignoreEmailVisibility(state: boolean): void - } - interface Record { + setDeadline(t: time.Time): void /** - * WithUnknownData toggles the export/serialization of unknown data fields - * (false by default). + * SetReadDeadline sets the deadline for future Read calls + * and any currently-blocked Read call. + * A zero value for t means Read will not time out. */ - withUnknownData(state: boolean): void - } - interface Record { + setReadDeadline(t: time.Time): void /** - * 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. + * SetWriteDeadline sets the deadline for future Write calls + * and any currently-blocked Write call. + * Even if write times out, it may return n > 0, indicating that + * some of the data was successfully written. + * A zero value for t means Write will not time out. */ - set(key: string, value: any): void + setWriteDeadline(t: time.Time): void } - interface Record { + /** + * A Listener is a generic network listener for stream-oriented protocols. + * + * Multiple goroutines may invoke methods on a Listener simultaneously. + */ + interface Listener { + [key:string]: any; /** - * Get returns a normalized single record model data value for "key". + * Accept waits for and returns the next connection to the listener. */ - get(key: string): any - } - interface Record { + accept(): Conn /** - * GetBool returns the data value for "key" as a bool. + * Close closes the listener. + * Any blocked Accept operations will be unblocked and return errors. */ - getBool(key: string): boolean - } - interface Record { + close(): void /** - * GetString returns the data value for "key" as a string. + * Addr returns the listener's network address. */ - getString(key: string): string + addr(): Addr } - interface Record { + // @ts-ignore + import _cgopackage = cgo +} + +/** + * Package jwt is a Go implementation of JSON Web Tokens: http://self-issued.info/docs/draft-jones-json-web-token.html + * + * See README.md for more info. + */ +namespace jwt { + /** + * MapClaims is a claims type that uses the map[string]any for JSON + * decoding. This is the default claims type if you don't supply one + */ + interface MapClaims extends _TygojaDict{} + interface MapClaims { /** - * GetInt returns the data value for "key" as an int. + * GetExpirationTime implements the Claims interface. */ - getInt(key: string): number + getExpirationTime(): (NumericDate) } - interface Record { + interface MapClaims { /** - * GetFloat returns the data value for "key" as a float64. + * GetNotBefore implements the Claims interface. */ - getFloat(key: string): number + getNotBefore(): (NumericDate) } - interface Record { + interface MapClaims { /** - * GetTime returns the data value for "key" as a [time.Time] instance. + * GetIssuedAt implements the Claims interface. */ - getTime(key: string): time.Time + getIssuedAt(): (NumericDate) } - interface Record { + interface MapClaims { /** - * GetDateTime returns the data value for "key" as a DateTime instance. + * GetAudience implements the Claims interface. */ - getDateTime(key: string): types.DateTime + getAudience(): ClaimStrings } - interface Record { + interface MapClaims { /** - * GetStringSlice returns the data value for "key" as a slice of unique strings. + * GetIssuer implements the Claims interface. */ - getStringSlice(key: string): Array + getIssuer(): string } - interface Record { + interface MapClaims { /** - * 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. + * GetSubject implements the Claims interface. */ - expandedOne(relField: string): (Record | undefined) + getSubject(): string } - interface Record { - /** - * 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. - */ - expandedAll(relField: string): Array<(Record | undefined)> +} + +/** + * Package syntax parses regular expressions into parse trees and compiles + * parse trees into programs. Most clients of regular expressions will use the + * facilities of package [regexp] (such as [regexp.Compile] and [regexp.Match]) instead of this package. + * + * # Syntax + * + * The regular expression syntax understood by this package when parsing with the [Perl] flag is as follows. + * Parts of the syntax can be disabled by passing alternate flags to [Parse]. + * + * Single characters: + * + * ``` + * . any character, possibly including newline (flag s=true) + * [xyz] character class + * [^xyz] negated character class + * \d Perl character class + * \D negated Perl character class + * [[:alpha:]] ASCII character class + * [[:^alpha:]] negated ASCII character class + * \pN Unicode character class (one-letter name) + * \p{Greek} Unicode character class + * \PN negated Unicode character class (one-letter name) + * \P{Greek} negated Unicode character class + * ``` + * + * Composites: + * + * ``` + * xy x followed by y + * x|y x or y (prefer x) + * ``` + * + * Repetitions: + * + * ``` + * x* zero or more x, prefer more + * x+ one or more x, prefer more + * x? zero or one x, prefer one + * x{n,m} n or n+1 or ... or m x, prefer more + * x{n,} n or more x, prefer more + * x{n} exactly n x + * x*? zero or more x, prefer fewer + * x+? one or more x, prefer fewer + * x?? zero or one x, prefer zero + * x{n,m}? n or n+1 or ... or m x, prefer fewer + * x{n,}? n or more x, prefer fewer + * x{n}? exactly n x + * ``` + * + * Implementation restriction: The counting forms x{n,m}, x{n,}, and x{n} + * reject forms that create a minimum or maximum repetition count above 1000. + * Unlimited repetitions are not subject to this restriction. + * + * Grouping: + * + * ``` + * (re) numbered capturing group (submatch) + * (?Pre) named & numbered capturing group (submatch) + * (?re) named & numbered capturing group (submatch) + * (?:re) non-capturing group + * (?flags) set flags within current group; non-capturing + * (?flags:re) set flags during re; non-capturing + * + * Flag syntax is xyz (set) or -xyz (clear) or xy-z (set xy, clear z). The flags are: + * + * i case-insensitive (default false) + * m multi-line mode: ^ and $ match begin/end line in addition to begin/end text (default false) + * s let . match \n (default false) + * U ungreedy: swap meaning of x* and x*?, x+ and x+?, etc (default false) + * ``` + * + * Empty strings: + * + * ``` + * ^ at beginning of text or line (flag m=true) + * $ at end of text (like \z not \Z) or line (flag m=true) + * \A at beginning of text + * \b at ASCII word boundary (\w on one side and \W, \A, or \z on the other) + * \B not at ASCII word boundary + * \z at end of text + * ``` + * + * Escape sequences: + * + * ``` + * \a bell (== \007) + * \f form feed (== \014) + * \t horizontal tab (== \011) + * \n newline (== \012) + * \r carriage return (== \015) + * \v vertical tab character (== \013) + * \* literal *, for any punctuation character * + * \123 octal character code (up to three digits) + * \x7F hex character code (exactly two digits) + * \x{10FFFF} hex character code + * \Q...\E literal text ... even if ... has punctuation + * ``` + * + * Character class elements: + * + * ``` + * x single character + * A-Z character range (inclusive) + * \d Perl character class + * [:foo:] ASCII character class foo + * \p{Foo} Unicode character class Foo + * \pF Unicode character class F (one-letter name) + * ``` + * + * Named character classes as character class elements: + * + * ``` + * [\d] digits (== \d) + * [^\d] not digits (== \D) + * [\D] not digits (== \D) + * [^\D] not not digits (== \d) + * [[:name:]] named ASCII class inside character class (== [:name:]) + * [^[:name:]] named ASCII class inside negated character class (== [:^name:]) + * [\p{Name}] named Unicode property inside character class (== \p{Name}) + * [^\p{Name}] named Unicode property inside negated character class (== \P{Name}) + * ``` + * + * Perl character classes (all ASCII-only): + * + * ``` + * \d digits (== [0-9]) + * \D not digits (== [^0-9]) + * \s whitespace (== [\t\n\f\r ]) + * \S not whitespace (== [^\t\n\f\r ]) + * \w word characters (== [0-9A-Za-z_]) + * \W not word characters (== [^0-9A-Za-z_]) + * ``` + * + * ASCII character classes: + * + * ``` + * [[:alnum:]] alphanumeric (== [0-9A-Za-z]) + * [[:alpha:]] alphabetic (== [A-Za-z]) + * [[:ascii:]] ASCII (== [\x00-\x7F]) + * [[:blank:]] blank (== [\t ]) + * [[:cntrl:]] control (== [\x00-\x1F\x7F]) + * [[:digit:]] digits (== [0-9]) + * [[:graph:]] graphical (== [!-~] == [A-Za-z0-9!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]) + * [[:lower:]] lower case (== [a-z]) + * [[:print:]] printable (== [ -~] == [ [:graph:]]) + * [[:punct:]] punctuation (== [!-/:-@[-`{-~]) + * [[:space:]] whitespace (== [\t\n\v\f\r ]) + * [[:upper:]] upper case (== [A-Z]) + * [[:word:]] word characters (== [0-9A-Za-z_]) + * [[:xdigit:]] hex digit (== [0-9A-Fa-f]) + * ``` + * + * Unicode character classes are those in [unicode.Categories], + * [unicode.CategoryAliases], and [unicode.Scripts]. + */ +namespace syntax { + /** + * Flags control the behavior of the parser and record information about regexp context. + */ + interface Flags extends Number{} +} + +/** + * Package bufio implements buffered I/O. It wraps an io.Reader or io.Writer + * object, creating another object (Reader or Writer) that also implements + * the interface but provides buffering and some help for textual I/O. + */ +namespace bufio { + /** + * ReadWriter stores pointers to a [Reader] and a [Writer]. + * It implements [io.ReadWriter]. + */ + type _smwfmDI = Reader&Writer + interface ReadWriter extends _smwfmDI { } - interface Record { - /** - * 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) - * ``` - */ - unmarshalJSONField(key: string, result: any): void +} + +/** + * Package multipart implements MIME multipart parsing, as defined in RFC + * 2046. + * + * The implementation is sufficient for HTTP (RFC 2388) and the multipart + * bodies generated by popular browsers. + * + * # Limits + * + * To protect against malicious inputs, this package sets limits on the size + * of the MIME data it processes. + * + * [Reader.NextPart] and [Reader.NextRawPart] limit the number of headers in a + * part to 10000 and [Reader.ReadForm] limits the total number of headers in all + * FileHeaders to 10000. + * These limits may be adjusted with the GODEBUG=multipartmaxheaders= + * setting. + * + * Reader.ReadForm further limits the number of parts in a form to 1000. + * This limit may be adjusted with the GODEBUG=multipartmaxparts= + * setting. + */ +namespace multipart { + /** + * A FileHeader describes a file part of a multipart request. + */ + interface FileHeader { + filename: string + header: textproto.MIMEHeader + size: number } - interface Record { + interface FileHeader { /** - * BaseFilesPath returns the storage dir path used by the record. + * Open opens and returns the [FileHeader]'s associated File. */ - baseFilesPath(): string + open(): File } - interface Record { +} + +/** + * Package http provides HTTP client and server implementations. + * + * [Get], [Head], [Post], and [PostForm] make HTTP (or HTTPS) requests: + * + * ``` + * resp, err := http.Get("http://example.com/") + * ... + * resp, err := http.Post("http://example.com/upload", "image/jpeg", &buf) + * ... + * resp, err := http.PostForm("http://example.com/form", + * url.Values{"key": {"Value"}, "id": {"123"}}) + * ``` + * + * The caller must close the response body when finished with it: + * + * ``` + * resp, err := http.Get("http://example.com/") + * if err != nil { + * // handle error + * } + * defer resp.Body.Close() + * body, err := io.ReadAll(resp.Body) + * // ... + * ``` + * + * # Clients and Transports + * + * For control over HTTP client headers, redirect policy, and other + * settings, create a [Client]: + * + * ``` + * client := &http.Client{ + * CheckRedirect: redirectPolicyFunc, + * } + * + * resp, err := client.Get("http://example.com") + * // ... + * + * req, err := http.NewRequest("GET", "http://example.com", nil) + * // ... + * req.Header.Add("If-None-Match", `W/"wyzzy"`) + * resp, err := client.Do(req) + * // ... + * ``` + * + * For control over proxies, TLS configuration, keep-alives, + * compression, and other settings, create a [Transport]: + * + * ``` + * tr := &http.Transport{ + * MaxIdleConns: 10, + * IdleConnTimeout: 30 * time.Second, + * DisableCompression: true, + * } + * client := &http.Client{Transport: tr} + * resp, err := client.Get("https://example.com") + * ``` + * + * Clients and Transports are safe for concurrent use by multiple + * goroutines and for efficiency should only be created once and re-used. + * + * # Servers + * + * ListenAndServe starts an HTTP server with a given address and handler. + * The handler is usually nil, which means to use [DefaultServeMux]. + * [Handle] and [HandleFunc] add handlers to [DefaultServeMux]: + * + * ``` + * http.Handle("/foo", fooHandler) + * + * http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { + * fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) + * }) + * + * log.Fatal(http.ListenAndServe(":8080", nil)) + * ``` + * + * More control over the server's behavior is available by creating a + * custom Server: + * + * ``` + * s := &http.Server{ + * Addr: ":8080", + * Handler: myHandler, + * ReadTimeout: 10 * time.Second, + * WriteTimeout: 10 * time.Second, + * MaxHeaderBytes: 1 << 20, + * } + * log.Fatal(s.ListenAndServe()) + * ``` + * + * # HTTP/2 + * + * The http package has transparent support for the HTTP/2 protocol. + * + * [Server] and [DefaultTransport] automatically enable HTTP/2 support + * when using HTTPS. [Transport] does not enable HTTP/2 by default. + * + * To enable or disable support for HTTP/1, HTTP/2, and/or unencrypted HTTP/2, + * see the [Server.Protocols] and [Transport.Protocols] configuration fields. + * + * To configure advanced HTTP/2 features, see the [Server.HTTP2] and + * [Transport.HTTP2] configuration fields. + * + * Alternatively, the following GODEBUG settings are currently supported: + * + * ``` + * GODEBUG=http2client=0 # disable HTTP/2 client support + * GODEBUG=http2server=0 # disable HTTP/2 server support + * GODEBUG=http2debug=1 # enable verbose HTTP/2 debug logs + * GODEBUG=http2debug=2 # ... even more verbose, with frame dumps + * ``` + * + * The "omithttp2" build tag may be used to disable the HTTP/2 implementation + * contained in the http package. + */ +namespace http { + /** + * PushOptions describes options for [Pusher.Push]. + */ + interface PushOptions { /** - * FindFileFieldByFile returns the first file type field for which - * any of the record's data contains the provided filename. + * Method specifies the HTTP method for the promised request. + * If set, it must be "GET" or "HEAD". Empty means "GET". */ - findFileFieldByFile(filename: string): (schema.SchemaField | undefined) - } - interface Record { + method: string /** - * Load bulk loads the provided data into the current Record model. + * Header specifies additional promised request headers. This cannot + * include HTTP/2 pseudo header fields like ":path" and ":scheme", + * which will be added automatically. */ - load(data: _TygojaDict): void + header: Header } - interface Record { + // @ts-ignore + import urlpkg = url + /** + * A Request represents an HTTP request received by a server + * or to be sent by a client. + * + * The field semantics differ slightly between client and server + * usage. In addition to the notes on the fields below, see the + * documentation for [Request.Write] and [RoundTripper]. + */ + interface Request { /** - * ColumnValueMap implements [ColumnValueMapper] interface. + * Method specifies the HTTP method (GET, POST, PUT, etc.). + * For client requests, an empty string means GET. */ - columnValueMap(): _TygojaDict - } - interface Record { + method: string /** - * PublicExport exports only the record fields that are safe to be public. + * URL specifies either the URI being requested (for server + * requests) or the URL to access (for client requests). * - * For auth records, to force the export of the email field you need to set - * `m.IgnoreEmailVisibility(true)`. - */ - publicExport(): _TygojaDict - } - interface Record { - /** - * MarshalJSON implements the [json.Marshaler] interface. + * For server requests, the URL is parsed from the URI + * supplied on the Request-Line as stored in RequestURI. For + * most requests, fields other than Path and RawQuery will be + * empty. (See RFC 7230, Section 5.3) * - * Only the data exported by `PublicExport()` will be serialized. + * For client requests, the URL's Host specifies the server to + * connect to, while the Request's Host field optionally + * specifies the Host header value to send in the HTTP + * request. */ - marshalJSON(): string - } - interface Record { + url?: url.URL /** - * UnmarshalJSON implements the [json.Unmarshaler] interface. + * The protocol version for incoming server requests. + * + * For client requests, these fields are ignored. The HTTP + * client code always uses either HTTP/1.1 or HTTP/2. + * See the docs on Transport for details. */ - unmarshalJSON(data: string): void - } - interface Record { + proto: string // "HTTP/1.0" + protoMajor: number // 1 + protoMinor: number // 0 /** - * ReplaceModifers returns a new map with applied modifier - * values based on the current record and the specified data. + * Header contains the request header fields either received + * by the server or to be sent by the client. * - * The resolved modifier keys will be removed. + * If a server received a request with header lines, * - * Multiple modifiers will be applied one after another, - * while reusing the previous base key value result (eg. 1; -5; +2 => -2). + * ``` + * Host: example.com + * accept-encoding: gzip, deflate + * Accept-Language: en-us + * fOO: Bar + * foo: two + * ``` * - * Example usage: + * then * * ``` - * newData := record.ReplaceModifers(data) - * // record: {"field": 10} - * // data: {"field+": 5} - * // newData: {"field": 15} + * Header = map[string][]string{ + * "Accept-Encoding": {"gzip, deflate"}, + * "Accept-Language": {"en-us"}, + * "Foo": {"Bar", "two"}, + * } * ``` - */ - replaceModifers(data: _TygojaDict): _TygojaDict - } - interface Record { - /** - * Username returns the "username" auth record data value. - */ - username(): string - } - interface Record { - /** - * SetUsername sets the "username" auth record data value. * - * This method doesn't check whether the provided value is a valid username. + * For incoming requests, the Host header is promoted to the + * Request.Host field and removed from the Header map. * - * Returns an error if the record is not from an auth collection. - */ - setUsername(username: string): void - } - interface Record { - /** - * Email returns the "email" auth record data value. + * HTTP defines that header names are case-insensitive. The + * request parser implements this by using CanonicalHeaderKey, + * making the first character and any characters following a + * hyphen uppercase and the rest lowercase. + * + * For client requests, certain headers such as Content-Length + * and Connection are automatically written when needed and + * values in Header may be ignored. See the documentation + * for the Request.Write method. */ - email(): string - } - interface Record { + header: Header /** - * SetEmail sets the "email" auth record data value. + * Body is the request's body. + * + * For client requests, a nil body means the request has no + * body, such as a GET request. The HTTP Client's Transport + * is responsible for calling the Close method. * - * This method doesn't check whether the provided value is a valid email. + * For server requests, the Request Body is always non-nil + * but will return EOF immediately when no body is present. + * The Server will close the request body. The ServeHTTP + * Handler does not need to. * - * Returns an error if the record is not from an auth collection. - */ - setEmail(email: string): void - } - interface Record { - /** - * Verified returns the "emailVisibility" auth record data value. + * Body must allow Read to be called concurrently with Close. + * In particular, calling Close should unblock a Read waiting + * for input. */ - emailVisibility(): boolean - } - interface Record { + body: io.ReadCloser /** - * SetEmailVisibility sets the "emailVisibility" auth record data value. + * GetBody defines an optional func to return a new copy of + * Body. It is used for client requests when a redirect requires + * reading the body more than once. Use of GetBody still + * requires setting Body. * - * Returns an error if the record is not from an auth collection. - */ - setEmailVisibility(visible: boolean): void - } - interface Record { - /** - * Verified returns the "verified" auth record data value. + * For server requests, it is unused. */ - verified(): boolean - } - interface Record { + getBody: () => io.ReadCloser /** - * SetVerified sets the "verified" auth record data value. + * ContentLength records the length of the associated content. + * The value -1 indicates that the length is unknown. + * Values >= 0 indicate that the given number of bytes may + * be read from Body. * - * Returns an error if the record is not from an auth collection. + * For client requests, a value of 0 with a non-nil Body is + * also treated as unknown. */ - setVerified(verified: boolean): void - } - interface Record { + contentLength: number /** - * TokenKey returns the "tokenKey" auth record data value. + * TransferEncoding lists the transfer encodings from outermost to + * innermost. An empty list denotes the "identity" encoding. + * TransferEncoding can usually be ignored; chunked encoding is + * automatically added and removed as necessary when sending and + * receiving requests. */ - tokenKey(): string - } - interface Record { + transferEncoding: Array /** - * SetTokenKey sets the "tokenKey" auth record data value. + * Close indicates whether to close the connection after + * replying to this request (for servers) or after sending this + * request and reading its response (for clients). * - * Returns an error if the record is not from an auth collection. - */ - setTokenKey(key: string): void - } - interface Record { - /** - * RefreshTokenKey generates and sets new random auth record "tokenKey". + * For server requests, the HTTP server handles this automatically + * and this field is not needed by Handlers. * - * Returns an error if the record is not from an auth collection. - */ - refreshTokenKey(): void - } - interface Record { - /** - * LastResetSentAt returns the "lastResentSentAt" auth record data value. + * For client requests, setting this field prevents re-use of + * TCP connections between requests to the same hosts, as if + * Transport.DisableKeepAlives were set. */ - lastResetSentAt(): types.DateTime - } - interface Record { + close: boolean /** - * SetLastResetSentAt sets the "lastResentSentAt" auth record data value. + * For server requests, Host specifies the host on which the + * URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this + * is either the value of the "Host" header or the host name + * given in the URL itself. For HTTP/2, it is the value of the + * ":authority" pseudo-header field. + * It may be of the form "host:port". For international domain + * names, Host may be in Punycode or Unicode form. Use + * golang.org/x/net/idna to convert it to either format if + * needed. + * To prevent DNS rebinding attacks, server Handlers should + * validate that the Host header has a value for which the + * Handler considers itself authoritative. The included + * ServeMux supports patterns registered to particular host + * names and thus protects its registered Handlers. * - * Returns an error if the record is not from an auth collection. + * For client requests, Host optionally overrides the Host + * header to send. If empty, the Request.Write method uses + * the value of URL.Host. Host may contain an international + * domain name. */ - setLastResetSentAt(dateTime: types.DateTime): void - } - interface Record { + host: string /** - * LastVerificationSentAt returns the "lastVerificationSentAt" auth record data value. + * Form contains the parsed form data, including both the URL + * field's query parameters and the PATCH, POST, or PUT form data. + * This field is only available after ParseForm is called. + * The HTTP client ignores Form and uses Body instead. */ - lastVerificationSentAt(): types.DateTime - } - interface Record { + form: url.Values /** - * SetLastVerificationSentAt sets an "lastVerificationSentAt" auth record data value. + * PostForm contains the parsed form data from PATCH, POST + * or PUT body parameters. * - * Returns an error if the record is not from an auth collection. + * This field is only available after ParseForm is called. + * The HTTP client ignores PostForm and uses Body instead. */ - setLastVerificationSentAt(dateTime: types.DateTime): void - } - interface Record { + postForm: url.Values /** - * PasswordHash returns the "passwordHash" auth record data value. + * MultipartForm is the parsed multipart form, including file uploads. + * This field is only available after ParseMultipartForm is called. + * The HTTP client ignores MultipartForm and uses Body instead. */ - passwordHash(): string - } - interface Record { + multipartForm?: multipart.Form /** - * ValidatePassword validates a plain password against the auth record password. + * Trailer specifies additional headers that are sent after the request + * body. * - * Returns false if the password is incorrect or record is not from an auth collection. - */ - validatePassword(password: string): boolean - } - interface Record { - /** - * SetPassword sets cryptographically secure string to the auth record "password" field. - * This method also resets the "lastResetSentAt" and the "tokenKey" fields. + * For server requests, the Trailer map initially contains only the + * trailer keys, with nil values. (The client declares which trailers it + * will later send.) While the handler is reading from Body, it must + * not reference Trailer. After reading from Body returns EOF, Trailer + * can be read again and will contain non-nil values, if they were sent + * by the client. * - * Returns an error if the record is not from an auth collection or - * an empty password is provided. - */ - setPassword(password: string): void - } - /** - * RequestInfo defines a HTTP request data struct, usually used - * as part of the `@request.*` filter resolver. - */ - interface RequestInfo { - method: string - query: _TygojaDict - data: _TygojaDict - headers: _TygojaDict - authRecord?: Record - admin?: Admin - } - interface RequestInfo { - /** - * HasModifierDataKeys loosely checks if the current struct has any modifier Data keys. - */ - hasModifierDataKeys(): boolean - } -} - -/** - * Package echo implements high performance, minimalist Go web framework. - * - * Example: - * - * ``` - * package main - * - * import ( - * "github.com/labstack/echo/v5" - * "github.com/labstack/echo/v5/middleware" - * "log" - * "net/http" - * ) - * - * // Handler - * func hello(c echo.Context) error { - * return c.String(http.StatusOK, "Hello, World!") - * } - * - * func main() { - * // Echo instance - * e := echo.New() - * - * // Middleware - * e.Use(middleware.Logger()) - * e.Use(middleware.Recover()) - * - * // Routes - * e.GET("/", hello) - * - * // Start server - * if err := e.Start(":8080"); err != http.ErrServerClosed { - * log.Fatal(err) - * } - * } - * ``` - * - * Learn more at https://echo.labstack.com - */ -namespace echo { - /** - * Context represents the context of the current HTTP request. It holds request and - * response objects, path, path parameters, data and registered handler. - */ - interface Context { - /** - * Request returns `*http.Request`. - */ - request(): (http.Request | undefined) - /** - * SetRequest sets `*http.Request`. - */ - setRequest(r: http.Request): void - /** - * SetResponse sets `*Response`. - */ - setResponse(r: Response): void - /** - * Response returns `*Response`. + * For client requests, Trailer must be initialized to a map containing + * the trailer keys to later send. The values may be nil or their final + * values. The ContentLength must be 0 or -1, to send a chunked request. + * After the HTTP request is sent the map values can be updated while + * the request body is read. Once the body returns EOF, the caller must + * not mutate Trailer. + * + * Writing a request whose Trailer contains a key with invalid bytes + * (such as CR or LF), or such a value present when Write begins, + * returns an error. + * + * Few HTTP clients, servers, or proxies support HTTP trailers. */ - response(): (Response | undefined) + trailer: Header /** - * IsTLS returns true if HTTP connection is TLS otherwise false. + * RemoteAddr allows HTTP servers and other software to record + * the network address that sent the request, usually for + * logging. This field is not filled in by ReadRequest and + * has no defined format. The HTTP server in this package + * sets RemoteAddr to an "IP:port" address before invoking a + * handler. + * This field is ignored by the HTTP client. */ - isTLS(): boolean + remoteAddr: string /** - * IsWebSocket returns true if HTTP connection is WebSocket otherwise false. + * RequestURI is the unmodified request-target of the + * Request-Line (RFC 7230, Section 3.1.1) as sent by the client + * to a server. Usually the URL field should be used instead. + * It is an error to set this field in an HTTP client request. */ - isWebSocket(): boolean + requestURI: string /** - * Scheme returns the HTTP protocol scheme, `http` or `https`. + * TLS allows HTTP servers and other software to record + * information about the TLS connection on which the request + * was received. This field is not filled in by ReadRequest. + * The HTTP server in this package sets the field for + * TLS-enabled connections before invoking a handler; + * otherwise it leaves the field nil. + * This field is ignored by the HTTP client. */ - scheme(): string + tls?: any /** - * RealIP returns the client's network address based on `X-Forwarded-For` - * or `X-Real-IP` request header. - * The behavior can be configured using `Echo#IPExtractor`. + * Cancel is an optional channel whose closure indicates that the client + * request should be regarded as canceled. Not all implementations of + * RoundTripper may support Cancel. + * + * For server requests, this field is not applicable. + * + * Deprecated: Set the Request's context with NewRequestWithContext + * instead. If a Request's Cancel field and context are both + * set, it is undefined whether Cancel is respected. */ - realIP(): string + cancel: undefined /** - * RouteInfo returns current request route information. Method, Path, Name and params if they exist for matched route. - * In case of 404 (route not found) and 405 (method not allowed) RouteInfo returns generic struct for these cases. + * Response is the redirect response which caused this request + * to be created. This field is only populated during client + * redirects. */ - routeInfo(): RouteInfo + response?: Response /** - * Path returns the registered path for the handler. + * Pattern is the [ServeMux] pattern that matched the request. + * It is empty if the request was not matched against a pattern. */ - path(): string + pattern: string + } + interface Request { /** - * PathParam returns path parameter by name. + * Context returns the request's context. To change the context, use + * [Request.Clone] or [Request.WithContext]. + * + * The returned context is always non-nil; it defaults to the + * background context. + * + * For outgoing client requests, the context controls cancellation. + * + * For incoming server requests, the context is canceled when the + * client's connection closes, the request is canceled (with HTTP/2), + * or when the ServeHTTP method returns. */ - pathParam(name: string): string + context(): context.Context + } + interface Request { /** - * PathParamDefault returns the path parameter or default value for the provided name. + * WithContext returns a shallow copy of r with its context changed + * to ctx. The provided ctx must be non-nil. + * + * For outgoing client request, the context controls the entire + * lifetime of a request and its response: obtaining a connection, + * sending the request, and reading the response headers and body. * - * Notes for DefaultRouter implementation: - * Path parameter could be empty for cases like that: - * * route `/release-:version/bin` and request URL is `/release-/bin` - * * route `/api/:version/image.jpg` and request URL is `/api//image.jpg` - * but not when path parameter is last part of route path - * * route `/download/file.:ext` will not match request `/download/file.` + * To create a new request with a context, use [NewRequestWithContext]. + * To make a deep copy of a request with a new context, use [Request.Clone]. */ - pathParamDefault(name: string, defaultValue: string): string + withContext(ctx: context.Context): (Request) + } + interface Request { /** - * PathParams returns path parameter values. + * Clone returns a deep copy of r with its context changed to ctx. + * The provided ctx must be non-nil. + * + * Clone only makes a shallow copy of the Body field. + * + * For an outgoing client request, the context controls the entire + * lifetime of a request and its response: obtaining a connection, + * sending the request, and reading the response headers and body. */ - pathParams(): PathParams + clone(ctx: context.Context): (Request) + } + interface Request { /** - * SetPathParams sets path parameters for current request. + * ProtoAtLeast reports whether the HTTP protocol used + * in the request is at least major.minor. */ - setPathParams(params: PathParams): void + protoAtLeast(major: number, minor: number): boolean + } + interface Request { /** - * QueryParam returns the query param for the provided name. + * UserAgent returns the client's User-Agent, if sent in the request. */ - queryParam(name: string): string + userAgent(): string + } + interface Request { /** - * QueryParamDefault returns the query param or default value for the provided name. + * Cookies parses and returns the HTTP cookies sent with the request. */ - queryParamDefault(name: string): string + cookies(): Array<(Cookie | undefined)> + } + interface Request { /** - * QueryParams returns the query parameters as `url.Values`. + * CookiesNamed parses and returns the named HTTP cookies sent with the request + * or an empty slice if none matched. */ - queryParams(): url.Values + cookiesNamed(name: string): Array<(Cookie | undefined)> + } + interface Request { /** - * QueryString returns the URL query string. + * Cookie returns the named cookie provided in the request or + * [ErrNoCookie] if not found. + * If multiple cookies match the given name, only one cookie will + * be returned. */ - queryString(): string + cookie(name: string): (Cookie) + } + interface Request { /** - * FormValue returns the form field value for the provided name. + * AddCookie adds a cookie to the request. Per RFC 6265 section 5.4, + * AddCookie does not attach more than one [Cookie] header field. That + * means all cookies, if any, are written into the same line, + * separated by semicolon. + * AddCookie only sanitizes c's name and value, and does not sanitize + * a Cookie header already present in the request. */ - formValue(name: string): string + addCookie(c: Cookie): void + } + interface Request { /** - * FormValueDefault returns the form field value or default value for the provided name. + * Referer returns the referring URL, if sent in the request. + * + * Referer is misspelled as in the request itself, a mistake from the + * earliest days of HTTP. This value can also be fetched from the + * [Header] map as Header["Referer"]; the benefit of making it available + * as a method is that the compiler can diagnose programs that use the + * alternate (correct English) spelling req.Referrer() but cannot + * diagnose programs that use Header["Referrer"]. */ - formValueDefault(name: string): string + referer(): string + } + interface Request { /** - * FormValues returns the form field values as `url.Values`. + * MultipartReader returns a MIME multipart reader if this is a + * multipart/form-data or a multipart/mixed POST request, else returns nil and an error. + * Use this function instead of [Request.ParseMultipartForm] to + * process the request body as a stream. */ - formValues(): url.Values + multipartReader(): (multipart.Reader) + } + interface Request { /** - * FormFile returns the multipart form file for the provided name. + * Write writes an HTTP/1.1 request, which is the header and body, in wire format. + * This method consults the following fields of the request: + * + * ``` + * Host + * URL + * Method (defaults to "GET") + * Header + * ContentLength + * TransferEncoding + * Body + * ``` + * + * If Body is present, Content-Length is <= 0 and [Request.TransferEncoding] + * hasn't been set to "identity", Write adds "Transfer-Encoding: + * chunked" to the header. Body is closed after it is sent. + * + * Header values for Host, Content-Length, Transfer-Encoding, + * and Trailer are not used; these are derived from other Request fields. + * If the Header does not contain a User-Agent value, Write uses + * "Go-http-client/1.1". */ - formFile(name: string): (multipart.FileHeader | undefined) + write(w: io.Writer): void + } + interface Request { /** - * MultipartForm returns the multipart form. + * WriteProxy is like [Request.Write] but writes the request in the form + * expected by an HTTP proxy. In particular, [Request.WriteProxy] writes the + * initial Request-URI line of the request with an absolute URI, per + * section 5.3 of RFC 7230, including the scheme and host. + * In either case, WriteProxy also writes a Host header, using + * either r.Host or r.URL.Host. */ - multipartForm(): (multipart.Form | undefined) + writeProxy(w: io.Writer): void + } + interface Request { /** - * Cookie returns the named cookie provided in the request. + * BasicAuth returns the username and password provided in the request's + * Authorization header, if the request uses HTTP Basic Authentication. + * See RFC 2617, Section 2. */ - cookie(name: string): (http.Cookie | undefined) + basicAuth(): [string, string, boolean] + } + interface Request { /** - * SetCookie adds a `Set-Cookie` header in HTTP response. + * SetBasicAuth sets the request's Authorization header to use HTTP + * Basic Authentication with the provided username and password. + * + * With HTTP Basic Authentication the provided username and password + * are not encrypted. It should generally only be used in an HTTPS + * request. + * + * The username may not contain a colon. Some protocols may impose + * additional requirements on pre-escaping the username and + * password. For instance, when used with OAuth2, both arguments must + * be URL encoded first with [url.QueryEscape]. */ - setCookie(cookie: http.Cookie): void + setBasicAuth(username: string, password: string): void + } + interface Request { /** - * Cookies returns the HTTP cookies sent with the request. + * ParseForm populates r.Form and r.PostForm. + * + * For all requests, ParseForm parses the raw query from the URL and updates + * r.Form. + * + * For POST, PUT, and PATCH requests, it also reads the request body, parses it + * as a form and puts the results into both r.PostForm and r.Form. Request body + * parameters take precedence over URL query string values in r.Form. + * + * If the request Body's size has not already been limited by [MaxBytesReader], + * the size is capped at 10MB. + * + * For other HTTP methods, or when the Content-Type is not + * application/x-www-form-urlencoded, the request Body is not read, and + * r.PostForm is initialized to a non-nil, empty value. + * + * [Request.ParseMultipartForm] calls ParseForm automatically. + * ParseForm is idempotent. */ - cookies(): Array<(http.Cookie | undefined)> + parseForm(): void + } + interface Request { /** - * Get retrieves data from the context. + * ParseMultipartForm parses a request body as multipart/form-data. + * The whole request body is parsed and up to a total of maxMemory bytes of + * its file parts are stored in memory, with the remainder stored on + * disk in temporary files. + * ParseMultipartForm calls [Request.ParseForm] if necessary. + * If ParseForm returns an error, ParseMultipartForm returns it but also + * continues parsing the request body. + * After one call to ParseMultipartForm, subsequent calls have no effect. */ - get(key: string): { + parseMultipartForm(maxMemory: number): void } + interface Request { /** - * Set saves data in the context. + * FormValue returns the first value for the named component of the query. + * The precedence order: + * 1. application/x-www-form-urlencoded form body (POST, PUT, PATCH only) + * 2. query parameters (always) + * 3. multipart/form-data form body (always) + * + * FormValue calls [Request.ParseMultipartForm] and [Request.ParseForm] + * if necessary and ignores any errors returned by these functions. + * If key is not present, FormValue returns the empty string. + * To access multiple values of the same key, call ParseForm and + * then inspect [Request.Form] directly. */ - set(key: string, val: { - }): void + formValue(key: string): string + } + interface Request { /** - * Bind binds path params, query params and the request body into provided type `i`. The default binder - * binds body based on Content-Type header. + * PostFormValue returns the first value for the named component of the POST, + * PUT, or PATCH request body. URL query parameters are ignored. + * PostFormValue calls [Request.ParseMultipartForm] and [Request.ParseForm] if necessary and ignores + * any errors returned by these functions. + * If key is not present, PostFormValue returns the empty string. */ - bind(i: { - }): void + postFormValue(key: string): string + } + interface Request { /** - * Validate validates provided `i`. It is usually called after `Context#Bind()`. - * Validator must be registered using `Echo#Validator`. + * FormFile returns the first file for the provided form key. + * FormFile calls [Request.ParseMultipartForm] and [Request.ParseForm] if necessary. */ - validate(i: { - }): void + formFile(key: string): [multipart.File, (multipart.FileHeader)] + } + interface Request { /** - * Render renders a template with data and sends a text/html response with status - * code. Renderer must be registered using `Echo.Renderer`. + * PathValue returns the value for the named path wildcard in the [ServeMux] pattern + * that matched the request. + * It returns the empty string if the request was not matched against a pattern + * or there is no such wildcard in the pattern. + * + * The value is unescaped. For example, if the pattern "/b/{bucket}" matches + * the path "/b/a%2fb", PathValue("bucket") returns "a/b". */ - render(code: number, name: string, data: { - }): void + pathValue(name: string): string + } + interface Request { /** - * HTML sends an HTTP response with status code. + * SetPathValue sets name to value, so that subsequent calls to r.PathValue(name) + * return value. + * It does not unescape value. */ - html(code: number, html: string): void + setPathValue(name: string, value: string): void + } + /** + * A Handler responds to an HTTP request. + * + * [Handler.ServeHTTP] should write reply headers and data to the [ResponseWriter] + * and then return. Returning signals that the request is finished; it + * is not valid to use the [ResponseWriter] or read from the + * [Request.Body] after or concurrently with the completion of the + * ServeHTTP call. + * + * Depending on the HTTP client software, HTTP protocol version, and + * any intermediaries between the client and the Go server, it may not + * be possible to read from the [Request.Body] after writing to the + * [ResponseWriter]. Cautious handlers should read the [Request.Body] + * first, and then reply. + * + * Except for reading the body, handlers should not modify the + * provided Request. + * + * If ServeHTTP panics, the server (the caller of ServeHTTP) assumes + * that the effect of the panic was isolated to the active request. + * It recovers the panic, logs a stack trace to the server error log, + * and either closes the network connection or sends an HTTP/2 + * RST_STREAM, depending on the HTTP protocol. To abort a handler so + * the client sees an interrupted response but the server doesn't log + * an error, panic with the value [ErrAbortHandler]. + */ + interface Handler { + [key:string]: any; + serveHTTP(_arg0: ResponseWriter, _arg1: Request): void + } + /** + * A ResponseWriter interface is used by an HTTP handler to + * construct an HTTP response. + * + * A ResponseWriter may not be used after [Handler.ServeHTTP] has returned. + */ + interface ResponseWriter { + [key:string]: any; /** - * HTMLBlob sends an HTTP blob response with status code. + * Header returns the header map that will be sent by + * [ResponseWriter.WriteHeader]. The [Header] map also is the mechanism with which + * [Handler] implementations can set HTTP trailers. + * + * Changing the header map after a call to [ResponseWriter.WriteHeader] (or + * [ResponseWriter.Write]) has no effect unless the HTTP status code was of the + * 1xx class or the modified headers are trailers. + * + * There are two ways to set Trailers. The preferred way is to + * predeclare in the headers which trailers you will later + * send by setting the "Trailer" header to the names of the + * trailer keys which will come later. In this case, those + * keys of the Header map are treated as if they were + * trailers. See the example. The second way, for trailer + * keys not known to the [Handler] until after the first [ResponseWriter.Write], + * is to prefix the [Header] map keys with the [TrailerPrefix] + * constant value. + * + * To suppress automatic response headers (such as "Date"), set + * their value to nil. */ - htmlBlob(code: number, b: string): void + header(): Header /** - * String sends a string response with status code. + * Write writes the data to the connection as part of an HTTP reply. + * + * If [ResponseWriter.WriteHeader] has not yet been called, Write calls + * WriteHeader(http.StatusOK) before writing the data. If the Header + * does not contain a Content-Type line, Write adds a Content-Type set + * to the result of passing the initial 512 bytes of written data to + * [DetectContentType]. Additionally, if the total size of all written + * data is under a few KB and there are no Flush calls, the + * Content-Length header is added automatically. + * + * Depending on the HTTP protocol version and the client, calling + * Write or WriteHeader may prevent future reads on the + * Request.Body. For HTTP/1.x requests, handlers should read any + * needed request body data before writing the response. Once the + * headers have been flushed (due to either an explicit Flusher.Flush + * call or writing enough data to trigger a flush), the request body + * may be unavailable. For HTTP/2 requests, the Go HTTP server permits + * handlers to continue to read the request body while concurrently + * writing the response. However, such behavior may not be supported + * by all HTTP/2 clients. Handlers should read before writing if + * possible to maximize compatibility. */ - string(code: number, s: string): void + write(_arg0: string|Array): number /** - * JSON sends a JSON response with status code. + * WriteHeader sends an HTTP response header with the provided + * status code. + * + * If WriteHeader is not called explicitly, the first call to Write + * will trigger an implicit WriteHeader(http.StatusOK). + * Thus explicit calls to WriteHeader are mainly used to + * send error codes or 1xx informational responses. + * + * The provided code must be a valid HTTP 1xx-5xx status code. + * Any number of 1xx headers may be written, followed by at most + * one 2xx-5xx header. 1xx headers are sent immediately, but 2xx-5xx + * headers may be buffered. Use the Flusher interface to send + * buffered data. The header map is cleared when 2xx-5xx headers are + * sent, but not with 1xx headers. + * + * The server will automatically send a 100 (Continue) header + * on the first read from the request body if the request has + * an "Expect: 100-continue" header. */ - json(code: number, i: { - }): void + writeHeader(statusCode: number): void + } + /** + * A Server defines parameters for running an HTTP server. + * The zero value for Server is a valid configuration. + */ + interface Server { /** - * JSONPretty sends a pretty-print JSON with status code. + * Addr optionally specifies the TCP address for the server to listen on, + * in the form "host:port". If empty, ":http" (port 80) is used. + * The service names are defined in RFC 6335 and assigned by IANA. + * See net.Dial for details of the address format. */ - jsonPretty(code: number, i: { - }, indent: string): void + addr: string + handler: Handler // handler to invoke, http.DefaultServeMux if nil /** - * JSONBlob sends a JSON blob response with status code. + * DisableGeneralOptionsHandler, if true, passes "OPTIONS *" requests to the Handler, + * otherwise responds with 200 OK and Content-Length: 0. */ - jsonBlob(code: number, b: string): void + disableGeneralOptionsHandler: boolean /** - * JSONP sends a JSONP response with status code. It uses `callback` to construct - * the JSONP payload. + * TLSConfig optionally provides a TLS configuration for use + * by ServeTLS and ListenAndServeTLS. Note that this value is + * cloned by ServeTLS and ListenAndServeTLS, so it's not + * possible to modify the configuration with methods like + * tls.Config.SetSessionTicketKeys. To use + * SetSessionTicketKeys, use Server.Serve with a TLS Listener + * instead. */ - jsonp(code: number, callback: string, i: { - }): void + tlsConfig?: any /** - * JSONPBlob sends a JSONP blob response with status code. It uses `callback` - * to construct the JSONP payload. + * ReadTimeout is the maximum duration for reading the entire + * request, including the body. A zero or negative value means + * there will be no timeout. + * + * Because ReadTimeout does not let Handlers make per-request + * decisions on each request body's acceptable deadline or + * upload rate, most users will prefer to use + * ReadHeaderTimeout. It is valid to use them both. */ - jsonpBlob(code: number, callback: string, b: string): void + readTimeout: time.Duration /** - * XML sends an XML response with status code. + * ReadHeaderTimeout is the amount of time allowed to read + * request headers. The connection's read deadline is reset + * after reading the headers and the Handler can decide what + * is considered too slow for the body. If zero, the value of + * ReadTimeout is used. If negative, or if zero and ReadTimeout + * is zero or negative, there is no timeout. */ - xml(code: number, i: { - }): void + readHeaderTimeout: time.Duration /** - * XMLPretty sends a pretty-print XML with status code. + * WriteTimeout is the maximum duration before timing out + * writes of the response. It is reset whenever a new + * request's header is read. Like ReadTimeout, it does not + * let Handlers make decisions on a per-request basis. + * A zero or negative value means there will be no timeout. */ - xmlPretty(code: number, i: { - }, indent: string): void + writeTimeout: time.Duration /** - * XMLBlob sends an XML blob response with status code. + * IdleTimeout is the maximum amount of time to wait for the + * next request when keep-alives are enabled. If zero, the value + * of ReadTimeout is used. If negative, or if zero and ReadTimeout + * is zero or negative, there is no timeout. */ - xmlBlob(code: number, b: string): void + idleTimeout: time.Duration /** - * Blob sends a blob response with status code and content type. + * MaxHeaderBytes controls the maximum number of bytes the + * server will read parsing the request header's keys and + * values, including the request line. It does not limit the + * size of the request body. + * If zero, DefaultMaxHeaderBytes is used. */ - blob(code: number, contentType: string, b: string): void + maxHeaderBytes: number /** - * Stream sends a streaming response with status code and content type. + * MaxHeaderValueCount controls the maximum number of header + * values that the server is willing to parse from a request. + * If zero, DefaultMaxHeaderValueCount is used. + * Note that comma-separated values in a single header line are + * counted once, while values sent as multiple header lines are + * counted multiple times. */ - stream(code: number, contentType: string, r: io.Reader): void + maxHeaderValueCount: number /** - * File sends a response with the content of the file. + * TLSNextProto optionally specifies a function to take over + * ownership of the provided TLS connection when an ALPN + * protocol upgrade has occurred. The map key is the protocol + * name negotiated. The Handler argument should be used to + * handle HTTP requests and will initialize the Request's TLS + * and RemoteAddr if not already set. The connection is + * automatically closed when the function returns. + * If TLSNextProto is not nil, HTTP/2 support is not enabled + * automatically. + * + * Historically, TLSNextProto was used to disable HTTP/2 support. + * The Server.Protocols field now provides a simpler way to do this. */ - file(file: string): void + tlsNextProto: _TygojaDict /** - * FileFS sends a response with the content of the file from given filesystem. + * ConnState specifies an optional callback function that is + * called when a client connection changes state. See the + * ConnState type and associated constants for details. */ - fileFS(file: string, filesystem: fs.FS): void + connState: (_arg0: net.Conn, _arg1: ConnState) => void /** - * Attachment sends a response as attachment, prompting client to save the - * file. + * ErrorLog specifies an optional logger for errors accepting + * connections, unexpected behavior from handlers, and + * underlying FileSystem errors. + * If nil, logging is done via the log package's standard logger. */ - attachment(file: string, name: string): void + errorLog?: any /** - * Inline sends a response as inline, opening the file in the browser. + * BaseContext optionally specifies a function that returns + * the base context for incoming requests on this server. + * The provided Listener is the specific Listener that's + * about to start accepting requests. + * If BaseContext is nil, the default is context.Background(). + * If non-nil, it must return a non-nil context. */ - inline(file: string, name: string): void + baseContext: (_arg0: net.Listener) => context.Context /** - * NoContent sends a response with no body and a status code. + * ConnContext optionally specifies a function that modifies + * the context used for a new connection c. The provided ctx + * is derived from the base context and has a ServerContextKey + * value. */ - noContent(code: number): void + connContext: (ctx: context.Context, c: net.Conn) => context.Context /** - * Redirect redirects the request to a provided URL with status code. + * HTTP2 configures HTTP/2 connections. */ - redirect(code: number, url: string): void + http2?: HTTP2Config /** - * Error invokes the registered global HTTP error handler. Generally used by middleware. - * A side-effect of calling global error handler is that now Response has been committed (sent to the client) and - * middlewares up in chain can not change Response status code or Response body anymore. + * Protocols is the set of protocols accepted by the server. * - * Avoid using this method in handlers as no middleware will be able to effectively handle errors after that. - * Instead of calling this method in handler return your error and let it be handled by middlewares or global error handler. - */ - error(err: Error): void - /** - * Echo returns the `Echo` instance. + * If Protocols includes UnencryptedHTTP2, the server will accept + * unencrypted HTTP/2 connections. The server can serve both + * HTTP/1 and unencrypted HTTP/2 on the same address and port. * - * WARNING: Remember that Echo public fields and methods are coroutine safe ONLY when you are NOT mutating them - * anywhere in your code after Echo server has started. + * If Protocols is nil, the default is usually HTTP/1 and HTTP/2. + * If TLSNextProto is non-nil and does not contain an "h2" entry, + * the default is HTTP/1 only. */ - echo(): (Echo | undefined) - } - // @ts-ignore - import stdContext = context - /** - * Echo is the top-level framework instance. - * - * Goroutine safety: Do not mutate Echo instance fields after server has started. Accessing these - * fields from handlers/middlewares and changing field values at the same time leads to data-races. - * Same rule applies to adding new routes after server has been started - Adding a route is not Goroutine safe action. - */ - interface Echo { - /** - * NewContextFunc allows using custom context implementations, instead of default *echo.context - */ - newContextFunc: (e: Echo, pathParamAllocSize: number) => ServableContext - debug: boolean - httpErrorHandler: HTTPErrorHandler - binder: Binder - jsonSerializer: JSONSerializer - validator: Validator - renderer: Renderer - logger: Logger - ipExtractor: IPExtractor + protocols?: Protocols /** - * Filesystem is file system used by Static and File handlers to access files. - * Defaults to os.DirFS(".") + * DisableClientPriority specifies whether client-specified priority, as + * specified in RFC 9218, should be respected or not. * - * When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary - * prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths - * including `assets/images` as their prefix. - */ - filesystem: fs.FS - /** - * OnAddRoute is called when Echo adds new route to specific host router. Handler is called for every router - * and before route is added to the host router. - */ - onAddRoute: (host: string, route: Routable) => void - } - /** - * HandlerFunc defines a function to serve HTTP requests. - */ - interface HandlerFunc {(c: Context): void } - /** - * MiddlewareFunc defines a function to process middleware. - */ - interface MiddlewareFunc {(next: HandlerFunc): HandlerFunc } - interface Echo { - /** - * NewContext returns a new Context instance. + * This field only takes effect if using HTTP/2, and if no custom write + * scheduler is defined for the HTTP/2 server. Otherwise, this field is a + * no-op. * - * Note: both request and response can be left to nil as Echo.ServeHTTP will call c.Reset(req,resp) anyway - * these arguments are useful when creating context for tests and cases like that. + * If set to true, requests will be served in a round-robin manner, without + * prioritization. */ - newContext(r: http.Request, w: http.ResponseWriter): Context + disableClientPriority: boolean } - interface Echo { + interface Server { /** - * Router returns the default router. + * Close immediately closes all active net.Listeners and any + * connections in state [StateNew], [StateActive], or [StateIdle]. For a + * graceful shutdown, use [Server.Shutdown]. + * + * Close does not attempt to close (and does not even know about) + * any hijacked connections, such as WebSockets. + * + * Close returns any error returned from closing the [Server]'s + * underlying Listener(s). */ - router(): Router + close(): void } - interface Echo { + interface Server { /** - * Routers returns the new map of host => router. + * Shutdown gracefully shuts down the server without interrupting any + * active connections. Shutdown works by first closing all open + * listeners, then closing all idle connections, and then waiting + * indefinitely for connections to return to idle and then shut down. + * If the provided context expires before the shutdown is complete, + * Shutdown returns the context's error, otherwise it returns any + * error returned from closing the [Server]'s underlying Listener(s). + * + * When Shutdown is called, [Serve], [ServeTLS], [ListenAndServe], and + * [ListenAndServeTLS] immediately return [ErrServerClosed]. Make sure the + * program doesn't exit and waits instead for Shutdown to return. + * + * Shutdown does not attempt to close nor wait for hijacked + * connections such as WebSockets. The caller of Shutdown should + * separately notify such long-lived connections of shutdown and wait + * for them to close, if desired. See [Server.RegisterOnShutdown] for a way to + * register shutdown notification functions. + * + * Once Shutdown has been called on a server, it may not be reused; + * future calls to methods such as Serve will return ErrServerClosed. */ - routers(): _TygojaDict + shutdown(ctx: context.Context): void } - interface Echo { + interface Server { /** - * RouterFor returns Router for given host. When host is left empty the default router is returned. + * RegisterOnShutdown registers a function to call on [Server.Shutdown]. + * This can be used to gracefully shutdown connections that have + * undergone ALPN protocol upgrade or that have been hijacked. + * This function should start protocol-specific graceful shutdown, + * but should not wait for shutdown to complete. */ - routerFor(host: string): [Router, boolean] + registerOnShutdown(f: () => void): void } - interface Echo { + interface Server { /** - * ResetRouterCreator resets callback for creating new router instances. - * Note: current (default) router is immediately replaced with router created with creator func and vhost routers are cleared. + * ListenAndServe listens on the TCP network address s.Addr and then + * calls [Serve] to handle requests on incoming connections. + * Accepted connections are configured to enable TCP keep-alives. + * + * If s.Addr is blank, ":http" is used. + * + * ListenAndServe always returns a non-nil error. After [Server.Shutdown] or [Server.Close], + * the returned error is [ErrServerClosed]. */ - resetRouterCreator(creator: (e: Echo) => Router): void + listenAndServe(): void } - interface Echo { + interface Server { /** - * Pre adds middleware to the chain which is run before router tries to find matching route. - * Meaning middleware is executed even for 404 (not found) cases. + * Serve accepts incoming connections on the Listener l, creating a + * new service goroutine for each. The service goroutines read requests and + * then call s.Handler to reply to them. + * + * HTTP/2 support is only enabled if the Listener returns [*tls.Conn] + * connections and they were configured with "h2" in the TLS + * Config.NextProtos. + * + * Serve always returns a non-nil error and closes l. + * After [Server.Shutdown] or [Server.Close], the returned error is [ErrServerClosed]. */ - pre(...middleware: MiddlewareFunc[]): void + serve(l: net.Listener): void } - interface Echo { + interface Server { /** - * Use adds middleware to the chain which is run after router has found matching route and before route/request handler method is executed. + * ServeTLS accepts incoming connections on the Listener l, creating a + * new service goroutine for each. The service goroutines perform TLS + * setup and then read requests, calling s.Handler to reply to them. + * + * Files containing a certificate and matching private key for the + * server must be provided if neither the [Server]'s + * TLSConfig.Certificates, TLSConfig.GetCertificate nor + * config.GetConfigForClient are populated. + * If the certificate is signed by a certificate authority, the + * certFile should be the concatenation of the server's certificate, + * any intermediates, and the CA's certificate. + * + * ServeTLS always returns a non-nil error. After [Server.Shutdown] or [Server.Close], the + * returned error is [ErrServerClosed]. */ - use(...middleware: MiddlewareFunc[]): void + serveTLS(l: net.Listener, certFile: string, keyFile: string): void } - interface Echo { + interface Server { /** - * CONNECT registers a new CONNECT route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * SetKeepAlivesEnabled controls whether HTTP keep-alives are enabled. + * By default, keep-alives are always enabled. Only very + * resource-constrained environments or servers in the process of + * shutting down should disable them. */ - connect(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + setKeepAlivesEnabled(v: boolean): void } - interface Echo { + interface Server { /** - * DELETE registers a new DELETE route for a path with matching handler in the router - * with optional route-level middleware. Panics on error. + * ListenAndServeTLS listens on the TCP network address s.Addr and + * then calls [ServeTLS] to handle requests on incoming TLS connections. + * Accepted connections are configured to enable TCP keep-alives. + * + * Filenames containing a certificate and matching private key for the + * server must be provided if neither the [Server]'s TLSConfig.Certificates + * nor TLSConfig.GetCertificate are populated. If the certificate is + * signed by a certificate authority, the certFile should be the + * concatenation of the server's certificate, any intermediates, and + * the CA's certificate. + * + * If s.Addr is blank, ":https" is used. + * + * ListenAndServeTLS always returns a non-nil error. After [Server.Shutdown] or + * [Server.Close], the returned error is [ErrServerClosed]. */ - delete(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + listenAndServeTLS(certFile: string, keyFile: string): void } - interface Echo { - /** - * GET registers a new GET route for a path with matching handler in the router - * with optional route-level middleware. Panics on error. - */ - get(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo +} + +/** + * Package types implements some commonly used db serializable types + * like datetime, json, etc. + */ +namespace types { + /** + * DateTime represents a [time.Time] instance in UTC that is wrapped + * and serialized using the app default date layout. + */ + interface DateTime { } - interface Echo { + interface DateTime { /** - * HEAD registers a new HEAD route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * Time returns the internal [time.Time] instance. */ - head(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + time(): time.Time } - interface Echo { + interface DateTime { /** - * OPTIONS registers a new OPTIONS route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * Add returns a new DateTime based on the current DateTime + the specified duration. */ - options(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + add(duration: time.Duration): DateTime } - interface Echo { + interface DateTime { /** - * PATCH registers a new PATCH route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * Sub returns a [time.Duration] by subtracting the specified DateTime from the current one. + * + * If the result exceeds the maximum (or minimum) value that can be stored in a [time.Duration], + * the maximum (or minimum) duration will be returned. */ - patch(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + sub(u: DateTime): time.Duration } - interface Echo { + interface DateTime { /** - * POST registers a new POST route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * AddDate returns a new DateTime based on the current one + duration. + * + * It follows the same rules as [time.AddDate]. */ - post(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + addDate(years: number, months: number, days: number): DateTime } - interface Echo { + interface DateTime { /** - * PUT registers a new PUT route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * After reports whether the current DateTime instance is after u. */ - put(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + after(u: DateTime): boolean } - interface Echo { + interface DateTime { /** - * TRACE registers a new TRACE route for a path with matching handler in the - * router with optional route-level middleware. Panics on error. + * Before reports whether the current DateTime instance is before u. */ - trace(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + before(u: DateTime): boolean } - interface Echo { + interface DateTime { /** - * RouteNotFound registers a special-case route which is executed when no other route is found (i.e. HTTP 404 cases) - * for current request URL. - * Path supports static and named/any parameters just like other http method is defined. Generally path is ended with - * wildcard/match-any character (`/*`, `/download/*` etc). - * - * Example: `e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })` + * Compare compares the current DateTime instance with u. + * If the current instance is before u, it returns -1. + * If the current instance is after u, it returns +1. + * If they're the same, it returns 0. */ - routeNotFound(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + compare(u: DateTime): number } - interface Echo { + interface DateTime { /** - * Any registers a new route for all HTTP methods (supported by Echo) and path with matching handler - * in the router with optional route-level middleware. - * - * Note: this method only adds specific set of supported HTTP methods as handler and is not true - * "catch-any-arbitrary-method" way of matching requests. + * Equal reports whether the current DateTime and u represent the same time instant. + * Two DateTime can be equal even if they are in different locations. + * For example, 6:00 +0200 and 4:00 UTC are Equal. */ - any(path: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): Routes + equal(u: DateTime): boolean } - interface Echo { + interface DateTime { /** - * Match registers a new route for multiple HTTP methods and path with matching - * handler in the router with optional route-level middleware. Panics on error. + * Unix returns the current DateTime as a Unix time, aka. + * the number of seconds elapsed since January 1, 1970 UTC. */ - match(methods: Array, path: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): Routes + unix(): number } - interface Echo { + interface DateTime { /** - * Static registers a new route with path prefix to serve static files from the provided root directory. + * IsZero checks whether the current DateTime instance has zero time value. */ - static(pathPrefix: string): RouteInfo + isZero(): boolean } - interface Echo { + interface DateTime { /** - * StaticFS registers a new route with path prefix to serve static files from the provided file system. + * String serializes the current DateTime instance into a formatted + * UTC date string. * - * When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary - * prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths - * including `assets/images` as their prefix. - */ - staticFS(pathPrefix: string, filesystem: fs.FS): RouteInfo - } - interface Echo { - /** - * FileFS registers a new route with path to serve file from the provided file system. + * The zero value is serialized to an empty string. */ - fileFS(path: string, filesystem: fs.FS, ...m: MiddlewareFunc[]): RouteInfo + string(): string } - interface Echo { + interface DateTime { /** - * File registers a new route with path to serve a static file with optional route-level middleware. Panics on error. + * MarshalJSON implements the [json.Marshaler] interface. */ - file(path: string, ...middleware: MiddlewareFunc[]): RouteInfo + marshalJSON(): string|Array } - interface Echo { + interface DateTime { /** - * AddRoute registers a new Route with default host Router + * UnmarshalJSON implements the [json.Unmarshaler] interface. */ - addRoute(route: Routable): RouteInfo + unmarshalJSON(b: string|Array): void } - interface Echo { + interface DateTime { /** - * Add registers a new route for an HTTP method and path with matching handler - * in the router with optional route-level middleware. + * Value implements the [driver.Valuer] interface. */ - add(method: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): RouteInfo + value(): any } - interface Echo { + interface DateTime { /** - * Host creates a new router group for the provided host and optional host-level middleware. + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current DateTime instance. */ - host(name: string, ...m: MiddlewareFunc[]): (Group | undefined) + scan(value: any): void } - interface Echo { - /** - * Group creates a new router group with prefix and optional group-level middleware. - */ - group(prefix: string, ...m: MiddlewareFunc[]): (Group | undefined) + /** + * GeoPoint defines a struct for storing geo coordinates as serialized json object + * (e.g. {lon:0,lat:0}). + * + * Note: using object notation and not a plain array to avoid the confusion + * as there doesn't seem to be a fixed standard for the coordinates order. + */ + interface GeoPoint { + lon: number + lat: number } - interface Echo { + interface GeoPoint { /** - * AcquireContext returns an empty `Context` instance from the pool. - * You must return the context by calling `ReleaseContext()`. + * String returns the string representation of the current GeoPoint instance. */ - acquireContext(): Context + string(): string } - interface Echo { + interface GeoPoint { /** - * ReleaseContext returns the `Context` instance back to the pool. - * You must call it after `AcquireContext()`. + * AsMap implements [core.mapExtractor] and returns a value suitable + * to be used in an API rule expression. */ - releaseContext(c: Context): void + asMap(): _TygojaDict } - interface Echo { + interface GeoPoint { /** - * ServeHTTP implements `http.Handler` interface, which serves HTTP requests. + * Value implements the [driver.Valuer] interface. */ - serveHTTP(w: http.ResponseWriter, r: http.Request): void + value(): any } - interface Echo { + interface GeoPoint { /** - * Start stars HTTP server on given address with Echo as a handler serving requests. The server can be shutdown by - * sending os.Interrupt signal with `ctrl+c`. - * - * Note: this method is created for use in examples/demos and is deliberately simple without providing configuration - * options. - * - * In need of customization use: - * - * ``` - * sc := echo.StartConfig{Address: ":8080"} - * if err := sc.Start(e); err != http.ErrServerClosed { - * log.Fatal(err) - * } - * ``` - * - * // or standard library `http.Server` + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current GeoPoint instance. * - * ``` - * s := http.Server{Addr: ":8080", Handler: e} - * if err := s.ListenAndServe(); err != http.ErrServerClosed { - * log.Fatal(err) - * } - * ``` + * The value argument could be nil (no-op), another GeoPoint instance, + * map or serialized json object with lat-lon props. */ - start(address: string): void + scan(value: any): void } -} - -namespace settings { - // @ts-ignore - import validation = ozzo_validation /** - * Settings defines common app configuration options. + * JSONArray defines a slice that is safe for json and db read/write. */ - interface Settings { - meta: MetaConfig - logs: LogsConfig - smtp: SmtpConfig - s3: S3Config - backups: BackupsConfig - adminAuthToken: TokenConfig - adminPasswordResetToken: TokenConfig - adminFileToken: TokenConfig - recordAuthToken: TokenConfig - recordPasswordResetToken: TokenConfig - recordEmailChangeToken: TokenConfig - recordVerificationToken: TokenConfig - recordFileToken: TokenConfig - /** - * Deprecated: Will be removed in v0.9+ - */ - emailAuth: EmailAuthConfig - googleAuth: AuthProviderConfig - facebookAuth: AuthProviderConfig - githubAuth: AuthProviderConfig - gitlabAuth: AuthProviderConfig - discordAuth: AuthProviderConfig - twitterAuth: AuthProviderConfig - microsoftAuth: AuthProviderConfig - spotifyAuth: AuthProviderConfig - kakaoAuth: AuthProviderConfig - twitchAuth: AuthProviderConfig - stravaAuth: AuthProviderConfig - giteeAuth: AuthProviderConfig - livechatAuth: AuthProviderConfig - giteaAuth: AuthProviderConfig - oidcAuth: AuthProviderConfig - oidc2Auth: AuthProviderConfig - oidc3Auth: AuthProviderConfig - appleAuth: AuthProviderConfig - instagramAuth: AuthProviderConfig - vkAuth: AuthProviderConfig - yandexAuth: AuthProviderConfig - } - interface Settings { - /** - * Validate makes Settings validatable by implementing [validation.Validatable] interface. - */ - validate(): void - } - interface Settings { - /** - * Merge merges `other` settings into the current one. - */ - merge(other: Settings): void - } - interface Settings { - /** - * Clone creates a new deep copy of the current settings. - */ - clone(): (Settings | undefined) - } - interface Settings { + interface JSONArray extends Array{} + interface JSONArray { /** - * RedactClone creates a new deep copy of the current settings, - * while replacing the secret values with `******`. + * MarshalJSON implements the [json.Marshaler] interface. */ - redactClone(): (Settings | undefined) + marshalJSON(): string|Array } - interface Settings { + interface JSONArray { /** - * NamedAuthProviderConfigs returns a map with all registered OAuth2 - * provider configurations (indexed by their name identifier). + * String returns the string representation of the current json array. */ - namedAuthProviderConfigs(): _TygojaDict + string(): string } -} - -/** - * Package daos handles common PocketBase DB model manipulations. - * - * Think of daos as DB repository and service layer in one. - */ -namespace daos { - interface Dao { + interface JSONArray { /** - * AdminQuery returns a new Admin select query. + * Value implements the [driver.Valuer] interface. */ - adminQuery(): (dbx.SelectQuery | undefined) + value(): any } - interface Dao { + interface JSONArray { /** - * FindAdminById finds the admin with the provided id. + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current JSONArray[T] instance. */ - findAdminById(id: string): (models.Admin | undefined) + scan(value: any): void } - interface Dao { + /** + * JSONMap defines a map that is safe for json and db read/write. + */ + interface JSONMap extends _TygojaDict{} + interface JSONMap { /** - * FindAdminByEmail finds the admin with the provided email address. + * Get retrieves a single value from the current JSONMap[T]. + * + * This helper was added primarily to assist the goja integration since custom map types + * don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). */ - findAdminByEmail(email: string): (models.Admin | undefined) + get(key: string): T } - interface Dao { + interface JSONMap { /** - * FindAdminByToken finds the admin associated with the provided JWT token. + * Set sets a single value in the current JSONMap[T]. * - * Returns an error if the JWT token is invalid or expired. + * This helper was added primarily to assist the goja integration since custom map types + * don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). */ - findAdminByToken(token: string, baseTokenKey: string): (models.Admin | undefined) + set(key: string, value: T): void } - interface Dao { + interface JSONMap { /** - * TotalAdmins returns the number of existing admin records. + * MarshalJSON implements the [json.Marshaler] interface. */ - totalAdmins(): number + marshalJSON(): string|Array } - interface Dao { + interface JSONMap { /** - * IsAdminEmailUnique checks if the provided email address is not - * already in use by other admins. + * String returns the string representation of the current json map. */ - isAdminEmailUnique(email: string, ...excludeIds: string[]): boolean + string(): string } - interface Dao { + interface JSONMap { /** - * DeleteAdmin deletes the provided Admin model. - * - * Returns an error if there is only 1 admin. + * Value implements the [driver.Valuer] interface. */ - deleteAdmin(admin: models.Admin): void + value(): any } - interface Dao { + interface JSONMap { /** - * SaveAdmin upserts the provided Admin model. + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current JSONMap[T] instance. */ - saveAdmin(admin: models.Admin): void + scan(value: any): void } /** - * Dao handles various db operations. - * - * You can think of Dao as a repository and service layer in one. + * JSONRaw defines a json value type that is safe for db read/write. */ - interface Dao { + interface JSONRaw extends Array{} + interface JSONRaw { /** - * MaxLockRetries specifies the default max "database is locked" auto retry attempts. + * String returns the current JSONRaw instance as a json encoded string. */ - maxLockRetries: number - /** - * 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: (eventDao: Dao, m: models.Model, action: () => void) => void - afterCreateFunc: (eventDao: Dao, m: models.Model) => void - beforeUpdateFunc: (eventDao: Dao, m: models.Model, action: () => void) => void - afterUpdateFunc: (eventDao: Dao, m: models.Model) => void - beforeDeleteFunc: (eventDao: Dao, m: models.Model, action: () => void) => void - afterDeleteFunc: (eventDao: Dao, m: models.Model) => void + string(): string } - interface Dao { + interface JSONRaw { /** - * 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. + * MarshalJSON implements the [json.Marshaler] interface. */ - db(): dbx.Builder + marshalJSON(): string|Array } - interface Dao { + interface JSONRaw { /** - * 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. + * UnmarshalJSON implements the [json.Unmarshaler] interface. */ - concurrentDB(): dbx.Builder + unmarshalJSON(b: string|Array): void } - interface Dao { + interface JSONRaw { /** - * 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. + * Value implements the [driver.Valuer] interface. */ - nonconcurrentDB(): dbx.Builder + value(): any } - interface Dao { + interface JSONRaw { /** - * Clone returns a new Dao with the same configuration options as the current one. + * Scan implements [sql.Scanner] interface to scan the provided value + * into the current JSONRaw instance. */ - clone(): (Dao | undefined) + scan(value: any): void } - interface Dao { +} + +namespace auth { + /** + * @todo refactor and consider replace with a plain struct + * + * Provider defines a common interface for an OAuth2 client. + */ + interface Provider { + [key:string]: any; /** - * WithoutHooks returns a new Dao with the same configuration options - * as the current one, but without create/update/delete hooks. + * @todo temp backport + * + * Order returns the sorting order of the provider usually used in the auth methods list response. */ - withoutHooks(): (Dao | undefined) - } - interface Dao { + logo(): string /** - * ModelQuery creates a new preconfigured select query with preset - * SELECT, FROM and other common fields based on the provided model. + * @todo temp backport + * + * Order returns the sorting order of the provider usually used in the auth methods list response. */ - modelQuery(m: models.Model): (dbx.SelectQuery | undefined) - } - interface Dao { + order(): number /** - * FindById finds a single db record with the specified id and - * scans the result into m. + * Context returns the context associated with the provider (if any). */ - findById(m: models.Model, id: string): void - } - interface Dao { + context(): context.Context /** - * RunInTransaction wraps fn into a transaction. - * - * It is safe to nest RunInTransaction calls as long as you use the txDao. + * SetContext assigns the specified context to the current provider. */ - runInTransaction(fn: (txDao: Dao) => void): void - } - interface Dao { + setContext(ctx: context.Context): void /** - * Delete deletes the provided model. + * PKCE indicates whether the provider can use the PKCE flow. */ - delete(m: models.Model): void - } - interface Dao { + pkce(): boolean /** - * 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(). + * SetPKCE toggles the state whether the provider can use the PKCE flow or not. */ - save(m: models.Model): void - } - interface Dao { + setPKCE(enable: boolean): void /** - * CollectionQuery returns a new Collection select query. + * DisplayName usually returns provider name as it is officially written + * and it could be used directly in the UI. */ - collectionQuery(): (dbx.SelectQuery | undefined) - } - interface Dao { + displayName(): string /** - * FindCollectionsByType finds all collections by the given type. + * SetDisplayName sets the provider's display name. */ - findCollectionsByType(collectionType: string): Array<(models.Collection | undefined)> - } - interface Dao { + setDisplayName(displayName: string): void /** - * FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id. + * Scopes returns the provider access permissions that will be requested. */ - findCollectionByNameOrId(nameOrId: string): (models.Collection | undefined) - } - interface Dao { + scopes(): Array /** - * 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. + * SetScopes sets the provider access permissions that will be requested later. */ - isCollectionNameUnique(name: string, ...excludeIds: string[]): boolean - } - interface Dao { + setScopes(scopes: Array): void /** - * 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. + * ClientId returns the provider client's app ID. */ - findCollectionReferences(collection: models.Collection, ...excludeIds: string[]): _TygojaDict - } - interface Dao { + clientId(): string /** - * 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 + * SetClientId sets the provider client's ID. */ - deleteCollection(collection: models.Collection): void - } - interface Dao { + setClientId(clientId: string): void /** - * 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(). + * ClientSecret returns the provider client's app secret. */ - saveCollection(collection: models.Collection): void - } - interface Dao { + clientSecret(): string /** - * 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]. + * SetClientSecret sets the provider client's app secret. */ - importCollections(importedCollections: Array<(models.Collection | undefined)>, deleteMissing: boolean, afterSync: (txDao: Dao, mappedImported: _TygojaDict) => void): void - } - interface Dao { + setClientSecret(secret: string): void /** - * ExternalAuthQuery returns a new ExternalAuth select query. + * RedirectURL returns the end address to redirect the user + * going through the OAuth flow. */ - externalAuthQuery(): (dbx.SelectQuery | undefined) - } - interface Dao { + redirectURL(): string /** - * FindAllExternalAuthsByRecord returns all ExternalAuth models - * linked to the provided auth record. + * SetRedirectURL sets the provider's RedirectURL. */ - findAllExternalAuthsByRecord(authRecord: models.Record): Array<(models.ExternalAuth | undefined)> - } - interface Dao { + setRedirectURL(url: string): void /** - * FindExternalAuthByProvider returns the first available - * ExternalAuth model for the specified provider and providerId. + * AuthURL returns the provider's authorization service url. */ - findExternalAuthByProvider(provider: string): (models.ExternalAuth | undefined) - } - interface Dao { + authURL(): string /** - * FindExternalAuthByRecordAndProvider returns the first available - * ExternalAuth model for the specified record data and provider. + * SetAuthURL sets the provider's AuthURL. */ - findExternalAuthByRecordAndProvider(authRecord: models.Record, provider: string): (models.ExternalAuth | undefined) - } - interface Dao { + setAuthURL(url: string): void /** - * SaveExternalAuth upserts the provided ExternalAuth model. + * TokenURL returns the provider's token exchange service url. */ - saveExternalAuth(model: models.ExternalAuth): void - } - interface Dao { + tokenURL(): string /** - * DeleteExternalAuth deletes the provided ExternalAuth model. + * SetTokenURL sets the provider's TokenURL. */ - deleteExternalAuth(model: models.ExternalAuth): void - } - interface Dao { + setTokenURL(url: string): void /** - * ParamQuery returns a new Param select query. + * UserInfoURL returns the provider's user info api url. */ - paramQuery(): (dbx.SelectQuery | undefined) - } - interface Dao { + userInfoURL(): string /** - * FindParamByKey finds the first Param model with the provided key. + * SetUserInfoURL sets the provider's UserInfoURL. */ - findParamByKey(key: string): (models.Param | undefined) - } - interface Dao { + setUserInfoURL(url: string): void /** - * 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. + * Extra returns a shallow copy of any custom config data + * that the provider may need. */ - saveParam(key: string, value: any, ...optEncryptionKey: string[]): void - } - interface Dao { + extra(): _TygojaDict /** - * DeleteParam deletes the provided Param model. + * SetExtra updates the provider's custom config data. */ - deleteParam(param: models.Param): void - } - interface Dao { + setExtra(data: _TygojaDict): void /** - * 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. + * Client returns an http client using the provided token. */ - recordQuery(collectionModelOrIdentifier: any): (dbx.SelectQuery | undefined) - } - interface Dao { + client(token: oauth2.Token): (any) /** - * FindRecordById finds the Record model by its id. + * BuildAuthURL returns a URL to the provider's consent page + * that asks for permissions for the required scopes explicitly. */ - findRecordById(collectionNameOrId: string, recordId: string, ...optFilters: ((q: dbx.SelectQuery) => void)[]): (models.Record | undefined) - } - interface Dao { + buildAuthURL(state: string, ...opts: oauth2.AuthCodeOption[]): string /** - * FindRecordsByIds finds all Record models by the provided ids. - * If no records are found, returns an empty slice. + * FetchToken converts an authorization code to token. */ - findRecordsByIds(collectionNameOrId: string, recordIds: Array, ...optFilters: ((q: dbx.SelectQuery) => void)[]): Array<(models.Record | undefined)> - } - interface Dao { + fetchToken(code: string, ...opts: oauth2.AuthCodeOption[]): (oauth2.Token) /** - * @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) - * ``` + * FetchRawUserInfo requests and marshalizes into `result` the + * the OAuth user api response. */ - findRecordsByExpr(collectionNameOrId: string, ...exprs: dbx.Expression[]): Array<(models.Record | undefined)> - } - interface Dao { + fetchRawUserInfo(token: oauth2.Token): string|Array /** - * FindFirstRecordByData returns the first found record matching - * the provided key-value pair. + * FetchAuthUser is similar to FetchRawUserInfo, but normalizes and + * marshalizes the user api response into a standardized AuthUser struct. */ - findFirstRecordByData(collectionNameOrId: string, key: string, value: any): (models.Record | undefined) + fetchAuthUser(token: oauth2.Token): (AuthUser) } - interface Dao { + /** + * AuthUser defines a standardized OAuth2 user data structure. + */ + interface AuthUser { + expiry: types.DateTime + rawUser: _TygojaDict + id: string + name: string + username: string + avatarURL: string + accessToken: string + refreshToken: string /** - * 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: + * The VERIFIED OAuth2 account email. * - * ``` - * dao.FindRecordsByFilter( - * "posts", - * "title ~ {:title} && visible = {:visible}", - * "-created", - * 10, - * 0, - * dbx.Params{"title": "lorem ipsum", "visible": true} - * ) - * ``` + * It must be empty if the provider is not able to verify the email ownership. */ - findRecordsByFilter(collectionNameOrId: string, filter: string, sort: string, limit: number, offset: number, ...params: dbx.Params[]): Array<(models.Record | undefined)> - } - interface Dao { + email: string /** - * 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"}) - * ``` + * @todo + * deprecated: use AvatarURL instead + * AvatarUrl will be removed after dropping v0.22 support */ - findFirstRecordByFilter(collectionNameOrId: string, filter: string, ...params: dbx.Params[]): (models.Record | undefined) + avatarUrl: string } - interface Dao { + interface AuthUser { /** - * 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. + * MarshalJSON implements the [json.Marshaler] interface. * - * 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"}` + * @todo remove after dropping v0.22 support */ - isRecordValueUnique(collectionNameOrId: string, key: string, value: any, ...excludeIds: string[]): boolean + marshalJSON(): string|Array + } +} + +/** + * Package slog provides structured logging, + * in which log records include a message, + * a severity level, and various other attributes + * expressed as key-value pairs. + * + * It defines a type, [Logger], + * which provides several methods (such as [Logger.Info] and [Logger.Error]) + * for reporting events of interest. + * + * Each Logger is associated with a [Handler]. + * A Logger output method creates a [Record] from the method arguments + * and passes it to the Handler, which decides how to handle it. + * There is a default Logger accessible through top-level functions + * (such as [Info] and [Error]) that call the corresponding Logger methods. + * + * A log record consists of a time, a level, a message, and a set of key-value + * pairs, where the keys are strings and the values may be of any type. + * As an example, + * + * ``` + * slog.Info("hello", "count", 3) + * ``` + * + * creates a record containing the time of the call, + * a level of Info, the message "hello", and a single + * pair with key "count" and value 3. + * + * The [Info] top-level function calls the [Logger.Info] method on the default Logger. + * In addition to [Logger.Info], there are methods for Debug, Warn and Error levels. + * Besides these convenience methods for common levels, + * there is also a [Logger.Log] method which takes the level as an argument. + * Each of these methods has a corresponding top-level function that uses the + * default logger. + * + * The default handler formats the log record's message, time, level, and attributes + * as a string and passes it to the [log] package. + * + * ``` + * 2022/11/08 15:28:26 INFO hello count=3 + * ``` + * + * For more control over the output format, create a logger with a different handler. + * This statement uses [New] to create a new logger with a [TextHandler] + * that writes structured records in text form to standard error: + * + * ``` + * logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + * ``` + * + * [TextHandler] output is a sequence of key=value pairs, easily and unambiguously + * parsed by machine. This statement: + * + * ``` + * logger.Info("hello", "count", 3) + * ``` + * + * produces this output: + * + * ``` + * time=2022-11-08T15:28:26.000-05:00 level=INFO msg=hello count=3 + * ``` + * + * The package also provides [JSONHandler], whose output is line-delimited JSON: + * + * ``` + * logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + * logger.Info("hello", "count", 3) + * ``` + * + * produces this output: + * + * ``` + * {"time":"2022-11-08T15:28:26.000000000-05:00","level":"INFO","msg":"hello","count":3} + * ``` + * + * Both [TextHandler] and [JSONHandler] can be configured with [HandlerOptions]. + * There are options for setting the minimum level (see Levels, below), + * displaying the source file and line of the log call, and + * modifying attributes before they are logged. + * + * Setting a logger as the default with + * + * ``` + * slog.SetDefault(logger) + * ``` + * + * will cause the top-level functions like [Info] to use it. + * [SetDefault] also updates the default logger used by the [log] package, + * so that existing applications that use [log.Printf] and related functions + * will send log records to the logger's handler without needing to be rewritten. + * + * Some attributes are common to many log calls. + * For example, you may wish to include the URL or trace identifier of a server request + * with all log events arising from the request. + * Rather than repeat the attribute with every log call, you can use [Logger.With] + * to construct a new Logger containing the attributes: + * + * ``` + * logger2 := logger.With("url", r.URL) + * ``` + * + * The arguments to With are the same key-value pairs used in [Logger.Info]. + * The result is a new Logger with the same handler as the original, but additional + * attributes that will appear in the output of every call. + * + * # Levels + * + * A [Level] is an integer representing the importance or severity of a log event. + * The higher the level, the more severe the event. + * This package defines constants for the most common levels, + * but any int can be used as a level. + * + * In an application, you may wish to log messages only at a certain level or greater. + * One common configuration is to log messages at Info or higher levels, + * suppressing debug logging until it is needed. + * The built-in handlers can be configured with the minimum level to output by + * setting [HandlerOptions.Level]. + * The program's `main` function typically does this. + * The default value is LevelInfo. + * + * Setting the [HandlerOptions.Level] field to a [Level] value + * fixes the handler's minimum level throughout its lifetime. + * Setting it to a [LevelVar] allows the level to be varied dynamically. + * A LevelVar holds a Level and is safe to read or write from multiple + * goroutines. + * To vary the level dynamically for an entire program, first initialize + * a global LevelVar: + * + * ``` + * var programLevel = new(slog.LevelVar) // Info by default + * ``` + * + * Then use the LevelVar to construct a handler, and make it the default: + * + * ``` + * h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: programLevel}) + * slog.SetDefault(slog.New(h)) + * ``` + * + * Now the program can change its logging level with a single statement: + * + * ``` + * programLevel.Set(slog.LevelDebug) + * ``` + * + * # Groups + * + * Attributes can be collected into groups. + * A group has a name that is used to qualify the names of its attributes. + * How this qualification is displayed depends on the handler. + * [TextHandler] separates the group and attribute names with a dot. + * [JSONHandler] treats each group as a separate JSON object, with the group name as the key. + * + * Use [Group] to create a Group attribute from a name and a list of key-value pairs: + * + * ``` + * slog.Group("request", + * "method", r.Method, + * "url", r.URL) + * ``` + * + * TextHandler would display this group as + * + * ``` + * request.method=GET request.url=http://example.com + * ``` + * + * JSONHandler would display it as + * + * ``` + * "request":{"method":"GET","url":"http://example.com"} + * ``` + * + * Use [Logger.WithGroup] to qualify all of a Logger's output + * with a group name. Calling WithGroup on a Logger results in a + * new Logger with the same Handler as the original, but with all + * its attributes qualified by the group name. + * + * This can help prevent duplicate attribute keys in large systems, + * where subsystems might use the same keys. + * Pass each subsystem a different Logger with its own group name so that + * potential duplicates are qualified: + * + * ``` + * logger := slog.Default().With("id", systemID) + * parserLogger := logger.WithGroup("parser") + * parseInput(input, parserLogger) + * ``` + * + * When parseInput logs with parserLogger, its keys will be qualified with "parser", + * so even if it uses the common key "id", the log line will have distinct keys. + * + * # Contexts + * + * Some handlers may wish to include information from the [context.Context] that is + * available at the call site. One example of such information + * is the identifier for the current span when tracing is enabled. + * + * The [Logger.Log] and [Logger.LogAttrs] methods take a context as a first + * argument, as do their corresponding top-level functions. + * + * Although the convenience methods on Logger (Info and so on) and the + * corresponding top-level functions do not take a context, the alternatives ending + * in "Context" do. For example, + * + * ``` + * slog.InfoContext(ctx, "message") + * ``` + * + * It is recommended to pass a context to an output method if one is available. + * + * # Attrs and Values + * + * An [Attr] is a key-value pair. The Logger output methods accept Attrs as well as + * alternating keys and values. The statement + * + * ``` + * slog.Info("hello", slog.Int("count", 3)) + * ``` + * + * behaves the same as + * + * ``` + * slog.Info("hello", "count", 3) + * ``` + * + * There are convenience constructors for [Attr] such as [Int], [String], and [Bool] + * for common types, as well as the function [Any] for constructing Attrs of any + * type. + * + * The value part of an Attr is a type called [Value]. + * Like an [any], a Value can hold any Go value, + * but it can represent typical values, including all numbers and strings, + * without an allocation. + * + * For the most efficient log output, use [Logger.LogAttrs]. + * It is similar to [Logger.Log] but accepts only Attrs, not alternating + * keys and values; this allows it, too, to avoid allocation. + * + * The call + * + * ``` + * logger.LogAttrs(ctx, slog.LevelInfo, "hello", slog.Int("count", 3)) + * ``` + * + * is the most efficient way to achieve the same output as + * + * ``` + * slog.InfoContext(ctx, "hello", "count", 3) + * ``` + * + * # Customizing a type's logging behavior + * + * If a type implements the [LogValuer] interface, the [Value] returned from its LogValue + * method is used for logging. You can use this to control how values of the type + * appear in logs. For example, you can redact secret information like passwords, + * or gather a struct's fields in a Group. See the examples under [LogValuer] for + * details. + * + * A LogValue method may return a Value that itself implements [LogValuer]. The [Value.Resolve] + * method handles these cases carefully, avoiding infinite loops and unbounded recursion. + * Handler authors and others may wish to use [Value.Resolve] instead of calling LogValue directly. + * + * # Wrapping output methods + * + * The logger functions use reflection over the call stack to find the file name + * and line number of the logging call within the application. This can produce + * incorrect source information for functions that wrap slog. For instance, if you + * define this function in file mylog.go: + * + * ``` + * func Infof(logger *slog.Logger, format string, args ...any) { + * logger.Info(fmt.Sprintf(format, args...)) + * } + * ``` + * + * and you call it like this in main.go: + * + * ``` + * Infof(slog.Default(), "hello, %s", "world") + * ``` + * + * then slog will report the source file as mylog.go, not main.go. + * + * A correct implementation of Infof will obtain the source location + * (pc) and pass it to NewRecord. + * The Infof function in the package-level example called "wrapping" + * demonstrates how to do this. + * + * # Working with Records + * + * Sometimes a Handler will need to modify a Record + * before passing it on to another Handler or backend. + * A Record contains a mixture of simple public fields (e.g. Time, Level, Message) + * and hidden fields that refer to state (such as attributes) indirectly. This + * means that modifying a simple copy of a Record (e.g. by calling + * [Record.Add] or [Record.AddAttrs] to add attributes) + * may have unexpected effects on the original. + * Before modifying a Record, use [Record.Clone] to + * create a copy that shares no state with the original, + * or create a new Record with [NewRecord] + * and build up its Attrs by traversing the old ones with [Record.Attrs]. + * + * # Performance considerations + * + * If profiling your application demonstrates that logging is taking significant time, + * the following suggestions may help. + * + * If many log lines have a common attribute, use [Logger.With] to create a Logger with + * that attribute. The built-in handlers will format that attribute only once, at the + * call to [Logger.With]. The [Handler] interface is designed to allow that optimization, + * and a well-written Handler should take advantage of it. + * + * The arguments to a log call are always evaluated, even if the log event is discarded. + * If possible, defer computation so that it happens only if the value is actually logged. + * For example, consider the call + * + * ``` + * slog.Info("starting request", "url", r.URL.String()) // may compute String unnecessarily + * ``` + * + * The URL.String method will be called even if the logger discards Info-level events. + * Instead, pass the URL directly: + * + * ``` + * slog.Info("starting request", "url", &r.URL) // calls URL.String only if needed + * ``` + * + * The built-in [TextHandler] will call its String method, but only + * if the log event is enabled. + * Avoiding the call to String also preserves the structure of the underlying value. + * For example [JSONHandler] emits the components of the parsed URL as a JSON object. + * If you want to avoid eagerly paying the cost of the String call + * without causing the handler to potentially inspect the structure of the value, + * wrap the value in a fmt.Stringer implementation that hides its Marshal methods. + * + * You can also use the [LogValuer] interface to avoid unnecessary work in disabled log + * calls. Say you need to log some expensive value: + * + * ``` + * slog.Debug("frobbing", "value", computeExpensiveValue(arg)) + * ``` + * + * Even if this line is disabled, computeExpensiveValue will be called. + * To avoid that, define a type implementing LogValuer: + * + * ``` + * type expensive struct { arg int } + * + * func (e expensive) LogValue() slog.Value { + * return slog.AnyValue(computeExpensiveValue(e.arg)) + * } + * ``` + * + * Then use a value of that type in log calls: + * + * ``` + * slog.Debug("frobbing", "value", expensive{arg}) + * ``` + * + * Now computeExpensiveValue will only be called when the line is enabled. + * + * The built-in handlers acquire a lock before calling [io.Writer.Write] + * to ensure that exactly one [Record] is written at a time in its entirety. + * Although each log record has a timestamp, + * the built-in handlers do not use that time to sort the written records. + * User-defined handlers are responsible for their own locking and sorting. + * + * # Writing a handler + * + * For a guide to writing a custom handler, see https://golang.org/s/slog-handler-guide. + */ +namespace slog { + // @ts-ignore + import loginternal = internal + /** + * A Logger records structured information about each call to its + * Log, Debug, Info, Warn, and Error methods. + * For each call, it creates a [Record] and passes it to a [Handler]. + * + * To create a new Logger, call [New] or a Logger method + * that begins "With". + */ + interface Logger { } - interface Dao { + interface Logger { /** - * 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. + * Handler returns l's Handler. */ - findAuthRecordByToken(token: string, baseTokenKey: string): (models.Record | undefined) + handler(): Handler } - interface Dao { + interface Logger { /** - * 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. + * With returns a Logger that includes the given attributes + * in each output operation. Arguments are converted to + * attributes as if by [Logger.Log]. */ - findAuthRecordByEmail(collectionNameOrId: string, email: string): (models.Record | undefined) + with(...args: any[]): (Logger) } - interface Dao { + interface Logger { /** - * FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive). + * WithGroup returns a Logger that starts a group, if name is non-empty. + * The keys of all attributes added to the Logger will be qualified by the given + * name. (How that qualification happens depends on the [Handler.WithGroup] + * method of the Logger's Handler.) * - * Returns an error if it is not an auth collection or the record is not found. + * If name is empty, WithGroup returns the receiver. */ - findAuthRecordByUsername(collectionNameOrId: string, username: string): (models.Record | undefined) + withGroup(name: string): (Logger) } - interface Dao { + interface Logger { /** - * 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. + * Enabled reports whether l emits log records at the given context and level. */ - suggestUniqueAuthRecordUsername(collectionNameOrId: string, baseUsername: string, ...excludeIds: string[]): string + enabled(ctx: context.Context, level: Level): boolean } - interface Dao { + interface Logger { /** - * 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: + * Log emits a log record with the current time and the given level and message. + * The Record's Attrs consist of the Logger's attributes followed by + * the Attrs specified by args. * + * The attribute arguments are processed as follows: * ``` - * 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 { ... } + * - If an argument is an Attr, it is used as is. + * - If an argument is a string and this is not the last argument, + * the following argument is treated as the value and the two are combined + * into an Attr. + * - Otherwise, the argument is treated as a value with key "!BADKEY". * ``` */ - canAccessRecord(record: models.Record, requestInfo: models.RequestInfo, accessRule: string): boolean + log(ctx: context.Context, level: Level, msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * 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(). + * LogAttrs is a more efficient version of [Logger.Log] that accepts only Attrs. */ - saveRecord(record: models.Record): void + logAttrs(ctx: context.Context, level: Level, msg: string, ...attrs: Attr[]): void } - interface Dao { + interface Logger { /** - * 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). + * Debug logs at [LevelDebug]. + * It uses [context.Background] internally; to specify the context, use + * [Logger.DebugContext]. */ - deleteRecord(record: models.Record): void + debug(msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * 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. + * DebugContext logs at [LevelDebug] with the given context. */ - expandRecord(record: models.Record, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict + debugContext(ctx: context.Context, msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * 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. + * Info logs at [LevelInfo]. + * It uses [context.Background] internally; to specify the context, use + * [Logger.InfoContext]. */ - expandRecords(records: Array<(models.Record | undefined)>, expands: Array, optFetchFunc: ExpandFetchFunc): _TygojaDict + info(msg: string, ...args: any[]): void } - // @ts-ignore - import validation = ozzo_validation - interface Dao { + interface Logger { /** - * 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. + * InfoContext logs at [LevelInfo] with the given context. */ - syncRecordTableSchema(newCollection: models.Collection, oldCollection: models.Collection): void + infoContext(ctx: context.Context, msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * RequestQuery returns a new Request logs select query. + * Warn logs at [LevelWarn]. + * It uses [context.Background] internally; to specify the context, use + * [Logger.WarnContext]. */ - requestQuery(): (dbx.SelectQuery | undefined) + warn(msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * FindRequestById finds a single Request log by its id. + * WarnContext logs at [LevelWarn] with the given context. */ - findRequestById(id: string): (models.Request | undefined) + warnContext(ctx: context.Context, msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * RequestsStats returns hourly grouped requests logs statistics. + * Error logs at [LevelError]. + * It uses [context.Background] internally; to specify the context, use + * [Logger.ErrorContext]. */ - requestsStats(expr: dbx.Expression): Array<(RequestsStatsItem | undefined)> + error(msg: string, ...args: any[]): void } - interface Dao { + interface Logger { /** - * DeleteOldRequests delete all requests that are created before createdBefore. + * ErrorContext logs at [LevelError] with the given context. */ - deleteOldRequests(createdBefore: time.Time): void + errorContext(ctx: context.Context, msg: string, ...args: any[]): void } - interface Dao { +} + +/** + * Package sql provides a generic interface around SQL (or SQL-like) + * databases. + * + * The sql package must be used in conjunction with a database driver. + * See https://golang.org/s/sqldrivers for a list of drivers. + * + * Drivers that do not support context cancellation will not return until + * after the query is completed. + * + * For usage examples, see the wiki page at + * https://golang.org/s/sqlwiki. + */ +namespace sql { + /** + * TxOptions holds the transaction options to be used in [DB.BeginTx]. + */ + interface TxOptions { /** - * SaveRequest upserts the provided Request model. + * Isolation is the transaction isolation level. + * If zero, the driver or database's default level is used. */ - saveRequest(request: models.Request): void + isolation: IsolationLevel + readOnly: boolean } - interface Dao { - /** - * 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. - */ - findSettings(...optEncryptionKey: string[]): (settings.Settings | undefined) + /** + * NullString represents a string that may be null. + * NullString implements the [Scanner] interface so + * it can be used as a scan destination: + * + * ``` + * var s NullString + * err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s) + * ... + * if s.Valid { + * // use s.String + * } else { + * // NULL value + * } + * ``` + */ + interface NullString { + string: string + valid: boolean // Valid is true if String is not NULL } - interface Dao { + interface NullString { /** - * SaveSettings persists the specified settings configuration. - * - * If optEncryptionKey is set, then the stored serialized value will be encrypted with it. + * Scan implements the [Scanner] interface. */ - saveSettings(newSettings: settings.Settings, ...optEncryptionKey: string[]): void + scan(value: any): void } - interface Dao { + interface NullString { /** - * HasTable checks if a table (or view) with the provided name exists (case insensitive). + * Value implements the [driver.Valuer] interface. */ - hasTable(tableName: string): boolean + value(): any } - interface Dao { - /** - * TableColumns returns all column names of a single table by its name. - */ - tableColumns(tableName: string): Array + /** + * DB is a database handle representing a pool of zero or more + * underlying connections. It's safe for concurrent use by multiple + * goroutines. + * + * The sql package creates and frees connections automatically; it + * also maintains a free pool of idle connections. If the database has + * a concept of per-connection state, such state can be reliably observed + * within a transaction ([Tx]) or connection ([Conn]). Once [DB.Begin] is called, the + * returned [Tx] is bound to a single connection. Once [Tx.Commit] or + * [Tx.Rollback] is called on the transaction, that transaction's + * connection is returned to [DB]'s idle connection pool. The pool size + * can be controlled with [DB.SetMaxIdleConns]. + */ + interface DB { } - interface Dao { + interface DB { /** - * TableInfo returns the `table_info` pragma result for the specified table. + * PingContext verifies a connection to the database is still alive, + * establishing a connection if necessary. */ - tableInfo(tableName: string): Array<(models.TableInfoRow | undefined)> + pingContext(ctx: context.Context): void } - interface Dao { + interface DB { /** - * TableIndexes returns a name grouped map with all non empty index of the specified table. + * Ping verifies a connection to the database is still alive, + * establishing a connection if necessary. * - * Note: This method doesn't return an error on nonexisting table. + * Ping uses [context.Background] internally; to specify the context, use + * [DB.PingContext]. */ - tableIndexes(tableName: string): _TygojaDict + ping(): void } - interface Dao { + interface DB { /** - * DeleteTable drops the specified table. - * - * This method is a no-op if a table with the provided name doesn't exist. + * Close closes the database and prevents new queries from starting. + * Close then waits for all queries that have started processing on the server + * to finish. * - * Be aware that this method is vulnerable to SQL injection and the - * "tableName" argument must come only from trusted input! - */ - deleteTable(tableName: string): void - } - interface Dao { - /** - * Vacuum executes VACUUM on the current dao.DB() instance in order to - * reclaim unused db disk space. + * It is rare to Close a [DB], as the [DB] handle is meant to be + * long-lived and shared between many goroutines. */ - vacuum(): void + close(): void } - interface Dao { + interface DB { /** - * DeleteView drops the specified view name. + * SetMaxIdleConns sets the maximum number of connections in the idle + * connection pool. * - * This method is a no-op if a view with the provided name doesn't exist. + * If MaxOpenConns is greater than 0 but less than the new MaxIdleConns, + * then the new MaxIdleConns will be reduced to match the MaxOpenConns limit. * - * Be aware that this method is vulnerable to SQL injection and the - * "name" argument must come only from trusted input! - */ - deleteView(name: string): void - } - interface Dao { - /** - * SaveView creates (or updates already existing) persistent SQL view. + * If n <= 0, no idle connections are retained. * - * Be aware that this method is vulnerable to SQL injection and the - * "selectQuery" argument must come only from trusted input! + * The default max idle connections is currently 2. This may change in + * a future release. */ - saveView(name: string, selectQuery: string): void + setMaxIdleConns(n: number): void } - interface Dao { + interface DB { /** - * CreateViewSchema creates a new view schema from the provided select query. + * SetMaxOpenConns sets the maximum number of open connections to the database. * - * There are some caveats: - * - The select query must have an "id" column. - * - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data. - */ - createViewSchema(selectQuery: string): schema.Schema - } - interface Dao { - /** - * FindRecordByViewFile returns the original models.Record of the - * provided view collection file. + * If MaxIdleConns is greater than 0 and the new MaxOpenConns is less than + * MaxIdleConns, then MaxIdleConns will be reduced to match the new + * MaxOpenConns limit. + * + * If n <= 0, then there is no limit on the number of open connections. + * The default is 0 (unlimited). */ - findRecordByViewFile(viewCollectionNameOrId: string, fileFieldName: string, filename: string): (models.Record | undefined) + setMaxOpenConns(n: number): void } -} - -/** - * Package core is the backbone of PocketBase. - * - * It defines the main PocketBase App interface and its base implementation. - */ -namespace core { - /** - * App defines the main PocketBase app interface. - */ - interface App { + interface DB { /** - * 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(). + * SetConnMaxLifetime sets the maximum amount of time a connection may be reused. * - * DB returns the default app database instance. - */ - db(): (dbx.DB | undefined) - /** - * Dao returns the default app Dao instance. + * Expired connections may be closed lazily before reuse. * - * 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. + * If d <= 0, connections are not closed due to a connection's age. */ - dao(): (daos.Dao | undefined) + setConnMaxLifetime(d: time.Duration): void + } + interface DB { /** - * 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(). + * SetConnMaxIdleTime sets the maximum amount of time a connection may be idle. * - * LogsDB returns the app logs database instance. - */ - logsDB(): (dbx.DB | undefined) - /** - * LogsDao returns the app logs Dao instance. + * Expired connections may be closed lazily before reuse. * - * 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 | undefined) - /** - * DataDir returns the app data directory path. - */ - dataDir(): string - /** - * EncryptionEnv returns the name of the app secret env key - * (used for settings encryption). - */ - encryptionEnv(): string - /** - * IsDebug returns whether the app is in debug mode - * (showing more detailed error logs, executed sql statements, etc.). - */ - isDebug(): boolean - /** - * Settings returns the loaded app settings. - */ - settings(): (settings.Settings | undefined) - /** - * Cache returns the app internal cache store. + * If d <= 0, connections are not closed due to a connection's idle time. */ - cache(): (store.Store | undefined) + setConnMaxIdleTime(d: time.Duration): void + } + interface DB { /** - * SubscriptionsBroker returns the app realtime subscriptions broker instance. + * Stats returns database statistics. */ - subscriptionsBroker(): (subscriptions.Broker | undefined) + stats(): DBStats + } + interface DB { /** - * NewMailClient creates and returns a configured app mail client. + * PrepareContext creates a prepared statement for later queries or executions. + * Multiple queries or executions may be run concurrently from the + * returned statement. + * The caller must call the statement's [*Stmt.Close] method + * when the statement is no longer needed. + * + * The provided context is used for the preparation of the statement, not for the + * execution of the statement. */ - newMailClient(): mailer.Mailer + prepareContext(ctx: context.Context, query: string): (Stmt) + } + interface DB { /** - * NewFilesystem creates and returns a configured filesystem.System instance - * for managing regular app files (eg. collection uploads). + * Prepare creates a prepared statement for later queries or executions. + * Multiple queries or executions may be run concurrently from the + * returned statement. + * The caller must call the statement's [*Stmt.Close] method + * when the statement is no longer needed. * - * NB! Make sure to call Close() on the returned result - * after you are done working with it. + * Prepare uses [context.Background] internally; to specify the context, use + * [DB.PrepareContext]. */ - newFilesystem(): (filesystem.System | undefined) + prepare(query: string): (Stmt) + } + interface DB { /** - * NewBackupsFilesystem creates and returns a configured filesystem.System instance - * for managing app backups. - * - * NB! Make sure to call Close() on the returned result - * after you are done working with it. + * ExecContext executes a query without returning any rows. + * The args are for any placeholder parameters in the query. */ - newBackupsFilesystem(): (filesystem.System | undefined) + execContext(ctx: context.Context, query: string, ...args: any[]): Result + } + interface DB { /** - * RefreshSettings reinitializes and reloads the stored application settings. + * Exec executes a query without returning any rows. + * The args are for any placeholder parameters in the query. + * + * Exec uses [context.Background] internally; to specify the context, use + * [DB.ExecContext]. */ - refreshSettings(): void + exec(query: string, ...args: any[]): Result + } + interface DB { /** - * IsBootstrapped checks if the application was initialized - * (aka. whether Bootstrap() was called). + * QueryContext executes a query that returns rows, typically a SELECT. + * The args are for any placeholder parameters in the query. */ - isBootstrapped(): boolean + queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows) + } + interface DB { /** - * Bootstrap takes care for initializing the application - * (open db connections, load settings, etc.). + * Query executes a query that returns rows, typically a SELECT. + * The args are for any placeholder parameters in the query. * - * It will call ResetBootstrapState() if the application was already bootstrapped. + * Query uses [context.Background] internally; to specify the context, use + * [DB.QueryContext]. */ - bootstrap(): void + query(query: string, ...args: any[]): (Rows) + } + interface DB { /** - * ResetBootstrapState takes care for releasing initialized app resources - * (eg. closing db connections). + * QueryRowContext executes a query that is expected to return at most one row. + * QueryRowContext always returns a non-nil value. Errors are deferred until + * [Row]'s Scan method is called. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, [*Row.Scan] scans the first selected row and discards + * the rest. */ - resetBootstrapState(): void + queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row) + } + interface DB { /** - * 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. + * QueryRow executes a query that is expected to return at most one row. + * QueryRow always returns a non-nil value. Errors are deferred until + * [Row]'s Scan method is called. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, [*Row.Scan] scans the first selected row and discards + * the rest. * - * Please refer to the godoc of the specific core.App implementation - * for details on the backup procedures. + * QueryRow uses [context.Background] internally; to specify the context, use + * [DB.QueryRowContext]. */ - createBackup(ctx: context.Context, name: string): void + queryRow(query: string, ...args: any[]): (Row) + } + interface DB { /** - * RestoreBackup restores the backup with the specified name and restarts - * the current running application process. - * - * 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. + * BeginTx starts a transaction. * - * Please refer to the godoc of the specific core.App implementation - * for details on the restore procedures. + * The provided context is used until the transaction is committed or rolled back. + * If the context is canceled, the sql package will roll back + * the transaction. [Tx.Commit] will return an error if the context provided to + * BeginTx is canceled. * - * NB! This feature is experimental and currently is expected to work only on UNIX based systems. + * The provided [TxOptions] is optional and may be nil if defaults should be used. + * If a non-default isolation level is used that the driver doesn't support, + * an error will be returned. */ - restoreBackup(ctx: context.Context, name: string): void + beginTx(ctx: context.Context, opts: TxOptions): (Tx) + } + interface DB { /** - * Restart restarts the current running application process. + * Begin starts a transaction. The default isolation level is dependent on + * the driver. * - * Currently it is relying on execve so it is supported only on UNIX based systems. - */ - restart(): void - /** - * OnBeforeBootstrap hook is triggered before initializing the main - * application resources (eg. before db open and initial settings load). - */ - onBeforeBootstrap(): (hook.Hook | undefined) - /** - * OnAfterBootstrap hook is triggered after initializing the main - * application resources (eg. after db open and initial settings load). + * Begin uses [context.Background] internally; to specify the context, use + * [DB.BeginTx]. */ - onAfterBootstrap(): (hook.Hook | undefined) + begin(): (Tx) + } + interface DB { /** - * OnBeforeServe hook is triggered before serving the internal router (echo), - * allowing you to adjust its options and attach new routes or middlewares. + * Driver returns the database's underlying driver. */ - onBeforeServe(): (hook.Hook | undefined) + driver(): any + } + interface DB { /** - * 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. + * Conn returns a single connection by either opening a new connection + * or returning an existing connection from the connection pool. Conn will + * block until either a connection is returned or ctx is canceled. + * Queries run on the same Conn will be run in the same database session. + * + * Every Conn must be returned to the database pool after use by + * calling [Conn.Close]. */ - onBeforeApiError(): (hook.Hook | undefined) + conn(ctx: context.Context): (Conn) + } + /** + * Tx is an in-progress database transaction. + * + * A transaction must end with a call to [Tx.Commit] or [Tx.Rollback]. + * + * After a call to [Tx.Commit] or [Tx.Rollback], all operations on the + * transaction fail with [ErrTxDone]. + * + * The statements prepared for a transaction by calling + * the transaction's [Tx.Prepare] or [Tx.Stmt] methods are closed + * by the call to [Tx.Commit] or [Tx.Rollback]. + */ + interface Tx { + } + interface Tx { /** - * 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. + * Commit commits the transaction. */ - onAfterApiError(): (hook.Hook | undefined) + commit(): void + } + interface Tx { /** - * OnTerminate hook is triggered when the app is in the process - * of being terminated (eg. on SIGTERM signal). + * Rollback aborts the transaction. */ - onTerminate(): (hook.Hook | undefined) + rollback(): void + } + interface Tx { /** - * OnModelBeforeCreate hook is triggered before inserting a new - * model in the DB, allowing you to modify or validate the stored data. + * PrepareContext creates a prepared statement for use within a transaction. * - * 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 | undefined) - /** - * OnModelAfterCreate hook is triggered after successfully - * inserting a new model in the DB. + * The returned statement operates within the transaction and will be closed + * when the transaction has been committed or rolled back. * - * 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 | undefined) - /** - * OnModelBeforeUpdate hook is triggered before updating existing - * model in the DB, allowing you to modify or validate the stored data. + * To use an existing prepared statement on this transaction, see [Tx.Stmt]. * - * 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. + * The provided context will be used for the preparation of the context, not + * for the execution of the returned statement. The returned statement + * will run in the transaction context. */ - onModelBeforeUpdate(...tags: string[]): (hook.TaggedHook | undefined) + prepareContext(ctx: context.Context, query: string): (Stmt) + } + interface Tx { /** - * OnModelAfterUpdate hook is triggered after successfully updating - * existing model in the DB. + * Prepare creates a prepared statement for use within a transaction. + * + * The returned statement operates within the transaction and will be closed + * when the transaction has been committed or rolled back. * - * 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. + * To use an existing prepared statement on this transaction, see [Tx.Stmt]. + * + * Prepare uses [context.Background] internally; to specify the context, use + * [Tx.PrepareContext]. */ - onModelAfterUpdate(...tags: string[]): (hook.TaggedHook | undefined) + prepare(query: string): (Stmt) + } + interface Tx { /** - * OnModelBeforeDelete hook is triggered before deleting an - * existing model from the DB. + * StmtContext returns a transaction-specific prepared statement from + * an existing statement. + * + * Example: + * + * ``` + * updateMoney, err := db.Prepare("UPDATE balance SET money=money+? WHERE id=?") + * ... + * tx, err := db.Begin() + * ... + * res, err := tx.StmtContext(ctx, updateMoney).Exec(123.45, 98293203) + * ``` + * + * The provided context is used for the preparation of the statement, not for the + * execution of the statement. * - * 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. + * The returned statement operates within the transaction and will be closed + * when the transaction has been committed or rolled back. */ - onModelBeforeDelete(...tags: string[]): (hook.TaggedHook | undefined) + stmtContext(ctx: context.Context, stmt: Stmt): (Stmt) + } + interface Tx { /** - * OnModelAfterDelete hook is triggered after successfully deleting an - * existing model from the DB. + * Stmt returns a transaction-specific prepared statement from + * an existing statement. + * + * Example: + * + * ``` + * updateMoney, err := db.Prepare("UPDATE balance SET money=money+? WHERE id=?") + * ... + * tx, err := db.Begin() + * ... + * res, err := tx.Stmt(updateMoney).Exec(123.45, 98293203) + * ``` * - * 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. + * The returned statement operates within the transaction and will be closed + * when the transaction has been committed or rolled back. + * + * Stmt uses [context.Background] internally; to specify the context, use + * [Tx.StmtContext]. */ - onModelAfterDelete(...tags: string[]): (hook.TaggedHook | undefined) + stmt(stmt: Stmt): (Stmt) + } + interface Tx { /** - * 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. + * ExecContext executes a query that doesn't return rows. + * For example: an INSERT and UPDATE. */ - onMailerBeforeAdminResetPasswordSend(): (hook.Hook | undefined) + execContext(ctx: context.Context, query: string, ...args: any[]): Result + } + interface Tx { /** - * OnMailerAfterAdminResetPasswordSend hook is triggered after - * admin password reset email was successfully sent. + * Exec executes a query that doesn't return rows. + * For example: an INSERT and UPDATE. + * + * Exec uses [context.Background] internally; to specify the context, use + * [Tx.ExecContext]. */ - onMailerAfterAdminResetPasswordSend(): (hook.Hook | undefined) + exec(query: string, ...args: any[]): Result + } + interface Tx { /** - * 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. - * - * 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. + * QueryContext executes a query that returns rows, typically a SELECT. */ - onMailerBeforeRecordResetPasswordSend(...tags: string[]): (hook.TaggedHook | undefined) + queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows) + } + interface Tx { /** - * OnMailerAfterRecordResetPasswordSend hook is triggered after - * an auth record password reset email was successfully sent. + * Query executes a query that returns rows, typically a SELECT. * - * 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. + * Query uses [context.Background] internally; to specify the context, use + * [Tx.QueryContext]. */ - onMailerAfterRecordResetPasswordSend(...tags: string[]): (hook.TaggedHook | undefined) + query(query: string, ...args: any[]): (Rows) + } + interface Tx { /** - * 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. - * - * 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. + * QueryRowContext executes a query that is expected to return at most one row. + * QueryRowContext always returns a non-nil value. Errors are deferred until + * [Row]'s Scan method is called. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, the [*Row.Scan] scans the first selected row and discards + * the rest. */ - onMailerBeforeRecordVerificationSend(...tags: string[]): (hook.TaggedHook | undefined) + queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row) + } + interface Tx { /** - * OnMailerAfterRecordVerificationSend hook is triggered after a - * verification email was successfully sent to an auth record. + * QueryRow executes a query that is expected to return at most one row. + * QueryRow always returns a non-nil value. Errors are deferred until + * [Row]'s Scan method is called. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, the [*Row.Scan] scans the first selected row and discards + * the rest. * - * 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. + * QueryRow uses [context.Background] internally; to specify the context, use + * [Tx.QueryRowContext]. */ - onMailerAfterRecordVerificationSend(...tags: string[]): (hook.TaggedHook | undefined) + queryRow(query: string, ...args: any[]): (Row) + } + /** + * Stmt is a prepared statement. + * A Stmt is safe for concurrent use by multiple goroutines. + * + * If a Stmt is prepared on a [Tx] or [Conn], it will be bound to a single + * underlying connection forever. If the [Tx] or [Conn] closes, the Stmt will + * become unusable and all operations will return an error. + * If a Stmt is prepared on a [DB], it will remain usable for the lifetime of the + * [DB]. When the Stmt needs to execute on a new underlying connection, it will + * prepare itself on the new connection automatically. + */ + interface Stmt { + } + interface Stmt { /** - * 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. - * - * 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. + * ExecContext executes a prepared statement with the given arguments and + * returns a [Result] summarizing the effect of the statement. */ - onMailerBeforeRecordChangeEmailSend(...tags: string[]): (hook.TaggedHook | undefined) + execContext(ctx: context.Context, ...args: any[]): Result + } + interface Stmt { /** - * OnMailerAfterRecordChangeEmailSend hook is triggered after a - * verification email was successfully sent to an auth record. + * Exec executes a prepared statement with the given arguments and + * returns a [Result] summarizing the effect of the statement. * - * 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. + * Exec uses [context.Background] internally; to specify the context, use + * [Stmt.ExecContext]. */ - onMailerAfterRecordChangeEmailSend(...tags: string[]): (hook.TaggedHook | undefined) + exec(...args: any[]): Result + } + interface Stmt { /** - * OnRealtimeConnectRequest hook is triggered right before establishing - * the SSE client connection. + * QueryContext executes a prepared query statement with the given arguments + * and returns the query results as a [*Rows]. */ - onRealtimeConnectRequest(): (hook.Hook | undefined) + queryContext(ctx: context.Context, ...args: any[]): (Rows) + } + interface Stmt { /** - * OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted - * SSE client connection. + * Query executes a prepared query statement with the given arguments + * and returns the query results as a *Rows. + * + * Query uses [context.Background] internally; to specify the context, use + * [Stmt.QueryContext]. */ - onRealtimeDisconnectRequest(): (hook.Hook | undefined) + query(...args: any[]): (Rows) + } + interface Stmt { /** - * OnRealtimeBeforeMessage hook is triggered right before sending - * an SSE message to a client. - * - * Returning [hook.StopPropagation] will prevent sending the message. - * Returning any other non-nil error will close the realtime connection. + * QueryRowContext executes a prepared query statement with the given arguments. + * If an error occurs during the execution of the statement, that error will + * be returned by a call to Scan on the returned [*Row], which is always non-nil. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, the [*Row.Scan] scans the first selected row and discards + * the rest. */ - onRealtimeBeforeMessageSend(): (hook.Hook | undefined) + queryRowContext(ctx: context.Context, ...args: any[]): (Row) + } + interface Stmt { /** - * OnRealtimeBeforeMessage hook is triggered right after sending - * an SSE message to a client. + * QueryRow executes a prepared query statement with the given arguments. + * If an error occurs during the execution of the statement, that error will + * be returned by a call to Scan on the returned [*Row], which is always non-nil. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, the [*Row.Scan] scans the first selected row and discards + * the rest. + * + * Example usage: + * + * ``` + * var name string + * err := nameByUseridStmt.QueryRow(id).Scan(&name) + * ``` + * + * QueryRow uses [context.Background] internally; to specify the context, use + * [Stmt.QueryRowContext]. */ - onRealtimeAfterMessageSend(): (hook.Hook | undefined) + queryRow(...args: any[]): (Row) + } + interface Stmt { /** - * OnRealtimeBeforeSubscribeRequest hook is triggered before changing - * the client subscriptions, allowing you to further validate and - * modify the submitted change. + * Close closes the statement. */ - onRealtimeBeforeSubscribeRequest(): (hook.Hook | undefined) + close(): void + } + /** + * Rows is the result of a query. Its cursor starts before the first row + * of the result set. Use [Rows.Next] to advance from row to row. + */ + interface Rows { + } + interface Rows { /** - * OnRealtimeAfterSubscribeRequest hook is triggered after the client - * subscriptions were successfully changed. + * Next prepares the next result row for reading with the [Rows.Scan] method. It + * returns true on success, or false if there is no next result row or an error + * happened while preparing it. [Rows.Err] should be consulted to distinguish between + * the two cases. + * + * Every call to [Rows.Scan], even the first one, must be preceded by a call to [Rows.Next]. */ - onRealtimeAfterSubscribeRequest(): (hook.Hook | undefined) + next(): boolean + } + interface Rows { /** - * OnSettingsListRequest hook is triggered on each successful - * API Settings list request. + * NextResultSet prepares the next result set for reading. It reports whether + * there is further result sets, or false if there is no further result set + * or if there is an error advancing to it. The [Rows.Err] method should be consulted + * to distinguish between the two cases. * - * Could be used to validate or modify the response before - * returning it to the client. + * After calling NextResultSet, the [Rows.Next] method should always be called before + * scanning. If there are further result sets they may not have rows in the result + * set. */ - onSettingsListRequest(): (hook.Hook | undefined) + nextResultSet(): boolean + } + interface Rows { /** - * OnSettingsBeforeUpdateRequest hook is triggered before each API - * Settings update request (after request data load and before settings persistence). - * - * Could be used to additionally validate the request data or - * implement completely different persistence behavior. + * Err returns the error, if any, that was encountered during iteration. + * Err may be called after an explicit or implicit [Rows.Close]. */ - onSettingsBeforeUpdateRequest(): (hook.Hook | undefined) + err(): void + } + interface Rows { /** - * OnSettingsAfterUpdateRequest hook is triggered after each - * successful API Settings update request. + * Columns returns the column names. + * Columns returns an error if the rows are closed. */ - onSettingsAfterUpdateRequest(): (hook.Hook | undefined) + columns(): Array + } + interface Rows { /** - * 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. + * ColumnTypes returns column information such as column type, length, + * and nullable. Some information may not be available from some drivers. */ - onFileDownloadRequest(...tags: string[]): (hook.TaggedHook | undefined) + columnTypes(): Array<(ColumnType | undefined)> + } + interface Rows { /** - * OnFileBeforeTokenRequest hook is triggered before each file - * token API request. + * Scan copies the columns in the current row into the values pointed + * at by dest. The number of values in dest must be the same as the + * number of columns in [Rows]. * - * 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. + * Scan converts columns read from the database into the following + * common Go types and special types provided by the sql package: * - * 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 | undefined) - /** - * OnFileAfterTokenRequest hook is triggered after each - * successful file token API request. + * ``` + * *string + * *[]byte + * *int, *int8, *int16, *int32, *int64 + * *uint, *uint8, *uint16, *uint32, *uint64 + * *bool + * *float32, *float64 + * *any + * *RawBytes + * *Rows (cursor value) + * *time.Time + * *uuid.UUID + * any type implementing Scanner (see Scanner docs) + * ``` * - * 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 | undefined) - /** - * OnAdminsListRequest hook is triggered on each API Admins list request. + * In the most simple case, if the type of the value from the source + * column is an integer, bool or string type T and dest is of type *T, + * Scan simply assigns the value through the pointer. * - * Could be used to validate or modify the response before returning it to the client. - */ - onAdminsListRequest(): (hook.Hook | undefined) - /** - * OnAdminViewRequest hook is triggered on each API Admin view request. + * Scan also converts between string and numeric types, as long as no + * information would be lost. While Scan stringifies all numbers + * scanned from numeric database columns into *string, scans into + * numeric types are checked for overflow. For example, a float64 with + * value 300 or a string with value "300" can scan into a uint16, but + * not into a uint8, though float64(255) or "255" can scan into a + * uint8. One exception is that scans of some float64 numbers to + * strings may lose information when stringifying. In general, scan + * floating point columns into *float64. * - * Could be used to validate or modify the response before returning it to the client. - */ - onAdminViewRequest(): (hook.Hook | undefined) - /** - * OnAdminBeforeCreateRequest hook is triggered before each API - * Admin create request (after request data load and before model persistence). + * If a dest argument has type *[]byte, Scan saves in that argument a + * copy of the corresponding data. The copy is owned by the caller and + * can be modified and held indefinitely. The copy can be avoided by + * using an argument of type [*RawBytes] instead; see the documentation + * for [RawBytes] for restrictions on its use. * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. - */ - onAdminBeforeCreateRequest(): (hook.Hook | undefined) - /** - * OnAdminAfterCreateRequest hook is triggered after each - * successful API Admin create request. - */ - onAdminAfterCreateRequest(): (hook.Hook | undefined) - /** - * OnAdminBeforeUpdateRequest hook is triggered before each API - * Admin update request (after request data load and before model persistence). + * If an argument has type *any, Scan copies the value + * provided by the underlying driver without conversion. When scanning + * from a source value of type []byte to *interface{}, a copy of the + * slice is made and the caller owns the result. * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. - */ - onAdminBeforeUpdateRequest(): (hook.Hook | undefined) - /** - * OnAdminAfterUpdateRequest hook is triggered after each - * successful API Admin update request. - */ - onAdminAfterUpdateRequest(): (hook.Hook | undefined) - /** - * OnAdminBeforeDeleteRequest hook is triggered before each API - * Admin delete request (after model load and before actual deletion). + * Source values of type [time.Time] may be scanned into values of type + * *time.Time, *interface{}, *string, or *[]byte. When converting to + * the latter two, [time.RFC3339Nano] is used. * - * Could be used to additionally validate the request data or implement - * completely different delete behavior. - */ - onAdminBeforeDeleteRequest(): (hook.Hook | undefined) - /** - * OnAdminAfterDeleteRequest hook is triggered after each - * successful API Admin delete request. - */ - onAdminAfterDeleteRequest(): (hook.Hook | undefined) - /** - * OnAdminAuthRequest hook is triggered on each successful API Admin - * authentication request (sign-in, token refresh, etc.). + * Source values of type string and byte may be scanned into + * values of type [*uuid.UUID]. The source may contain the string + * representation of a UUID, or a 16-byte []byte. * - * Could be used to additionally validate or modify the - * authenticated admin data and token. - */ - onAdminAuthRequest(): (hook.Hook | undefined) - /** - * OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin - * auth with password API request (after request data load and before password validation). + * Source values of type bool may be scanned into types *bool, + * *interface{}, *string, *[]byte, or [*RawBytes]. + * + * For scanning into *bool, the source may be true, false, 1, 0, or + * string inputs parseable by [strconv.ParseBool]. + * + * Scan can also convert a cursor returned from a query, such as + * "select cursor(select * from my_table) from dual", into a + * [*Rows] value that can itself be scanned from. The parent + * select query will close any cursor [*Rows] if the parent [*Rows] is closed. * - * Could be used to implement for example a custom password validation - * or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]). + * If any of the first arguments implementing [Scanner] returns an error, + * that error will be wrapped in the returned error. */ - onAdminBeforeAuthWithPasswordRequest(): (hook.Hook | undefined) + scan(...dest: any[]): void + } + interface Rows { /** - * OnAdminAfterAuthWithPasswordRequest hook is triggered after each - * successful Admin auth with password API request. + * Close closes the [Rows], preventing further enumeration. If [Rows.Next] is called + * and returns false and there are no further result sets, + * the [Rows] are closed automatically and it will suffice to check the + * result of [Rows.Err]. Close is idempotent and does not affect the result of [Rows.Err]. */ - onAdminAfterAuthWithPasswordRequest(): (hook.Hook | undefined) + close(): void + } + /** + * A Result summarizes an executed SQL command. + */ + interface Result { + [key:string]: any; /** - * OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin - * 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. + * LastInsertId returns the integer generated by the database + * in response to a command. Typically this will be from an + * "auto increment" column when inserting a new row. Not all + * databases support this feature, and the syntax of such + * statements varies. */ - onAdminBeforeAuthRefreshRequest(): (hook.Hook | undefined) + lastInsertId(): number /** - * OnAdminAfterAuthRefreshRequest hook is triggered after each - * successful auth refresh API request (right after generating a new auth token). + * RowsAffected returns the number of rows affected by an + * update, insert, or delete. Not every database or database + * driver may support this. */ - onAdminAfterAuthRefreshRequest(): (hook.Hook | undefined) + rowsAffected(): number + } +} + +/** + * Package blob defines a lightweight abstration for interacting with + * various storage services (local filesystem, S3, etc.). + * + * NB! + * For compatibility with earlier PocketBase versions and to prevent + * unnecessary breaking changes, this package is based and implemented + * as a minimal, stripped down version of the previously used gocloud.dev/blob. + * While there is no promise that it won't diverge in the future to accommodate + * better some PocketBase specific use cases, currently it copies and + * tries to follow as close as possible the same implementations, + * conventions and rules for the key escaping/unescaping, blob read/write + * interfaces and struct options as gocloud.dev/blob, therefore the + * credits goes to the original Go Cloud Development Kit Authors. + */ +namespace blob { + /** + * ListObject represents a single blob returned from List. + */ + interface ListObject { /** - * OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin - * request password reset API request (after request data load and before sending the reset email). - * - * Could be used to additionally validate the request data or implement - * completely different password reset behavior. + * Key is the key for this blob. */ - onAdminBeforeRequestPasswordResetRequest(): (hook.Hook | undefined) + key: string /** - * OnAdminAfterRequestPasswordResetRequest hook is triggered after each - * successful request password reset API request. + * ModTime is the time the blob was last modified. */ - onAdminAfterRequestPasswordResetRequest(): (hook.Hook | undefined) + modTime: time.Time /** - * OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin - * confirm password reset API request (after request data load and before persistence). - * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. + * Size is the size of the blob's content in bytes. */ - onAdminBeforeConfirmPasswordResetRequest(): (hook.Hook | undefined) + size: number /** - * OnAdminAfterConfirmPasswordResetRequest hook is triggered after each - * successful confirm password reset API request. + * MD5 is an MD5 hash of the blob contents or nil if not available. */ - onAdminAfterConfirmPasswordResetRequest(): (hook.Hook | undefined) + md5: string|Array /** - * 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. + * IsDir indicates that this result represents a "directory" in the + * hierarchical namespace, ending in ListOptions.Delimiter. Key can be + * passed as ListOptions.Prefix to list items in the "directory". + * Fields other than Key and IsDir will not be set if IsDir is true. */ - onRecordAuthRequest(...tags: string[]): (hook.TaggedHook | undefined) + isDir: boolean + } + /** + * Attributes contains attributes about a blob. + */ + interface Attributes { /** - * OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record - * auth with password API request (after request data load and before password validation). - * - * 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. + * CacheControl specifies caching attributes that services may use + * when serving the blob. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control */ - onRecordBeforeAuthWithPasswordRequest(...tags: string[]): (hook.TaggedHook | undefined) + cacheControl: string /** - * OnRecordAfterAuthWithPasswordRequest hook is triggered after each - * successful Record auth with password 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. + * ContentDisposition specifies whether the blob content is expected to be + * displayed inline or as an attachment. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition */ - onRecordAfterAuthWithPasswordRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentDisposition: string /** - * OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record - * OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking). - * - * If the [RecordAuthWithOAuth2Event.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 [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. + * ContentEncoding specifies the encoding used for the blob's content, if any. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding */ - onRecordBeforeAuthWithOAuth2Request(...tags: string[]): (hook.TaggedHook | undefined) + contentEncoding: string /** - * OnRecordAfterAuthWithOAuth2Request hook is triggered after each - * successful Record OAuth2 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. + * ContentLanguage specifies the language used in the blob's content, if any. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language */ - onRecordAfterAuthWithOAuth2Request(...tags: string[]): (hook.TaggedHook | undefined) + contentLanguage: string /** - * OnRecordBeforeAuthRefreshRequest hook is triggered before 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. + * ContentType is the MIME type of the blob. It will not be empty. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type */ - onRecordBeforeAuthRefreshRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentType: string /** - * OnRecordAfterAuthRefreshRequest hook is triggered after each - * successful auth refresh API request (right after generating a new auth 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. + * Metadata holds key/value pairs associated with the blob. + * Keys are guaranteed to be in lowercase, even if the backend service + * has case-sensitive keys (although note that Metadata written via + * this package will always be lowercased). If there are duplicate + * case-insensitive keys (e.g., "foo" and "FOO"), only one value + * will be kept, and it is undefined which one. */ - onRecordAfterAuthRefreshRequest(...tags: string[]): (hook.TaggedHook | undefined) + metadata: _TygojaDict /** - * 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. - * - * 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. + * CreateTime is the time the blob was created, if available. If not available, + * CreateTime will be the zero time. */ - onRecordListExternalAuthsRequest(...tags: string[]): (hook.TaggedHook | undefined) + createTime: time.Time /** - * 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. - * - * 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. + * ModTime is the time the blob was last modified. */ - onRecordBeforeUnlinkExternalAuthRequest(...tags: string[]): (hook.TaggedHook | undefined) + modTime: time.Time /** - * OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each - * successful API record external auth unlink 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. + * Size is the size of the blob's content in bytes. */ - onRecordAfterUnlinkExternalAuthRequest(...tags: string[]): (hook.TaggedHook | undefined) + size: number /** - * OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record - * request password reset API request (after request data load and before sending the reset email). - * - * 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. + * MD5 is an MD5 hash of the blob contents or nil if not available. */ - onRecordBeforeRequestPasswordResetRequest(...tags: string[]): (hook.TaggedHook | undefined) + md5: string|Array /** - * OnRecordAfterRequestPasswordResetRequest hook is triggered after each - * successful request password reset 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. + * ETag for the blob; see https://en.wikipedia.org/wiki/HTTP_ETag. */ - onRecordAfterRequestPasswordResetRequest(...tags: string[]): (hook.TaggedHook | undefined) + eTag: string + } + /** + * WriterOptions sets options for NewWriter. + */ + interface WriterOptions { /** - * OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record - * confirm password reset API request (after request data load and before persistence). + * BufferSize changes the default size in bytes of the chunks that + * Writer will upload in a single request; larger blobs will be split into + * multiple requests. * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. + * This option may be ignored by some drivers. * - * 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 | undefined) - /** - * OnRecordAfterConfirmPasswordResetRequest hook is triggered after each - * successful confirm password reset API request. + * If 0, the driver will choose a reasonable default. * - * 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. + * If the Writer is used to do many small writes concurrently, using a + * smaller BufferSize may reduce memory usage. */ - onRecordAfterConfirmPasswordResetRequest(...tags: string[]): (hook.TaggedHook | undefined) + bufferSize: number /** - * OnRecordBeforeRequestVerificationRequest hook is triggered before each Record - * request verification API request (after request data load and before sending the verification email). + * MaxConcurrency changes the default concurrency for parts of an upload. * - * Could be used to additionally validate the loaded request data or implement - * completely different verification behavior. + * This option may be ignored by some drivers. * - * 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. + * If 0, the driver will choose a reasonable default. */ - onRecordBeforeRequestVerificationRequest(...tags: string[]): (hook.TaggedHook | undefined) + maxConcurrency: number /** - * OnRecordAfterRequestVerificationRequest hook is triggered after each - * successful request verification 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. + * CacheControl specifies caching attributes that services may use + * when serving the blob. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control */ - onRecordAfterRequestVerificationRequest(...tags: string[]): (hook.TaggedHook | undefined) + cacheControl: string /** - * OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record - * confirm verification API request (after request data load and before persistence). - * - * 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. + * ContentDisposition specifies whether the blob content is expected to be + * displayed inline or as an attachment. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition */ - onRecordBeforeConfirmVerificationRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentDisposition: string /** - * OnRecordAfterConfirmVerificationRequest hook is triggered after each - * successful confirm verification 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. + * ContentEncoding specifies the encoding used for the blob's content, if any. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding */ - onRecordAfterConfirmVerificationRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentEncoding: string /** - * 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). - * - * 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. + * ContentLanguage specifies the language used in the blob's content, if any. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language */ - onRecordBeforeRequestEmailChangeRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentLanguage: string /** - * OnRecordAfterRequestEmailChangeRequest hook is triggered after each - * successful request email change 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. + * ContentType specifies the MIME type of the blob being written. If not set, + * it will be inferred from the content using the algorithm described at + * http://mimesniff.spec.whatwg.org/. + * Set DisableContentTypeDetection to true to disable the above and force + * the ContentType to stay empty. + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type */ - onRecordAfterRequestEmailChangeRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentType: string /** - * OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record - * confirm email change API request (after request data load and before persistence). - * - * 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. + * When true, if ContentType is the empty string, it will stay the empty + * string rather than being inferred from the content. + * Note that while the blob will be written with an empty string ContentType, + * most providers will fill one in during reads, so don't expect an empty + * ContentType if you read the blob back. */ - onRecordBeforeConfirmEmailChangeRequest(...tags: string[]): (hook.TaggedHook | undefined) + disableContentTypeDetection: boolean /** - * OnRecordAfterConfirmEmailChangeRequest hook is triggered after each - * successful confirm email change 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. + * ContentMD5 is used as a message integrity check. + * If len(ContentMD5) > 0, the MD5 hash of the bytes written must match + * ContentMD5, or Close will return an error without completing the write. + * https://tools.ietf.org/html/rfc1864 */ - onRecordAfterConfirmEmailChangeRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentMD5: string|Array /** - * 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. + * Metadata holds key/value strings to be associated with the blob, or nil. + * Keys may not be empty, and are lowercased before being written. + * Duplicate case-insensitive keys (e.g., "foo" and "FOO") will result in + * an error. */ - onRecordsListRequest(...tags: string[]): (hook.TaggedHook | undefined) + metadata: _TygojaDict + } + /** + * Reader reads bytes from a blob. + * It implements io.ReadSeekCloser, and must be closed after reads are finished. + */ + interface Reader { + } + interface Reader { /** - * 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. + * Read implements io.Reader (https://golang.org/pkg/io/#Reader). */ - onRecordViewRequest(...tags: string[]): (hook.TaggedHook | undefined) + read(p: string|Array): number + } + interface Reader { /** - * OnRecordBeforeCreateRequest hook is triggered before each API Record - * create request (after request data load and before model persistence). - * - * 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. + * Seek implements io.Seeker (https://golang.org/pkg/io/#Seeker). */ - onRecordBeforeCreateRequest(...tags: string[]): (hook.TaggedHook | undefined) + seek(offset: number, whence: number): number + } + interface Reader { /** - * 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. + * Close implements io.Closer (https://golang.org/pkg/io/#Closer). */ - onRecordAfterCreateRequest(...tags: string[]): (hook.TaggedHook | undefined) + close(): void + } + interface Reader { /** - * OnRecordBeforeUpdateRequest hook is triggered before each API Record - * update request (after request data load and before model persistence). - * - * 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. + * ContentType returns the MIME type of the blob. */ - onRecordBeforeUpdateRequest(...tags: string[]): (hook.TaggedHook | undefined) + contentType(): string + } + interface Reader { /** - * 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. + * ModTime returns the time the blob was last modified. */ - onRecordAfterUpdateRequest(...tags: string[]): (hook.TaggedHook | undefined) + modTime(): time.Time + } + interface Reader { /** - * OnRecordBeforeDeleteRequest hook is triggered before each API Record - * delete request (after model load and before actual deletion). - * - * 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. + * Size returns the size of the blob content in bytes. */ - onRecordBeforeDeleteRequest(...tags: string[]): (hook.TaggedHook | undefined) + size(): number + } + interface Reader { /** - * OnRecordAfterDeleteRequest hook is triggered after each - * successful API Record delete request. + * WriteTo reads from r and writes to w until there's no more data or + * an error occurs. + * The return value is the number of bytes written to w. * - * 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. + * It implements the io.WriterTo interface. */ - onRecordAfterDeleteRequest(...tags: string[]): (hook.TaggedHook | undefined) + writeTo(w: io.Writer): number + } + /** + * Writer writes bytes to a blob. + * + * It implements io.WriteCloser (https://golang.org/pkg/io/#Closer), and must be + * closed after all writes are done. + */ + interface Writer { + } + interface Writer { /** - * OnCollectionsListRequest hook is triggered on each API Collections list request. + * Write implements the io.Writer interface (https://golang.org/pkg/io/#Writer). * - * Could be used to validate or modify the response before returning it to the client. + * Writes may happen asynchronously, so the returned error can be nil + * even if the actual write eventually fails. The write is only guaranteed to + * have succeeded if Close returns no error. */ - onCollectionsListRequest(): (hook.Hook | undefined) + write(p: string|Array): number + } + interface Writer { /** - * OnCollectionViewRequest hook is triggered on each API Collection view request. + * Close closes the blob writer. The write operation is not guaranteed + * to have succeeded until Close returns with no error. * - * Could be used to validate or modify the response before returning it to the client. + * Close may return an error if the context provided to create the + * Writer is canceled or reaches its deadline. */ - onCollectionViewRequest(): (hook.Hook | undefined) + close(): void + } + interface Writer { /** - * OnCollectionBeforeCreateRequest hook is triggered before each API Collection - * create request (after request data load and before model persistence). + * ReadFrom reads from r and writes to w until EOF or error. + * The return value is the number of bytes read from r. * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. + * It implements the io.ReaderFrom interface. */ - onCollectionBeforeCreateRequest(): (hook.Hook | undefined) + readFrom(r: io.Reader): number + } +} + +namespace store { + /** + * Store defines a concurrent safe in memory key-value data store. + */ + interface Store { + } + interface Store { /** - * OnCollectionAfterCreateRequest hook is triggered after each - * successful API Collection create request. + * Reset clears the store and replaces the store data with a + * shallow copy of the provided newData. */ - onCollectionAfterCreateRequest(): (hook.Hook | undefined) + reset(newData: _TygojaDict): void + } + interface Store { /** - * OnCollectionBeforeUpdateRequest hook is triggered before each API Collection - * update request (after request data load and before model persistence). - * - * Could be used to additionally validate the request data or implement - * completely different persistence behavior. + * Length returns the current number of elements in the store. */ - onCollectionBeforeUpdateRequest(): (hook.Hook | undefined) + length(): number + } + interface Store { /** - * OnCollectionAfterUpdateRequest hook is triggered after each - * successful API Collection update request. + * RemoveAll removes all the existing store entries. */ - onCollectionAfterUpdateRequest(): (hook.Hook | undefined) + removeAll(): void + } + interface Store { /** - * OnCollectionBeforeDeleteRequest hook is triggered before each API - * Collection delete request (after model load and before actual deletion). + * Remove removes a single entry from the store. * - * Could be used to additionally validate the request data or implement - * completely different delete behavior. + * Remove does nothing if key doesn't exist in the store. */ - onCollectionBeforeDeleteRequest(): (hook.Hook | undefined) + remove(key: K): void + } + interface Store { /** - * OnCollectionAfterDeleteRequest hook is triggered after each - * successful API Collection delete request. + * Has checks if element with the specified key exist or not. */ - onCollectionAfterDeleteRequest(): (hook.Hook | undefined) + has(key: K): boolean + } + interface Store { /** - * OnCollectionsBeforeImportRequest hook is triggered before each API - * collections import request (after request data load and before the actual import). + * Get returns a single element value from the store. * - * Could be used to additionally validate the imported collections or - * to implement completely different import behavior. - */ - onCollectionsBeforeImportRequest(): (hook.Hook | undefined) - /** - * OnCollectionsAfterImportRequest hook is triggered after each - * successful API collections import request. + * If key is not set, the zero T value is returned. */ - onCollectionsAfterImportRequest(): (hook.Hook | undefined) + get(key: K): T } -} - -/** - * Package cobra is a commander providing a simple interface to create powerful modern CLI interfaces. - * In addition to providing an interface, Cobra simultaneously provides a controller to organize your application code. - */ -namespace cobra { - interface Command { + interface Store { /** - * GenBashCompletion generates bash completion file and writes to the passed writer. + * GetOk is similar to Get but returns also a boolean indicating whether the key exists or not. */ - genBashCompletion(w: io.Writer): void + getOk(key: K): [T, boolean] } - interface Command { + interface Store { /** - * GenBashCompletionFile generates bash completion file. + * GetAll returns a shallow copy of the current store data. */ - genBashCompletionFile(filename: string): void + getAll(): _TygojaDict } - interface Command { + interface Store { /** - * GenBashCompletionFileV2 generates Bash completion version 2. + * Keys returns a slice with all of the store keys. */ - genBashCompletionFileV2(filename: string, includeDesc: boolean): void + keys(): Array } - interface Command { + interface Store { /** - * GenBashCompletionV2 generates Bash completion file version 2 - * and writes it to the passed writer. + * Values returns a slice with all of the store values. */ - genBashCompletionV2(w: io.Writer, includeDesc: boolean): void + values(): Array } - // @ts-ignore - import flag = pflag - /** - * Command is just that, a command for your application. - * E.g. 'go run ...' - 'run' is the command. Cobra requires - * you to define the usage and description as part of your command - * definition to ensure usability. - */ - interface Command { - /** - * Use is the one-line usage message. - * Recommended syntax is as follows: - * ``` - * [ ] identifies an optional argument. Arguments that are not enclosed in brackets are required. - * ... indicates that you can specify multiple values for the previous argument. - * | indicates mutually exclusive information. You can use the argument to the left of the separator or the - * argument to the right of the separator. You cannot use both arguments in a single use of the command. - * { } delimits a set of mutually exclusive arguments when one of the arguments is required. If the arguments are - * optional, they are enclosed in brackets ([ ]). - * ``` - * Example: add [-F file | -D dir]... [-f format] profile + interface Store { + /** + * Set sets (or overwrite if already exists) a new value for key. */ - use: string + set(key: K, value: T): void + } + interface Store { /** - * Aliases is an array of aliases that can be used instead of the first word in Use. + * SetFunc sets (or overwrite if already exists) a new value resolved + * from the function callback for the provided key. + * + * The function callback receives as argument the old store element value (if exists). + * If there is no old store element, the argument will be the T zero value. + * + * Example: + * + * ``` + * s := store.New[string, int](nil) + * s.SetFunc("count", func(old int) int { + * return old + 1 + * }) + * ``` */ - aliases: Array + setFunc(key: K, fn: (old: T) => T): void + } + interface Store { /** - * SuggestFor is an array of command names for which this command will be suggested - - * similar to aliases but only suggests. + * GetOrSet retrieves a single existing value for the provided key + * or stores a new one if it doesn't exist. */ - suggestFor: Array + getOrSet(key: K, setFunc: () => T): T + } + interface Store { /** - * Short is the short description shown in the 'help' output. + * SetIfLessThanLimit sets (or overwrite if already exist) a new value for key. + * + * This method is similar to Set() but **it will skip adding new elements** + * to the store if the store length has reached the specified limit. + * false is returned if maxAllowedElements limit is reached. */ - short: string + setIfLessThanLimit(key: K, value: T, maxAllowedElements: number): boolean + } + interface Store { /** - * The group id under which this subcommand is grouped in the 'help' output of its parent. + * UnmarshalJSON implements [json.Unmarshaler] and imports the + * provided JSON data into the store. + * + * The store entries that match with the ones from the data will be overwritten with the new value. */ - groupID: string + unmarshalJSON(data: string|Array): void + } + interface Store { /** - * Long is the long message shown in the 'help ' output. + * MarshalJSON implements [json.Marshaler] and export the current + * store data into valid JSON. */ - long: string + marshalJSON(): string|Array + } +} + +namespace hook { + /** + * Event implements [Resolver] and it is intended to be used as a base + * Hook event that you can embed in your custom typed event structs. + * + * Example: + * + * ``` + * type CustomEvent struct { + * hook.Event + * + * SomeField int + * } + * ``` + */ + interface Event { + } + interface Event { /** - * Example is examples of how to use the command. + * Next calls the next hook handler. */ - example: string + next(): void + } + /** + * Handler defines a single Hook handler. + * Multiple handlers can share the same id. + * If Id is not explicitly set it will be autogenerated by Hook.Add and Hook.AddHandler. + */ + interface Handler { /** - * ValidArgs is list of all valid non-flag arguments that are accepted in shell completions + * Func defines the handler function to execute. + * + * Note that users need to call e.Next() in order to proceed with + * the execution of the hook chain. */ - validArgs: Array + func: (_arg0: T) => void /** - * ValidArgsFunction is an optional function that provides valid non-flag arguments for shell completion. - * It is a dynamic version of using ValidArgs. - * Only one of ValidArgs and ValidArgsFunction can be used for a command. + * Id is the unique identifier of the handler. + * + * It could be used later to remove the handler from a hook via [Hook.Remove]. + * + * If missing, an autogenerated value will be assigned when adding + * the handler to a hook. */ - validArgsFunction: (cmd: Command, args: Array, toComplete: string) => [Array, ShellCompDirective] + id: string /** - * Expected arguments + * Priority allows changing the default exec priority of the handler within a hook. + * + * If 0, the handler will be executed in the same order it was registered. */ - args: PositionalArgs + priority: number + } + /** + * Hook defines a generic concurrent safe structure for managing event hooks. + * + * When using custom event it must embed the base [hook.Event]. + * + * Example: + * + * ``` + * type CustomEvent struct { + * hook.Event + * SomeField int + * } + * + * h := Hook[*CustomEvent]{} + * + * h.BindFunc(func(e *CustomEvent) error { + * println(e.SomeField) + * + * return e.Next() + * }) + * + * h.Trigger(&CustomEvent{ SomeField: 123 }) + * ``` + */ + interface Hook { + } + interface Hook { /** - * ArgAliases is List of aliases for ValidArgs. - * These are not suggested to the user in the shell completion, - * but accepted if entered manually. + * Bind registers the provided handler to the current hooks queue. + * + * If handler.Id is empty it is updated with autogenerated value. + * + * If a handler from the current hook list has Id matching handler.Id + * then the old handler is replaced with the new one. */ - argAliases: Array + bind(handler: Handler): string + } + interface Hook { /** - * BashCompletionFunction is custom bash functions used by the legacy bash autocompletion generator. - * For portability with other shells, it is recommended to instead use ValidArgsFunction + * BindFunc is similar to Bind but registers a new handler from just the provided function. + * + * The registered handler is added with a default 0 priority and the id will be autogenerated. + * + * If you want to register a handler with custom priority or id use the [Hook.Bind] method. */ - bashCompletionFunction: string + bindFunc(fn: (e: T) => void): string + } + interface Hook { /** - * Deprecated defines, if this command is deprecated and should print this string when used. + * Unbind removes one or many hook handler by their id. */ - deprecated: string + unbind(...idsToRemove: string[]): void + } + interface Hook { /** - * Annotations are key/value pairs that can be used by applications to identify or - * group commands. + * UnbindAll removes all registered handlers. */ - annotations: _TygojaDict + unbindAll(): void + } + interface Hook { /** - * Version defines the version for this command. If this value is non-empty and the command does not - * define a "version" flag, a "version" boolean flag will be added to the command and, if specified, - * will print content of the "Version" variable. A shorthand "v" flag will also be added if the - * command does not define one. + * Length returns to total number of registered hook handlers. */ - version: string + length(): number + } + interface Hook { /** - * The *Run functions are executed in the following order: - * ``` - * * PersistentPreRun() - * * PreRun() - * * Run() - * * PostRun() - * * PersistentPostRun() - * ``` - * All functions get the same args, the arguments after the command name. + * Trigger executes all registered hook handlers one by one + * with the specified event as an argument. * - * PersistentPreRun: children of this command will inherit and execute. + * Optionally, this method allows also to register additional one off + * handler funcs that will be temporary appended to the handlers queue. + * + * NB! Each hook handler must call event.Next() in order the hook chain to proceed. */ - persistentPreRun: (cmd: Command, args: Array) => void + trigger(event: T, ...oneOffHandlerFuncs: ((_arg0: T) => void)[]): void + } + /** + * TaggedHook defines a proxy hook which register handlers that are triggered only + * if the TaggedHook.tags are empty or includes at least one of the event data tag(s). + */ + type _seLgbMO = mainHook + interface TaggedHook extends _seLgbMO { + } + interface TaggedHook { /** - * PersistentPreRunE: PersistentPreRun but returns an error. + * CanTriggerOn checks if the current TaggedHook can be triggered with + * the provided event data tags. + * + * It returns always true if the hook doesn't have any tags. */ - persistentPreRunE: (cmd: Command, args: Array) => void + canTriggerOn(tagsToCheck: Array): boolean + } + interface TaggedHook { /** - * PreRun: children of this command will not inherit. + * Bind registers the provided handler to the current hooks queue. + * + * It is similar to [Hook.Bind] with the difference that the handler + * function is invoked only if the event data tags satisfy h.CanTriggerOn. */ - preRun: (cmd: Command, args: Array) => void + bind(handler: Handler): string + } + interface TaggedHook { /** - * PreRunE: PreRun but returns an error. + * BindFunc registers a new handler with the specified function. + * + * It is similar to [Hook.Bind] with the difference that the handler + * function is invoked only if the event data tags satisfy h.CanTriggerOn. */ - preRunE: (cmd: Command, args: Array) => void + bindFunc(fn: (e: T) => void): string + } +} + +namespace search { + /** + * Result defines the returned search result structure. + */ + interface Result { + items: any + page: number + perPage: number + totalItems: number + totalPages: number + } + /** + * ResolverResult defines a single FieldResolver.Resolve() successfully parsed result. + */ + interface ResolverResult { /** - * Run: Typically the actual work function. Most commands will only implement this. + * Identifier is the plain SQL identifier/column that will be used + * in the final db expression as left or right operand. */ - run: (cmd: Command, args: Array) => void + identifier: string /** - * RunE: Run but returns an error. + * NullFallback specify the preference for how NULL or empty values + * should be resolved (default to "auto"). + * + * Set to NullFallbackDisabled to prevent any COALESCE or NULL fallbacks. + * Set to NullFallbackEnforced to prefer COALESCE or NULL fallbacks when needed. */ - runE: (cmd: Command, args: Array) => void + nullFallback: NullFallbackPreference /** - * PostRun: run after the Run command. + * Params is a map with db placeholder->value pairs that will be added + * to the query when building both resolved operands/sides in a single expression. */ - postRun: (cmd: Command, args: Array) => void + params: dbx.Params /** - * PostRunE: PostRun but returns an error. + * MultiMatchSubQuery is an optional sub query expression that will be added + * in addition to the combined ResolverResult expression during build. */ - postRunE: (cmd: Command, args: Array) => void + multiMatchSubQuery?: MultiMatchSubquery /** - * PersistentPostRun: children of this command will inherit and execute after PostRun. + * AfterBuild is an optional function that will be called after building + * and combining the result of both resolved operands/sides in a single expression. */ - persistentPostRun: (cmd: Command, args: Array) => void + afterBuild: (expr: dbx.Expression) => dbx.Expression + } +} + +namespace router { + // @ts-ignore + import validation = ozzo_validation + /** + * ApiError defines the struct for a basic api error response. + */ + interface ApiError { + data: _TygojaDict + message: string + status: number + } + interface ApiError { /** - * PersistentPostRunE: PersistentPostRun but returns an error. + * Error makes it compatible with the `error` interface. */ - persistentPostRunE: (cmd: Command, args: Array) => void + error(): string + } + interface ApiError { /** - * FParseErrWhitelist flag parse errors to be ignored + * RawData returns the unformatted error data (could be an internal error, text, etc.) */ - fParseErrWhitelist: FParseErrWhitelist + rawData(): any + } + interface ApiError { /** - * CompletionOptions is a set of options to control the handling of shell completion + * Is reports whether the current ApiError wraps the target. */ - completionOptions: CompletionOptions + is(target: Error): boolean + } + /** + * Event specifies based Route handler event that is usually intended + * to be embedded as part of a custom event struct. + * + * NB! It is expected that the Response and Request fields are always set. + */ + type _sXuRnNn = hook.Event + interface Event extends _sXuRnNn { + response: http.ResponseWriter + request?: http.Request + } + interface Event { /** - * TraverseChildren parses flags on all parents before executing child command. + * Written reports whether the current response has already been written. + * + * This method always returns false if e.ResponseWritter doesn't implement the WriteTracker interface + * (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). */ - traverseChildren: boolean + written(): boolean + } + interface Event { /** - * Hidden defines, if this command is hidden and should NOT show up in the list of available commands. + * Status reports the status code of the current response. + * + * This method always returns 0 if e.Response doesn't implement the StatusTracker interface + * (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). */ - hidden: boolean + status(): number + } + interface Event { /** - * SilenceErrors is an option to quiet errors down stream. + * Flush flushes buffered data to the current response. + * + * Returns [http.ErrNotSupported] if e.Response doesn't implement the [http.Flusher] interface + * (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). */ - silenceErrors: boolean + flush(): void + } + interface Event { /** - * SilenceUsage is an option to silence usage when an error occurs. + * IsTLS reports whether the connection on which the request was received is TLS. */ - silenceUsage: boolean + isTLS(): boolean + } + interface Event { /** - * DisableFlagParsing disables the flag parsing. - * If this is true all flags will be passed to the command as arguments. + * SetCookie is an alias for [http.SetCookie]. + * + * SetCookie adds a Set-Cookie header to the current response's headers. + * The provided cookie must have a valid Name. + * Invalid cookies may be silently dropped. */ - disableFlagParsing: boolean + setCookie(cookie: http.Cookie): void + } + interface Event { /** - * DisableAutoGenTag defines, if gen tag ("Auto generated by spf13/cobra...") - * will be printed by generating docs for this command. + * RemoteIP returns the IP address of the client that sent the request. + * + * IPv6 addresses are returned expanded. + * For example, "2001:db8::1" becomes "2001:0db8:0000:0000:0000:0000:0000:0001". + * + * Note that if you are behind reverse proxy(ies), this method returns + * the IP of the last connecting proxy. */ - disableAutoGenTag: boolean + remoteIP(): string + } + interface Event { /** - * DisableFlagsInUseLine will disable the addition of [flags] to the usage - * line of a command when printing help or generating docs + * FindUploadedFiles extracts all form files of "key" from a http request + * and returns a slice with filesystem.File instances (if any). */ - disableFlagsInUseLine: boolean + findUploadedFiles(key: string): Array<(filesystem.File | undefined)> + } + interface Event { /** - * DisableSuggestions disables the suggestions based on Levenshtein distance - * that go along with 'unknown command' messages. + * Get retrieves single value from the current event data store. */ - disableSuggestions: boolean + get(key: string): any + } + interface Event { /** - * SuggestionsMinimumDistance defines minimum levenshtein distance to display suggestions. - * Must be > 0. + * GetAll returns a copy of the current event data store. */ - suggestionsMinimumDistance: number + getAll(): _TygojaDict } - interface Command { + interface Event { /** - * Context returns underlying command context. If command was executed - * with ExecuteContext or the context was set with SetContext, the - * previously set context will be returned. Otherwise, nil is returned. - * - * Notice that a call to Execute and ExecuteC will replace a nil context of - * a command with a context.Background, so a background context will be - * returned by Context after one of these functions has been called. + * Set saves single value into the current event data store. */ - context(): context.Context + set(key: string, value: any): void } - interface Command { + interface Event { /** - * SetContext sets context for the command. This context will be overwritten by - * Command.ExecuteContext or Command.ExecuteContextC. + * SetAll saves all items from m into the current event data store. */ - setContext(ctx: context.Context): void + setAll(m: _TygojaDict): void } - interface Command { + interface Event { /** - * SetArgs sets arguments for the command. It is set to os.Args[1:] by default, if desired, can be overridden - * particularly useful when testing. + * String writes a plain string response. */ - setArgs(a: Array): void + string(status: number, data: string): void } - interface Command { + interface Event { /** - * SetOutput sets the destination for usage and error messages. - * If output is nil, os.Stderr is used. - * Deprecated: Use SetOut and/or SetErr instead + * HTML writes an HTML response. */ - setOutput(output: io.Writer): void + html(status: number, data: string): void } - interface Command { + interface Event { /** - * SetOut sets the destination for usage messages. - * If newOut is nil, os.Stdout is used. + * JSON writes a JSON response. + * + * It also provides a generic response data fields picker if the "fields" query parameter is set. + * For example, if you are requesting `?fields=a,b` for `e.JSON(200, map[string]int{ "a":1, "b":2, "c":3 })`, + * it should result in a JSON response like: `{"a":1, "b": 2}`. + * + * Note that invalid UTF8 characters are mangled for compatibility + * with earlier versions and to prevent unnecessary causing a response error. */ - setOut(newOut: io.Writer): void + json(status: number, data: any): void } - interface Command { + interface Event { /** - * SetErr sets the destination for error messages. - * If newErr is nil, os.Stderr is used. + * XML writes an XML response. + * It automatically prepends the generic [xml.Header] string to the response. */ - setErr(newErr: io.Writer): void + xml(status: number, data: any): void } - interface Command { + interface Event { /** - * SetIn sets the source for input data - * If newIn is nil, os.Stdin is used. + * Stream streams the specified reader into the response. */ - setIn(newIn: io.Reader): void + stream(status: number, contentType: string, reader: io.Reader): void } - interface Command { + interface Event { /** - * SetUsageFunc sets usage function. Usage can be defined by application. + * Blob writes a blob (bytes slice) response. */ - setUsageFunc(f: (_arg0: Command) => void): void + blob(status: number, contentType: string, b: string|Array): void } - interface Command { + interface Event { /** - * SetUsageTemplate sets usage template. Can be defined by Application. + * FileFS serves the specified filename from fsys. + * + * It is similar to [echo.FileFS] for consistency with earlier versions. */ - setUsageTemplate(s: string): void + fileFS(fsys: fs.FS, filename: string): void } - interface Command { + interface Event { /** - * SetFlagErrorFunc sets a function to generate an error when flag parsing - * fails. + * NoContent writes a response with no body (ex. 204). */ - setFlagErrorFunc(f: (_arg0: Command, _arg1: Error) => void): void + noContent(status: number): void } - interface Command { + interface Event { /** - * SetHelpFunc sets help function. Can be defined by Application. + * Redirect writes a redirect response to the specified url. + * The status code must be in between 300 – 399 range. */ - setHelpFunc(f: (_arg0: Command, _arg1: Array) => void): void + redirect(status: number, url: string): void } - interface Command { + interface Event { + error(status: number, message: string, errData: any): (ApiError) + } + interface Event { + badRequestError(message: string, errData: any): (ApiError) + } + interface Event { + notFoundError(message: string, errData: any): (ApiError) + } + interface Event { + forbiddenError(message: string, errData: any): (ApiError) + } + interface Event { + unauthorizedError(message: string, errData: any): (ApiError) + } + interface Event { + tooManyRequestsError(message: string, errData: any): (ApiError) + } + interface Event { + internalServerError(message: string, errData: any): (ApiError) + } + interface Event { /** - * SetHelpCommand sets help command. + * BindBody unmarshal the request body into the provided dst. + * + * dst must be either a struct pointer or map[string]any. + * + * The rules how the body will be scanned depends on the request Content-Type. + * + * Currently the following Content-Types are supported: + * ``` + * - application/json + * - text/xml, application/xml + * - multipart/form-data, application/x-www-form-urlencoded + * ``` + * + * Respectively the following struct tags are supported (again, which one will be used depends on the Content-Type): + * ``` + * - "json" (json body)- uses the builtin Go json package for unmarshaling. + * - "xml" (xml body) - uses the builtin Go xml package for unmarshaling. + * - "form" (form data) - utilizes the custom [router.UnmarshalRequestData] method. + * ``` + * + * NB! When dst is a struct make sure that it doesn't have public fields + * that shouldn't be bindable and it is advisible such fields to be unexported + * or have a separate struct just for the binding. For example: + * + * ``` + * data := struct{ + * somethingPrivate string + * + * Title string `json:"title" form:"title"` + * Total int `json:"total" form:"total"` + * }{} + * err := e.BindBody(&data) + * ``` */ - setHelpCommand(cmd: Command): void + bindBody(dst: any): void } - interface Command { + /** + * Router defines a thin wrapper around the standard Go [http.ServeMux] by + * adding support for routing sub-groups, middlewares and other common utils. + * + * Example: + * + * ``` + * r := NewRouter[*MyEvent](eventFactory) + * + * // middlewares + * r.BindFunc(m1, m2) + * + * // routes + * r.GET("/test", handler1) + * + * // sub-routers/groups + * api := r.Group("/api") + * api.GET("/admins", handler2) + * + * // generate a http.ServeMux instance based on the router configurations + * mux, _ := r.BuildMux() + * + * http.ListenAndServe("localhost:8090", mux) + * ``` + */ + type _srubjsS = RouterGroup + interface Router extends _srubjsS { + } + interface Router { /** - * SetHelpCommandGroupID sets the group id of the help command. + * BuildMux constructs a new mux [http.Handler] instance from the current router configurations. */ - setHelpCommandGroupID(groupID: string): void + buildMux(): http.Handler } - interface Command { +} + +/** + * Package cron implements a crontab-like service to execute and schedule + * repeative tasks/jobs. + * + * Example: + * + * ``` + * c := cron.New() + * c.MustAdd("dailyReport", "0 0 * * *", func() { ... }) + * c.Start() + * ``` + */ +namespace cron { + /** + * Cron is a crontab-like struct for tasks/jobs scheduling. + */ + interface Cron { + } + interface Cron { /** - * SetCompletionCommandGroupID sets the group id of the completion command. + * SetInterval changes the current cron tick interval + * (it usually should be >= 1 minute). */ - setCompletionCommandGroupID(groupID: string): void + setInterval(d: time.Duration): void } - interface Command { + interface Cron { /** - * SetHelpTemplate sets help template to be used. Application can use it to set custom template. + * SetTimezone changes the current cron tick timezone. */ - setHelpTemplate(s: string): void + setTimezone(l: time.Location): void } - interface Command { + interface Cron { /** - * SetVersionTemplate sets version template to be used. Application can use it to set custom template. + * MustAdd is similar to Add() but panic on failure. */ - setVersionTemplate(s: string): void + mustAdd(jobId: string, cronExpr: string, run: () => void): void } - interface Command { + interface Cron { /** - * SetGlobalNormalizationFunc sets a normalization function to all flag sets and also to child commands. - * The user should not have a cyclic dependency on commands. + * Add registers a single cron job. + * + * If there is already a job with the provided id, then the old job + * will be replaced with the new one. + * + * cronExpr is a regular cron expression, eg. "0 *\/3 * * *" (aka. at minute 0 past every 3rd hour). + * Check cron.NewSchedule() for the supported tokens. */ - setGlobalNormalizationFunc(n: (f: any, name: string) => any): void + add(jobId: string, cronExpr: string, fn: () => void): void } - interface Command { + interface Cron { /** - * OutOrStdout returns output to stdout. + * Remove removes a single cron job by its id. */ - outOrStdout(): io.Writer + remove(jobId: string): void } - interface Command { + interface Cron { /** - * OutOrStderr returns output to stderr + * RemoveAll removes all registered cron jobs. */ - outOrStderr(): io.Writer + removeAll(): void } - interface Command { + interface Cron { /** - * ErrOrStderr returns output to stderr + * Total returns the current total number of registered cron jobs. */ - errOrStderr(): io.Writer + total(): number } - interface Command { + interface Cron { /** - * InOrStdin returns input to stdin + * Jobs returns a shallow copy of the currently registered cron jobs. */ - inOrStdin(): io.Reader + jobs(): Array<(Job | undefined)> } - interface Command { + interface Cron { /** - * UsageFunc returns either the function set by SetUsageFunc for this command - * or a parent, or it returns a default usage function. + * Stop stops the current cron ticker (if not already). + * + * You can resume the ticker by calling Start(). */ - usageFunc(): (_arg0: Command) => void + stop(): void } - interface Command { + interface Cron { /** - * Usage puts out the usage for the command. - * Used when a user provides invalid input. - * Can be defined by user by overriding UsageFunc. + * Start starts the cron ticker. + * + * Calling Start() on already started cron will restart the ticker. */ - usage(): void + start(): void } - interface Command { + interface Cron { /** - * HelpFunc returns either the function set by SetHelpFunc for this command - * or a parent, or it returns a function with default help behavior. + * HasStarted checks whether the current Cron ticker has been started. */ - helpFunc(): (_arg0: Command, _arg1: Array) => void + hasStarted(): boolean } - interface Command { +} + +namespace exec { + /** + * Cmd represents an external command being prepared or run. + * + * A Cmd cannot be reused after calling its [Cmd.Start], [Cmd.Run], + * [Cmd.Output], or [Cmd.CombinedOutput] methods. + */ + interface Cmd { /** - * Help puts out the help for the command. - * Used when a user calls help [command]. - * Can be defined by user by overriding HelpFunc. + * Path is the path of the command to run. + * + * This is the only field that must be set to a non-zero + * value. If Path is relative, it is evaluated relative + * to Dir. */ - help(): void - } - interface Command { + path: string /** - * UsageString returns usage string. + * Args holds command line arguments, including the command as Args[0]. + * If the Args field is empty or nil, Run uses {Path}. + * + * In typical use, both Path and Args are set by calling Command. */ - usageString(): string - } - interface Command { + args: Array /** - * FlagErrorFunc returns either the function set by SetFlagErrorFunc for this - * command or a parent, or it returns a function which returns the original - * error. + * Env specifies the environment of the process. + * Each entry is of the form "key=value". + * If Env is nil, the new process uses the current process's + * environment. + * If Env contains duplicate environment keys, only the last + * value in the slice for each duplicate key is used. + * As a special case on Windows, SYSTEMROOT is always added if + * missing and not explicitly set to the empty string. + * + * See also the Dir field, which may set PWD in the environment. */ - flagErrorFunc(): (_arg0: Command, _arg1: Error) => void - } - interface Command { + env: Array /** - * UsagePadding return padding for the usage. + * Dir specifies the working directory of the command. + * If Dir is the empty string, Run runs the command in the + * calling process's current directory. + * + * On Unix systems, the value of Dir also determines the + * child process's PWD environment variable if not otherwise + * specified. A Unix process represents its working directory + * not by name but as an implicit reference to a node in the + * file tree. So, if the child process obtains its working + * directory by calling a function such as C's getcwd, which + * computes the canonical name by walking up the file tree, it + * will not recover the original value of Dir if that value + * was an alias involving symbolic links. However, if the + * child process calls Go's [os.Getwd] or GNU C's + * get_current_dir_name, and the value of PWD is an alias for + * the current directory, those functions will return the + * value of PWD, which matches the value of Dir. */ - usagePadding(): number - } - interface Command { + dir: string /** - * CommandPathPadding return padding for the command path. + * Stdin specifies the process's standard input. + * + * If Stdin is nil, the process reads from the null device (os.DevNull). + * + * If Stdin is an *os.File, the process's standard input is connected + * directly to that file. + * + * Otherwise, during the execution of the command a separate + * goroutine reads from Stdin and delivers that data to the command + * over a pipe. In this case, Wait does not complete until the goroutine + * stops copying, either because it has reached the end of Stdin + * (EOF or a read error), or because writing to the pipe returned an error, + * or because a nonzero WaitDelay was set and expired. + * + * Regardless of WaitDelay, Wait can block until a Read from + * Stdin completes. If you need to use a blocking io.Reader, + * use the StdinPipe method to get a pipe, copy from the Reader + * to the pipe, and arrange to close the Reader after Wait returns. */ - commandPathPadding(): number - } - interface Command { + stdin: io.Reader /** - * NamePadding returns padding for the name. + * Stdout and Stderr specify the process's standard output and error. + * + * If either is nil, Run connects the corresponding file descriptor + * to the null device (os.DevNull). + * + * If either is an *os.File, the corresponding output from the process + * is connected directly to that file. + * + * Otherwise, during the execution of the command a separate goroutine + * reads from the process over a pipe and delivers that data to the + * corresponding Writer. In this case, Wait does not complete until the + * goroutine reaches EOF or encounters an error or a nonzero WaitDelay + * expires. + * + * Regardless of WaitDelay, Wait can block until a Write to + * Stdout or Stderr completes. If you need to use a blocking io.Writer, + * use the StdoutPipe or StderrPipe method to get a pipe, + * copy from the pipe to the Writer, and arrange to close the + * Writer after Wait returns. + * + * If Stdout and Stderr are the same writer, and have a type that can + * be compared with ==, at most one goroutine at a time will call Write. */ - namePadding(): number - } - interface Command { + stdout: io.Writer + stderr: io.Writer /** - * UsageTemplate returns usage template for the command. + * ExtraFiles specifies additional open files to be inherited by the + * new process. It does not include standard input, standard output, or + * standard error. If non-nil, entry i becomes file descriptor 3+i. + * + * ExtraFiles is not supported on Windows. */ - usageTemplate(): string - } - interface Command { + extraFiles: Array<(os.File | undefined)> /** - * HelpTemplate return help template for the command. + * SysProcAttr holds optional, operating system-specific attributes. + * Run passes it to os.StartProcess as the os.ProcAttr's Sys field. */ - helpTemplate(): string - } - interface Command { + sysProcAttr?: syscall.SysProcAttr /** - * VersionTemplate return version template for the command. + * Process is the underlying process, once started. */ - versionTemplate(): string - } - interface Command { + process?: os.Process /** - * Find the target command given the args and command tree - * Meant to be run on the highest node. Only searches down. + * ProcessState contains information about an exited process. + * If the process was started successfully, Wait or Run will + * populate its ProcessState when the command completes. */ - find(args: Array): [(Command | undefined), Array] - } - interface Command { + processState?: os.ProcessState + err: Error // LookPath error, if any. /** - * Traverse the command tree to find the command, and parse args for - * each parent. + * If Cancel is non-nil, the command must have been created with + * CommandContext and Cancel will be called when the command's + * Context is done. By default, CommandContext sets Cancel to + * call the Kill method on the command's Process. + * + * Typically a custom Cancel will send a signal to the command's + * Process, but it may instead take other actions to initiate cancellation, + * such as closing a stdin or stdout pipe or sending a shutdown request on a + * network socket. + * + * If the command exits with a success status after Cancel is + * called, and Cancel does not return an error equivalent to + * os.ErrProcessDone, then Wait and similar methods will return a non-nil + * error: either an error wrapping the one returned by Cancel, + * or the error from the Context. + * (If the command exits with a non-success status, or Cancel + * returns an error that wraps os.ErrProcessDone, Wait and similar methods + * continue to return the command's usual exit status.) + * + * If Cancel is set to nil, nothing will happen immediately when the command's + * Context is done, but a nonzero WaitDelay will still take effect. That may + * be useful, for example, to work around deadlocks in commands that do not + * support shutdown signals but are expected to always finish quickly. + * + * Cancel will not be called if Start returns a non-nil error. */ - traverse(args: Array): [(Command | undefined), Array] - } - interface Command { + cancel: () => void /** - * SuggestionsFor provides suggestions for the typedName. + * If WaitDelay is non-zero, it bounds the time spent waiting on two sources + * of unexpected delay in Wait: a child process that fails to exit after the + * associated Context is canceled, and a child process that exits but leaves + * its I/O pipes unclosed. + * + * The WaitDelay timer starts when either the associated Context is done or a + * call to Wait observes that the child process has exited, whichever occurs + * first. When the delay has elapsed, the command shuts down the child process + * and/or its I/O pipes. + * + * If the child process has failed to exit — perhaps because it ignored or + * failed to receive a shutdown signal from a Cancel function, or because no + * Cancel function was set — then it will be terminated using os.Process.Kill. + * + * Then, if the I/O pipes communicating with the child process are still open, + * those pipes are closed in order to unblock any goroutines currently blocked + * on Read or Write calls. + * + * If pipes are closed due to WaitDelay, no Cancel call has occurred, + * and the command has otherwise exited with a successful status, Wait and + * similar methods will return ErrWaitDelay instead of nil. + * + * If WaitDelay is zero (the default), I/O pipes will be read until EOF, + * which might not occur until orphaned subprocesses of the command have + * also closed their descriptors for the pipes. */ - suggestionsFor(typedName: string): Array + waitDelay: time.Duration } - interface Command { + interface Cmd { /** - * VisitParents visits all parents of the command and invokes fn on each parent. + * String returns a human-readable description of c. + * It is intended only for debugging. + * In particular, it is not suitable for use as input to a shell. + * The output of String may vary across Go releases. */ - visitParents(fn: (_arg0: Command) => void): void + string(): string } - interface Command { + interface Cmd { /** - * Root finds root command. + * Run starts the specified command and waits for it to complete. + * + * The returned error is nil if the command runs, has no problems + * copying stdin, stdout, and stderr, and exits with a zero exit + * status. + * + * If the command starts but does not complete successfully, the error is of + * type [*ExitError]. Other error types may be returned for other situations. + * + * If the calling goroutine has locked the operating system thread + * 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. */ - root(): (Command | undefined) + run(): void } - interface Command { + interface Cmd { /** - * ArgsLenAtDash will return the length of c.Flags().Args at the moment - * when a -- was found during args parsing. + * Start starts the specified command but does not wait for it to complete. + * + * If Start returns successfully, the c.Process field will be set. + * + * After a successful call to Start the [Cmd.Wait] method must be called in + * order to release associated system resources. */ - argsLenAtDash(): number + start(): void } - interface Command { + interface Cmd { /** - * ExecuteContext is the same as Execute(), but sets the ctx on the command. - * Retrieve ctx by calling cmd.Context() inside your *Run lifecycle or ValidArgs - * functions. + * Wait waits for the command to exit and waits for any copying to + * stdin or copying from stdout or stderr to complete. + * + * The command must have been started by [Cmd.Start]. + * + * The returned error is nil if the command runs, has no problems + * copying stdin, stdout, and stderr, and exits with a zero exit + * status. + * + * If the command fails to run or doesn't complete successfully, the + * error is of type [*ExitError]. Other error types may be + * returned for I/O problems. + * + * If any of c.Stdin, c.Stdout or c.Stderr are not an [*os.File], Wait also waits + * for the respective I/O loop copying to or from the process to complete. + * + * Wait must not be called concurrently from multiple goroutines. + * A custom Cmd.Cancel function should not call Wait. + * + * Wait releases any resources associated with the [Cmd]. */ - executeContext(ctx: context.Context): void + wait(): void } - interface Command { + interface Cmd { /** - * Execute uses the args (os.Args[1:] by default) - * and run through the command tree finding appropriate matches - * for commands and then corresponding flags. + * Output runs the command and returns its standard output. + * Any returned error will usually be of type [*ExitError]. + * If c.Stderr was nil and the returned error is of type + * [*ExitError], Output populates the Stderr field of the + * returned error. */ - execute(): void + output(): string|Array } - interface Command { + interface Cmd { /** - * ExecuteContextC is the same as ExecuteC(), but sets the ctx on the command. - * Retrieve ctx by calling cmd.Context() inside your *Run lifecycle or ValidArgs - * functions. + * CombinedOutput runs the command and returns its combined standard + * output and standard error. */ - executeContextC(ctx: context.Context): (Command | undefined) + combinedOutput(): string|Array } - interface Command { + interface Cmd { /** - * ExecuteC executes the command. + * StdinPipe returns a pipe that will be connected to the command's + * standard input when the command starts. + * The pipe will be closed automatically after [Cmd.Wait] sees the command exit. + * A caller need only call Close to force the pipe to close sooner. + * For example, if the command being run will not exit until standard input + * is closed, the caller must close the pipe. */ - executeC(): (Command | undefined) - } - interface Command { - validateArgs(args: Array): void + stdinPipe(): io.WriteCloser } - interface Command { + interface Cmd { /** - * ValidateRequiredFlags validates all required flags are present and returns an error otherwise + * StdoutPipe returns a pipe that will be connected to the command's + * standard output when the command starts. + * + * [Cmd.Wait] will close the pipe after seeing the command exit, so most callers + * need not close the pipe themselves. It is thus incorrect to call Wait + * before all reads from the pipe have completed. + * For the same reason, it is incorrect to call [Cmd.Run] when using StdoutPipe. + * See the example for idiomatic usage. */ - validateRequiredFlags(): void + stdoutPipe(): io.ReadCloser } - interface Command { + interface Cmd { /** - * InitDefaultHelpFlag adds default help flag to c. - * It is called automatically by executing the c or by calling help and usage. - * If c already has help flag, it will do nothing. + * StderrPipe returns a pipe that will be connected to the command's + * standard error when the command starts. + * + * [Cmd.Wait] will close the pipe after seeing the command exit, so most callers + * need not close the pipe themselves. It is thus incorrect to call Wait + * before all reads from the pipe have completed. + * For the same reason, it is incorrect to use [Cmd.Run] when using StderrPipe. + * See the StdoutPipe example for idiomatic usage. */ - initDefaultHelpFlag(): void + stderrPipe(): io.ReadCloser } - interface Command { + interface Cmd { /** - * InitDefaultVersionFlag adds default version flag to c. - * It is called automatically by executing the c. - * If c already has a version flag, it will do nothing. - * If c.Version is empty, it will do nothing. + * Environ returns a copy of the environment in which the command would be run + * as it is currently configured. */ - initDefaultVersionFlag(): void + environ(): Array } - interface Command { +} + +namespace mailer { + /** + * Message defines a generic email message struct. + */ + interface Message { + from: { address: string; name?: string; } + to: Array<{ address: string; name?: string; }> + bcc: Array<{ address: string; name?: string; }> + cc: Array<{ address: string; name?: string; }> + subject: string + html: string + text: string + headers: _TygojaDict + attachments: _TygojaDict + inlineAttachments: _TygojaDict + } + /** + * Mailer defines a base mail client interface. + */ + interface Mailer { + [key:string]: any; /** - * InitDefaultHelpCmd adds default help command to c. - * It is called automatically by executing the c or by calling help and usage. - * If c already has help command or c has no subcommands, it will do nothing. + * Send sends an email with the provided Message. */ - initDefaultHelpCmd(): void + send(message: Message): void } +} + +/** + * Package cobra is a commander providing a simple interface to create powerful modern CLI interfaces. + * In addition to providing an interface, Cobra simultaneously provides a controller to organize your application code. + */ +namespace cobra { interface Command { /** - * ResetCommands delete parent, subcommand and help command from c. + * GenBashCompletion generates bash completion file and writes to the passed writer. */ - resetCommands(): void + genBashCompletion(w: io.Writer): void } interface Command { /** - * Commands returns a sorted slice of child commands. + * GenBashCompletionFile generates bash completion file. */ - commands(): Array<(Command | undefined)> + genBashCompletionFile(filename: string): void } interface Command { /** - * AddCommand adds one or more commands to this parent command. + * GenBashCompletionFileV2 generates Bash completion version 2. */ - addCommand(...cmds: (Command | undefined)[]): void + genBashCompletionFileV2(filename: string, includeDesc: boolean): void } interface Command { /** - * Groups returns a slice of child command groups. + * GenBashCompletionV2 generates Bash completion file version 2 + * and writes it to the passed writer. */ - groups(): Array<(Group | undefined)> + genBashCompletionV2(w: io.Writer, includeDesc: boolean): void } + // @ts-ignore + import flag = pflag + /** + * Command is just that, a command for your application. + * E.g. 'go run ...' - 'run' is the command. Cobra requires + * you to define the usage and description as part of your command + * definition to ensure usability. + */ interface Command { /** - * AllChildCommandsHaveGroup returns if all subcommands are assigned to a group + * Use is the one-line usage message. + * Recommended syntax is as follows: + * ``` + * [ ] identifies an optional argument. Arguments that are not enclosed in brackets are required. + * ... indicates that you can specify multiple values for the previous argument. + * | indicates mutually exclusive information. You can use the argument to the left of the separator or the + * argument to the right of the separator. You cannot use both arguments in a single use of the command. + * { } delimits a set of mutually exclusive arguments when one of the arguments is required. If the arguments are + * optional, they are enclosed in brackets ([ ]). + * ``` + * Example: add [-F file | -D dir]... [-f format] profile */ - allChildCommandsHaveGroup(): boolean - } - interface Command { + use: string /** - * ContainsGroup return if groupID exists in the list of command groups. + * Aliases is an array of aliases that can be used instead of the first word in Use. */ - containsGroup(groupID: string): boolean - } - interface Command { + aliases: Array /** - * AddGroup adds one or more command groups to this parent command. + * SuggestFor is an array of command names for which this command will be suggested - + * similar to aliases but only suggests. */ - addGroup(...groups: (Group | undefined)[]): void - } - interface Command { + suggestFor: Array /** - * RemoveCommand removes one or more commands from a parent command. + * Short is the short description shown in the 'help' output. */ - removeCommand(...cmds: (Command | undefined)[]): void - } - interface Command { + short: string /** - * Print is a convenience method to Print to the defined output, fallback to Stderr if not set. + * The group id under which this subcommand is grouped in the 'help' output of its parent. */ - print(...i: { - }[]): void - } - interface Command { + groupID: string /** - * Println is a convenience method to Println to the defined output, fallback to Stderr if not set. + * Long is the long message shown in the 'help ' output. */ - println(...i: { - }[]): void - } - interface Command { + long: string /** - * Printf is a convenience method to Printf to the defined output, fallback to Stderr if not set. + * Example is examples of how to use the command. */ - printf(format: string, ...i: { - }[]): void - } - interface Command { + example: string /** - * PrintErr is a convenience method to Print to the defined Err output, fallback to Stderr if not set. + * ValidArgs is list of all valid non-flag arguments that are accepted in shell completions */ - printErr(...i: { - }[]): void - } - interface Command { + validArgs: Array /** - * PrintErrln is a convenience method to Println to the defined Err output, fallback to Stderr if not set. + * ValidArgsFunction is an optional function that provides valid non-flag arguments for shell completion. + * It is a dynamic version of using ValidArgs. + * Only one of ValidArgs and ValidArgsFunction can be used for a command. */ - printErrln(...i: { - }[]): void - } - interface Command { + validArgsFunction: CompletionFunc /** - * PrintErrf is a convenience method to Printf to the defined Err output, fallback to Stderr if not set. + * Expected arguments */ - printErrf(format: string, ...i: { - }[]): void - } - interface Command { + args: PositionalArgs /** - * CommandPath returns the full path to this command. + * ArgAliases is List of aliases for ValidArgs. + * These are not suggested to the user in the shell completion, + * but accepted if entered manually. */ - commandPath(): string - } - interface Command { + argAliases: Array /** - * UseLine puts out the full usage for a given command (including parents). + * BashCompletionFunction is custom bash functions used by the legacy bash autocompletion generator. + * For portability with other shells, it is recommended to instead use ValidArgsFunction */ - useLine(): string - } - interface Command { + bashCompletionFunction: string /** - * DebugFlags used to determine which flags have been assigned to which commands - * and which persist. + * Deprecated defines, if this command is deprecated and should print this string when used. */ - debugFlags(): void - } - interface Command { + deprecated: string /** - * Name returns the command's name: the first word in the use line. + * Annotations are key/value pairs that can be used by applications to identify or + * group commands or set special options. */ - name(): string - } - interface Command { + annotations: _TygojaDict /** - * HasAlias determines if a given string is an alias of the command. + * Version defines the version for this command. If this value is non-empty and the command does not + * define a "version" flag, a "version" boolean flag will be added to the command and, if specified, + * will print content of the "Version" variable. A shorthand "v" flag will also be added if the + * command does not define one. */ - hasAlias(s: string): boolean - } - interface Command { + version: string /** - * CalledAs returns the command name or alias that was used to invoke - * this command or an empty string if the command has not been called. + * The *Run functions are executed in the following order: + * ``` + * * PersistentPreRun() + * * PreRun() + * * Run() + * * PostRun() + * * PersistentPostRun() + * ``` + * All functions get the same args, the arguments after the command name. + * The *PreRun and *PostRun functions will only be executed if the Run function of the current + * command has been declared. + * + * PersistentPreRun: children of this command will inherit and execute. */ - calledAs(): string - } - interface Command { + persistentPreRun: (cmd: Command, args: Array) => void /** - * NameAndAliases returns a list of the command name and all aliases + * PersistentPreRunE: PersistentPreRun but returns an error. */ - nameAndAliases(): string - } - interface Command { + persistentPreRunE: (cmd: Command, args: Array) => void /** - * HasExample determines if the command has example. + * PreRun: children of this command will not inherit. */ - hasExample(): boolean - } - interface Command { + preRun: (cmd: Command, args: Array) => void /** - * Runnable determines if the command is itself runnable. + * PreRunE: PreRun but returns an error. */ - runnable(): boolean - } - interface Command { + preRunE: (cmd: Command, args: Array) => void /** - * HasSubCommands determines if the command has children commands. + * Run: Typically the actual work function. Most commands will only implement this. */ - hasSubCommands(): boolean - } - interface Command { + run: (cmd: Command, args: Array) => void /** - * IsAvailableCommand determines if a command is available as a non-help command - * (this includes all non deprecated/hidden commands). + * RunE: Run but returns an error. */ - isAvailableCommand(): boolean - } - interface Command { + runE: (cmd: Command, args: Array) => void /** - * IsAdditionalHelpTopicCommand determines if a command is an additional - * help topic command; additional help topic command is determined by the - * fact that it is NOT runnable/hidden/deprecated, and has no sub commands that - * are runnable/hidden/deprecated. - * Concrete example: https://github.com/spf13/cobra/issues/393#issuecomment-282741924. + * PostRun: run after the Run command. */ - isAdditionalHelpTopicCommand(): boolean - } - interface Command { + postRun: (cmd: Command, args: Array) => void /** - * HasHelpSubCommands determines if a command has any available 'help' sub commands - * that need to be shown in the usage/help default template under 'additional help - * topics'. + * PostRunE: PostRun but returns an error. */ - hasHelpSubCommands(): boolean - } - interface Command { + postRunE: (cmd: Command, args: Array) => void /** - * HasAvailableSubCommands determines if a command has available sub commands that - * need to be shown in the usage/help default template under 'available commands'. + * PersistentPostRun: children of this command will inherit and execute after PostRun. */ - hasAvailableSubCommands(): boolean - } - interface Command { + persistentPostRun: (cmd: Command, args: Array) => void /** - * HasParent determines if the command is a child command. + * PersistentPostRunE: PersistentPostRun but returns an error. */ - hasParent(): boolean - } - interface Command { + persistentPostRunE: (cmd: Command, args: Array) => void /** - * GlobalNormalizationFunc returns the global normalization function or nil if it doesn't exist. + * FParseErrWhitelist flag parse errors to be ignored */ - globalNormalizationFunc(): (f: any, name: string) => any - } - interface Command { + fParseErrWhitelist: FParseErrWhitelist /** - * Flags returns the complete FlagSet that applies - * to this command (local and persistent declared here and by all parents). + * CompletionOptions is a set of options to control the handling of shell completion */ - flags(): (any | undefined) - } - interface Command { + completionOptions: CompletionOptions /** - * LocalNonPersistentFlags are flags specific to this command which will NOT persist to subcommands. + * TraverseChildren parses flags on all parents before executing child command. */ - localNonPersistentFlags(): (any | undefined) - } - interface Command { + traverseChildren: boolean /** - * LocalFlags returns the local FlagSet specifically set in the current command. + * Hidden defines, if this command is hidden and should NOT show up in the list of available commands. */ - localFlags(): (any | undefined) - } - interface Command { + hidden: boolean /** - * InheritedFlags returns all flags which were inherited from parent commands. + * SilenceErrors is an option to quiet errors down stream. */ - inheritedFlags(): (any | undefined) - } - interface Command { + silenceErrors: boolean /** - * NonInheritedFlags returns all flags which were not inherited from parent commands. + * SilenceUsage is an option to silence usage when an error occurs. */ - nonInheritedFlags(): (any | undefined) - } - interface Command { + silenceUsage: boolean /** - * PersistentFlags returns the persistent FlagSet specifically set in the current command. + * DisableFlagParsing disables the flag parsing. + * If this is true all flags will be passed to the command as arguments. */ - persistentFlags(): (any | undefined) - } - interface Command { + disableFlagParsing: boolean /** - * ResetFlags deletes all flags from command. + * DisableAutoGenTag defines, if gen tag ("Auto generated by spf13/cobra...") + * will be printed by generating docs for this command. */ - resetFlags(): void - } - interface Command { + disableAutoGenTag: boolean /** - * HasFlags checks if the command contains any flags (local plus persistent from the entire structure). + * DisableFlagsInUseLine will disable the addition of [flags] to the usage + * line of a command when printing help or generating docs */ - hasFlags(): boolean - } - interface Command { + disableFlagsInUseLine: boolean /** - * HasPersistentFlags checks if the command contains persistent flags. + * DisableSuggestions disables the suggestions based on Levenshtein distance + * that go along with 'unknown command' messages. */ - hasPersistentFlags(): boolean - } - interface Command { + disableSuggestions: boolean /** - * HasLocalFlags checks if the command has flags specifically declared locally. + * SuggestionsMinimumDistance defines minimum levenshtein distance to display suggestions. + * Must be > 0. */ - hasLocalFlags(): boolean + suggestionsMinimumDistance: number } interface Command { /** - * HasInheritedFlags checks if the command has flags inherited from its parent command. + * Context returns underlying command context. If command was executed + * with ExecuteContext or the context was set with SetContext, the + * previously set context will be returned. Otherwise, nil is returned. + * + * Notice that a call to Execute and ExecuteC will replace a nil context of + * a command with a context.Background, so a background context will be + * returned by Context after one of these functions has been called. */ - hasInheritedFlags(): boolean + context(): context.Context } interface Command { /** - * HasAvailableFlags checks if the command contains any flags (local plus persistent from the entire - * structure) which are not hidden or deprecated. + * SetContext sets context for the command. This context will be overwritten by + * Command.ExecuteContext or Command.ExecuteContextC. */ - hasAvailableFlags(): boolean + setContext(ctx: context.Context): void } interface Command { /** - * HasAvailablePersistentFlags checks if the command contains persistent flags which are not hidden or deprecated. + * SetArgs sets arguments for the command. It is set to os.Args[1:] by default, if desired, can be overridden + * particularly useful when testing. */ - hasAvailablePersistentFlags(): boolean + setArgs(a: Array): void } interface Command { /** - * HasAvailableLocalFlags checks if the command has flags specifically declared locally which are not hidden - * or deprecated. + * SetOutput sets the destination for usage and error messages. + * If output is nil, os.Stderr is used. + * + * Deprecated: Use SetOut and/or SetErr instead */ - hasAvailableLocalFlags(): boolean + setOutput(output: io.Writer): void } interface Command { /** - * HasAvailableInheritedFlags checks if the command has flags inherited from its parent command which are - * not hidden or deprecated. + * SetOut sets the destination for usage messages. + * If newOut is nil, os.Stdout is used. */ - hasAvailableInheritedFlags(): boolean + setOut(newOut: io.Writer): void } interface Command { /** - * Flag climbs up the command tree looking for matching flag. + * SetErr sets the destination for error messages. + * If newErr is nil, os.Stderr is used. */ - flag(name: string): (any | undefined) + setErr(newErr: io.Writer): void } interface Command { /** - * ParseFlags parses persistent flag tree and local flags. + * SetIn sets the source for input data + * If newIn is nil, os.Stdin is used. */ - parseFlags(args: Array): void + setIn(newIn: io.Reader): void } interface Command { /** - * Parent returns a commands parent command. + * SetUsageFunc sets usage function. Usage can be defined by application. */ - parent(): (Command | undefined) + setUsageFunc(f: (_arg0: Command) => void): void } interface Command { /** - * RegisterFlagCompletionFunc should be called to register a function to provide completion for a flag. + * SetUsageTemplate sets usage template. Can be defined by Application. */ - registerFlagCompletionFunc(flagName: string, f: (cmd: Command, args: Array, toComplete: string) => [Array, ShellCompDirective]): void + setUsageTemplate(s: string): void } interface Command { /** - * InitDefaultCompletionCmd adds a default 'completion' command to c. - * This function will do nothing if any of the following is true: - * 1- the feature has been explicitly disabled by the program, - * 2- c has no subcommands (to avoid creating one), - * 3- c already has a 'completion' command provided by the program. + * SetFlagErrorFunc sets a function to generate an error when flag parsing + * fails. */ - initDefaultCompletionCmd(): void + setFlagErrorFunc(f: (_arg0: Command, _arg1: Error) => void): void } interface Command { /** - * GenFishCompletion generates fish completion file and writes to the passed writer. + * SetHelpFunc sets help function. Can be defined by Application. */ - genFishCompletion(w: io.Writer, includeDesc: boolean): void + setHelpFunc(f: (_arg0: Command, _arg1: Array) => void): void } interface Command { /** - * GenFishCompletionFile generates fish completion file. + * SetHelpCommand sets help command. */ - genFishCompletionFile(filename: string, includeDesc: boolean): void + setHelpCommand(cmd: Command): void } interface Command { /** - * MarkFlagsRequiredTogether marks the given flags with annotations so that Cobra errors - * if the command is invoked with a subset (but not all) of the given flags. + * SetHelpCommandGroupID sets the group id of the help command. */ - markFlagsRequiredTogether(...flagNames: string[]): void + setHelpCommandGroupID(groupID: string): void } interface Command { /** - * MarkFlagsMutuallyExclusive marks the given flags with annotations so that Cobra errors - * if the command is invoked with more than one flag from the given set of flags. + * SetCompletionCommandGroupID sets the group id of the completion command. */ - markFlagsMutuallyExclusive(...flagNames: string[]): void + setCompletionCommandGroupID(groupID: string): void } interface Command { /** - * ValidateFlagGroups validates the mutuallyExclusive/requiredAsGroup logic and returns the - * first error encountered. + * SetHelpTemplate sets help template to be used. Application can use it to set custom template. */ - validateFlagGroups(): void + setHelpTemplate(s: string): void } interface Command { /** - * GenPowerShellCompletionFile generates powershell completion file without descriptions. + * SetVersionTemplate sets version template to be used. Application can use it to set custom template. */ - genPowerShellCompletionFile(filename: string): void + setVersionTemplate(s: string): void } interface Command { /** - * GenPowerShellCompletion generates powershell completion file without descriptions - * and writes it to the passed writer. + * SetErrPrefix sets error message prefix to be used. Application can use it to set custom prefix. */ - genPowerShellCompletion(w: io.Writer): void + setErrPrefix(s: string): void } interface Command { /** - * GenPowerShellCompletionFileWithDesc generates powershell completion file with descriptions. + * SetGlobalNormalizationFunc sets a normalization function to all flag sets and also to child commands. + * The user should not have a cyclic dependency on commands. */ - genPowerShellCompletionFileWithDesc(filename: string): void + setGlobalNormalizationFunc(n: (f: any, name: string) => any): void } interface Command { /** - * GenPowerShellCompletionWithDesc generates powershell completion file with descriptions - * and writes it to the passed writer. + * OutOrStdout returns output to stdout. */ - genPowerShellCompletionWithDesc(w: io.Writer): void + outOrStdout(): io.Writer } interface Command { /** - * MarkFlagRequired instructs the various shell completion implementations to - * prioritize the named flag when performing completion, - * and causes your command to report an error if invoked without the flag. + * OutOrStderr returns output to stderr */ - markFlagRequired(name: string): void + outOrStderr(): io.Writer } interface Command { /** - * MarkPersistentFlagRequired instructs the various shell completion implementations to - * prioritize the named persistent flag when performing completion, - * and causes your command to report an error if invoked without the flag. + * ErrOrStderr returns output to stderr */ - markPersistentFlagRequired(name: string): void + errOrStderr(): io.Writer } interface Command { /** - * MarkFlagFilename instructs the various shell completion implementations to - * limit completions for the named flag to the specified file extensions. + * InOrStdin returns input to stdin */ - markFlagFilename(name: string, ...extensions: string[]): void + inOrStdin(): io.Reader } interface Command { /** - * MarkFlagCustom adds the BashCompCustom annotation to the named flag, if it exists. - * The bash completion script will call the bash function f for the flag. - * - * This will only work for bash completion. - * It is recommended to instead use c.RegisterFlagCompletionFunc(...) which allows - * to register a Go function which will work across all shells. + * UsageFunc returns either the function set by SetUsageFunc for this command + * or a parent, or it returns a default usage function. */ - markFlagCustom(name: string, f: string): void + usageFunc(): (_arg0: Command) => void } interface Command { /** - * MarkPersistentFlagFilename instructs the various shell completion - * implementations to limit completions for the named persistent flag to the - * specified file extensions. + * Usage puts out the usage for the command. + * Used when a user provides invalid input. + * Can be defined by user by overriding UsageFunc. */ - markPersistentFlagFilename(name: string, ...extensions: string[]): void + usage(): void } interface Command { /** - * MarkFlagDirname instructs the various shell completion implementations to - * limit completions for the named flag to directory names. + * HelpFunc returns either the function set by SetHelpFunc for this command + * or a parent, or it returns a function with default help behavior. */ - markFlagDirname(name: string): void + helpFunc(): (_arg0: Command, _arg1: Array) => void } interface Command { /** - * MarkPersistentFlagDirname instructs the various shell completion - * implementations to limit completions for the named persistent flag to - * directory names. + * Help puts out the help for the command. + * Used when a user calls help [command]. + * Can be defined by user by overriding HelpFunc. */ - markPersistentFlagDirname(name: string): void + help(): void } interface Command { /** - * GenZshCompletionFile generates zsh completion file including descriptions. + * UsageString returns usage string. */ - genZshCompletionFile(filename: string): void + usageString(): string } interface Command { /** - * GenZshCompletion generates zsh completion file including descriptions - * and writes it to the passed writer. + * FlagErrorFunc returns either the function set by SetFlagErrorFunc for this + * command or a parent, or it returns a function which returns the original + * error. */ - genZshCompletion(w: io.Writer): void + flagErrorFunc(): (_arg0: Command, _arg1: Error) => void } interface Command { /** - * GenZshCompletionFileNoDesc generates zsh completion file without descriptions. + * UsagePadding return padding for the usage. */ - genZshCompletionFileNoDesc(filename: string): void + usagePadding(): number } interface Command { /** - * GenZshCompletionNoDesc generates zsh completion file without descriptions - * and writes it to the passed writer. + * CommandPathPadding return padding for the command path. */ - genZshCompletionNoDesc(w: io.Writer): void + commandPathPadding(): number } interface Command { /** - * MarkZshCompPositionalArgumentFile only worked for zsh and its behavior was - * not consistent with Bash completion. It has therefore been disabled. - * Instead, when no other completion is specified, file completion is done by - * default for every argument. One can disable file completion on a per-argument - * basis by using ValidArgsFunction and ShellCompDirectiveNoFileComp. - * To achieve file extension filtering, one can use ValidArgsFunction and - * ShellCompDirectiveFilterFileExt. - * - * Deprecated + * NamePadding returns padding for the name. */ - markZshCompPositionalArgumentFile(argPosition: number, ...patterns: string[]): void + namePadding(): number } interface Command { /** - * MarkZshCompPositionalArgumentWords only worked for zsh. It has therefore - * been disabled. - * To achieve the same behavior across all shells, one can use - * ValidArgs (for the first argument only) or ValidArgsFunction for - * any argument (can include the first one also). - * - * Deprecated + * UsageTemplate returns usage template for the command. + * This function is kept for backwards-compatibility reasons. */ - markZshCompPositionalArgumentWords(argPosition: number, ...words: string[]): void - } -} - -/** - * Package syscall contains an interface to the low-level operating system - * primitives. The details vary depending on the underlying system, and - * by default, godoc will display the syscall documentation for the current - * system. If you want godoc to display syscall documentation for another - * system, set $GOOS and $GOARCH to the desired system. For example, if - * you want to view documentation for freebsd/arm on linux/amd64, set $GOOS - * to freebsd and $GOARCH to arm. - * The primary use of syscall is inside other packages that provide a more - * portable interface to the system, such as "os", "time" and "net". Use - * those packages rather than this one if you can. - * For details of the functions and data types in this package consult - * the manuals for the appropriate operating system. - * These calls return err == nil to indicate success; otherwise - * err is an operating system error describing the failure. - * On most systems, that error has type syscall.Errno. - * - * Deprecated: this package is locked down. Callers should use the - * corresponding package in the golang.org/x/sys repository instead. - * That is also where updates required by new systems or versions - * should be applied. See https://golang.org/s/go1.4-syscall for more - * information. - */ -namespace syscall { - /** - * SysProcIDMap holds Container ID to Host ID mappings used for User Namespaces in Linux. - * See user_namespaces(7). - */ - interface SysProcIDMap { - containerID: number // Container ID. - hostID: number // Host ID. - size: number // Size. - } - // @ts-ignore - import errorspkg = errors - /** - * Credential holds user and group identities to be assumed - * by a child process started by StartProcess. - */ - interface Credential { - uid: number // User ID. - gid: number // Group ID. - groups: Array // Supplementary group IDs. - noSetGroups: boolean // If true, don't set supplementary groups - } - /** - * A Signal is a number describing a process signal. - * It implements the os.Signal interface. - */ - interface Signal extends Number{} - interface Signal { - signal(): void + usageTemplate(): string } - interface Signal { - string(): string + interface Command { + /** + * HelpTemplate return help template for the command. + * This function is kept for backwards-compatibility reasons. + */ + helpTemplate(): string } -} - -/** - * Package time provides functionality for measuring and displaying time. - * - * The calendrical calculations always assume a Gregorian calendar, with - * no leap seconds. - * - * Monotonic Clocks - * - * Operating systems provide both a “wall clock,” which is subject to - * changes for clock synchronization, and a “monotonic clock,” which is - * not. The general rule is that the wall clock is for telling time and - * the monotonic clock is for measuring time. Rather than split the API, - * in this package the Time returned by time.Now contains both a wall - * clock reading and a monotonic clock reading; later time-telling - * operations use the wall clock reading, but later time-measuring - * operations, specifically comparisons and subtractions, use the - * monotonic clock reading. - * - * For example, this code always computes a positive elapsed time of - * approximately 20 milliseconds, even if the wall clock is changed during - * the operation being timed: - * - * ``` - * start := time.Now() - * ... operation that takes 20 milliseconds ... - * t := time.Now() - * elapsed := t.Sub(start) - * ``` - * - * Other idioms, such as time.Since(start), time.Until(deadline), and - * time.Now().Before(deadline), are similarly robust against wall clock - * resets. - * - * The rest of this section gives the precise details of how operations - * use monotonic clocks, but understanding those details is not required - * to use this package. - * - * The Time returned by time.Now contains a monotonic clock reading. - * If Time t has a monotonic clock reading, t.Add adds the same duration to - * both the wall clock and monotonic clock readings to compute the result. - * Because t.AddDate(y, m, d), t.Round(d), and t.Truncate(d) are wall time - * computations, they always strip any monotonic clock reading from their results. - * Because t.In, t.Local, and t.UTC are used for their effect on the interpretation - * of the wall time, they also strip any monotonic clock reading from their results. - * The canonical way to strip a monotonic clock reading is to use t = t.Round(0). - * - * If Times t and u both contain monotonic clock readings, the operations - * t.After(u), t.Before(u), t.Equal(u), and t.Sub(u) are carried out - * using the monotonic clock readings alone, ignoring the wall clock - * readings. If either t or u contains no monotonic clock reading, these - * operations fall back to using the wall clock readings. - * - * On some systems the monotonic clock will stop if the computer goes to sleep. - * On such a system, t.Sub(u) may not accurately reflect the actual - * time that passed between t and u. - * - * Because the monotonic clock reading has no meaning outside - * the current process, the serialized forms generated by t.GobEncode, - * t.MarshalBinary, t.MarshalJSON, and t.MarshalText omit the monotonic - * clock reading, and t.Format provides no format for it. Similarly, the - * constructors time.Date, time.Parse, time.ParseInLocation, and time.Unix, - * as well as the unmarshalers t.GobDecode, t.UnmarshalBinary. - * t.UnmarshalJSON, and t.UnmarshalText always create times with - * no monotonic clock reading. - * - * Note that the Go == operator compares not just the time instant but - * also the Location and the monotonic clock reading. See the - * documentation for the Time type for a discussion of equality - * testing for Time values. - * - * For debugging, the result of t.String does include the monotonic - * clock reading if present. If t != u because of different monotonic clock readings, - * that difference will be visible when printing t.String() and u.String(). - */ -namespace time { - /** - * A Month specifies a month of the year (January = 1, ...). - */ - interface Month extends Number{} - interface Month { + interface Command { /** - * String returns the English name of the month ("January", "February", ...). + * VersionTemplate return version template for the command. + * This function is kept for backwards-compatibility reasons. */ - string(): string + versionTemplate(): string } - /** - * A Weekday specifies a day of the week (Sunday = 0, ...). - */ - interface Weekday extends Number{} - interface Weekday { + interface Command { /** - * String returns the English name of the day ("Sunday", "Monday", ...). + * ErrPrefix return error message prefix for the command */ - string(): string - } - /** - * A Location maps time instants to the zone in use at that time. - * Typically, the Location represents the collection of time offsets - * in use in a geographical area. For many Locations the time offset varies - * depending on whether daylight savings time is in use at the time instant. - */ - interface Location { + errPrefix(): string } - interface Location { + interface Command { /** - * String returns a descriptive name for the time zone information, - * corresponding to the name argument to LoadLocation or FixedZone. + * Find the target command given the args and command tree + * Meant to be run on the highest node. Only searches down. */ - string(): string - } -} - -/** - * Package context defines the Context type, which carries deadlines, - * cancellation signals, and other request-scoped values across API boundaries - * and between processes. - * - * Incoming requests to a server should create a Context, and outgoing - * calls to servers should accept a Context. The chain of function - * calls between them must propagate the Context, optionally replacing - * it with a derived Context created using WithCancel, WithDeadline, - * WithTimeout, or WithValue. When a Context is canceled, all - * Contexts derived from it are also canceled. - * - * The WithCancel, WithDeadline, and WithTimeout functions take a - * Context (the parent) and return a derived Context (the child) and a - * CancelFunc. Calling the CancelFunc cancels the child and its - * children, removes the parent's reference to the child, and stops - * any associated timers. Failing to call the CancelFunc leaks the - * child and its children until the parent is canceled or the timer - * fires. The go vet tool checks that CancelFuncs are used on all - * control-flow paths. - * - * Programs that use Contexts should follow these rules to keep interfaces - * consistent across packages and enable static analysis tools to check context - * propagation: - * - * Do not store Contexts inside a struct type; instead, pass a Context - * explicitly to each function that needs it. The Context should be the first - * parameter, typically named ctx: - * - * ``` - * func DoSomething(ctx context.Context, arg Arg) error { - * // ... use ctx ... - * } - * ``` - * - * Do not pass a nil Context, even if a function permits it. Pass context.TODO - * if you are unsure about which Context to use. - * - * Use context Values only for request-scoped data that transits processes and - * APIs, not for passing optional parameters to functions. - * - * The same Context may be passed to functions running in different goroutines; - * Contexts are safe for simultaneous use by multiple goroutines. - * - * See https://blog.golang.org/context for example code for a server that uses - * Contexts. - */ -namespace context { -} - -/** - * 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 { - /** - * ReadCloser is the interface that groups the basic Read and Close methods. - */ - interface ReadCloser { + find(args: Array): [(Command), Array] } - /** - * WriteCloser is the interface that groups the basic Write and Close methods. - */ - interface WriteCloser { + interface Command { + /** + * Traverse the command tree to find the command, and parse args for + * each parent. + */ + traverse(args: Array): [(Command), Array] } -} - -/** - * Package fs defines basic interfaces to a file system. - * A file system can be provided by the host operating system - * but also by other packages. - */ -namespace fs { -} - -/** - * Package url parses URLs and implements query escaping. - */ -namespace url { - /** - * A URL represents a parsed URL (technically, a URI reference). - * - * The general form represented is: - * - * ``` - * [scheme:][//[userinfo@]host][/]path[?query][#fragment] - * ``` - * - * URLs that do not start with a slash after the scheme are interpreted as: - * - * ``` - * scheme:opaque[?query][#fragment] - * ``` - * - * Note that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/. - * A consequence is that it is impossible to tell which slashes in the Path were - * slashes in the raw URL and which were %2f. This distinction is rarely important, - * but when it is, the code should use RawPath, an optional field which only gets - * set if the default encoding is different from Path. - * - * URL's String method uses the EscapedPath method to obtain the path. See the - * EscapedPath method for more details. - */ - interface URL { - scheme: string - opaque: string // encoded opaque data - user?: Userinfo // username and password information - host: string // host or host:port - path: string // path (relative paths may omit leading slash) - rawPath: string // encoded path hint (see EscapedPath method) - forceQuery: boolean // append a query ('?') even if RawQuery is empty - rawQuery: string // encoded query values, without '?' - fragment: string // fragment for references, without '#' - rawFragment: string // encoded fragment hint (see EscapedFragment method) + interface Command { + /** + * SuggestionsFor provides suggestions for the typedName. + */ + suggestionsFor(typedName: string): Array } - interface URL { + interface Command { /** - * EscapedPath returns the escaped form of u.Path. - * In general there are multiple possible escaped forms of any path. - * EscapedPath returns u.RawPath when it is a valid escaping of u.Path. - * Otherwise EscapedPath ignores u.RawPath and computes an escaped - * form on its own. - * The String and RequestURI methods use EscapedPath to construct - * their results. - * In general, code should call EscapedPath instead of - * reading u.RawPath directly. + * VisitParents visits all parents of the command and invokes fn on each parent. */ - escapedPath(): string + visitParents(fn: (_arg0: Command) => void): void } - interface URL { + interface Command { /** - * EscapedFragment returns the escaped form of u.Fragment. - * In general there are multiple possible escaped forms of any fragment. - * EscapedFragment returns u.RawFragment when it is a valid escaping of u.Fragment. - * Otherwise EscapedFragment ignores u.RawFragment and computes an escaped - * form on its own. - * The String method uses EscapedFragment to construct its result. - * In general, code should call EscapedFragment instead of - * reading u.RawFragment directly. + * Root finds root command. */ - escapedFragment(): string + root(): (Command) } - interface URL { + interface Command { /** - * String reassembles the URL into a valid URL string. - * The general form of the result is one of: - * - * ``` - * scheme:opaque?query#fragment - * scheme://userinfo@host/path?query#fragment - * ``` - * - * If u.Opaque is non-empty, String uses the first form; - * otherwise it uses the second form. - * Any non-ASCII characters in host are escaped. - * To obtain the path, String uses u.EscapedPath(). - * - * In the second form, the following rules apply: - * ``` - * - if u.Scheme is empty, scheme: is omitted. - * - if u.User is nil, userinfo@ is omitted. - * - if u.Host is empty, host/ is omitted. - * - if u.Scheme and u.Host are empty and u.User is nil, - * the entire scheme://userinfo@host/ is omitted. - * - if u.Host is non-empty and u.Path begins with a /, - * the form host/path does not add its own /. - * - if u.RawQuery is empty, ?query is omitted. - * - if u.Fragment is empty, #fragment is omitted. - * ``` + * ArgsLenAtDash will return the length of c.Flags().Args at the moment + * when a -- was found during args parsing. */ - string(): string + argsLenAtDash(): number } - interface URL { + interface Command { /** - * Redacted is like String but replaces any password with "xxxxx". - * Only the password in u.URL is redacted. + * ExecuteContext is the same as Execute(), but sets the ctx on the command. + * Retrieve ctx by calling cmd.Context() inside your *Run lifecycle or ValidArgs + * functions. */ - redacted(): string + executeContext(ctx: context.Context): void } - /** - * Values maps a string key to a list of values. - * It is typically used for query parameters and form values. - * Unlike in the http.Header map, the keys in a Values map - * are case-sensitive. - */ - interface Values extends _TygojaDict{} - interface Values { + interface Command { /** - * Get gets the first value associated with the given key. - * If there are no values associated with the key, Get returns - * the empty string. To access multiple values, use the map - * directly. + * Execute uses the args (os.Args[1:] by default) + * and run through the command tree finding appropriate matches + * for commands and then corresponding flags. */ - get(key: string): string + execute(): void } - interface Values { + interface Command { /** - * Set sets the key to value. It replaces any existing - * values. + * ExecuteContextC is the same as ExecuteC(), but sets the ctx on the command. + * Retrieve ctx by calling cmd.Context() inside your *Run lifecycle or ValidArgs + * functions. */ - set(key: string): void + executeContextC(ctx: context.Context): (Command) } - interface Values { + interface Command { /** - * Add adds the value to key. It appends to any existing - * values associated with key. + * ExecuteC executes the command. */ - add(key: string): void + executeC(): (Command) } - interface Values { + interface Command { + validateArgs(args: Array): void + } + interface Command { /** - * Del deletes the values associated with key. + * ValidateRequiredFlags validates all required flags are present and returns an error otherwise */ - del(key: string): void + validateRequiredFlags(): void } - interface Values { + interface Command { /** - * Has checks whether a given key is set. + * InitDefaultHelpFlag adds default help flag to c. + * It is called automatically by executing the c or by calling help and usage. + * If c already has help flag, it will do nothing. */ - has(key: string): boolean + initDefaultHelpFlag(): void } - interface Values { + interface Command { /** - * Encode encodes the values into ``URL encoded'' form - * ("bar=baz&foo=quux") sorted by key. + * InitDefaultVersionFlag adds default version flag to c. + * It is called automatically by executing the c. + * If c already has a version flag, it will do nothing. + * If c.Version is empty, it will do nothing. */ - encode(): string + initDefaultVersionFlag(): void } - interface URL { + interface Command { /** - * IsAbs reports whether the URL is absolute. - * Absolute means that it has a non-empty scheme. + * InitDefaultHelpCmd adds default help command to c. + * It is called automatically by executing the c or by calling help and usage. + * If c already has help command or c has no subcommands, it will do nothing. */ - isAbs(): boolean + initDefaultHelpCmd(): void } - interface URL { + interface Command { /** - * Parse parses a URL in the context of the receiver. The provided URL - * may be relative or absolute. Parse returns nil, err on parse - * failure, otherwise its return value is the same as ResolveReference. + * ResetCommands delete parent, subcommand and help command from c. */ - parse(ref: string): (URL | undefined) + resetCommands(): void } - interface URL { + interface Command { /** - * ResolveReference resolves a URI reference to an absolute URI from - * an absolute base URI u, per RFC 3986 Section 5.2. The URI reference - * may be relative or absolute. ResolveReference always returns a new - * URL instance, even if the returned URL is identical to either the - * base or reference. If ref is an absolute URL, then ResolveReference - * ignores base and returns a copy of ref. + * Commands returns a sorted slice of child commands. */ - resolveReference(ref: URL): (URL | undefined) + commands(): Array<(Command | undefined)> } - interface URL { + interface Command { /** - * Query parses RawQuery and returns the corresponding values. - * It silently discards malformed value pairs. - * To check errors use ParseQuery. + * AddCommand adds one or more commands to this parent command. */ - query(): Values + addCommand(...cmds: (Command | undefined)[]): void } - interface URL { + interface Command { /** - * RequestURI returns the encoded path?query or opaque?query - * string that would be used in an HTTP request for u. + * Groups returns a slice of child command groups. */ - requestURI(): string + groups(): Array<(Group | undefined)> } - interface URL { + interface Command { /** - * Hostname returns u.Host, stripping any valid port number if present. - * - * If the result is enclosed in square brackets, as literal IPv6 addresses are, - * the square brackets are removed from the result. + * AllChildCommandsHaveGroup returns if all subcommands are assigned to a group */ - hostname(): string + allChildCommandsHaveGroup(): boolean } - interface URL { + interface Command { /** - * Port returns the port part of u.Host, without the leading colon. - * - * If u.Host doesn't contain a valid numeric port, Port returns an empty string. + * ContainsGroup return if groupID exists in the list of command groups. */ - port(): string - } - interface URL { - marshalBinary(): string - } - interface URL { - unmarshalBinary(text: string): void + containsGroup(groupID: string): boolean } -} - -/** - * Package net provides a portable interface for network I/O, including - * TCP/IP, UDP, domain name resolution, and Unix domain sockets. - * - * Although the package provides access to low-level networking - * primitives, most clients will need only the basic interface provided - * by the Dial, Listen, and Accept functions and the associated - * Conn and Listener interfaces. The crypto/tls package uses - * the same interfaces and similar Dial and Listen functions. - * - * The Dial function connects to a server: - * - * ``` - * conn, err := net.Dial("tcp", "golang.org:80") - * if err != nil { - * // handle error - * } - * fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n") - * status, err := bufio.NewReader(conn).ReadString('\n') - * // ... - * ``` - * - * The Listen function creates servers: - * - * ``` - * ln, err := net.Listen("tcp", ":8080") - * if err != nil { - * // handle error - * } - * for { - * conn, err := ln.Accept() - * if err != nil { - * // handle error - * } - * go handleConnection(conn) - * } - * ``` - * - * Name Resolution - * - * The method for resolving domain names, whether indirectly with functions like Dial - * or directly with functions like LookupHost and LookupAddr, varies by operating system. - * - * On Unix systems, the resolver has two options for resolving names. - * It can use a pure Go resolver that sends DNS requests directly to the servers - * listed in /etc/resolv.conf, or it can use a cgo-based resolver that calls C - * library routines such as getaddrinfo and getnameinfo. - * - * By default the pure Go resolver is used, because a blocked DNS request consumes - * only a goroutine, while a blocked C call consumes an operating system thread. - * When cgo is available, the cgo-based resolver is used instead under a variety of - * conditions: on systems that do not let programs make direct DNS requests (OS X), - * when the LOCALDOMAIN environment variable is present (even if empty), - * when the RES_OPTIONS or HOSTALIASES environment variable is non-empty, - * when the ASR_CONFIG environment variable is non-empty (OpenBSD only), - * when /etc/resolv.conf or /etc/nsswitch.conf specify the use of features that the - * Go resolver does not implement, and when the name being looked up ends in .local - * or is an mDNS name. - * - * The resolver decision can be overridden by setting the netdns value of the - * GODEBUG environment variable (see package runtime) to go or cgo, as in: - * - * ``` - * export GODEBUG=netdns=go # force pure Go resolver - * export GODEBUG=netdns=cgo # force cgo resolver - * ``` - * - * The decision can also be forced while building the Go source tree - * by setting the netgo or netcgo build tag. - * - * A numeric netdns setting, as in GODEBUG=netdns=1, causes the resolver - * to print debugging information about its decisions. - * To force a particular resolver while also printing debugging information, - * join the two settings by a plus sign, as in GODEBUG=netdns=go+1. - * - * On Plan 9, the resolver always accesses /net/cs and /net/dns. - * - * On Windows, the resolver always uses C library functions, such as GetAddrInfo and DnsQuery. - */ -namespace net { - /** - * Conn is a generic stream-oriented network connection. - * - * Multiple goroutines may invoke methods on a Conn simultaneously. - */ - interface Conn { + interface Command { /** - * Read reads data from the connection. - * Read can be made to time out and return an error after a fixed - * time limit; see SetDeadline and SetReadDeadline. + * AddGroup adds one or more command groups to this parent command. */ - read(b: string): number + addGroup(...groups: (Group | undefined)[]): void + } + interface Command { /** - * Write writes data to the connection. - * Write can be made to time out and return an error after a fixed - * time limit; see SetDeadline and SetWriteDeadline. + * RemoveCommand removes one or more commands from a parent command. */ - write(b: string): number + removeCommand(...cmds: (Command | undefined)[]): void + } + interface Command { /** - * Close closes the connection. - * Any blocked Read or Write operations will be unblocked and return errors. + * Print is a convenience method to Print to the defined output, fallback to Stderr if not set. */ - close(): void + print(...i: { + }[]): void + } + interface Command { /** - * LocalAddr returns the local network address, if known. + * Println is a convenience method to Println to the defined output, fallback to Stderr if not set. */ - localAddr(): Addr + println(...i: { + }[]): void + } + interface Command { /** - * RemoteAddr returns the remote network address, if known. + * Printf is a convenience method to Printf to the defined output, fallback to Stderr if not set. */ - remoteAddr(): Addr + printf(format: string, ...i: { + }[]): void + } + interface Command { /** - * SetDeadline sets the read and write deadlines associated - * with the connection. It is equivalent to calling both - * SetReadDeadline and SetWriteDeadline. - * - * A deadline is an absolute time after which I/O operations - * fail instead of blocking. The deadline applies to all future - * and pending I/O, not just the immediately following call to - * Read or Write. After a deadline has been exceeded, the - * connection can be refreshed by setting a deadline in the future. - * - * If the deadline is exceeded a call to Read or Write or to other - * I/O methods will return an error that wraps os.ErrDeadlineExceeded. - * This can be tested using errors.Is(err, os.ErrDeadlineExceeded). - * The error's Timeout method will return true, but note that there - * are other possible errors for which the Timeout method will - * return true even if the deadline has not been exceeded. - * - * An idle timeout can be implemented by repeatedly extending - * the deadline after successful Read or Write calls. - * - * A zero value for t means I/O operations will not time out. + * PrintErr is a convenience method to Print to the defined Err output, fallback to Stderr if not set. */ - setDeadline(t: time.Time): void + printErr(...i: { + }[]): void + } + interface Command { /** - * SetReadDeadline sets the deadline for future Read calls - * and any currently-blocked Read call. - * A zero value for t means Read will not time out. + * PrintErrln is a convenience method to Println to the defined Err output, fallback to Stderr if not set. */ - setReadDeadline(t: time.Time): void + printErrln(...i: { + }[]): void + } + interface Command { /** - * SetWriteDeadline sets the deadline for future Write calls - * and any currently-blocked Write call. - * Even if write times out, it may return n > 0, indicating that - * some of the data was successfully written. - * A zero value for t means Write will not time out. + * PrintErrf is a convenience method to Printf to the defined Err output, fallback to Stderr if not set. */ - setWriteDeadline(t: time.Time): void + printErrf(format: string, ...i: { + }[]): void } - /** - * A Listener is a generic network listener for stream-oriented protocols. - * - * Multiple goroutines may invoke methods on a Listener simultaneously. - */ - interface Listener { + interface Command { /** - * Accept waits for and returns the next connection to the listener. + * CommandPath returns the full path to this command. */ - accept(): Conn + commandPath(): string + } + interface Command { /** - * Close closes the listener. - * Any blocked Accept operations will be unblocked and return errors. + * DisplayName returns the name to display in help text. Returns command Name() + * If CommandDisplayNameAnnoation is not set */ - close(): void + displayName(): string + } + interface Command { /** - * Addr returns the listener's network address. + * UseLine puts out the full usage for a given command (including parents). */ - addr(): Addr + useLine(): string } -} - -/** - * Package textproto implements generic support for text-based request/response - * protocols in the style of HTTP, NNTP, and SMTP. - * - * The package provides: - * - * Error, which represents a numeric error response from - * a server. - * - * Pipeline, to manage pipelined requests and responses - * in a client. - * - * Reader, to read numeric response code lines, - * key: value headers, lines wrapped with leading spaces - * on continuation lines, and whole text blocks ending - * with a dot on a line by itself. - * - * Writer, to write dot-encoded text blocks. - * - * Conn, a convenient packaging of Reader, Writer, and Pipeline for use - * with a single network connection. - */ -namespace textproto { - /** - * A MIMEHeader represents a MIME-style header mapping - * keys to sets of values. - */ - interface MIMEHeader extends _TygojaDict{} - interface MIMEHeader { + interface Command { /** - * Add adds the key, value pair to the header. - * It appends to any existing values associated with key. + * DebugFlags used to determine which flags have been assigned to which commands + * and which persist. */ - add(key: string): void + debugFlags(): void } - interface MIMEHeader { + interface Command { /** - * Set sets the header entries associated with key to - * the single element value. It replaces any existing - * values associated with key. + * Name returns the command's name: the first word in the use line. */ - set(key: string): void + name(): string } - interface MIMEHeader { + interface Command { /** - * Get gets the first value associated with the given key. - * It is case insensitive; CanonicalMIMEHeaderKey is used - * to canonicalize the provided key. - * If there are no values associated with the key, Get returns "". - * To use non-canonical keys, access the map directly. + * HasAlias determines if a given string is an alias of the command. */ - get(key: string): string + hasAlias(s: string): boolean } - interface MIMEHeader { + interface Command { /** - * Values returns all values associated with the given key. - * It is case insensitive; CanonicalMIMEHeaderKey is - * used to canonicalize the provided key. To use non-canonical - * keys, access the map directly. - * The returned slice is not a copy. + * CalledAs returns the command name or alias that was used to invoke + * this command or an empty string if the command has not been called. */ - values(key: string): Array + calledAs(): string } - interface MIMEHeader { + interface Command { /** - * Del deletes the values associated with key. + * NameAndAliases returns a list of the command name and all aliases */ - del(key: string): void + nameAndAliases(): string } -} - -/** - * Package multipart implements MIME multipart parsing, as defined in RFC - * 2046. - * - * The implementation is sufficient for HTTP (RFC 2388) and the multipart - * bodies generated by popular browsers. - */ -namespace multipart { - interface Reader { + interface Command { /** - * ReadForm parses an entire multipart message whose parts have - * a Content-Disposition of "form-data". - * It stores up to maxMemory bytes + 10MB (reserved for non-file parts) - * in memory. File parts which can't be stored in memory will be stored on - * disk in temporary files. - * It returns ErrMessageTooLarge if all non-file parts can't be stored in - * memory. + * HasExample determines if the command has example. */ - readForm(maxMemory: number): (Form | undefined) - } - /** - * Form is a parsed multipart form. - * Its File parts are stored either in memory or on disk, - * and are accessible via the *FileHeader's Open method. - * Its Value parts are stored as strings. - * Both are keyed by field name. - */ - interface Form { - value: _TygojaDict - file: _TygojaDict + hasExample(): boolean } - interface Form { + interface Command { /** - * RemoveAll removes any temporary files associated with a Form. + * Runnable determines if the command is itself runnable. */ - removeAll(): void - } - /** - * File is an interface to access the file part of a multipart message. - * Its contents may be either stored in memory or on disk. - * If stored on disk, the File's underlying concrete type will be an *os.File. - */ - interface File { - } - /** - * Reader is an iterator over parts in a MIME multipart body. - * Reader's underlying parser consumes its input as needed. Seeking - * isn't supported. - */ - interface Reader { + runnable(): boolean } - interface Reader { + interface Command { /** - * NextPart returns the next part in the multipart or an error. - * When there are no more parts, the error io.EOF is returned. - * - * As a special case, if the "Content-Transfer-Encoding" header - * has a value of "quoted-printable", that header is instead - * hidden and the body is transparently decoded during Read calls. + * HasSubCommands determines if the command has children commands. */ - nextPart(): (Part | undefined) + hasSubCommands(): boolean } - interface Reader { + interface Command { /** - * NextRawPart returns the next part in the multipart or an error. - * When there are no more parts, the error io.EOF is returned. - * - * Unlike NextPart, it does not have special handling for - * "Content-Transfer-Encoding: quoted-printable". + * IsAvailableCommand determines if a command is available as a non-help command + * (this includes all non deprecated/hidden commands). */ - nextRawPart(): (Part | undefined) + isAvailableCommand(): boolean } -} - -/** - * Package http provides HTTP client and server implementations. - * - * Get, Head, Post, and PostForm make HTTP (or HTTPS) requests: - * - * ``` - * resp, err := http.Get("http://example.com/") - * ... - * resp, err := http.Post("http://example.com/upload", "image/jpeg", &buf) - * ... - * resp, err := http.PostForm("http://example.com/form", - * url.Values{"key": {"Value"}, "id": {"123"}}) - * ``` - * - * The client must close the response body when finished with it: - * - * ``` - * resp, err := http.Get("http://example.com/") - * if err != nil { - * // handle error - * } - * defer resp.Body.Close() - * body, err := io.ReadAll(resp.Body) - * // ... - * ``` - * - * For control over HTTP client headers, redirect policy, and other - * settings, create a Client: - * - * ``` - * client := &http.Client{ - * CheckRedirect: redirectPolicyFunc, - * } - * - * resp, err := client.Get("http://example.com") - * // ... - * - * req, err := http.NewRequest("GET", "http://example.com", nil) - * // ... - * req.Header.Add("If-None-Match", `W/"wyzzy"`) - * resp, err := client.Do(req) - * // ... - * ``` - * - * For control over proxies, TLS configuration, keep-alives, - * compression, and other settings, create a Transport: - * - * ``` - * tr := &http.Transport{ - * MaxIdleConns: 10, - * IdleConnTimeout: 30 * time.Second, - * DisableCompression: true, - * } - * client := &http.Client{Transport: tr} - * resp, err := client.Get("https://example.com") - * ``` - * - * Clients and Transports are safe for concurrent use by multiple - * goroutines and for efficiency should only be created once and re-used. - * - * ListenAndServe starts an HTTP server with a given address and handler. - * The handler is usually nil, which means to use DefaultServeMux. - * Handle and HandleFunc add handlers to DefaultServeMux: - * - * ``` - * http.Handle("/foo", fooHandler) - * - * http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { - * fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) - * }) - * - * log.Fatal(http.ListenAndServe(":8080", nil)) - * ``` - * - * More control over the server's behavior is available by creating a - * custom Server: - * - * ``` - * s := &http.Server{ - * Addr: ":8080", - * Handler: myHandler, - * ReadTimeout: 10 * time.Second, - * WriteTimeout: 10 * time.Second, - * MaxHeaderBytes: 1 << 20, - * } - * log.Fatal(s.ListenAndServe()) - * ``` - * - * Starting with Go 1.6, the http package has transparent support for the - * HTTP/2 protocol when using HTTPS. Programs that must disable HTTP/2 - * can do so by setting Transport.TLSNextProto (for clients) or - * Server.TLSNextProto (for servers) to a non-nil, empty - * map. Alternatively, the following GODEBUG environment variables are - * currently supported: - * - * ``` - * GODEBUG=http2client=0 # disable HTTP/2 client support - * GODEBUG=http2server=0 # disable HTTP/2 server support - * GODEBUG=http2debug=1 # enable verbose HTTP/2 debug logs - * GODEBUG=http2debug=2 # ... even more verbose, with frame dumps - * ``` - * - * The GODEBUG variables are not covered by Go's API compatibility - * promise. Please report any issues before disabling HTTP/2 - * support: https://golang.org/s/http2bug - * - * The http package's Transport and Server both automatically enable - * HTTP/2 support for simple configurations. To enable HTTP/2 for more - * complex configurations, to use lower-level HTTP/2 features, or to use - * a newer version of Go's http2 package, import "golang.org/x/net/http2" - * directly and use its ConfigureTransport and/or ConfigureServer - * functions. Manually configuring HTTP/2 via the golang.org/x/net/http2 - * package takes precedence over the net/http package's built-in HTTP/2 - * support. - */ -namespace http { - /** - * A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an - * HTTP response or the Cookie header of an HTTP request. - * - * See https://tools.ietf.org/html/rfc6265 for details. - */ - interface Cookie { - name: string - value: string - path: string // optional - domain: string // optional - expires: time.Time // optional - rawExpires: string // for reading cookies only + interface Command { /** - * MaxAge=0 means no 'Max-Age' attribute specified. - * MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0' - * MaxAge>0 means Max-Age attribute present and given in seconds + * IsAdditionalHelpTopicCommand determines if a command is an additional + * help topic command; additional help topic command is determined by the + * fact that it is NOT runnable/hidden/deprecated, and has no sub commands that + * are runnable/hidden/deprecated. + * Concrete example: https://github.com/spf13/cobra/issues/393#issuecomment-282741924. */ - maxAge: number - secure: boolean - httpOnly: boolean - sameSite: SameSite - raw: string - unparsed: Array // Raw text of unparsed attribute-value pairs + isAdditionalHelpTopicCommand(): boolean } - interface Cookie { + interface Command { /** - * String returns the serialization of the cookie for use in a Cookie - * header (if only Name and Value are set) or a Set-Cookie response - * header (if other fields are set). - * If c is nil or c.Name is invalid, the empty string is returned. + * HasHelpSubCommands determines if a command has any available 'help' sub commands + * that need to be shown in the usage/help default template under 'additional help + * topics'. */ - string(): string + hasHelpSubCommands(): boolean } - interface Cookie { + interface Command { /** - * Valid reports whether the cookie is valid. + * HasAvailableSubCommands determines if a command has available sub commands that + * need to be shown in the usage/help default template under 'available commands'. */ - valid(): void + hasAvailableSubCommands(): boolean } - // @ts-ignore - import mathrand = rand - /** - * A Header represents the key-value pairs in an HTTP header. - * - * The keys should be in canonical form, as returned by - * CanonicalHeaderKey. - */ - interface Header extends _TygojaDict{} - interface Header { + interface Command { /** - * Add adds the key, value pair to the header. - * It appends to any existing values associated with key. - * The key is case insensitive; it is canonicalized by - * CanonicalHeaderKey. + * HasParent determines if the command is a child command. */ - add(key: string): void + hasParent(): boolean } - interface Header { + interface Command { /** - * Set sets the header entries associated with key to the - * single element value. It replaces any existing values - * associated with key. The key is case insensitive; it is - * canonicalized by textproto.CanonicalMIMEHeaderKey. - * To use non-canonical keys, assign to the map directly. + * GlobalNormalizationFunc returns the global normalization function or nil if it doesn't exist. */ - set(key: string): void + globalNormalizationFunc(): (f: any, name: string) => any } - interface Header { + interface Command { /** - * Get gets the first value associated with the given key. If - * there are no values associated with the key, Get returns "". - * It is case insensitive; textproto.CanonicalMIMEHeaderKey is - * used to canonicalize the provided key. To use non-canonical keys, - * access the map directly. + * Flags returns the complete FlagSet that applies + * to this command (local and persistent declared here and by all parents). */ - get(key: string): string + flags(): (any) } - interface Header { + interface Command { /** - * Values returns all values associated with the given key. - * It is case insensitive; textproto.CanonicalMIMEHeaderKey is - * used to canonicalize the provided key. To use non-canonical - * keys, access the map directly. - * The returned slice is not a copy. + * LocalNonPersistentFlags are flags specific to this command which will NOT persist to subcommands. + * This function does not modify the flags of the current command, it's purpose is to return the current state. */ - values(key: string): Array + localNonPersistentFlags(): (any) } - interface Header { + interface Command { /** - * Del deletes the values associated with key. - * The key is case insensitive; it is canonicalized by - * CanonicalHeaderKey. + * LocalFlags returns the local FlagSet specifically set in the current command. + * This function does not modify the flags of the current command, it's purpose is to return the current state. */ - del(key: string): void + localFlags(): (any) } - interface Header { + interface Command { /** - * Write writes a header in wire format. + * InheritedFlags returns all flags which were inherited from parent commands. + * This function does not modify the flags of the current command, it's purpose is to return the current state. */ - write(w: io.Writer): void + inheritedFlags(): (any) } - interface Header { + interface Command { /** - * Clone returns a copy of h or nil if h is nil. + * NonInheritedFlags returns all flags which were not inherited from parent commands. + * This function does not modify the flags of the current command, it's purpose is to return the current state. */ - clone(): Header + nonInheritedFlags(): (any) } - interface Header { + interface Command { /** - * WriteSubset writes a header in wire format. - * If exclude is not nil, keys where exclude[key] == true are not written. - * Keys are not canonicalized before checking the exclude map. + * PersistentFlags returns the persistent FlagSet specifically set in the current command. */ - writeSubset(w: io.Writer, exclude: _TygojaDict): void + persistentFlags(): (any) } - // @ts-ignore - import urlpkg = url - /** - * Response represents the response from an HTTP request. - * - * The Client and Transport return Responses from servers once - * the response headers have been received. The response body - * is streamed on demand as the Body field is read. - */ - interface Response { - status: string // e.g. "200 OK" - statusCode: number // e.g. 200 - proto: string // e.g. "HTTP/1.0" - protoMajor: number // e.g. 1 - protoMinor: number // e.g. 0 + interface Command { /** - * Header maps header keys to values. If the response had multiple - * headers with the same key, they may be concatenated, with comma - * delimiters. (RFC 7230, section 3.2.2 requires that multiple headers - * be semantically equivalent to a comma-delimited sequence.) When - * Header values are duplicated by other fields in this struct (e.g., - * ContentLength, TransferEncoding, Trailer), the field values are - * authoritative. - * - * Keys in the map are canonicalized (see CanonicalHeaderKey). + * ResetFlags deletes all flags from command. */ - header: Header + resetFlags(): void + } + interface Command { /** - * Body represents the response body. - * - * The response body is streamed on demand as the Body field - * is read. If the network connection fails or the server - * terminates the response, Body.Read calls return an error. - * - * The http Client and Transport guarantee that Body is always - * non-nil, even on responses without a body or responses with - * a zero-length body. It is the caller's responsibility to - * close Body. The default HTTP client's Transport may not - * reuse HTTP/1.x "keep-alive" TCP connections if the Body is - * not read to completion and closed. - * - * The Body is automatically dechunked if the server replied - * with a "chunked" Transfer-Encoding. - * - * As of Go 1.12, the Body will also implement io.Writer - * on a successful "101 Switching Protocols" response, - * as used by WebSockets and HTTP/2's "h2c" mode. + * HasFlags checks if the command contains any flags (local plus persistent from the entire structure). */ - body: io.ReadCloser + hasFlags(): boolean + } + interface Command { /** - * ContentLength records the length of the associated content. The - * value -1 indicates that the length is unknown. Unless Request.Method - * is "HEAD", values >= 0 indicate that the given number of bytes may - * be read from Body. + * HasPersistentFlags checks if the command contains persistent flags. */ - contentLength: number + hasPersistentFlags(): boolean + } + interface Command { /** - * Contains transfer encodings from outer-most to inner-most. Value is - * nil, means that "identity" encoding is used. + * HasLocalFlags checks if the command has flags specifically declared locally. */ - transferEncoding: Array + hasLocalFlags(): boolean + } + interface Command { /** - * Close records whether the header directed that the connection be - * closed after reading Body. The value is advice for clients: neither - * ReadResponse nor Response.Write ever closes a connection. + * HasInheritedFlags checks if the command has flags inherited from its parent command. */ - close: boolean + hasInheritedFlags(): boolean + } + interface Command { /** - * Uncompressed reports whether the response was sent compressed but - * was decompressed by the http package. When true, reading from - * Body yields the uncompressed content instead of the compressed - * content actually set from the server, ContentLength is set to -1, - * and the "Content-Length" and "Content-Encoding" fields are deleted - * from the responseHeader. To get the original response from - * the server, set Transport.DisableCompression to true. + * HasAvailableFlags checks if the command contains any flags (local plus persistent from the entire + * structure) which are not hidden or deprecated. */ - uncompressed: boolean + hasAvailableFlags(): boolean + } + interface Command { /** - * Trailer maps trailer keys to values in the same - * format as Header. - * - * The Trailer initially contains only nil values, one for - * each key specified in the server's "Trailer" header - * value. Those values are not added to Header. - * - * Trailer must not be accessed concurrently with Read calls - * on the Body. - * - * After Body.Read has returned io.EOF, Trailer will contain - * any trailer values sent by the server. + * HasAvailablePersistentFlags checks if the command contains persistent flags which are not hidden or deprecated. */ - trailer: Header + hasAvailablePersistentFlags(): boolean + } + interface Command { /** - * Request is the request that was sent to obtain this Response. - * Request's Body is nil (having already been consumed). - * This is only populated for Client requests. + * HasAvailableLocalFlags checks if the command has flags specifically declared locally which are not hidden + * or deprecated. */ - request?: Request + hasAvailableLocalFlags(): boolean + } + interface Command { /** - * TLS contains information about the TLS connection on which the - * response was received. It is nil for unencrypted responses. - * The pointer is shared between responses and should not be - * modified. + * HasAvailableInheritedFlags checks if the command has flags inherited from its parent command which are + * not hidden or deprecated. */ - tls?: any + hasAvailableInheritedFlags(): boolean } - interface Response { + interface Command { /** - * Cookies parses and returns the cookies set in the Set-Cookie headers. + * Flag climbs up the command tree looking for matching flag. */ - cookies(): Array<(Cookie | undefined)> + flag(name: string): (any) } - interface Response { + interface Command { /** - * Location returns the URL of the response's "Location" header, - * if present. Relative redirects are resolved relative to - * the Response's Request. ErrNoLocation is returned if no - * Location header is present. + * ParseFlags parses persistent flag tree and local flags. */ - location(): (url.URL | undefined) + parseFlags(args: Array): void } - interface Response { + interface Command { /** - * ProtoAtLeast reports whether the HTTP protocol used - * in the response is at least major.minor. + * Parent returns a commands parent command. */ - protoAtLeast(major: number): boolean + parent(): (Command) } - interface Response { + interface Command { /** - * Write writes r to w in the HTTP/1.x server response format, - * including the status line, headers, body, and optional trailer. - * - * This method consults the following fields of the response r: - * - * StatusCode - * ProtoMajor - * ProtoMinor - * Request.Method - * TransferEncoding - * Trailer - * Body - * ContentLength - * Header, values for non-canonical keys will have unpredictable behavior + * RegisterFlagCompletionFunc should be called to register a function to provide completion for a flag. * - * The Response Body is closed after it is sent. + * You can use pre-defined completion functions such as [FixedCompletions] or [NoFileCompletions], + * or you can define your own. */ - write(w: io.Writer): void + registerFlagCompletionFunc(flagName: string, f: CompletionFunc): void } - /** - * A Handler responds to an HTTP request. - * - * ServeHTTP should write reply headers and data to the ResponseWriter - * and then return. Returning signals that the request is finished; it - * is not valid to use the ResponseWriter or read from the - * Request.Body after or concurrently with the completion of the - * ServeHTTP call. - * - * Depending on the HTTP client software, HTTP protocol version, and - * any intermediaries between the client and the Go server, it may not - * be possible to read from the Request.Body after writing to the - * ResponseWriter. Cautious handlers should read the Request.Body - * first, and then reply. - * - * Except for reading the body, handlers should not modify the - * provided Request. - * - * If ServeHTTP panics, the server (the caller of ServeHTTP) assumes - * that the effect of the panic was isolated to the active request. - * It recovers the panic, logs a stack trace to the server error log, - * and either closes the network connection or sends an HTTP/2 - * RST_STREAM, depending on the HTTP protocol. To abort a handler so - * the client sees an interrupted response but the server doesn't log - * an error, panic with the value ErrAbortHandler. - */ - interface Handler { - serveHTTP(_arg0: ResponseWriter, _arg1: Request): void + interface Command { + /** + * GetFlagCompletionFunc returns the completion function for the given flag of the command, if available. + */ + getFlagCompletionFunc(flagName: string): [CompletionFunc, boolean] } - /** - * A ConnState represents the state of a client connection to a server. - * It's used by the optional Server.ConnState hook. - */ - interface ConnState extends Number{} - interface ConnState { - string(): string + interface Command { + /** + * InitDefaultCompletionCmd adds a default 'completion' command to c. + * This function will do nothing if any of the following is true: + * 1- the feature has been explicitly disabled by the program, + * 2- c has no subcommands (to avoid creating one), + * 3- c already has a 'completion' command provided by the program. + */ + initDefaultCompletionCmd(...args: string[]): void } -} - -/** - * Package oauth2 provides support for making - * OAuth2 authorized and authenticated HTTP requests, - * as specified in RFC 6749. - * It can additionally grant authorization with Bearer JWT. - */ -namespace oauth2 { - /** - * An AuthCodeOption is passed to Config.AuthCodeURL. - */ - interface AuthCodeOption { + interface Command { + /** + * GenFishCompletion generates fish completion file and writes to the passed writer. + */ + genFishCompletion(w: io.Writer, includeDesc: boolean): void } - /** - * Token represents the credentials used to authorize - * the requests to access protected resources on the OAuth 2.0 - * provider's backend. - * - * Most users of this package should not access fields of Token - * directly. They're exported mostly for use by related packages - * implementing derivative OAuth2 flows. - */ - interface Token { + interface Command { + /** + * GenFishCompletionFile generates fish completion file. + */ + genFishCompletionFile(filename: string, includeDesc: boolean): void + } + interface Command { /** - * AccessToken is the token that authorizes and authenticates - * the requests. + * MarkFlagsRequiredTogether marks the given flags with annotations so that Cobra errors + * if the command is invoked with a subset (but not all) of the given flags. */ - accessToken: string + markFlagsRequiredTogether(...flagNames: string[]): void + } + interface Command { /** - * TokenType is the type of token. - * The Type method returns either this or "Bearer", the default. + * MarkFlagsOneRequired marks the given flags with annotations so that Cobra errors + * if the command is invoked without at least one flag from the given set of flags. */ - tokenType: string + markFlagsOneRequired(...flagNames: string[]): void + } + interface Command { /** - * RefreshToken is a token that's used by the application - * (as opposed to the user) to refresh the access token - * if it expires. + * MarkFlagsMutuallyExclusive marks the given flags with annotations so that Cobra errors + * if the command is invoked with more than one flag from the given set of flags. */ - refreshToken: string + markFlagsMutuallyExclusive(...flagNames: string[]): void + } + interface Command { /** - * Expiry is the optional expiration time of the access token. - * - * If zero, TokenSource implementations will reuse the same - * token forever and RefreshToken or equivalent - * mechanisms for that TokenSource will not be used. + * ValidateFlagGroups validates the mutuallyExclusive/oneRequired/requiredAsGroup logic and returns the + * first error encountered. */ - expiry: time.Time + validateFlagGroups(): void } - interface Token { + interface Command { /** - * Type returns t.TokenType if non-empty, else "Bearer". + * GenPowerShellCompletionFile generates powershell completion file without descriptions. */ - type(): string + genPowerShellCompletionFile(filename: string): void } - interface Token { + interface Command { /** - * SetAuthHeader sets the Authorization header to r using the access - * token in t. - * - * This method is unnecessary when using Transport or an HTTP Client - * returned by this package. + * GenPowerShellCompletion generates powershell completion file without descriptions + * and writes it to the passed writer. */ - setAuthHeader(r: http.Request): void + genPowerShellCompletion(w: io.Writer): void } - interface Token { + interface Command { /** - * WithExtra returns a new Token that's a clone of t, but using the - * provided raw extra map. This is only intended for use by packages - * implementing derivative OAuth2 flows. + * GenPowerShellCompletionFileWithDesc generates powershell completion file with descriptions. */ - withExtra(extra: { - }): (Token | undefined) + genPowerShellCompletionFileWithDesc(filename: string): void } - interface Token { + interface Command { /** - * Extra returns an extra field. - * Extra fields are key-value pairs returned by the server as a - * part of the token retrieval response. + * GenPowerShellCompletionWithDesc generates powershell completion file with descriptions + * and writes it to the passed writer. */ - extra(key: string): { + genPowerShellCompletionWithDesc(w: io.Writer): void } + interface Command { + /** + * MarkFlagRequired instructs the various shell completion implementations to + * prioritize the named flag when performing completion, + * and causes your command to report an error if invoked without the flag. + */ + markFlagRequired(name: string): void } - interface Token { + interface Command { /** - * Valid reports whether t is non-nil, has an AccessToken, and is not expired. + * MarkPersistentFlagRequired instructs the various shell completion implementations to + * prioritize the named persistent flag when performing completion, + * and causes your command to report an error if invoked without the flag. */ - valid(): boolean + markPersistentFlagRequired(name: string): void } -} - -namespace store { - /** - * Store defines a concurrent safe in memory key-value data store. - */ - interface Store { + interface Command { + /** + * MarkFlagFilename instructs the various shell completion implementations to + * limit completions for the named flag to the specified file extensions. + */ + markFlagFilename(name: string, ...extensions: string[]): void } - interface Store { + interface Command { /** - * Reset clears the store and replaces the store data with a - * shallow copy of the provided newData. + * MarkFlagCustom adds the BashCompCustom annotation to the named flag, if it exists. + * The bash completion script will call the bash function f for the flag. + * + * This will only work for bash completion. + * It is recommended to instead use c.RegisterFlagCompletionFunc(...) which allows + * to register a Go function which will work across all shells. */ - reset(newData: _TygojaDict): void + markFlagCustom(name: string, f: string): void } - interface Store { + interface Command { /** - * Length returns the current number of elements in the store. + * MarkPersistentFlagFilename instructs the various shell completion + * implementations to limit completions for the named persistent flag to the + * specified file extensions. */ - length(): number + markPersistentFlagFilename(name: string, ...extensions: string[]): void } - interface Store { + interface Command { /** - * RemoveAll removes all the existing store entries. + * MarkFlagDirname instructs the various shell completion implementations to + * limit completions for the named flag to directory names. */ - removeAll(): void + markFlagDirname(name: string): void } - interface Store { + interface Command { /** - * Remove removes a single entry from the store. - * - * Remove does nothing if key doesn't exist in the store. + * MarkPersistentFlagDirname instructs the various shell completion + * implementations to limit completions for the named persistent flag to + * directory names. */ - remove(key: string): void + markPersistentFlagDirname(name: string): void } - interface Store { + interface Command { /** - * Has checks if element with the specified key exist or not. + * GenZshCompletionFile generates zsh completion file including descriptions. */ - has(key: string): boolean + genZshCompletionFile(filename: string): void } - interface Store { + interface Command { /** - * Get returns a single element value from the store. - * - * If key is not set, the zero T value is returned. + * GenZshCompletion generates zsh completion file including descriptions + * and writes it to the passed writer. */ - get(key: string): T + genZshCompletion(w: io.Writer): void } - interface Store { + interface Command { /** - * GetAll returns a shallow copy of the current store data. + * GenZshCompletionFileNoDesc generates zsh completion file without descriptions. */ - getAll(): _TygojaDict + genZshCompletionFileNoDesc(filename: string): void } - interface Store { + interface Command { /** - * Set sets (or overwrite if already exist) a new value for key. + * GenZshCompletionNoDesc generates zsh completion file without descriptions + * and writes it to the passed writer. */ - set(key: string, value: T): void + genZshCompletionNoDesc(w: io.Writer): void } - interface Store { + interface Command { /** - * SetIfLessThanLimit sets (or overwrite if already exist) a new value for key. + * MarkZshCompPositionalArgumentFile only worked for zsh and its behavior was + * not consistent with Bash completion. It has therefore been disabled. + * Instead, when no other completion is specified, file completion is done by + * default for every argument. One can disable file completion on a per-argument + * basis by using ValidArgsFunction and ShellCompDirectiveNoFileComp. + * To achieve file extension filtering, one can use ValidArgsFunction and + * ShellCompDirectiveFilterFileExt. * - * This method is similar to Set() but **it will skip adding new elements** - * to the store if the store length has reached the specified limit. - * false is returned if maxAllowedElements limit is reached. + * Deprecated */ - setIfLessThanLimit(key: string, value: T, maxAllowedElements: number): boolean + markZshCompPositionalArgumentFile(argPosition: number, ...patterns: string[]): void } -} - -namespace mailer { - /** - * Mailer defines a base mail client interface. - */ - interface Mailer { + interface Command { /** - * Send sends an email with the provided Message. + * MarkZshCompPositionalArgumentWords only worked for zsh. It has therefore + * been disabled. + * To achieve the same behavior across all shells, one can use + * ValidArgs (for the first argument only) or ValidArgsFunction for + * any argument (can include the first one also). + * + * Deprecated */ - send(message: Message): void + markZshCompPositionalArgumentWords(argPosition: number, ...words: string[]): void } } -/** - * Package echo implements high performance, minimalist Go web framework. - * - * Example: - * - * ``` - * package main - * - * import ( - * "github.com/labstack/echo/v5" - * "github.com/labstack/echo/v5/middleware" - * "log" - * "net/http" - * ) - * - * // Handler - * func hello(c echo.Context) error { - * return c.String(http.StatusOK, "Hello, World!") - * } - * - * func main() { - * // Echo instance - * e := echo.New() - * - * // Middleware - * e.Use(middleware.Logger()) - * e.Use(middleware.Recover()) - * - * // Routes - * e.GET("/", hello) - * - * // Start server - * if err := e.Start(":8080"); err != http.ErrServerClosed { - * log.Fatal(err) - * } - * } - * ``` - * - * Learn more at https://echo.labstack.com - */ -namespace echo { +namespace subscriptions { /** - * Binder is the interface that wraps the Bind method. + * Broker defines a struct for managing subscriptions clients. */ - interface Binder { - bind(c: Context, i: { - }): void + interface Broker { } - /** - * ServableContext is interface that Echo context implementation must implement to be usable in middleware/handlers and - * be able to be routed by Router. - */ - interface ServableContext { + interface Broker { /** - * Reset resets the context after request completes. It must be called along - * with `Echo#AcquireContext()` and `Echo#ReleaseContext()`. - * See `Echo#ServeHTTP()` + * Clients returns a shallow copy of all registered clients indexed + * with their connection id. */ - reset(r: http.Request, w: http.ResponseWriter): void - } - // @ts-ignore - import stdContext = context - /** - * JSONSerializer is the interface that encodes and decodes JSON to and from interfaces. - */ - interface JSONSerializer { - serialize(c: Context, i: { - }, indent: string): void - deserialize(c: Context, i: { - }): void - } - /** - * HTTPErrorHandler is a centralized HTTP error handler. - */ - interface HTTPErrorHandler {(c: Context, err: Error): void } - /** - * Validator is the interface that wraps the Validate function. - */ - interface Validator { - validate(i: { - }): void - } - /** - * Renderer is the interface that wraps the Render function. - */ - interface Renderer { - render(_arg0: io.Writer, _arg1: string, _arg2: { - }, _arg3: Context): void - } - /** - * Group is a set of sub-routes for a specified route. It can be used for inner - * routes that share a common middleware or functionality that should be separate - * from the parent echo instance while still inheriting from it. - */ - interface Group { + clients(): _TygojaDict } - interface Group { + interface Broker { /** - * Use implements `Echo#Use()` for sub-routes within the Group. - * Group middlewares are not executed on request when there is no matching route found. + * ChunkedClients splits the current clients into a chunked slice. */ - use(...middleware: MiddlewareFunc[]): void + chunkedClients(chunkSize: number): Array> } - interface Group { + interface Broker { /** - * CONNECT implements `Echo#CONNECT()` for sub-routes within the Group. Panics on error. + * TotalClients returns the total number of registered clients. */ - connect(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + totalClients(): number } - interface Group { + interface Broker { /** - * DELETE implements `Echo#DELETE()` for sub-routes within the Group. Panics on error. + * ClientById finds a registered client by its id. + * + * Returns non-nil error when client with clientId is not registered. */ - delete(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + clientById(clientId: string): Client } - interface Group { + interface Broker { /** - * GET implements `Echo#GET()` for sub-routes within the Group. Panics on error. + * Register adds a new client to the broker instance. */ - get(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + register(client: Client): void } - interface Group { + interface Broker { /** - * HEAD implements `Echo#HEAD()` for sub-routes within the Group. Panics on error. + * Unregister removes a single client by its id and marks it as discarded. + * + * If client with clientId doesn't exist, this method does nothing. */ - head(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + unregister(clientId: string): void } - interface Group { + /** + * Client is an interface for a generic subscription client. + */ + interface Client { + [key:string]: any; /** - * OPTIONS implements `Echo#OPTIONS()` for sub-routes within the Group. Panics on error. + * Id Returns the unique id of the client. */ - options(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + id(): string /** - * PATCH implements `Echo#PATCH()` for sub-routes within the Group. Panics on error. + * Channel returns the client's communication channel. + * + * NB! The channel shouldn't be used after calling Discard(). */ - patch(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + channel(): undefined /** - * POST implements `Echo#POST()` for sub-routes within the Group. Panics on error. + * Subscriptions returns a shallow copy of the client subscriptions matching the prefixes. + * If no prefix is specified, returns all subscriptions. */ - post(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + subscriptions(...prefixes: string[]): _TygojaDict /** - * PUT implements `Echo#PUT()` for sub-routes within the Group. Panics on error. + * Subscribe subscribes the client to the provided subscriptions list. + * + * Each subscription can also have "options" (json serialized SubscriptionOptions) as query parameter. + * + * Example: + * + * ``` + * Subscribe( + * "subscriptionA", + * `subscriptionB?options={"query":{"a":1},"headers":{"x_token":"abc"}}`, + * ) + * ``` */ - put(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + subscribe(...subs: string[]): void /** - * TRACE implements `Echo#TRACE()` for sub-routes within the Group. Panics on error. + * Unsubscribe unsubscribes the client from the provided subscriptions list. */ - trace(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + unsubscribe(...subs: string[]): void /** - * Any implements `Echo#Any()` for sub-routes within the Group. Panics on error. + * HasSubscription checks if the client is subscribed to `sub`. */ - any(path: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): Routes - } - interface Group { + hasSubscription(sub: string): boolean /** - * Match implements `Echo#Match()` for sub-routes within the Group. Panics on error. + * Set stores any value to the client's context. */ - match(methods: Array, path: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): Routes - } - interface Group { + set(key: string, value: any): void /** - * Group creates a new sub-group with prefix and optional sub-group-level middleware. - * Important! Group middlewares are only executed in case there was exact route match and not - * for 404 (not found) or 405 (method not allowed) cases. If this kind of behaviour is needed then add - * a catch-all route `/*` for the group which handler returns always 404 + * Unset removes a single value from the client's context. */ - group(prefix: string, ...middleware: MiddlewareFunc[]): (Group | undefined) - } - interface Group { + unset(key: string): void /** - * Static implements `Echo#Static()` for sub-routes within the Group. + * Get retrieves the key value from the client's context. */ - static(pathPrefix: string): RouteInfo - } - interface Group { + get(key: string): any /** - * StaticFS implements `Echo#StaticFS()` for sub-routes within the Group. + * Discard marks the client as "discarded" (and closes its channel), + * meaning that it shouldn't be used anymore for sending new messages. * - * When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary - * prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths - * including `assets/images` as their prefix. + * It is safe to call Discard() multiple times. */ - staticFS(pathPrefix: string, filesystem: fs.FS): RouteInfo - } - interface Group { + discard(): void /** - * FileFS implements `Echo#FileFS()` for sub-routes within the Group. + * IsDiscarded indicates whether the client has been "discarded" + * and should no longer be used. */ - fileFS(path: string, filesystem: fs.FS, ...m: MiddlewareFunc[]): RouteInfo - } - interface Group { + isDiscarded(): boolean /** - * File implements `Echo#File()` for sub-routes within the Group. Panics on error. + * Send sends the specified message to the client's channel (if not discarded). */ - file(path: string, ...middleware: MiddlewareFunc[]): RouteInfo + send(m: Message): void } - interface Group { + /** + * Message defines a client's channel data. + */ + interface Message { + name: string + data: string|Array + } + interface Message { /** - * RouteNotFound implements `Echo#RouteNotFound()` for sub-routes within the Group. + * WriteSSE writes the current message in a SSE format into the provided writer. * - * Example: `g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })` + * For example, writing to a router.Event: + * + * ``` + * m := Message{Name: "users/create", Data: []byte{...}} + * m.WriteSSE(e.Response, "yourEventId") + * e.Flush() + * ``` */ - routeNotFound(path: string, h: HandlerFunc, ...m: MiddlewareFunc[]): RouteInfo + writeSSE(w: io.Writer, eventId: string): void } - interface Group { - /** - * Add implements `Echo#Add()` for sub-routes within the Group. Panics on error. - */ - add(method: string, handler: HandlerFunc, ...middleware: MiddlewareFunc[]): RouteInfo +} + +namespace sync { + // @ts-ignore + import isync = sync + /** + * A Locker represents an object that can be locked and unlocked. + */ + interface Locker { + [key:string]: any; + lock(): void + unlock(): void } - interface Group { - /** - * AddRoute registers a new Routable with Router - */ - addRoute(route: Routable): RouteInfo + // @ts-ignore + import rtatomic = atomic +} + +namespace io { + /** + * WriteCloser is the interface that groups the basic Write and Close methods. + */ + interface WriteCloser { + [key:string]: any; } +} + +namespace syscall { + // @ts-ignore + import errpkg = errors /** - * IPExtractor is a function to extract IP addr from http.Request. - * Set appropriate one to Echo#IPExtractor. - * See https://echo.labstack.com/guide/ip-address for more details. + * SysProcIDMap holds Container ID to Host ID mappings used for User Namespaces in Linux. + * See user_namespaces(7). + * + * Note that User Namespaces are not available on a number of popular Linux + * versions (due to security issues), or are available but subject to AppArmor + * restrictions like in Ubuntu 24.04. */ - interface IPExtractor {(_arg0: http.Request): string } + interface SysProcIDMap { + containerID: number // Container ID. + hostID: number // Host ID. + size: number // Size. + } + // @ts-ignore + import errorspkg = errors /** - * Logger defines the logging interface that Echo uses internally in few places. - * For logging in handlers use your own logger instance (dependency injected or package/public variable) from logging framework of your choice. + * Credential holds user and group identities to be assumed + * by a child process started by [StartProcess]. */ - interface Logger { - /** - * Write provides writer interface for http.Server `ErrorLog` and for logging startup messages. - * `http.Server.ErrorLog` logs errors from accepting connections, unexpected behavior from handlers, - * and underlying FileSystem errors. - * `logger` middleware will use this method to write its JSON payload. - */ - write(p: string): number - /** - * Error logs the error - */ - error(err: Error): void + interface Credential { + uid: number // User ID. + gid: number // Group ID. + groups: Array // Supplementary group IDs. + noSetGroups: boolean // If true, don't set supplementary groups } /** - * Response wraps an http.ResponseWriter and implements its interface to be used - * by an HTTP handler to construct an HTTP response. - * See: https://golang.org/pkg/net/http/#ResponseWriter + * A Signal is a number describing a process signal. + * It implements the [os.Signal] interface. */ - interface Response { - writer: http.ResponseWriter - status: number - size: number - committed: boolean + interface Signal extends Number{} + interface Signal { + signal(): void } - interface Response { - /** - * Header returns the header map for the writer that will be sent by - * WriteHeader. Changing the header after a call to WriteHeader (or Write) has - * no effect unless the modified headers were declared as trailers by setting - * the "Trailer" header before the call to WriteHeader (see example) - * To suppress implicit response headers, set their value to nil. - * Example: https://golang.org/pkg/net/http/#example_ResponseWriter_trailers - */ - header(): http.Header + interface Signal { + string(): string } - interface Response { +} + +namespace time { + /** + * A Month specifies a month of the year (January = 1, ...). + */ + interface Month extends Number{} + interface Month { /** - * Before registers a function which is called just before the response is written. + * String returns the English name of the month ("January", "February", ...). */ - before(fn: () => void): void + string(): string } - interface Response { + /** + * A Weekday specifies a day of the week (Sunday = 0, ...). + */ + interface Weekday extends Number{} + interface Weekday { /** - * After registers a function which is called just after the response is written. - * If the `Content-Length` is unknown, none of the after function is executed. + * String returns the English name of the day ("Sunday", "Monday", ...). */ - after(fn: () => void): void + string(): string } - interface Response { - /** - * WriteHeader sends an HTTP response header with status code. If WriteHeader is - * not called explicitly, the first call to Write will trigger an implicit - * WriteHeader(http.StatusOK). Thus explicit calls to WriteHeader are mainly - * used to send error codes. - */ - writeHeader(code: number): void + /** + * A Location maps time instants to the zone in use at that time. + * Typically, the Location represents the collection of time offsets + * in use in a geographical area. For many Locations the time offset varies + * depending on whether daylight savings time is in use at the time instant. + * + * Location is used to provide a time zone in a printed Time value and for + * calculations involving intervals that may cross daylight savings time + * boundaries. + */ + interface Location { } - interface Response { + interface Location { /** - * Write writes the data to the connection as part of an HTTP reply. + * String returns a descriptive name for the time zone information, + * corresponding to the name argument to [LoadLocation] or [FixedZone]. */ - write(b: string): number + string(): string } - interface Response { +} + +namespace fs { +} + +namespace store { +} + +/** + * Package url parses URLs and implements query escaping. + * + * See RFC 3986. This package generally follows RFC 3986, except where + * it deviates for compatibility reasons. + * RFC 6874 followed for IPv6 zone literals. + */ +namespace url { + /** + * A URL represents a parsed URL (technically, a URI reference). + * + * The general form represented is: + * + * ``` + * [scheme:][//[userinfo@]host][/]path[?query][#fragment] + * ``` + * + * URLs that do not start with a slash after the scheme are interpreted as: + * + * ``` + * scheme:opaque[?query][#fragment] + * ``` + * + * The Host field contains the host and port subcomponents of the URL. + * When the port is present, it is separated from the host with a colon. + * When the host is an IPv6 address, it must be enclosed in square brackets: + * "[fe80::1]:80". The [net.JoinHostPort] function combines a host and port + * into a string suitable for the Host field, adding square brackets to + * the host when necessary. + * + * Note that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/. + * A consequence is that it is impossible to tell which slashes in the Path were + * slashes in the raw URL and which were %2f. This distinction is rarely important, + * but when it is, the code should use the [URL.EscapedPath] method, which preserves + * the original encoding of Path. The Fragment field is also stored in decoded form, + * use [URL.EscapedFragment] to retrieve the original encoding. + * + * The [URL.String] method uses the [URL.EscapedPath] method to obtain the path. + */ + interface URL { + scheme: string + opaque: string // encoded opaque data + user?: Userinfo // username and password information + host: string // "host" or "host:port" (see Hostname and Port methods) + path: string // path (relative paths may omit leading slash) + fragment: string // fragment for references (without '#') /** - * Flush implements the http.Flusher interface to allow an HTTP handler to flush - * buffered data to the client. - * See [http.Flusher](https://golang.org/pkg/net/http/#Flusher) + * RawQuery contains the encoded query values, without the initial '?'. + * Use URL.Query to decode the query. */ - flush(): void - } - interface Response { + rawQuery: string /** - * Hijack implements the http.Hijacker interface to allow an HTTP handler to - * take over the connection. - * See [http.Hijacker](https://golang.org/pkg/net/http/#Hijacker) + * RawPath is an optional field containing an encoded path hint. + * See the EscapedPath method for more details. + * + * In general, code should call EscapedPath instead of reading RawPath. */ - hijack(): [net.Conn, (bufio.ReadWriter | undefined)] - } - interface Response { + rawPath: string /** - * Unwrap returns the original http.ResponseWriter. - * ResponseController can be used to access the original http.ResponseWriter. - * See [https://go.dev/blog/go1.20] + * RawFragment is an optional field containing an encoded fragment hint. + * See the EscapedFragment method for more details. + * + * In general, code should call EscapedFragment instead of reading RawFragment. */ - unwrap(): http.ResponseWriter - } - interface Routes { + rawFragment: string /** - * Reverse reverses route to URL string by replacing path parameters with given params values. + * ForceQuery indicates whether the original URL contained a query ('?') character. + * When set, the String method will include a trailing '?', even when RawQuery is empty. */ - reverse(name: string, ...params: { - }[]): string - } - interface Routes { + forceQuery: boolean /** - * FindByMethodPath searched for matching route info by method and path + * OmitHost indicates the URL has an empty host (authority). + * When set, the String method will not include the host when it is empty. */ - findByMethodPath(method: string, path: string): RouteInfo + omitHost: boolean } - interface Routes { + interface URL { /** - * FilterByMethod searched for matching route info by method + * EscapedPath returns the escaped form of u.Path. + * In general there are multiple possible escaped forms of any path. + * EscapedPath returns u.RawPath when it is a valid escaping of u.Path. + * Otherwise EscapedPath ignores u.RawPath and computes an escaped + * form on its own. + * The [URL.String] and [URL.RequestURI] methods use EscapedPath to construct + * their results. + * In general, code should call EscapedPath instead of + * reading u.RawPath directly. */ - filterByMethod(method: string): Routes + escapedPath(): string } - interface Routes { + interface URL { /** - * FilterByPath searched for matching route info by path + * EscapedFragment returns the escaped form of u.Fragment. + * In general there are multiple possible escaped forms of any fragment. + * EscapedFragment returns u.RawFragment when it is a valid escaping of u.Fragment. + * Otherwise EscapedFragment ignores u.RawFragment and computes an escaped + * form on its own. + * The [URL.String] method uses EscapedFragment to construct its result. + * In general, code should call EscapedFragment instead of + * reading u.RawFragment directly. */ - filterByPath(path: string): Routes + escapedFragment(): string } - interface Routes { + interface URL { /** - * FilterByName searched for matching route info by name + * String reassembles the [URL] into a valid URL string. + * The general form of the result is one of: + * + * ``` + * scheme:opaque?query#fragment + * scheme://userinfo@host/path?query#fragment + * ``` + * + * If u.Opaque is non-empty, String uses the first form; + * otherwise it uses the second form. + * Any non-ASCII characters in host are escaped. + * To obtain the path, String uses u.EscapedPath(). + * + * In the second form, the following rules apply: + * ``` + * - if u.Scheme is empty, scheme: is omitted. + * - if u.User is nil, userinfo@ is omitted. + * - if u.Host is empty, host/ is omitted. + * - if u.Scheme and u.Host are empty and u.User is nil, + * the entire scheme://userinfo@host/ is omitted. + * - if u.Host is non-empty and u.Path begins with a /, + * the form host/path does not add its own /. + * - if u.RawQuery is empty, ?query is omitted. + * - if u.Fragment is empty, #fragment is omitted. + * ``` */ - filterByName(name: string): Routes + string(): string } - /** - * Router is interface for routing request contexts to registered routes. - * - * Contract between Echo/Context instance and the router: - * ``` - * - all routes must be added through methods on echo.Echo instance. - * Reason: Echo instance uses RouteInfo.Params() length to allocate slice for paths parameters (see `Echo.contextPathParamAllocSize`). - * - Router must populate Context during Router.Route call with: - * - RoutableContext.SetPath - * - RoutableContext.SetRawPathParams (IMPORTANT! with same slice pointer that c.RawPathParams() returns) - * - RoutableContext.SetRouteInfo - * And optionally can set additional information to Context with RoutableContext.Set - * ``` - */ - interface Router { - /** - * Add registers Routable with the Router and returns registered RouteInfo - */ - add(routable: Routable): RouteInfo - /** - * Remove removes route from the Router - */ - remove(method: string, path: string): void - /** - * Routes returns information about all registered routes - */ - routes(): Routes + interface URL { /** - * Route searches Router for matching route and applies it to the given context. In case when no matching method - * was not found (405) or no matching route exists for path (404), router will return its implementation of 405/404 - * handler function. + * Redacted is like [URL.String] but replaces any password with "xxxxx". + * Only the password in u.User is redacted. */ - route(c: RoutableContext): HandlerFunc + redacted(): string } /** - * Routable is interface for registering Route with Router. During route registration process the Router will - * convert Routable to RouteInfo with ToRouteInfo method. By creating custom implementation of Routable additional - * information about registered route can be stored in Routes (i.e. privileges used with route etc.) + * Values maps a string key to a list of values. + * It is typically used for query parameters and form values. + * Unlike in the http.Header map, the keys in a Values map + * are case-sensitive. */ - interface Routable { + interface Values extends _TygojaDict{} + interface Values { /** - * ToRouteInfo converts Routable to RouteInfo - * - * This method is meant to be used by Router after it parses url for path parameters, to store information about - * route just added. + * Get gets the first value associated with the given key. + * If there are no values associated with the key, Get returns + * the empty string. To access multiple values, use the map + * directly. */ - toRouteInfo(params: Array): RouteInfo + get(key: string): string + } + interface Values { /** - * ToRoute converts Routable to Route which Router uses to register the method handler for path. - * - * This method is meant to be used by Router to get fields (including handler and middleware functions) needed to - * add Route to Router. + * Set sets the key to value. It replaces any existing + * values. */ - toRoute(): Route + set(key: string, value: string): void + } + interface Values { /** - * ForGroup recreates routable with added group prefix and group middlewares it is grouped to. - * - * Is necessary for Echo.Group to be able to add/register Routable with Router and having group prefix and group - * middlewares included in actually registered Route. + * Add adds the value to key. It appends to any existing + * values associated with key. */ - forGroup(pathPrefix: string, middlewares: Array): Routable + add(key: string, value: string): void } - /** - * Routes is collection of RouteInfo instances with various helper methods. - */ - interface Routes extends Array{} - /** - * RouteInfo describes registered route base fields. - * Method+Path pair uniquely identifies the Route. Name can have duplicates. - */ - interface RouteInfo { - method(): string - path(): string - name(): string - params(): Array + interface Values { /** - * Reverse reverses route to URL string by replacing path parameters with given params values. + * Del deletes the values associated with key. */ - reverse(...params: { - }[]): string + del(key: string): void } - /** - * PathParams is collections of PathParam instances with various helper methods - */ - interface PathParams extends Array{} - interface PathParams { + interface Values { /** - * Get returns path parameter value for given name or default value. + * Has checks whether a given key is set. */ - get(name: string, defaultValue: string): string + has(key: string): boolean } -} - -/** - * Package sql provides a generic interface around SQL (or SQL-like) - * databases. - * - * The sql package must be used in conjunction with a database driver. - * See https://golang.org/s/sqldrivers for a list of drivers. - * - * Drivers that do not support context cancellation will not return until - * after the query is completed. - * - * For usage examples, see the wiki page at - * https://golang.org/s/sqlwiki. - */ -namespace sql { - /** - * IsolationLevel is the transaction isolation level used in TxOptions. - */ - interface IsolationLevel extends Number{} - interface IsolationLevel { + interface Values { /** - * String returns the name of the transaction isolation level. + * Clone creates a deep copy of the subject [Values]. */ - string(): string + clone(): Values } - /** - * DBStats contains database statistics. - */ - interface DBStats { - maxOpenConnections: number // Maximum number of open connections to the database. + interface Values { /** - * Pool Status + * Encode encodes the values into “URL encoded” form + * ("bar=baz&foo=quux") sorted by key. */ - openConnections: number // The number of established connections both in use and idle. - inUse: number // The number of connections currently in use. - idle: number // The number of idle connections. + encode(): string + } + interface URL { /** - * Counters + * IsAbs reports whether the [URL] is absolute. + * Absolute means that it has a non-empty scheme. */ - waitCount: number // The total number of connections waited for. - waitDuration: time.Duration // The total time blocked waiting for a new connection. - maxIdleClosed: number // The total number of connections closed due to SetMaxIdleConns. - maxIdleTimeClosed: number // The total number of connections closed due to SetConnMaxIdleTime. - maxLifetimeClosed: number // The total number of connections closed due to SetConnMaxLifetime. - } - /** - * Conn represents a single database connection rather than a pool of database - * connections. Prefer running queries from DB unless there is a specific - * need for a continuous single database connection. - * - * A Conn must call Close to return the connection to the database pool - * and may do so concurrently with a running query. - * - * After a call to Close, all operations on the - * connection fail with ErrConnDone. - */ - interface Conn { + isAbs(): boolean } - interface Conn { + interface URL { /** - * PingContext verifies the connection to the database is still alive. + * Parse parses a [URL] in the context of the receiver. The provided URL + * may be relative or absolute. Parse returns nil, err on parse + * failure, otherwise its return value is the same as [URL.ResolveReference]. */ - pingContext(ctx: context.Context): void + parse(ref: string): (URL) } - interface Conn { + interface URL { /** - * ExecContext executes a query without returning any rows. - * The args are for any placeholder parameters in the query. + * ResolveReference resolves a URI reference to an absolute URI from + * an absolute base URI u, per RFC 3986 Section 5.2. The URI reference + * may be relative or absolute. ResolveReference always returns a new + * [URL] instance, even if the returned URL is identical to either the + * base or reference. If ref is an absolute URL, then ResolveReference + * ignores base and returns a copy of ref. */ - execContext(ctx: context.Context, query: string, ...args: any[]): Result + resolveReference(ref: URL): (URL) } - interface Conn { + interface URL { /** - * QueryContext executes a query that returns rows, typically a SELECT. - * The args are for any placeholder parameters in the query. + * Query parses RawQuery and returns the corresponding values. + * It silently discards malformed value pairs. + * To check errors use [ParseQuery]. */ - queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows | undefined) + query(): Values } - interface Conn { + interface URL { /** - * QueryRowContext executes a query that is expected to return at most one row. - * QueryRowContext always returns a non-nil value. Errors are deferred until - * Row's Scan method is called. - * If the query selects no rows, the *Row's Scan will return ErrNoRows. - * Otherwise, the *Row's Scan scans the first selected row and discards - * the rest. + * RequestURI returns the encoded path?query or opaque?query + * string that would be used in an HTTP request for u. */ - queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row | undefined) + requestURI(): string } - interface Conn { + interface URL { /** - * PrepareContext creates a prepared statement for later queries or executions. - * Multiple queries or executions may be run concurrently from the - * returned statement. - * The caller must call the statement's Close method - * when the statement is no longer needed. + * Hostname returns u.Host, stripping any valid port number if present. * - * The provided context is used for the preparation of the statement, not for the - * execution of the statement. + * If the result is enclosed in square brackets, as literal IPv6 addresses are, + * the square brackets are removed from the result. */ - prepareContext(ctx: context.Context, query: string): (Stmt | undefined) + hostname(): string } - interface Conn { + interface URL { /** - * Raw executes f exposing the underlying driver connection for the - * duration of f. The driverConn must not be used outside of f. + * Port returns the port part of u.Host, without the leading colon. * - * Once f returns and err is not driver.ErrBadConn, the Conn will continue to be usable - * until Conn.Close is called. + * If u.Host doesn't contain a valid numeric port, Port returns an empty string. */ - raw(f: (driverConn: any) => void): void + port(): string } - interface Conn { + interface URL { + marshalBinary(): string|Array + } + interface URL { + appendBinary(b: string|Array): string|Array + } + interface URL { + unmarshalBinary(text: string|Array): void + } + interface URL { /** - * BeginTx starts a transaction. - * - * The provided context is used until the transaction is committed or rolled back. - * If the context is canceled, the sql package will roll back - * the transaction. Tx.Commit will return an error if the context provided to - * BeginTx is canceled. - * - * The provided TxOptions is optional and may be nil if defaults should be used. - * If a non-default isolation level is used that the driver doesn't support, - * an error will be returned. + * JoinPath returns a new [URL] with the provided path elements joined to + * any existing path and the resulting path cleaned of any ./ or ../ elements. + * Any sequences of multiple / characters will be reduced to a single /. + * Path elements must already be in escaped form, as produced by [PathEscape]. */ - beginTx(ctx: context.Context, opts: TxOptions): (Tx | undefined) + joinPath(...elem: string[]): (URL) } - interface Conn { + interface URL { /** - * Close returns the connection to the connection pool. - * All operations after a Close will return with ErrConnDone. - * Close is safe to call concurrently with other operations and will - * block until all other operations finish. It may be useful to first - * cancel any used context and then call close directly after. + * Clone creates a deep copy of the fields of the subject [URL]. */ - close(): void + clone(): (URL) } +} + +namespace context { +} + +namespace net { /** - * ColumnType contains the name and type of a column. + * Addr represents a network end point address. + * + * The two methods [Addr.Network] and [Addr.String] conventionally return strings + * that can be passed as the arguments to [Dial], but the exact form + * and meaning of the strings is up to the implementation. */ - interface ColumnType { + interface Addr { + [key:string]: any; + network(): string // name of the network (for example, "tcp", "udp") + string(): string // string form of address (for example, "192.0.2.1:25", "[2001:db8::1]:80") } - interface ColumnType { - /** - * Name returns the name or alias of the column. - */ - name(): string + // @ts-ignore + import _cgopackage = cgo +} + +namespace jwt { + /** + * NumericDate represents a JSON numeric date value, as referenced at + * https://datatracker.ietf.org/doc/html/rfc7519#section-2. + */ + type _sFxgSNp = time.Time + interface NumericDate extends _sFxgSNp { } - interface ColumnType { + interface NumericDate { /** - * Length returns the column type length for variable length column types such - * as text and binary field types. If the type length is unbounded the value will - * be math.MaxInt64 (any database limits will still apply). - * If the column type is not variable length, such as an int, or if not supported - * by the driver ok is false. + * MarshalJSON is an implementation of the json.RawMessage interface and serializes the UNIX epoch + * represented in NumericDate to a byte array, using the precision specified in TimePrecision. */ - length(): [number, boolean] + marshalJSON(): string|Array } - interface ColumnType { + interface NumericDate { /** - * DecimalSize returns the scale and precision of a decimal type. - * If not applicable or if not supported ok is false. + * UnmarshalJSON is an implementation of the json.RawMessage interface and + * deserializes a [NumericDate] from a JSON representation, i.e. a + * [json.Number]. This number represents an UNIX epoch with either integer or + * non-integer seconds. */ - decimalSize(): [number, boolean] + unmarshalJSON(b: string|Array): void } - interface ColumnType { - /** - * ScanType returns a Go type suitable for scanning into using Rows.Scan. - * If a driver does not support this property ScanType will return - * the type of an empty interface. - */ - scanType(): any + /** + * ClaimStrings is basically just a slice of strings, but it can be either + * serialized from a string array or just a string. This type is necessary, + * since the "aud" claim can either be a single string or an array. + */ + interface ClaimStrings extends Array{} + interface ClaimStrings { + unmarshalJSON(data: string|Array): void } - interface ColumnType { + interface ClaimStrings { + marshalJSON(): string|Array + } +} + +namespace subscriptions { +} + +namespace bufio { + /** + * Reader implements buffering for an io.Reader object. + * A new Reader is created by calling [NewReader] or [NewReaderSize]; + * alternatively the zero value of a Reader may be used after calling [Reader.Reset] + * on it. + */ + interface Reader { + } + interface Reader { /** - * Nullable reports whether the column may be null. - * If a driver does not support this property ok will be false. + * Size returns the size of the underlying buffer in bytes. */ - nullable(): boolean + size(): number } - interface ColumnType { + interface Reader { /** - * DatabaseTypeName returns the database system name of the column type. If an empty - * string is returned, then the driver type name is not supported. - * Consult your driver documentation for a list of driver data types. Length specifiers - * are not included. - * Common type names include "VARCHAR", "TEXT", "NVARCHAR", "DECIMAL", "BOOL", - * "INT", and "BIGINT". + * Reset discards any buffered data, resets all state, and switches + * the buffered reader to read from r. + * Calling Reset on the zero value of [Reader] initializes the internal buffer + * to the default size. + * Calling b.Reset(b) (that is, resetting a [Reader] to itself) does nothing. */ - databaseTypeName(): string - } - /** - * Row is the result of calling QueryRow to select a single row. - */ - interface Row { + reset(r: io.Reader): void } - interface Row { + interface Reader { /** - * Scan copies the columns from the matched row into the values - * pointed at by dest. See the documentation on Rows.Scan for details. - * If more than one row matches the query, - * Scan uses the first row and discards the rest. If no row matches - * the query, Scan returns ErrNoRows. + * Peek returns the next n bytes without advancing the reader. The bytes stop + * being valid at the next read call. If necessary, Peek will read more bytes + * into the buffer in order to make n bytes available. If Peek returns fewer + * than n bytes, it also returns an error explaining why the read is short. + * The error is [ErrBufferFull] if n is larger than b's buffer size. + * + * Calling Peek prevents a [Reader.UnreadByte] or [Reader.UnreadRune] call from succeeding + * until the next read operation. */ - scan(...dest: any[]): void + peek(n: number): string|Array } - interface Row { + interface Reader { /** - * Err provides a way for wrapping packages to check for - * query errors without calling Scan. - * Err returns the error, if any, that was encountered while running the query. - * If this error is not nil, this error will also be returned from Scan. + * Discard skips the next n bytes, returning the number of bytes discarded. + * + * If Discard skips fewer than n bytes, it also returns an error. + * If 0 <= n <= b.Buffered(), Discard is guaranteed to succeed without + * reading from the underlying io.Reader. */ - err(): void - } -} - -/** - * Package types implements some commonly used db serializable types - * like datetime, json, etc. - */ -namespace types { - /** - * DateTime represents a [time.Time] instance in UTC that is wrapped - * and serialized using the app default date layout. - */ - interface DateTime { + discard(n: number): number } - interface DateTime { + interface Reader { /** - * Time returns the internal [time.Time] instance. + * Read reads data into p. + * It returns the number of bytes read into p. + * The bytes are taken from at most one Read on the underlying [Reader], + * hence n may be less than len(p). + * To read exactly len(p) bytes, use io.ReadFull(b, p). + * If the underlying [Reader] can return a non-zero count with io.EOF, + * then this Read method can do so as well; see the [io.Reader] docs. */ - time(): time.Time + read(p: string|Array): number } - interface DateTime { + interface Reader { /** - * IsZero checks whether the current DateTime instance has zero time value. + * ReadByte reads and returns a single byte. + * If no byte is available, returns an error. */ - isZero(): boolean + readByte(): number } - interface DateTime { + interface Reader { /** - * String serializes the current DateTime instance into a formatted - * UTC date string. + * UnreadByte unreads the last byte. Only the most recently read byte can be unread. * - * The zero value is serialized to an empty string. + * UnreadByte returns an error if the most recent method called on the + * [Reader] was not a read operation. Notably, [Reader.Peek], [Reader.Discard], and [Reader.WriteTo] are not + * considered read operations. */ - string(): string + unreadByte(): void } - interface DateTime { + interface Reader { /** - * MarshalJSON implements the [json.Marshaler] interface. + * ReadRune reads a single UTF-8 encoded Unicode character and returns the + * rune and its size in bytes. If the encoded rune is invalid, it consumes one byte + * and returns unicode.ReplacementChar (U+FFFD) with a size of 1. */ - marshalJSON(): string + readRune(): [number, number] } - interface DateTime { + interface Reader { /** - * UnmarshalJSON implements the [json.Unmarshaler] interface. + * UnreadRune unreads the last rune. If the most recent method called on + * the [Reader] was not a [Reader.ReadRune], [Reader.UnreadRune] returns an error. (In this + * regard it is stricter than [Reader.UnreadByte], which will unread the last byte + * from any read operation.) */ - unmarshalJSON(b: string): void + unreadRune(): void } - interface DateTime { + interface Reader { /** - * Value implements the [driver.Valuer] interface. + * Buffered returns the number of bytes that can be read from the current buffer. */ - value(): any + buffered(): number } - interface DateTime { + interface Reader { /** - * Scan implements [sql.Scanner] interface to scan the provided value - * into the current DateTime instance. + * ReadSlice reads until the first occurrence of delim in the input, + * returning a slice pointing at the bytes in the buffer. + * The bytes stop being valid at the next read. + * If ReadSlice encounters an error before finding a delimiter, + * it returns all the data in the buffer and the error itself (often io.EOF). + * ReadSlice fails with error [ErrBufferFull] if the buffer fills without a delim. + * Because the data returned from ReadSlice will be overwritten + * by the next I/O operation, most clients should use + * [Reader.ReadBytes] or ReadString instead. + * ReadSlice returns err != nil if and only if line does not end in delim. */ - scan(value: any): void - } -} - -namespace settings { - // @ts-ignore - import validation = ozzo_validation - interface TokenConfig { - secret: string - duration: number + readSlice(delim: number): string|Array } - interface TokenConfig { + interface Reader { /** - * Validate makes TokenConfig validatable by implementing [validation.Validatable] interface. + * ReadLine is a low-level line-reading primitive. Most callers should use + * [Reader.ReadBytes]('\n') or [Reader.ReadString]('\n') instead or use a [Scanner]. + * + * ReadLine tries to return a single line, not including the end-of-line bytes. + * If the line was too long for the buffer then isPrefix is set and the + * beginning of the line is returned. The rest of the line will be returned + * from future calls. isPrefix will be false when returning the last fragment + * of the line. The returned buffer is only valid until the next call to + * ReadLine. ReadLine either returns a non-nil line or it returns an error, + * never both. + * + * The text returned from ReadLine does not include the line end ("\r\n" or "\n"). + * No indication or error is given if the input ends without a final line end. + * Calling [Reader.UnreadByte] after ReadLine will always unread the last byte read + * (possibly a character belonging to the line end) even if that byte is not + * part of the line returned by ReadLine. */ - validate(): void + readLine(): [string|Array, boolean] } - interface SmtpConfig { - enabled: boolean - host: string - port: number - username: string - password: string - /** - * SMTP AUTH - PLAIN (default) or LOGIN - */ - authMethod: string + interface Reader { /** - * 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. + * ReadBytes reads until the first occurrence of delim in the input, + * returning a slice containing the data up to and including the delimiter. + * If ReadBytes encounters an error before finding a delimiter, + * it returns the data read before the error and the error itself (often io.EOF). + * ReadBytes returns err != nil if and only if the returned data does not end in + * delim. + * For simple uses, a Scanner may be more convenient. */ - tls: boolean + readBytes(delim: number): string|Array + } + interface Reader { /** - * 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. + * ReadString reads until the first occurrence of delim in the input, + * returning a string containing the data up to and including the delimiter. + * If ReadString encounters an error before finding a delimiter, + * it returns the data read before the error and the error itself (often io.EOF). + * ReadString returns err != nil if and only if the returned data does not end in + * delim. + * For simple uses, a Scanner may be more convenient. */ - localName: string + readString(delim: number): string } - interface SmtpConfig { + interface Reader { /** - * Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface. + * WriteTo implements io.WriterTo. + * This may make multiple calls to the [Reader.Read] method of the underlying [Reader]. + * If the underlying reader supports the [Reader.WriteTo] method, + * this calls the underlying [Reader.WriteTo] without buffering. */ - validate(): void + writeTo(w: io.Writer): number } - interface S3Config { - enabled: boolean - bucket: string - region: string - endpoint: string - accessKey: string - secret: string - forcePathStyle: boolean + /** + * Writer implements buffering for an [io.Writer] object. + * If an error occurs writing to a [Writer], no more data will be + * accepted and all subsequent writes, and [Writer.Flush], will return the error. + * After all data has been written, the client should call the + * [Writer.Flush] method to guarantee all data has been forwarded to + * the underlying [io.Writer]. + */ + interface Writer { } - interface S3Config { + interface Writer { /** - * Validate makes S3Config validatable by implementing [validation.Validatable] interface. + * Size returns the size of the underlying buffer in bytes. */ - validate(): void + size(): number } - interface BackupsConfig { + interface Writer { /** - * Cron is a cron expression to schedule auto backups, eg. "* * * * *". - * - * Leave it empty to disable the auto backups functionality. + * Reset discards any unflushed buffered data, clears any error, and + * resets b to write its output to w. + * Calling Reset on the zero value of [Writer] initializes the internal buffer + * to the default size. + * Calling w.Reset(w) (that is, resetting a [Writer] to itself) does nothing. */ - cron: string + reset(w: io.Writer): void + } + interface Writer { /** - * 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. + * Flush writes any buffered data to the underlying [io.Writer]. */ - cronMaxKeep: number + flush(): void + } + interface Writer { /** - * S3 is an optional S3 storage config specifying where to store the app backups. + * Available returns how many bytes are unused in the buffer. */ - s3: S3Config + available(): number } - interface BackupsConfig { + interface Writer { /** - * Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface. + * AvailableBuffer returns an empty buffer with b.Available() capacity. + * This buffer is intended to be appended to and + * passed to an immediately succeeding [Writer.Write] call. + * The buffer is only valid until the next write operation on b. */ - validate(): void - } - interface MetaConfig { - appName: string - appUrl: string - hideControls: boolean - senderName: string - senderAddress: string - verificationTemplate: EmailTemplate - resetPasswordTemplate: EmailTemplate - confirmEmailChangeTemplate: EmailTemplate + availableBuffer(): string|Array } - interface MetaConfig { + interface Writer { /** - * Validate makes MetaConfig validatable by implementing [validation.Validatable] interface. + * Buffered returns the number of bytes that have been written into the current buffer. */ - validate(): void - } - interface LogsConfig { - maxDays: number + buffered(): number } - interface LogsConfig { + interface Writer { /** - * Validate makes LogsConfig validatable by implementing [validation.Validatable] interface. + * Write writes the contents of p into the buffer. + * It returns the number of bytes written. + * If nn < len(p), it also returns an error explaining + * why the write is short. */ - validate(): void - } - interface AuthProviderConfig { - enabled: boolean - clientId: string - clientSecret: string - authUrl: string - tokenUrl: string - userApiUrl: string + write(p: string|Array): number } - interface AuthProviderConfig { + interface Writer { /** - * Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface. + * WriteByte writes a single byte. */ - validate(): void + writeByte(c: number): void } - interface AuthProviderConfig { + interface Writer { /** - * SetupProvider loads the current AuthProviderConfig into the specified provider. + * WriteRune writes a single Unicode code point, returning + * the number of bytes written and any error. */ - setupProvider(provider: auth.Provider): void + writeRune(r: number): number } - /** - * Deprecated: Will be removed in v0.9+ - */ - interface EmailAuthConfig { - enabled: boolean - exceptDomains: Array - onlyDomains: Array - minPasswordLength: number + interface Writer { + /** + * WriteString writes a string. + * It returns the number of bytes written. + * If the count is less than len(s), it also returns an error explaining + * why the write is short. + */ + writeString(s: string): number } - interface EmailAuthConfig { + interface Writer { /** - * Deprecated: Will be removed in v0.9+ + * ReadFrom implements [io.ReaderFrom]. If the underlying writer + * supports the ReadFrom method, this calls the underlying ReadFrom. + * If there is buffered data and an underlying ReadFrom, this fills + * the buffer and writes it before calling ReadFrom. */ - validate(): void + readFrom(r: io.Reader): number } } -namespace hook { +/** + * Package textproto implements generic support for text-based request/response + * protocols in the style of HTTP, NNTP, and SMTP. + * + * This package enforces the HTTP/1.1 character set defined by + * RFC 9112 for header keys and values. + * + * The package provides: + * + * [Error], which represents a numeric error response from + * a server. + * + * [Pipeline], to manage pipelined requests and responses + * in a client. + * + * [Reader], to read numeric response code lines, + * key: value headers, lines wrapped with leading spaces + * on continuation lines, and whole text blocks ending + * with a dot on a line by itself. + * + * [Writer], to write dot-encoded text blocks. + * + * [Conn], a convenient packaging of [Reader], [Writer], and [Pipeline] for use + * with a single network connection. + */ +namespace textproto { /** - * Hook defines a concurrent safe structure for handling event hooks - * (aka. callbacks propagation). + * A MIMEHeader represents a MIME-style header mapping + * keys to sets of values. */ - interface Hook { + interface MIMEHeader extends _TygojaDict{} + interface MIMEHeader { + /** + * Add adds the key, value pair to the header. + * It appends to any existing values associated with key. + */ + add(key: string, value: string): void } - interface Hook { + interface MIMEHeader { /** - * PreAdd registers a new handler to the hook by prepending it to the existing queue. - * - * Returns an autogenerated hook id that could be used later to remove the hook with Hook.Remove(id). + * Set sets the header entries associated with key to + * the single element value. It replaces any existing + * values associated with key. */ - preAdd(fn: Handler): string + set(key: string, value: string): void } - interface Hook { + interface MIMEHeader { /** - * Add registers a new handler to the hook by appending it to the existing queue. - * - * Returns an autogenerated hook id that could be used later to remove the hook with Hook.Remove(id). + * Get gets the first value associated with the given key. + * It is case insensitive; [CanonicalMIMEHeaderKey] is used + * to canonicalize the provided key. + * If there are no values associated with the key, Get returns "". + * To use non-canonical keys, access the map directly. */ - add(fn: Handler): string + get(key: string): string } - interface Hook { + interface MIMEHeader { /** - * Remove removes a single hook handler by its id. + * Values returns all values associated with the given key. + * It is case insensitive; [CanonicalMIMEHeaderKey] is + * used to canonicalize the provided key. To use non-canonical + * keys, access the map directly. + * The returned slice is not a copy. */ - remove(id: string): void + values(key: string): Array } - interface Hook { + interface MIMEHeader { /** - * RemoveAll removes all registered handlers. + * Del deletes the values associated with key. */ - removeAll(): void + del(key: string): void } - interface Hook { +} + +namespace multipart { + interface Reader { /** - * Trigger executes all registered hook handlers one by one - * with the specified `data` as an argument. - * - * Optionally, this method allows also to register additional one off - * handlers that will be temporary appended to the handlers queue. - * - * The execution stops when: - * - hook.StopPropagation is returned in one of the handlers - * - any non-nil error is returned in one of the handlers + * ReadForm parses an entire multipart message whose parts have + * a Content-Disposition of "form-data". + * It stores up to maxMemory bytes + 10MB (reserved for non-file parts) + * in memory. File parts which can't be stored in memory will be stored on + * disk in temporary files. + * It returns [ErrMessageTooLarge] if all non-file parts can't be stored in + * memory. */ - trigger(data: T, ...oneOffHandlers: Handler[]): void + readForm(maxMemory: number): (Form) } /** - * TaggedHook defines a proxy hook which register handlers that are triggered only - * if the TaggedHook.tags are empty or includes at least one of the event data tag(s). + * Form is a parsed multipart form. + * Its File parts are stored either in memory or on disk, + * and are accessible via the [*FileHeader]'s Open method. + * Its Value parts are stored as strings. + * Both are keyed by field name. */ - type _subJagJo = mainHook - interface TaggedHook extends _subJagJo { - } - interface TaggedHook { - /** - * CanTriggerOn checks if the current TaggedHook can be triggered with - * the provided event data tags. - */ - canTriggerOn(tags: Array): boolean - } - interface TaggedHook { - /** - * PreAdd registers a new handler to the hook by prepending it to the existing queue. - * - * The fn handler will be called only if the event data tags satisfy h.CanTriggerOn. - */ - preAdd(fn: Handler): string + interface Form { + value: _TygojaDict + file: _TygojaDict } - interface TaggedHook { + interface Form { /** - * Add registers a new handler to the hook by appending it to the existing queue. - * - * The fn handler will be called only if the event data tags satisfy h.CanTriggerOn. + * RemoveAll removes any temporary files associated with a [Form]. */ - add(fn: Handler): string + removeAll(): void } -} - -namespace subscriptions { /** - * Broker defines a struct for managing subscriptions clients. + * File is an interface to access the file part of a multipart message. + * Its contents may be either stored in memory or on disk. + * If stored on disk, the File's underlying concrete type will be an *os.File. */ - interface Broker { + interface File { + [key:string]: any; } - interface Broker { - /** - * Clients returns a shallow copy of all registered clients indexed - * with their connection id. - */ - clients(): _TygojaDict + /** + * Reader is an iterator over parts in a MIME multipart body. + * Reader's underlying parser consumes its input as needed. Seeking + * isn't supported. + */ + interface Reader { } - interface Broker { + interface Reader { /** - * ClientById finds a registered client by its id. + * NextPart returns the next part in the multipart or an error. + * When there are no more parts, the error [io.EOF] is returned. * - * Returns non-nil error when client with clientId is not registered. - */ - clientById(clientId: string): Client - } - interface Broker { - /** - * Register adds a new client to the broker instance. + * As a special case, if the "Content-Transfer-Encoding" header + * has a value of "quoted-printable", that header is instead + * hidden and the body is transparently decoded during Read calls. */ - register(client: Client): void + nextPart(): (Part) } - interface Broker { + interface Reader { /** - * Unregister removes a single client by its id. + * NextRawPart returns the next part in the multipart or an error. + * When there are no more parts, the error [io.EOF] is returned. * - * If client with clientId doesn't exist, this method does nothing. + * Unlike [Reader.NextPart], it does not have special handling for + * "Content-Transfer-Encoding: quoted-printable". */ - unregister(clientId: string): void + nextRawPart(): (Part) } } -/** - * Package cobra is a commander providing a simple interface to create powerful modern CLI interfaces. - * In addition to providing an interface, Cobra simultaneously provides a controller to organize your application code. - */ -namespace cobra { - interface PositionalArgs {(cmd: Command, args: Array): void } - // @ts-ignore - import flag = pflag - /** - * FParseErrWhitelist configures Flag parse errors to be ignored - */ - interface FParseErrWhitelist extends _TygojaAny{} - /** - * Group Structure to manage groups for commands - */ - interface Group { - id: string - title: string - } - /** - * ShellCompDirective is a bit map representing the different behaviors the shell - * can be instructed to have once completions have been provided. - */ - interface ShellCompDirective extends Number{} +namespace http { /** - * CompletionOptions are the options to control shell completion + * A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an + * HTTP response or the Cookie header of an HTTP request. + * + * See https://tools.ietf.org/html/rfc6265 for details. */ - interface CompletionOptions { - /** - * DisableDefaultCmd prevents Cobra from creating a default 'completion' command - */ - disableDefaultCmd: boolean + interface Cookie { + name: string + value: string + quoted: boolean // indicates whether the Value was originally quoted + path: string // optional + domain: string // optional + expires: time.Time // optional + rawExpires: string // for reading cookies only /** - * DisableNoDescFlag prevents Cobra from creating the '--no-descriptions' flag - * for shells that support completion descriptions + * MaxAge=0 means no 'Max-Age' attribute specified. + * MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0' + * MaxAge>0 means Max-Age attribute present and given in seconds */ - disableNoDescFlag: boolean + maxAge: number + secure: boolean + httpOnly: boolean + sameSite: SameSite + partitioned: boolean + raw: string + unparsed: Array // Raw text of unparsed attribute-value pairs + } + interface Cookie { /** - * DisableDescriptions turns off all completion descriptions for shells - * that support them + * String returns the serialization of the cookie for use in a [Cookie] + * header (if only Name and Value are set) or a Set-Cookie response + * header (if other fields are set). + * If c is nil or c.Name is invalid, the empty string is returned. */ - disableDescriptions: boolean + string(): string + } + interface Cookie { /** - * HiddenDefaultCmd makes the default 'completion' command hidden + * Valid reports whether the cookie is valid. */ - hiddenDefaultCmd: boolean + valid(): void } -} - -/** - * Package schema implements custom Schema and SchemaField datatypes - * for handling the Collection schema definitions. - */ -namespace schema { - // @ts-ignore - import validation = ozzo_validation /** - * SchemaField defines a single schema field structure. + * A Header represents the key-value pairs in an HTTP header. + * + * The keys should be in canonical form, as returned by + * [CanonicalHeaderKey]. */ - interface SchemaField { - system: boolean - id: string - name: string - type: string - required: boolean - /** - * Presentable indicates whether the field is suitable for - * visualization purposes (eg. in the Admin UI relation views). - */ - presentable: boolean - /** - * 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: boolean - options: any - } - interface SchemaField { + interface Header extends _TygojaDict{} + interface Header { /** - * ColDefinition returns the field db column type definition as string. + * Add adds the key, value pair to the header. + * It appends to any existing values associated with key. + * The key is case insensitive; it is canonicalized by + * [CanonicalHeaderKey]. */ - colDefinition(): string + add(key: string, value: string): void } - interface SchemaField { + interface Header { /** - * String serializes and returns the current field as string. + * Set sets the header entries associated with key to the + * single element value. It replaces any existing values + * associated with key. The key is case insensitive; it is + * canonicalized by [textproto.CanonicalMIMEHeaderKey]. + * To use non-canonical keys, assign to the map directly. */ - string(): string + set(key: string, value: string): void } - interface SchemaField { + interface Header { /** - * MarshalJSON implements the [json.Marshaler] interface. + * Get gets the first value associated with the given key. If + * there are no values associated with the key, Get returns "". + * It is case insensitive; [textproto.CanonicalMIMEHeaderKey] is + * used to canonicalize the provided key. Get assumes that all + * keys are stored in canonical form. To use non-canonical keys, + * access the map directly. */ - marshalJSON(): string + get(key: string): string } - interface SchemaField { + interface Header { /** - * UnmarshalJSON implements the [json.Unmarshaler] interface. - * - * The schema field options are auto initialized on success. + * Values returns all values associated with the given key. + * It is case insensitive; [textproto.CanonicalMIMEHeaderKey] is + * used to canonicalize the provided key. To use non-canonical + * keys, access the map directly. + * The returned slice is not a copy. */ - unmarshalJSON(data: string): void + values(key: string): Array } - interface SchemaField { + interface Header { /** - * Validate makes `SchemaField` validatable by implementing [validation.Validatable] interface. + * Del deletes the values associated with key. + * The key is case insensitive; it is canonicalized by + * [CanonicalHeaderKey]. */ - validate(): void + del(key: string): void } - interface SchemaField { + interface Header { /** - * InitOptions initializes the current field options based on its type. - * - * Returns error on unknown field type. + * Write writes a header in wire format. */ - initOptions(): void + write(w: io.Writer): void } - interface SchemaField { + interface Header { /** - * PrepareValue returns normalized and properly formatted field value. + * Clone returns a copy of h or nil if h is nil. */ - prepareValue(value: any): any + clone(): Header } - interface SchemaField { + interface Header { /** - * PrepareValueWithModifier returns normalized and properly formatted field value - * by "merging" baseValue with the modifierValue based on the specified modifier (+ or -). + * WriteSubset writes a header in wire format. + * If exclude is not nil, keys where exclude[key] == true are not written. + * Keys are not canonicalized before checking the exclude map. */ - prepareValueWithModifier(baseValue: any, modifier: string, modifierValue: any): any - } -} - -namespace migrate { - interface Migration { - file: string - up: (db: dbx.Builder) => void - down: (db: dbx.Builder) => void - } -} - -/** - * Package models implements all PocketBase DB models and DTOs. - */ -namespace models { - /** - * Model defines an interface with common methods that all db models should have. - */ - interface Model { - tableName(): string - isNew(): boolean - markAsNew(): void - markAsNotNew(): void - hasId(): boolean - getId(): string - setId(id: string): void - getCreated(): types.DateTime - getUpdated(): types.DateTime - refreshId(): void - refreshCreated(): void - refreshUpdated(): void + writeSubset(w: io.Writer, exclude: _TygojaDict): void } /** - * BaseModel defines common fields and methods used by all other models. + * Protocols is a set of HTTP protocols. + * The zero value is an empty set of protocols. + * + * The supported protocols are: + * + * ``` + * - HTTP1 is the HTTP/1.0 and HTTP/1.1 protocols. + * HTTP1 is supported on both unsecured TCP and secured TLS connections. + * + * - HTTP2 is the HTTP/2 protocol over a TLS connection. + * + * - UnencryptedHTTP2 is the HTTP/2 protocol over an unsecured TCP connection. + * ``` */ - interface BaseModel { - id: string - created: types.DateTime - updated: types.DateTime + interface Protocols { } - interface BaseModel { + interface Protocols { /** - * HasId returns whether the model has a nonzero id. + * HTTP1 reports whether p includes HTTP/1. */ - hasId(): boolean + http1(): boolean } - interface BaseModel { + interface Protocols { /** - * GetId returns the model id. + * SetHTTP1 adds or removes HTTP/1 from p. */ - getId(): string + setHTTP1(ok: boolean): void } - interface BaseModel { + interface Protocols { /** - * SetId sets the model id to the provided string value. + * HTTP2 reports whether p includes HTTP/2. */ - setId(id: string): void + http2(): boolean } - interface BaseModel { + interface Protocols { /** - * MarkAsNew marks the model as "new" (aka. enforces m.IsNew() to be true). + * SetHTTP2 adds or removes HTTP/2 from p. */ - markAsNew(): void + setHTTP2(ok: boolean): void } - interface BaseModel { + interface Protocols { /** - * MarkAsNotNew marks the model as "not new" (aka. enforces m.IsNew() to be false) + * UnencryptedHTTP2 reports whether p includes unencrypted HTTP/2. */ - markAsNotNew(): void + unencryptedHTTP2(): boolean } - interface BaseModel { + interface Protocols { /** - * IsNew indicates what type of db query (insert or update) - * should be used with the model instance. + * SetUnencryptedHTTP2 adds or removes unencrypted HTTP/2 from p. */ - isNew(): boolean + setUnencryptedHTTP2(ok: boolean): void } - interface BaseModel { - /** - * GetCreated returns the model Created datetime. - */ - getCreated(): types.DateTime + interface Protocols { + string(): string } - interface BaseModel { + /** + * HTTP2Config defines HTTP/2 configuration parameters common to + * both [Transport] and [Server]. + */ + interface HTTP2Config { /** - * GetUpdated returns the model Updated datetime. + * MaxConcurrentStreams optionally specifies the number of + * concurrent streams that a client may have open at a time. + * If zero, MaxConcurrentStreams defaults to at least 100. + * + * This parameter only applies to Servers. */ - getUpdated(): types.DateTime - } - interface BaseModel { + maxConcurrentStreams: number /** - * RefreshId generates and sets a new model id. + * StrictMaxConcurrentRequests controls whether an HTTP/2 server's + * concurrency limit should be respected across all connections + * to that server. + * If true, new requests sent when a connection's concurrency limit + * has been exceeded will block until an existing request completes. + * If false, an additional connection will be opened if all + * existing connections are at their limit. * - * The generated id is a cryptographically random 15 characters length string. + * This parameter only applies to Transports. */ - refreshId(): void - } - interface BaseModel { + strictMaxConcurrentRequests: boolean /** - * RefreshCreated updates the model Created field with the current datetime. + * MaxDecoderHeaderTableSize optionally specifies an upper limit for the + * size of the header compression table used for decoding headers sent + * by the peer. + * A valid value is less than 4MiB. + * If zero or invalid, a default value is used. */ - refreshCreated(): void - } - interface BaseModel { + maxDecoderHeaderTableSize: number /** - * RefreshUpdated updates the model Updated field with the current datetime. + * MaxEncoderHeaderTableSize optionally specifies an upper limit for the + * header compression table used for sending headers to the peer. + * A valid value is less than 4MiB. + * If zero or invalid, a default value is used. */ - refreshUpdated(): void - } - interface BaseModel { + maxEncoderHeaderTableSize: number /** - * PostScan implements the [dbx.PostScanner] interface. - * - * It is executed right after the model was populated with the db row values. + * MaxReadFrameSize optionally specifies the largest frame + * this endpoint is willing to read. + * A valid value is between 16KiB and 16MiB, inclusive. + * If zero or invalid, a default value is used. */ - postScan(): void + maxReadFrameSize: number + /** + * MaxReceiveBufferPerConnection is the maximum size of the + * flow control window for data received on a connection. + * A valid value is at least 64KiB and less than 4MiB. + * If invalid, a default value is used. + */ + maxReceiveBufferPerConnection: number + /** + * MaxReceiveBufferPerStream is the maximum size of + * the flow control window for data received on a stream (request). + * A valid value is less than 4MiB. + * If zero or invalid, a default value is used. + */ + maxReceiveBufferPerStream: number + /** + * SendPingTimeout is the timeout after which a health check using a ping + * frame will be carried out if no frame is received on a connection. + * If zero, no health check is performed. + */ + sendPingTimeout: time.Duration + /** + * PingTimeout is the timeout after which a connection will be closed + * if a response to a ping is not received. + * If zero, a default of 15 seconds is used. + */ + pingTimeout: time.Duration + /** + * WriteByteTimeout is the timeout after which a connection will be + * closed if no data can be written to it. The timeout begins when data is + * available to write, and is extended whenever any bytes are written. + */ + writeByteTimeout: time.Duration + /** + * PermitProhibitedCipherSuites, if true, permits the use of + * cipher suites prohibited by the HTTP/2 spec. + */ + permitProhibitedCipherSuites: boolean + /** + * CountError, if non-nil, is called on HTTP/2 errors. + * It is intended to increment a metric for monitoring. + * The errType contains only lowercase letters, digits, and underscores + * (a-z, 0-9, _). + */ + countError: (errType: string) => void } // @ts-ignore - import validation = ozzo_validation + import urlpkg = url /** - * CollectionBaseOptions defines the "base" Collection.Options fields. + * Response represents the response from an HTTP request. + * + * The [Client] and [Transport] return Responses from servers once + * the response headers have been received. The response body + * is streamed on demand as the Body field is read. */ - interface CollectionBaseOptions { - } - interface CollectionBaseOptions { + interface Response { + status: string // e.g. "200 OK" + statusCode: number // e.g. 200 + proto: string // e.g. "HTTP/1.0" + protoMajor: number // e.g. 1 + protoMinor: number // e.g. 0 /** - * Validate implements [validation.Validatable] interface. + * Header maps header keys to values. If the response had multiple + * headers with the same key, they may be concatenated, with comma + * delimiters. (RFC 7230, section 3.2.2 requires that multiple headers + * be semantically equivalent to a comma-delimited sequence.) When + * Header values are duplicated by other fields in this struct (e.g., + * ContentLength, TransferEncoding, Trailer), the field values are + * authoritative. + * + * Keys in the map are canonicalized (see CanonicalHeaderKey). */ - validate(): void - } - /** - * CollectionAuthOptions defines the "auth" Collection.Options fields. - */ - interface CollectionAuthOptions { - manageRule?: string - allowOAuth2Auth: boolean - allowUsernameAuth: boolean - allowEmailAuth: boolean - requireEmail: boolean - exceptEmailDomains: Array - onlyEmailDomains: Array - minPasswordLength: number - } - interface CollectionAuthOptions { + header: Header /** - * Validate implements [validation.Validatable] interface. + * Body represents the response body. + * + * The response body is streamed on demand as the Body field + * is read. If the network connection fails or the server + * terminates the response, Body.Read calls return an error. + * + * The http Client and Transport guarantee that Body is always + * non-nil, even on responses without a body or responses with + * a zero-length body. It is the caller's responsibility to + * close Body. The default HTTP client's Transport may not + * reuse HTTP/1.x "keep-alive" TCP connections if the Body is + * not read to completion and closed; however, manually reading + * the body to completion should not be needed in most cases, + * as closing the body will also cause the body to be read to + * completion asynchronously, up to a conservative limit. + * + * The Body is automatically dechunked if the server replied + * with a "chunked" Transfer-Encoding. + * + * As of Go 1.12, the Body will also implement io.Writer + * on a successful "101 Switching Protocols" response, + * as used by WebSockets and HTTP/2's "h2c" mode. */ - validate(): void - } - /** - * CollectionViewOptions defines the "view" Collection.Options fields. - */ - interface CollectionViewOptions { - query: string - } - interface CollectionViewOptions { + body: io.ReadCloser /** - * Validate implements [validation.Validatable] interface. + * ContentLength records the length of the associated content. The + * value -1 indicates that the length is unknown. Unless Request.Method + * is "HEAD", values >= 0 indicate that the given number of bytes may + * be read from Body. */ - validate(): void - } - type _subIOcbA = BaseModel - interface Param extends _subIOcbA { - key: string - value: types.JsonRaw - } - interface Param { - tableName(): string - } - type _subQlzoF = BaseModel - interface Request extends _subQlzoF { - url: string - method: string - status: number - auth: string - userIp: string - remoteIp: string - referer: string - userAgent: string - meta: types.JsonMap - } - interface Request { - tableName(): string - } - interface TableInfoRow { + contentLength: number /** - * the `db:"pk"` tag has special semantic so we cannot rename - * the original field without specifying a custom mapper + * Contains transfer encodings from outer-most to inner-most. Value is + * nil, means that "identity" encoding is used. */ - pk: number - index: number - name: string - type: string - notNull: boolean - defaultValue: types.JsonRaw - } -} - -/** - * Package daos handles common PocketBase DB model manipulations. - * - * Think of daos as DB repository and service layer in one. - */ -namespace daos { - /** - * ExpandFetchFunc defines the function that is used to fetch the expanded relation records. - */ - interface ExpandFetchFunc {(relCollection: models.Collection, relIds: Array): Array<(models.Record | undefined)> } - // @ts-ignore - import validation = ozzo_validation - interface RequestsStatsItem { - total: number - date: types.DateTime - } -} - -/** - * Package core is the backbone of PocketBase. - * - * It defines the main PocketBase App interface and its base implementation. - */ -namespace core { - interface BootstrapEvent { - app: App - } - interface TerminateEvent { - app: App - } - interface ServeEvent { - app: App - router?: echo.Echo - server?: http.Server - certManager?: any - } - interface ApiErrorEvent { - httpContext: echo.Context - error: Error - } - type _subsrtcV = BaseModelEvent - interface ModelEvent extends _subsrtcV { - dao?: daos.Dao - } - type _subEUrcd = BaseCollectionEvent - interface MailerRecordEvent extends _subEUrcd { - mailClient: mailer.Mailer - message?: mailer.Message - record?: models.Record - meta: _TygojaDict - } - interface MailerAdminEvent { - mailClient: mailer.Mailer - message?: mailer.Message - admin?: models.Admin - meta: _TygojaDict - } - interface RealtimeConnectEvent { - httpContext: echo.Context - client: subscriptions.Client - idleTimeout: time.Duration - } - interface RealtimeDisconnectEvent { - httpContext: echo.Context - client: subscriptions.Client - } - interface RealtimeMessageEvent { - httpContext: echo.Context - client: subscriptions.Client - message?: subscriptions.Message - } - interface RealtimeSubscribeEvent { - httpContext: echo.Context - client: subscriptions.Client - subscriptions: Array - } - interface SettingsListEvent { - httpContext: echo.Context - redactedSettings?: settings.Settings - } - interface SettingsUpdateEvent { - httpContext: echo.Context - oldSettings?: settings.Settings - newSettings?: settings.Settings - } - type _subkFzUJ = BaseCollectionEvent - interface RecordsListEvent extends _subkFzUJ { - httpContext: echo.Context - records: Array<(models.Record | undefined)> - result?: search.Result - } - type _subUlvkR = BaseCollectionEvent - interface RecordViewEvent extends _subUlvkR { - httpContext: echo.Context - record?: models.Record - } - type _subtvilr = BaseCollectionEvent - interface RecordCreateEvent extends _subtvilr { - httpContext: echo.Context - record?: models.Record - uploadedFiles: _TygojaDict - } - type _subeBtxU = BaseCollectionEvent - interface RecordUpdateEvent extends _subeBtxU { - httpContext: echo.Context - record?: models.Record - uploadedFiles: _TygojaDict - } - type _subiCCui = BaseCollectionEvent - interface RecordDeleteEvent extends _subiCCui { - httpContext: echo.Context - record?: models.Record - } - type _subGCcup = BaseCollectionEvent - interface RecordAuthEvent extends _subGCcup { - httpContext: echo.Context - record?: models.Record - token: string - meta: any - } - type _subCIVPf = BaseCollectionEvent - interface RecordAuthWithPasswordEvent extends _subCIVPf { - httpContext: echo.Context - record?: models.Record - identity: string - password: string - } - type _subevZyU = BaseCollectionEvent - interface RecordAuthWithOAuth2Event extends _subevZyU { - httpContext: echo.Context - providerName: string - providerClient: auth.Provider - record?: models.Record - oAuth2User?: auth.AuthUser - isNewRecord: boolean - } - type _subEIHlm = BaseCollectionEvent - interface RecordAuthRefreshEvent extends _subEIHlm { - httpContext: echo.Context - record?: models.Record - } - type _subocsaO = BaseCollectionEvent - interface RecordRequestPasswordResetEvent extends _subocsaO { - httpContext: echo.Context - record?: models.Record - } - type _subRQfkB = BaseCollectionEvent - interface RecordConfirmPasswordResetEvent extends _subRQfkB { - httpContext: echo.Context - record?: models.Record - } - type _subfPtyU = BaseCollectionEvent - interface RecordRequestVerificationEvent extends _subfPtyU { - httpContext: echo.Context - record?: models.Record - } - type _subFFwpD = BaseCollectionEvent - interface RecordConfirmVerificationEvent extends _subFFwpD { - httpContext: echo.Context - record?: models.Record - } - type _subiJqev = BaseCollectionEvent - interface RecordRequestEmailChangeEvent extends _subiJqev { - httpContext: echo.Context - record?: models.Record - } - type _subsJzlc = BaseCollectionEvent - interface RecordConfirmEmailChangeEvent extends _subsJzlc { - httpContext: echo.Context - record?: models.Record - } - type _subzMuOP = BaseCollectionEvent - interface RecordListExternalAuthsEvent extends _subzMuOP { - httpContext: echo.Context - record?: models.Record - externalAuths: Array<(models.ExternalAuth | undefined)> - } - type _subROEhY = BaseCollectionEvent - interface RecordUnlinkExternalAuthEvent extends _subROEhY { - httpContext: echo.Context - record?: models.Record - externalAuth?: models.ExternalAuth - } - interface AdminsListEvent { - httpContext: echo.Context - admins: Array<(models.Admin | undefined)> - result?: search.Result - } - interface AdminViewEvent { - httpContext: echo.Context - admin?: models.Admin - } - interface AdminCreateEvent { - httpContext: echo.Context - admin?: models.Admin - } - interface AdminUpdateEvent { - httpContext: echo.Context - admin?: models.Admin - } - interface AdminDeleteEvent { - httpContext: echo.Context - admin?: models.Admin - } - interface AdminAuthEvent { - httpContext: echo.Context - admin?: models.Admin - token: string - } - interface AdminAuthWithPasswordEvent { - httpContext: echo.Context - admin?: models.Admin - identity: string - password: string + transferEncoding: Array + /** + * Close records whether the header directed that the connection be + * closed after reading Body. The value is advice for clients: neither + * ReadResponse nor Response.Write ever closes a connection. + */ + close: boolean + /** + * Uncompressed reports whether the response was sent compressed but + * was decompressed by the http package. When true, reading from + * Body yields the uncompressed content instead of the compressed + * content actually set from the server, ContentLength is set to -1, + * and the "Content-Length" and "Content-Encoding" fields are deleted + * from the responseHeader. To get the original response from + * the server, set Transport.DisableCompression to true. + */ + uncompressed: boolean + /** + * Trailer maps trailer keys to values in the same + * format as Header. + * + * The Trailer initially contains only nil values, one for + * each key specified in the server's "Trailer" header + * value. Those values are not added to Header. + * + * Trailer must not be accessed concurrently with Read calls + * on the Body. + * + * After Body.Read has returned io.EOF, Trailer will contain + * any trailer values sent by the server. + */ + trailer: Header + /** + * Request is the request that was sent to obtain this Response. + * Request's Body is nil (having already been consumed). + * This is only populated for Client requests. + */ + request?: Request + /** + * TLS contains information about the TLS connection on which the + * response was received. It is nil for unencrypted responses. + * The pointer is shared between responses and should not be + * modified. + */ + tls?: any } - interface AdminAuthRefreshEvent { - httpContext: echo.Context - admin?: models.Admin + interface Response { + /** + * Cookies parses and returns the cookies set in the Set-Cookie headers. + */ + cookies(): Array<(Cookie | undefined)> } - interface AdminRequestPasswordResetEvent { - httpContext: echo.Context - admin?: models.Admin + interface Response { + /** + * Location returns the URL of the response's "Location" header, + * if present. Relative redirects are resolved relative to + * [Response.Request]. [ErrNoLocation] is returned if no + * Location header is present. + */ + location(): (url.URL) } - interface AdminConfirmPasswordResetEvent { - httpContext: echo.Context - admin?: models.Admin + interface Response { + /** + * ProtoAtLeast reports whether the HTTP protocol used + * in the response is at least major.minor. + */ + protoAtLeast(major: number, minor: number): boolean } - interface CollectionsListEvent { - httpContext: echo.Context - collections: Array<(models.Collection | undefined)> - result?: search.Result + interface Response { + /** + * Write writes r to w in the HTTP/1.x server response format, + * including the status line, headers, body, and optional trailer. + * + * This method consults the following fields of the response r: + * + * ``` + * StatusCode + * ProtoMajor + * ProtoMinor + * Request.Method + * TransferEncoding + * Trailer + * Body + * ContentLength + * Header, values for non-canonical keys will have unpredictable behavior + * ``` + * + * The Response Body is closed after it is sent. + */ + write(w: io.Writer): void } - type _subfeVBA = BaseCollectionEvent - interface CollectionViewEvent extends _subfeVBA { - httpContext: echo.Context + /** + * A ConnState represents the state of a client connection to a server. + * It's used by the optional [Server.ConnState] hook. + */ + interface ConnState extends Number{} + interface ConnState { + string(): string } - type _subQNEwU = BaseCollectionEvent - interface CollectionCreateEvent extends _subQNEwU { - httpContext: echo.Context +} + +namespace cobra { + interface PositionalArgs {(cmd: Command, args: Array): void } + // @ts-ignore + import flag = pflag + /** + * FParseErrWhitelist configures Flag parse errors to be ignored + */ + interface FParseErrWhitelist extends _TygojaAny{} + /** + * Group Structure to manage groups for commands + */ + interface Group { + id: string + title: string } - type _subXGZiT = BaseCollectionEvent - interface CollectionUpdateEvent extends _subXGZiT { - httpContext: echo.Context + /** + * CompletionOptions are the options to control shell completion + */ + interface CompletionOptions { + /** + * DisableDefaultCmd prevents Cobra from creating a default 'completion' command + */ + disableDefaultCmd: boolean + /** + * DisableNoDescFlag prevents Cobra from creating the '--no-descriptions' flag + * for shells that support completion descriptions + */ + disableNoDescFlag: boolean + /** + * DisableDescriptions turns off all completion descriptions for shells + * that support them + */ + disableDescriptions: boolean + /** + * HiddenDefaultCmd makes the default 'completion' command hidden + */ + hiddenDefaultCmd: boolean + /** + * DefaultShellCompDirective sets the ShellCompDirective that is returned + * if no special directive can be determined + */ + defaultShellCompDirective?: ShellCompDirective } - type _subuGikm = BaseCollectionEvent - interface CollectionDeleteEvent extends _subuGikm { - httpContext: echo.Context + interface CompletionOptions { + setDefaultShellCompDirective(directive: ShellCompDirective): void } - interface CollectionsImportEvent { - httpContext: echo.Context - collections: Array<(models.Collection | undefined)> + /** + * Completion is a string that can be used for completions + * + * two formats are supported: + * ``` + * - the completion choice + * - the completion choice with a textual description (separated by a TAB). + * ``` + * + * [CompletionWithDesc] can be used to create a completion string with a textual description. + * + * Note: Go type alias is used to provide a more descriptive name in the documentation, but any string can be used. + */ + interface Completion extends String{} + /** + * CompletionFunc is a function that provides completion results. + */ + interface CompletionFunc {(cmd: Command, args: Array, toComplete: string): [Array, ShellCompDirective] } +} + +namespace slog { + /** + * An Attr is a key-value pair. + */ + interface Attr { + key: string + value: Value } - type _subxBPbB = BaseModelEvent - interface FileTokenEvent extends _subxBPbB { - httpContext: echo.Context - token: string + interface Attr { + /** + * Equal reports whether a and b have equal keys and values. + */ + equal(b: Attr): boolean } - type _subYcWwE = BaseCollectionEvent - interface FileDownloadEvent extends _subYcWwE { - httpContext: echo.Context - record?: models.Record - fileField?: schema.SchemaField - servedPath: string - servedName: string + interface Attr { + string(): string } -} - -/** - * Package bufio implements buffered I/O. It wraps an io.Reader or io.Writer - * object, creating another object (Reader or Writer) that also implements - * the interface but provides buffering and some help for textual I/O. - */ -namespace bufio { /** - * ReadWriter stores pointers to a Reader and a Writer. - * It implements io.ReadWriter. + * A Handler handles log records produced by a Logger. + * + * A typical handler may print log records to standard error, + * or write them to a file or database, or perhaps augment them + * with additional attributes and pass them on to another handler. + * + * Any of the Handler's methods may be called concurrently with itself + * or with other methods. It is the responsibility of the Handler to + * manage this concurrency. + * + * Users of the slog package should not invoke Handler methods directly. + * They should use the methods of [Logger] instead. + * + * Before implementing your own handler, consult https://go.dev/s/slog-handler-guide. */ - type _subaTKZU = Reader&Writer - interface ReadWriter extends _subaTKZU { + interface Handler { + [key:string]: any; + /** + * Enabled reports whether the handler handles records at the given level. + * The handler ignores records whose level is lower. + * It is called early, before any arguments are processed, + * to save effort if the log event should be discarded. + * If called from a Logger method, the first argument is the context + * passed to that method, or context.Background() if nil was passed + * or the method does not take a context. + * The context is passed so Enabled can use its values + * to make a decision. + */ + enabled(_arg0: context.Context, _arg1: Level): boolean + /** + * Handle handles the Record. + * It will only be called when Enabled returns true. + * The Context argument is as for Enabled. + * It is present solely to provide Handlers access to the context's values. + * Canceling the context should not affect record processing. + * (Among other things, log messages may be necessary to debug a + * cancellation-related problem.) + * + * Handle methods that produce output should observe the following rules: + * ``` + * - If r.Time is the zero time, ignore the time. + * - If r.PC is zero, ignore it. + * - Attr's values should be resolved. + * - If an Attr's key and value are both the zero value, ignore the Attr. + * This can be tested with attr.Equal(Attr{}). + * - If a group's key is empty, inline the group's Attrs. + * - If a group has no Attrs (even if it has a non-empty key), + * ignore it. + * ``` + * + * [Logger] discards any errors from Handle. Wrap the Handle method to + * process any errors from Handlers. + */ + handle(_arg0: context.Context, _arg1: Record): void + /** + * WithAttrs returns a new Handler whose attributes consist of + * both the receiver's attributes and the arguments. + * The Handler owns the slice: it may retain, modify or discard it. + */ + withAttrs(attrs: Array): Handler + /** + * WithGroup returns a new Handler with the given group appended to + * the receiver's existing groups. + * The keys of all subsequent attributes, whether added by With or in a + * Record, should be qualified by the sequence of group names. + * + * How this qualification happens is up to the Handler, so long as + * this Handler's attribute keys differ from those of another Handler + * with a different sequence of group names. + * + * A Handler should treat WithGroup as starting a Group of Attrs that ends + * at the end of the log event. That is, + * + * ``` + * logger.WithGroup("s").LogAttrs(ctx, level, msg, slog.Int("a", 1), slog.Int("b", 2)) + * ``` + * + * should behave like + * + * ``` + * logger.LogAttrs(ctx, level, msg, slog.Group("s", slog.Int("a", 1), slog.Int("b", 2))) + * ``` + * + * If the name is empty, WithGroup returns the receiver. + */ + withGroup(name: string): Handler } -} - -/** - * Package url parses URLs and implements query escaping. - */ -namespace url { /** - * The Userinfo type is an immutable encapsulation of username and - * password details for a URL. An existing Userinfo value is guaranteed - * to have a username set (potentially empty, as allowed by RFC 2396), - * and optionally a password. + * A Level is the importance or severity of a log event. + * The higher the level, the more important or severe the event. */ - interface Userinfo { + interface Level extends Number{} + interface Level { + /** + * String returns a name for the level. + * If the level has a name, then that name + * in uppercase is returned. + * If the level is between named values, then + * an integer is appended to the uppercased name. + * Examples: + * + * ``` + * LevelWarn.String() => "WARN" + * (LevelInfo+2).String() => "INFO+2" + * ``` + */ + string(): string } - interface Userinfo { + interface Level { /** - * Username returns the username. + * MarshalJSON implements [encoding/json.Marshaler] + * by quoting the output of [Level.String]. */ - username(): string + marshalJSON(): string|Array } - interface Userinfo { + interface Level { /** - * Password returns the password in case it is set, and whether it is set. + * UnmarshalJSON implements [encoding/json.Unmarshaler] + * It accepts any string produced by [Level.MarshalJSON], + * ignoring case. + * It also accepts numeric offsets that would result in a different string on + * output. For example, "Error-8" would marshal as "INFO". */ - password(): [string, boolean] + unmarshalJSON(data: string|Array): void } - interface Userinfo { + interface Level { /** - * String returns the encoded userinfo information in the standard form - * of "username[:password]". + * AppendText implements [encoding.TextAppender] + * by calling [Level.String]. */ - string(): string + appendText(b: string|Array): string|Array } + interface Level { + /** + * MarshalText implements [encoding.TextMarshaler] + * by calling [Level.AppendText]. + */ + marshalText(): string|Array + } + interface Level { + /** + * UnmarshalText implements [encoding.TextUnmarshaler]. + * It accepts any string produced by [Level.MarshalText], + * ignoring case. + * It also accepts numeric offsets that would result in a different string on + * output. For example, "Error-8" would marshal as "INFO". + */ + unmarshalText(data: string|Array): void + } + interface Level { + /** + * Level returns the receiver. + * It implements [Leveler]. + */ + level(): Level + } + // @ts-ignore + import loginternal = internal } -/** - * Package net provides a portable interface for network I/O, including - * TCP/IP, UDP, domain name resolution, and Unix domain sockets. - * - * Although the package provides access to low-level networking - * primitives, most clients will need only the basic interface provided - * by the Dial, Listen, and Accept functions and the associated - * Conn and Listener interfaces. The crypto/tls package uses - * the same interfaces and similar Dial and Listen functions. - * - * The Dial function connects to a server: - * - * ``` - * conn, err := net.Dial("tcp", "golang.org:80") - * if err != nil { - * // handle error - * } - * fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n") - * status, err := bufio.NewReader(conn).ReadString('\n') - * // ... - * ``` - * - * The Listen function creates servers: - * - * ``` - * ln, err := net.Listen("tcp", ":8080") - * if err != nil { - * // handle error - * } - * for { - * conn, err := ln.Accept() - * if err != nil { - * // handle error - * } - * go handleConnection(conn) - * } - * ``` - * - * Name Resolution - * - * The method for resolving domain names, whether indirectly with functions like Dial - * or directly with functions like LookupHost and LookupAddr, varies by operating system. - * - * On Unix systems, the resolver has two options for resolving names. - * It can use a pure Go resolver that sends DNS requests directly to the servers - * listed in /etc/resolv.conf, or it can use a cgo-based resolver that calls C - * library routines such as getaddrinfo and getnameinfo. - * - * By default the pure Go resolver is used, because a blocked DNS request consumes - * only a goroutine, while a blocked C call consumes an operating system thread. - * When cgo is available, the cgo-based resolver is used instead under a variety of - * conditions: on systems that do not let programs make direct DNS requests (OS X), - * when the LOCALDOMAIN environment variable is present (even if empty), - * when the RES_OPTIONS or HOSTALIASES environment variable is non-empty, - * when the ASR_CONFIG environment variable is non-empty (OpenBSD only), - * when /etc/resolv.conf or /etc/nsswitch.conf specify the use of features that the - * Go resolver does not implement, and when the name being looked up ends in .local - * or is an mDNS name. - * - * The resolver decision can be overridden by setting the netdns value of the - * GODEBUG environment variable (see package runtime) to go or cgo, as in: - * - * ``` - * export GODEBUG=netdns=go # force pure Go resolver - * export GODEBUG=netdns=cgo # force cgo resolver - * ``` - * - * The decision can also be forced while building the Go source tree - * by setting the netgo or netcgo build tag. - * - * A numeric netdns setting, as in GODEBUG=netdns=1, causes the resolver - * to print debugging information about its decisions. - * To force a particular resolver while also printing debugging information, - * join the two settings by a plus sign, as in GODEBUG=netdns=go+1. - * - * On Plan 9, the resolver always accesses /net/cs and /net/dns. - * - * On Windows, the resolver always uses C library functions, such as GetAddrInfo and DnsQuery. - */ -namespace net { - /** - * Addr represents a network end point address. - * - * The two methods Network and String conventionally return strings - * that can be passed as the arguments to Dial, but the exact form - * and meaning of the strings is up to the implementation. - */ - interface Addr { - network(): string // name of the network (for example, "tcp", "udp") - string(): string // string form of address (for example, "192.0.2.1:25", "[2001:db8::1]:80") - } +namespace types { } -/** - * Package multipart implements MIME multipart parsing, as defined in RFC - * 2046. - * - * The implementation is sufficient for HTTP (RFC 2388) and the multipart - * bodies generated by popular browsers. +/** + * Package oauth2 provides support for making + * OAuth2 authorized and authenticated HTTP requests, + * as specified in RFC 6749. + * It can additionally grant authorization with Bearer JWT. */ -namespace multipart { +namespace oauth2 { /** - * A Part represents a single part in a multipart body. + * An AuthCodeOption is passed to Config.AuthCodeURL. */ - interface Part { + interface AuthCodeOption { + [key:string]: any; + } + /** + * Token represents the credentials used to authorize + * the requests to access protected resources on the OAuth 2.0 + * provider's backend. + * + * Most users of this package should not access fields of Token + * directly. They're exported mostly for use by related packages + * implementing derivative OAuth2 flows. + */ + interface Token { /** - * The headers of the body, if any, with the keys canonicalized - * in the same fashion that the Go http.Request headers are. - * For example, "foo-bar" changes case to "Foo-Bar" + * AccessToken is the token that authorizes and authenticates + * the requests. */ - header: textproto.MIMEHeader + accessToken: string + /** + * TokenType is the type of token. + * The Type method returns either this or "Bearer", the default. + */ + tokenType: string + /** + * RefreshToken is a token that's used by the application + * (as opposed to the user) to refresh the access token + * if it expires. + */ + refreshToken: string + /** + * Expiry is the optional expiration time of the access token. + * + * If zero, [TokenSource] implementations will reuse the same + * token forever and RefreshToken or equivalent + * mechanisms for that TokenSource will not be used. + */ + expiry: time.Time + /** + * ExpiresIn is the OAuth2 wire format "expires_in" field, + * which specifies how many seconds later the token expires, + * relative to an unknown time base approximately around "now". + * It is the application's responsibility to populate + * `Expiry` from `ExpiresIn` when required. + */ + expiresIn: number } - interface Part { + interface Token { /** - * FormName returns the name parameter if p has a Content-Disposition - * of type "form-data". Otherwise it returns the empty string. + * Type returns t.TokenType if non-empty, else "Bearer". */ - formName(): string + type(): string } - interface Part { + interface Token { /** - * FileName returns the filename parameter of the Part's Content-Disposition - * header. If not empty, the filename is passed through filepath.Base (which is - * platform dependent) before being returned. + * SetAuthHeader sets the Authorization header to r using the access + * token in t. + * + * This method is unnecessary when using [Transport] or an HTTP Client + * returned by this package. */ - fileName(): string + setAuthHeader(r: http.Request): void } - interface Part { + interface Token { /** - * Read reads the body of a part, after its headers and before the - * next part (if any) begins. + * WithExtra returns a new [Token] that's a clone of t, but using the + * provided raw extra map. This is only intended for use by packages + * implementing derivative OAuth2 flows. */ - read(d: string): number + withExtra(extra: any): (Token) } - interface Part { - close(): void + interface Token { + /** + * Extra returns an extra field. + * Extra fields are key-value pairs returned by the server as + * part of the token retrieval response. + */ + extra(key: string): any + } + interface Token { + /** + * Valid reports whether t is non-nil, has an AccessToken, and is not expired. + */ + valid(): boolean } } -/** - * Package http provides HTTP client and server implementations. - * - * Get, Head, Post, and PostForm make HTTP (or HTTPS) requests: - * - * ``` - * resp, err := http.Get("http://example.com/") - * ... - * resp, err := http.Post("http://example.com/upload", "image/jpeg", &buf) - * ... - * resp, err := http.PostForm("http://example.com/form", - * url.Values{"key": {"Value"}, "id": {"123"}}) - * ``` - * - * The client must close the response body when finished with it: - * - * ``` - * resp, err := http.Get("http://example.com/") - * if err != nil { - * // handle error - * } - * defer resp.Body.Close() - * body, err := io.ReadAll(resp.Body) - * // ... - * ``` - * - * For control over HTTP client headers, redirect policy, and other - * settings, create a Client: - * - * ``` - * client := &http.Client{ - * CheckRedirect: redirectPolicyFunc, - * } - * - * resp, err := client.Get("http://example.com") - * // ... - * - * req, err := http.NewRequest("GET", "http://example.com", nil) - * // ... - * req.Header.Add("If-None-Match", `W/"wyzzy"`) - * resp, err := client.Do(req) - * // ... - * ``` - * - * For control over proxies, TLS configuration, keep-alives, - * compression, and other settings, create a Transport: - * - * ``` - * tr := &http.Transport{ - * MaxIdleConns: 10, - * IdleConnTimeout: 30 * time.Second, - * DisableCompression: true, - * } - * client := &http.Client{Transport: tr} - * resp, err := client.Get("https://example.com") - * ``` - * - * Clients and Transports are safe for concurrent use by multiple - * goroutines and for efficiency should only be created once and re-used. - * - * ListenAndServe starts an HTTP server with a given address and handler. - * The handler is usually nil, which means to use DefaultServeMux. - * Handle and HandleFunc add handlers to DefaultServeMux: - * - * ``` - * http.Handle("/foo", fooHandler) - * - * http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) { - * fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) - * }) - * - * log.Fatal(http.ListenAndServe(":8080", nil)) - * ``` - * - * More control over the server's behavior is available by creating a - * custom Server: - * - * ``` - * s := &http.Server{ - * Addr: ":8080", - * Handler: myHandler, - * ReadTimeout: 10 * time.Second, - * WriteTimeout: 10 * time.Second, - * MaxHeaderBytes: 1 << 20, - * } - * log.Fatal(s.ListenAndServe()) - * ``` - * - * Starting with Go 1.6, the http package has transparent support for the - * HTTP/2 protocol when using HTTPS. Programs that must disable HTTP/2 - * can do so by setting Transport.TLSNextProto (for clients) or - * Server.TLSNextProto (for servers) to a non-nil, empty - * map. Alternatively, the following GODEBUG environment variables are - * currently supported: - * - * ``` - * GODEBUG=http2client=0 # disable HTTP/2 client support - * GODEBUG=http2server=0 # disable HTTP/2 server support - * GODEBUG=http2debug=1 # enable verbose HTTP/2 debug logs - * GODEBUG=http2debug=2 # ... even more verbose, with frame dumps - * ``` - * - * The GODEBUG variables are not covered by Go's API compatibility - * promise. Please report any issues before disabling HTTP/2 - * support: https://golang.org/s/http2bug - * - * The http package's Transport and Server both automatically enable - * HTTP/2 support for simple configurations. To enable HTTP/2 for more - * complex configurations, to use lower-level HTTP/2 features, or to use - * a newer version of Go's http2 package, import "golang.org/x/net/http2" - * directly and use its ConfigureTransport and/or ConfigureServer - * functions. Manually configuring HTTP/2 via the golang.org/x/net/http2 - * package takes precedence over the net/http package's built-in HTTP/2 - * support. - */ -namespace http { +namespace hook { /** - * SameSite allows a server to define a cookie attribute making it impossible for - * the browser to send this cookie along with cross-site requests. The main - * goal is to mitigate the risk of cross-origin information leakage, and provide - * some protection against cross-site request forgery attacks. - * - * See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. + * wrapped local Hook embedded struct to limit the public API surface. */ - interface SameSite extends Number{} - // @ts-ignore - import mathrand = rand - // @ts-ignore - import urlpkg = url -} - -namespace store { + type _sBJGyNm = Hook + interface mainHook extends _sBJGyNm { + } } -namespace mailer { +namespace sql { /** - * Message defines a generic email message struct. + * IsolationLevel is the transaction isolation level used in [TxOptions]. */ - interface Message { - from: mail.Address - to: Array - bcc: Array - cc: Array - subject: string - html: string - text: string - headers: _TygojaDict - attachments: _TygojaDict + interface IsolationLevel extends Number{} + interface IsolationLevel { + /** + * String returns the name of the transaction isolation level. + */ + string(): string } -} - -/** - * Package echo implements high performance, minimalist Go web framework. - * - * Example: - * - * ``` - * package main - * - * import ( - * "github.com/labstack/echo/v5" - * "github.com/labstack/echo/v5/middleware" - * "log" - * "net/http" - * ) - * - * // Handler - * func hello(c echo.Context) error { - * return c.String(http.StatusOK, "Hello, World!") - * } - * - * func main() { - * // Echo instance - * e := echo.New() - * - * // Middleware - * e.Use(middleware.Logger()) - * e.Use(middleware.Recover()) - * - * // Routes - * e.GET("/", hello) - * - * // Start server - * if err := e.Start(":8080"); err != http.ErrServerClosed { - * log.Fatal(err) - * } - * } - * ``` - * - * Learn more at https://echo.labstack.com - */ -namespace echo { - // @ts-ignore - import stdContext = context /** - * Route contains information to adding/registering new route with the router. - * Method+Path pair uniquely identifies the Route. It is mandatory to provide Method+Path+Handler fields. + * DBStats contains database statistics. */ - interface Route { - method: string - path: string - handler: HandlerFunc - middlewares: Array - name: string + interface DBStats { + maxOpenConnections: number // Maximum number of open connections to the database. + /** + * Pool Status + */ + openConnections: number // The number of established connections both in use and idle. + inUse: number // The number of connections currently in use. + idle: number // The number of idle connections. + /** + * Counters + */ + waitCount: number // The total number of connections waited for. + waitDuration: time.Duration // The total time blocked waiting for a new connection. + maxIdleClosed: number // The total number of connections closed due to SetMaxIdleConns. + maxIdleTimeClosed: number // The total number of connections closed due to SetConnMaxIdleTime. + maxLifetimeClosed: number // The total number of connections closed due to SetConnMaxLifetime. + } + /** + * Conn represents a single database connection rather than a pool of database + * connections. Prefer running queries from [DB] unless there is a specific + * need for a continuous single database connection. + * + * A Conn must call [Conn.Close] to return the connection to the database pool + * and may do so concurrently with a running query. + * + * After a call to [Conn.Close], all operations on the + * connection fail with [ErrConnDone]. + */ + interface Conn { + } + interface Conn { + /** + * PingContext verifies the connection to the database is still alive. + */ + pingContext(ctx: context.Context): void } - interface Route { + interface Conn { /** - * ToRouteInfo converts Route to RouteInfo + * ExecContext executes a query without returning any rows. + * The args are for any placeholder parameters in the query. */ - toRouteInfo(params: Array): RouteInfo + execContext(ctx: context.Context, query: string, ...args: any[]): Result } - interface Route { + interface Conn { /** - * ToRoute returns Route which Router uses to register the method handler for path. + * QueryContext executes a query that returns rows, typically a SELECT. + * The args are for any placeholder parameters in the query. */ - toRoute(): Route + queryContext(ctx: context.Context, query: string, ...args: any[]): (Rows) } - interface Route { + interface Conn { /** - * ForGroup recreates Route with added group prefix and group middlewares it is grouped to. + * QueryRowContext executes a query that is expected to return at most one row. + * QueryRowContext always returns a non-nil value. Errors are deferred until + * the [*Row.Scan] method is called. + * If the query selects no rows, the [*Row.Scan] will return [ErrNoRows]. + * Otherwise, the [*Row.Scan] scans the first selected row and discards + * the rest. */ - forGroup(pathPrefix: string, middlewares: Array): Routable + queryRowContext(ctx: context.Context, query: string, ...args: any[]): (Row) } - /** - * RoutableContext is additional interface that structures implementing Context must implement. Methods inside this - * interface are meant for request routing purposes and should not be used in middlewares. - */ - interface RoutableContext { + interface Conn { /** - * Request returns `*http.Request`. + * PrepareContext creates a prepared statement for later queries or executions. + * Multiple queries or executions may be run concurrently from the + * returned statement. + * The caller must call the statement's [*Stmt.Close] method + * when the statement is no longer needed. + * + * The provided context is used for the preparation of the statement, not for the + * execution of the statement. */ - request(): (http.Request | undefined) + prepareContext(ctx: context.Context, query: string): (Stmt) + } + interface Conn { /** - * RawPathParams returns raw path pathParams value. Allocation of PathParams is handled by Context. + * Raw executes f exposing the underlying driver connection for the + * duration of f. The driverConn must not be used outside of f. + * + * Once f returns and err is not [driver.ErrBadConn], the [Conn] will continue to be usable + * until [Conn.Close] is called. */ - rawPathParams(): (PathParams | undefined) + raw(f: (driverConn: any) => void): void + } + interface Conn { /** - * SetRawPathParams replaces any existing param values with new values for this context lifetime (request). - * Do not set any other value than what you got from RawPathParams as allocation of PathParams is handled by Context. + * BeginTx starts a transaction. + * + * The provided context is used until the transaction is committed or rolled back. + * If the context is canceled, the sql package will roll back + * the transaction. [Tx.Commit] will return an error if the context provided to + * BeginTx is canceled. + * + * The provided [TxOptions] is optional and may be nil if defaults should be used. + * If a non-default isolation level is used that the driver doesn't support, + * an error will be returned. */ - setRawPathParams(params: PathParams): void + beginTx(ctx: context.Context, opts: TxOptions): (Tx) + } + interface Conn { /** - * SetPath sets the registered path for the handler. + * Close returns the connection to the connection pool. + * All operations after a Close will return with [ErrConnDone]. + * Close is safe to call concurrently with other operations and will + * block until all other operations finish. It may be useful to first + * cancel any used context and then call close directly after. */ - setPath(p: string): void + close(): void + } + /** + * ColumnType contains the name and type of a column. + */ + interface ColumnType { + } + interface ColumnType { /** - * SetRouteInfo sets the route info of this request to the context. + * Name returns the name or alias of the column. */ - setRouteInfo(ri: RouteInfo): void + name(): string + } + interface ColumnType { /** - * Set saves data in the context. Allows router to store arbitrary (that only router has access to) data in context - * for later use in middlewares/handler. + * Length returns the column type length for variable length column types such + * as text and binary field types. If the type length is unbounded the value will + * be [math.MaxInt64] (any database limits will still apply). + * If the column type is not variable length, such as an int, or if not supported + * by the driver ok is false. */ - set(key: string, val: { - }): void + length(): [number, boolean] } - /** - * PathParam is tuple pf path parameter name and its value in request path - */ - interface PathParam { - name: string - value: string + interface ColumnType { + /** + * DecimalSize returns the scale and precision of a decimal type. + * If not applicable or if not supported ok is false. + */ + decimalSize(): [number, number, boolean] } -} - -/** - * Package types implements some commonly used db serializable types - * like datetime, json, etc. - */ -namespace types { - /** - * JsonRaw defines a json value type that is safe for db read/write. - */ - interface JsonRaw extends String{} - interface JsonRaw { + interface ColumnType { /** - * String returns the current JsonRaw instance as a json encoded string. + * ScanType returns a Go type suitable for scanning into using [Rows.Scan]. + * If a driver does not support this property ScanType will return + * the type of an empty interface. */ - string(): string + scanType(): any } - interface JsonRaw { + interface ColumnType { /** - * MarshalJSON implements the [json.Marshaler] interface. + * Nullable reports whether the column may be null. + * If a driver does not support this property ok will be false. */ - marshalJSON(): string + nullable(): [boolean, boolean] } - interface JsonRaw { + interface ColumnType { /** - * UnmarshalJSON implements the [json.Unmarshaler] interface. + * DatabaseTypeName returns the database system name of the column type. If an empty + * string is returned, then the driver type name is not supported. + * Consult your driver documentation for a list of driver data types. [ColumnType.Length] specifiers + * are not included. + * Common type names include "VARCHAR", "TEXT", "NVARCHAR", "DECIMAL", "BOOL", + * "INT", and "BIGINT". */ - unmarshalJSON(b: string): void + databaseTypeName(): string + } + /** + * Row is the result of calling [DB.QueryRow] to select a single row. + */ + interface Row { } - interface JsonRaw { + interface Row { /** - * Value implements the [driver.Valuer] interface. + * Scan copies the columns from the matched row into the values + * pointed at by dest. See the documentation on [Rows.Scan] for details. + * If more than one row matches the query, + * Scan uses the first row and discards the rest. If no row matches + * the query, Scan returns [ErrNoRows]. */ - value(): any + scan(...dest: any[]): void } - interface JsonRaw { + interface Row { /** - * Scan implements [sql.Scanner] interface to scan the provided value - * into the current JsonRaw instance. + * Err provides a way for wrapping packages to check for + * query errors without calling [Row.Scan]. + * Err returns the error, if any, that was encountered while running the query. + * If this error is not nil, this error will also be returned from [Row.Scan]. */ - scan(value: { - }): void + err(): void } } namespace search { /** - * Result defines the returned search result structure. + * MultiMatchSubquery defines a multi-match record subquery expression. */ - interface Result { - page: number - perPage: number - totalItems: number - totalPages: number - items: any + interface MultiMatchSubquery { + targetTableAlias: string + fromTableName: string + fromTableAlias: string + valueIdentifier: string + joins: Array<(Join | undefined)> + params: dbx.Params + } + interface MultiMatchSubquery { + /** + * Build converts the expression into a SQL fragment. + * + * Implements [dbx.Expression] interface. + */ + build(db: dbx.DB, params: dbx.Params): string } + interface NullFallbackPreference extends Number{} } -namespace settings { +namespace router { // @ts-ignore import validation = ozzo_validation - interface EmailTemplate { - body: string - subject: string - actionUrl: string + /** + * RouterGroup represents a collection of routes and other sub groups + * that share common pattern prefix and middlewares. + */ + interface RouterGroup { + prefix: string + middlewares: Array<(hook.Handler | undefined)> } - interface EmailTemplate { + interface RouterGroup { /** - * Validate makes EmailTemplate validatable by implementing [validation.Validatable] interface. + * Group creates and register a new child Group into the current one + * with the specified prefix. + * + * The prefix follows the standard Go net/http ServeMux pattern format ("[HOST]/[PATH]") + * and will be concatenated recursively into the final route path, meaning that + * only the root level group could have HOST as part of the prefix. + * + * Returns the newly created group to allow chaining and registering + * sub-routes and group specific middlewares. */ - validate(): void + group(prefix: string): (RouterGroup) } - interface EmailTemplate { + interface RouterGroup { /** - * Resolve replaces the placeholder parameters in the current email - * template and returns its components as ready-to-use strings. + * BindFunc registers one or multiple middleware functions to the current group. + * + * The registered middleware functions are "anonymous" and with default priority, + * aka. executes in the order they were registered. + * + * If you need to specify a named middleware (ex. so that it can be removed) + * or middleware with custom exec prirority, use [RouterGroup.Bind] method. + */ + bindFunc(...middlewareFuncs: ((e: T) => void)[]): (RouterGroup) + } + interface RouterGroup { + /** + * Bind registers one or multiple middleware handlers to the current group. + */ + bind(...middlewares: (hook.Handler | undefined)[]): (RouterGroup) + } + interface RouterGroup { + /** + * Unbind removes one or more middlewares with the specified id(s) + * from the current group and its children (if any). + * + * Anonymous middlewares are not removable, aka. this method does nothing + * if the middleware id is an empty string. + */ + unbind(...middlewareIds: string[]): (RouterGroup) + } + interface RouterGroup { + /** + * Route registers a single route into the current group. + * + * Note that the final route path will be the concatenation of all parent groups prefixes + the route path. + * The path follows the standard Go net/http ServeMux format ("[HOST]/[PATH]"), + * meaning that only a top level group route could have HOST as part of the prefix. + * + * Returns the newly created route to allow attaching route-only middlewares. + */ + route(method: string, path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * Any is a shorthand for [RouterGroup.AddRoute] with "" as route method (aka. matches any method). + */ + any(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * GET is a shorthand for [RouterGroup.AddRoute] with GET as route method. + */ + get(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * SEARCH is a shorthand for [RouterGroup.AddRoute] with SEARCH as route method. + */ + search(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * POST is a shorthand for [RouterGroup.AddRoute] with POST as route method. + */ + post(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * DELETE is a shorthand for [RouterGroup.AddRoute] with DELETE as route method. + */ + delete(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * PATCH is a shorthand for [RouterGroup.AddRoute] with PATCH as route method. + */ + patch(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * PUT is a shorthand for [RouterGroup.AddRoute] with PUT as route method. */ - resolve(appName: string, appUrl: string): string + put(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * HEAD is a shorthand for [RouterGroup.AddRoute] with HEAD as route method. + */ + head(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * OPTIONS is a shorthand for [RouterGroup.AddRoute] with OPTIONS as route method. + */ + options(path: string, action: (e: T) => void): (Route) + } + interface RouterGroup { + /** + * HasRoute checks whether the specified route pattern (method + path) + * is registered in the current group or its children. + * + * This could be useful to conditionally register and checks for routes + * in order prevent panic on duplicated routes. + * + * Note that routes with anonymous and named wildcard placeholder are treated as equal, + * aka. "GET /abc/" is considered the same as "GET /abc/{something...}". + */ + hasRoute(method: string, path: string): boolean } } -namespace subscriptions { +namespace cron { /** - * Message defines a client's channel data. + * Job defines a single registered cron job. */ - interface Message { - name: string - data: string + interface Job { } - /** - * Client is an interface for a generic subscription client. - */ - interface Client { + interface Job { /** - * Id Returns the unique id of the client. + * Id returns the cron job id. */ id(): string + } + interface Job { /** - * Channel returns the client's communication channel. - */ - channel(): undefined - /** - * Subscriptions returns all subscriptions to which the client has subscribed to. + * Expression returns the plain cron job schedule expression. */ - subscriptions(): _TygojaDict + expression(): string + } + interface Job { /** - * Subscribe subscribes the client to the provided subscriptions list. + * Run runs the cron job function. */ - subscribe(...subs: string[]): void + run(): void + } + interface Job { /** - * Unsubscribe unsubscribes the client from the provided subscriptions list. + * MarshalJSON implements [json.Marshaler] and export the current + * jobs data into valid JSON. */ - unsubscribe(...subs: string[]): void + marshalJSON(): string|Array + } +} + +namespace url { + /** + * The Userinfo type is an immutable encapsulation of username and + * password details for a [URL]. An existing Userinfo value is guaranteed + * to have a username set (potentially empty, as allowed by RFC 2396), + * and optionally a password. + */ + interface Userinfo { + } + interface Userinfo { /** - * HasSubscription checks if the client is subscribed to `sub`. + * Username returns the username. */ - hasSubscription(sub: string): boolean + username(): string + } + interface Userinfo { /** - * Set stores any value to the client's context. + * Password returns the password in case it is set, and whether it is set. */ - set(key: string, value: any): void + password(): [string, boolean] + } + interface Userinfo { /** - * Unset removes a single value from the client's context. + * String returns the encoded userinfo information in the standard form + * of "username[:password]". */ - unset(key: string): void + string(): string + } +} + +namespace multipart { + /** + * A Part represents a single part in a multipart body. + */ + interface Part { /** - * Get retrieves the key value from the client's context. + * The headers of the body, if any, with the keys canonicalized + * in the same fashion that the Go http.Request headers are. + * For example, "foo-bar" changes case to "Foo-Bar" */ - get(key: string): any + header: textproto.MIMEHeader + } + interface Part { /** - * Discard marks the client as "discarded", meaning that it - * shouldn't be used anymore for sending new messages. - * - * It is safe to call Discard() multiple times. + * FormName returns the name parameter if p has a Content-Disposition + * of type "form-data". Otherwise it returns the empty string. */ - discard(): void + formName(): string + } + interface Part { /** - * IsDiscarded indicates whether the client has been "discarded" - * and should no longer be used. + * FileName returns the filename parameter of the [Part]'s Content-Disposition + * header. If not empty, the filename is passed through filepath.Base (which is + * platform dependent) before being returned. */ - isDiscarded(): boolean + fileName(): string + } + interface Part { /** - * Send sends the specified message to the client's channel (if not discarded). + * Read reads the body of a part, after its headers and before the + * next part (if any) begins. */ - send(m: Message): void + read(d: string|Array): number + } + interface Part { + close(): void } } -namespace hook { - /** - * Handler defines a hook handler function. - */ - interface Handler {(e: T): void } +namespace http { /** - * wrapped local Hook embedded struct to limit the public API surface. + * SameSite allows a server to define a cookie attribute making it impossible for + * the browser to send this cookie along with cross-site requests. The main + * goal is to mitigate the risk of cross-origin information leakage, and provide + * some protection against cross-site request forgery attacks. + * + * See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. */ - type _subhWHbr = Hook - interface mainHook extends _subhWHbr { - } + interface SameSite extends Number{} + // @ts-ignore + import urlpkg = url } -/** - * Package core is the backbone of PocketBase. - * - * It defines the main PocketBase App interface and its base implementation. - */ -namespace core { - interface BaseModelEvent { - model: models.Model - } - interface BaseModelEvent { - tags(): Array - } - interface BaseCollectionEvent { - collection?: models.Collection - } - interface BaseCollectionEvent { - tags(): Array - } +namespace cobra { + // @ts-ignore + import flag = pflag + /** + * ShellCompDirective is a bit map representing the different behaviors the shell + * can be instructed to have once completions have been provided. + */ + interface ShellCompDirective extends Number{} } -/** - * Package bufio implements buffered I/O. It wraps an io.Reader or io.Writer - * object, creating another object (Reader or Writer) that also implements - * the interface but provides buffering and some help for textual I/O. - */ -namespace bufio { +namespace slog { + // @ts-ignore + import loginternal = internal /** - * Reader implements buffering for an io.Reader object. + * A Record holds information about a log event. + * Copies of a Record share state. + * Do not modify a Record after handing out a copy to it. + * Call [NewRecord] to create a new Record. + * Use [Record.Clone] to create a copy with no shared state. */ - interface Reader { - } - interface Reader { + interface Record { /** - * Size returns the size of the underlying buffer in bytes. + * The time at which the output method (Log, Info, etc.) was called. */ - size(): number - } - interface Reader { + time: time.Time /** - * Reset discards any buffered data, resets all state, and switches - * the buffered reader to read from r. - * Calling Reset on the zero value of Reader initializes the internal buffer - * to the default size. + * The log message. */ - reset(r: io.Reader): void - } - interface Reader { + message: string /** - * Peek returns the next n bytes without advancing the reader. The bytes stop - * being valid at the next read call. If Peek returns fewer than n bytes, it - * also returns an error explaining why the read is short. The error is - * ErrBufferFull if n is larger than b's buffer size. - * - * Calling Peek prevents a UnreadByte or UnreadRune call from succeeding - * until the next read operation. + * The level of the event. */ - peek(n: number): string - } - interface Reader { + level: Level /** - * Discard skips the next n bytes, returning the number of bytes discarded. + * The program counter at the time the record was constructed, as determined + * by runtime.Callers. If zero, no program counter is available. * - * If Discard skips fewer than n bytes, it also returns an error. - * If 0 <= n <= b.Buffered(), Discard is guaranteed to succeed without - * reading from the underlying io.Reader. + * The only valid use for this value is as an argument to + * [runtime.CallersFrames]. In particular, it must not be passed to + * [runtime.FuncForPC]. */ - discard(n: number): number + pc: number } - interface Reader { + interface Record { /** - * Read reads data into p. - * It returns the number of bytes read into p. - * The bytes are taken from at most one Read on the underlying Reader, - * hence n may be less than len(p). - * To read exactly len(p) bytes, use io.ReadFull(b, p). - * At EOF, the count will be zero and err will be io.EOF. + * Clone returns a copy of the record with no shared state. + * The original record and the clone can both be modified + * without interfering with each other. */ - read(p: string): number + clone(): Record } - interface Reader { + interface Record { /** - * ReadByte reads and returns a single byte. - * If no byte is available, returns an error. + * NumAttrs returns the number of attributes in the [Record]. */ - readByte(): string + numAttrs(): number } - interface Reader { + interface Record { /** - * UnreadByte unreads the last byte. Only the most recently read byte can be unread. - * - * UnreadByte returns an error if the most recent method called on the - * Reader was not a read operation. Notably, Peek, Discard, and WriteTo are not - * considered read operations. + * Attrs calls f on each Attr in the [Record]. + * Iteration stops if f returns false. */ - unreadByte(): void + attrs(f: (_arg0: Attr) => boolean): void } - interface Reader { + interface Record { /** - * ReadRune reads a single UTF-8 encoded Unicode character and returns the - * rune and its size in bytes. If the encoded rune is invalid, it consumes one byte - * and returns unicode.ReplacementChar (U+FFFD) with a size of 1. + * AddAttrs appends the given Attrs to the [Record]'s list of Attrs. + * It omits empty groups. */ - readRune(): [string, number] + addAttrs(...attrs: Attr[]): void } - interface Reader { + interface Record { /** - * UnreadRune unreads the last rune. If the most recent method called on - * the Reader was not a ReadRune, UnreadRune returns an error. (In this - * regard it is stricter than UnreadByte, which will unread the last byte - * from any read operation.) + * Add converts the args to Attrs as described in [Logger.Log], + * then appends the Attrs to the [Record]'s list of Attrs. + * It omits empty groups. */ - unreadRune(): void + add(...args: any[]): void } - interface Reader { + interface Record { /** - * Buffered returns the number of bytes that can be read from the current buffer. + * Source returns a new Source for the log event using r's PC. + * If the PC field is zero, meaning the Record was created without the necessary information + * or the location is unavailable, then nil is returned. */ - buffered(): number + source(): (Source) } - interface Reader { + /** + * A Value can represent any Go value, but unlike type any, + * it can represent most small values without an allocation. + * The zero Value corresponds to nil. + */ + interface Value { + } + interface Value { /** - * ReadSlice reads until the first occurrence of delim in the input, - * returning a slice pointing at the bytes in the buffer. - * The bytes stop being valid at the next read. - * If ReadSlice encounters an error before finding a delimiter, - * it returns all the data in the buffer and the error itself (often io.EOF). - * ReadSlice fails with error ErrBufferFull if the buffer fills without a delim. - * Because the data returned from ReadSlice will be overwritten - * by the next I/O operation, most clients should use - * ReadBytes or ReadString instead. - * ReadSlice returns err != nil if and only if line does not end in delim. + * Kind returns v's Kind. */ - readSlice(delim: string): string + kind(): Kind } - interface Reader { + interface Value { /** - * ReadLine is a low-level line-reading primitive. Most callers should use - * ReadBytes('\n') or ReadString('\n') instead or use a Scanner. - * - * ReadLine tries to return a single line, not including the end-of-line bytes. - * If the line was too long for the buffer then isPrefix is set and the - * beginning of the line is returned. The rest of the line will be returned - * from future calls. isPrefix will be false when returning the last fragment - * of the line. The returned buffer is only valid until the next call to - * ReadLine. ReadLine either returns a non-nil line or it returns an error, - * never both. - * - * The text returned from ReadLine does not include the line end ("\r\n" or "\n"). - * No indication or error is given if the input ends without a final line end. - * Calling UnreadByte after ReadLine will always unread the last byte read - * (possibly a character belonging to the line end) even if that byte is not - * part of the line returned by ReadLine. + * Any returns v's value as an any. */ - readLine(): [string, boolean] + any(): any } - interface Reader { + interface Value { /** - * ReadBytes reads until the first occurrence of delim in the input, - * returning a slice containing the data up to and including the delimiter. - * If ReadBytes encounters an error before finding a delimiter, - * it returns the data read before the error and the error itself (often io.EOF). - * ReadBytes returns err != nil if and only if the returned data does not end in - * delim. - * For simple uses, a Scanner may be more convenient. + * String returns Value's value as a string, formatted like [fmt.Sprint]. Unlike + * the methods Int64, Float64, and so on, which panic if v is of the + * wrong kind, String never panics. */ - readBytes(delim: string): string + string(): string } - interface Reader { + interface Value { /** - * ReadString reads until the first occurrence of delim in the input, - * returning a string containing the data up to and including the delimiter. - * If ReadString encounters an error before finding a delimiter, - * it returns the data read before the error and the error itself (often io.EOF). - * ReadString returns err != nil if and only if the returned data does not end in - * delim. - * For simple uses, a Scanner may be more convenient. + * Int64 returns v's value as an int64. It panics + * if v is not a signed integer. */ - readString(delim: string): string + int64(): number } - interface Reader { + interface Value { /** - * WriteTo implements io.WriterTo. - * This may make multiple calls to the Read method of the underlying Reader. - * If the underlying reader supports the WriteTo method, - * this calls the underlying WriteTo without buffering. + * Uint64 returns v's value as a uint64. It panics + * if v is not an unsigned integer. */ - writeTo(w: io.Writer): number - } - /** - * Writer implements buffering for an io.Writer object. - * If an error occurs writing to a Writer, no more data will be - * accepted and all subsequent writes, and Flush, will return the error. - * After all data has been written, the client should call the - * Flush method to guarantee all data has been forwarded to - * the underlying io.Writer. - */ - interface Writer { + uint64(): number } - interface Writer { + interface Value { /** - * Size returns the size of the underlying buffer in bytes. + * Bool returns v's value as a bool. It panics + * if v is not a bool. */ - size(): number + bool(): boolean } - interface Writer { + interface Value { /** - * Reset discards any unflushed buffered data, clears any error, and - * resets b to write its output to w. - * Calling Reset on the zero value of Writer initializes the internal buffer - * to the default size. + * Duration returns v's value as a [time.Duration]. It panics + * if v is not a time.Duration. */ - reset(w: io.Writer): void + duration(): time.Duration } - interface Writer { + interface Value { /** - * Flush writes any buffered data to the underlying io.Writer. + * Float64 returns v's value as a float64. It panics + * if v is not a float64. */ - flush(): void + float64(): number } - interface Writer { + interface Value { /** - * Available returns how many bytes are unused in the buffer. + * Time returns v's value as a [time.Time]. It panics + * if v is not a time.Time. */ - available(): number + time(): time.Time } - interface Writer { + interface Value { /** - * AvailableBuffer returns an empty buffer with b.Available() capacity. - * This buffer is intended to be appended to and - * passed to an immediately succeeding Write call. - * The buffer is only valid until the next write operation on b. + * LogValuer returns v's value as a LogValuer. It panics + * if v is not a LogValuer. */ - availableBuffer(): string + logValuer(): LogValuer } - interface Writer { + interface Value { /** - * Buffered returns the number of bytes that have been written into the current buffer. + * Group returns v's value as a []Attr. + * It panics if v's [Kind] is not [KindGroup]. */ - buffered(): number + group(): Array } - interface Writer { + interface Value { /** - * Write writes the contents of p into the buffer. - * It returns the number of bytes written. - * If nn < len(p), it also returns an error explaining - * why the write is short. + * Equal reports whether v and w represent the same Go value. */ - write(p: string): number + equal(w: Value): boolean } - interface Writer { + interface Value { /** - * WriteByte writes a single byte. + * Resolve repeatedly calls LogValue on v while it implements [LogValuer], + * and returns the result. + * If v resolves to a group, the group's attributes' values are not recursively + * resolved. + * If the number of LogValue calls exceeds a threshold, a Value containing an + * error is returned. + * Resolve's return value is guaranteed not to be of Kind [KindLogValuer]. */ - writeByte(c: string): void + resolve(): Value } - interface Writer { +} + +namespace oauth2 { +} + +namespace search { + /** + * Join defines common fields required for a single SQL JOIN clause. + */ + interface Join { + tableName: string + tableAlias: string + on: dbx.Expression + } +} + +namespace router { + // @ts-ignore + import validation = ozzo_validation + interface Route { + action: (e: T) => void + method: string + path: string + middlewares: Array<(hook.Handler | undefined)> + } + interface Route { /** - * WriteRune writes a single Unicode code point, returning - * the number of bytes written and any error. + * BindFunc registers one or multiple middleware functions to the current route. + * + * The registered middleware functions are "anonymous" and with default priority, + * aka. executes in the order they were registered. + * + * If you need to specify a named middleware (ex. so that it can be removed) + * or middleware with custom exec prirority, use the [Route.Bind] method. */ - writeRune(r: string): number + bindFunc(...middlewareFuncs: ((e: T) => void)[]): (Route) } - interface Writer { + interface Route { /** - * WriteString writes a string. - * It returns the number of bytes written. - * If the count is less than len(s), it also returns an error explaining - * why the write is short. + * Bind registers one or multiple middleware handlers to the current route. */ - writeString(s: string): number + bind(...middlewares: (hook.Handler | undefined)[]): (Route) } - interface Writer { + interface Route { /** - * ReadFrom implements io.ReaderFrom. If the underlying writer - * supports the ReadFrom method, this calls the underlying ReadFrom. - * If there is buffered data and an underlying ReadFrom, this fills - * the buffer and writes it before calling ReadFrom. + * Unbind removes one or more middlewares with the specified id(s) from the current route. + * + * It also adds the removed middleware ids to an exclude list so that they could be skipped from + * the execution chain in case the middleware is registered in a parent group. + * + * Anonymous middlewares are considered non-removable, aka. this method + * does nothing if the middleware id is an empty string. */ - readFrom(r: io.Reader): number + unbind(...middlewareIds: string[]): (Route) } } -/** - * Package mail implements parsing of mail messages. - * - * For the most part, this package follows the syntax as specified by RFC 5322 and - * extended by RFC 6532. - * Notable divergences: - * ``` - * * Obsolete address formats are not parsed, including addresses with - * embedded route information. - * * The full range of spacing (the CFWS syntax element) is not supported, - * such as breaking addresses across lines. - * * No unicode normalization is performed. - * * The special characters ()[]:;@\, are allowed to appear unquoted in names. - * ``` - */ -namespace mail { +namespace slog { + // @ts-ignore + import loginternal = internal /** - * Address represents a single mail address. - * An address such as "Barry Gibbs " is represented - * as Address{Name: "Barry Gibbs", Address: "bg@example.com"}. + * Source describes the location of a line of source code. */ - interface Address { - name: string // Proper name; may be empty. - address: string // user@domain - } - interface Address { + interface Source { /** - * String formats the address as a valid RFC 5322 address. - * If the address's name contains non-ASCII characters - * the name will be rendered according to RFC 2047. + * Function is the package path-qualified function name containing the + * source line. If non-empty, this string uniquely identifies a single + * function in the program. This may be the empty string if not known. */ + function: string + /** + * File and Line are the file name and line number (1-based) of the source + * line. These may be the empty string and zero, respectively, if not known. + */ + file: string + line: number + } + /** + * Kind is the kind of a [Value]. + */ + interface Kind extends Number{} + interface Kind { string(): string } + /** + * A LogValuer is any Go value that can convert itself into a Value for logging. + * + * This mechanism may be used to defer expensive operations until they are + * needed, or to expand a single value into a sequence of components. + */ + interface LogValuer { + [key:string]: any; + logValue(): Value + } } -namespace search { -} - -namespace subscriptions { +namespace router { + // @ts-ignore + import validation = ozzo_validation } diff --git a/plugins/jsvm/internal/types/types.go b/plugins/jsvm/internal/types/types.go index 2b4f33a352..d489d7f2bb 100644 --- a/plugins/jsvm/internal/types/types.go +++ b/plugins/jsvm/internal/types/types.go @@ -1,12 +1,14 @@ package main import ( + "fmt" "log" "os" "path/filepath" "reflect" "runtime" "strings" + "time" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/plugins/jsvm" @@ -69,9 +71,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._ @@ -81,8 +83,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; /** @@ -92,11 +94,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() * }) * ` + "```" + ` * @@ -104,34 +104,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 @@ -144,8 +117,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] +}; + +// core.App without the on* hook methods +type CoreApp = excludeHooks + +// pocketbase.PocketBase without the on* hook methods +interface PocketBase extends excludeHooks{} /** * ` + "`$app`" + ` is the current running PocketBase instance that is globally @@ -156,7 +140,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. @@ -173,30 +157,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. * - * If maxBytes is not specified it will read up to 32MB. + * @deprecated + * @group PocketBase + */ +declare function readerToString(reader: any, maxBytes?: number): string; + +/** + * toString stringifies the specified value. * - * Note that after this call the reader can't be used anymore. + * 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 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. @@ -207,39 +247,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" @@ -252,28 +398,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", @@ -285,44 +434,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 Admin implements models.Admin { - constructor(data?: Partial) +declare class JSONField implements core.JSONField { + constructor(data?: Partial) } -interface Schema extends schema.Schema{} // merge +interface RelationField extends core.RelationField{} // merge /** - * Schema model class, usually used to define the Collection.schema field. + * {@inheritDoc core.RelationField} * * @group PocketBase */ -declare class Schema implements schema.Schema { - constructor(data?: Partial) +declare class RelationField implements core.RelationField { + constructor(data?: Partial) } -interface SchemaField extends schema.SchemaField{} // merge +interface SelectField extends core.SelectField{} // merge /** - * SchemaField model class, usually used as part of the Schema model. + * {@inheritDoc core.SelectField} * * @group PocketBase */ -declare class SchemaField implements schema.SchemaField { - constructor(data?: Partial) +declare class SelectField implements core.SelectField { + constructor(data?: Partial) +} + +interface FileField extends core.FileField{} // merge +/** + * {@inheritDoc core.FileField} + * + * @group PocketBase + */ +declare class FileField implements core.FileField { + constructor(data?: Partial) +} + +interface GeoPointField extends core.GeoPointField{} // merge +/** + * {@inheritDoc core.GeoPointField} + * + * @group PocketBase + */ +declare class GeoPointField implements core.GeoPointField { + constructor(data?: Partial) } interface MailerMessage extends mailer.Message{} // merge @@ -370,55 +643,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. + * + * Example: * - * const canAccess = $app.dao().canAccessRecord(record, info, "@request.auth.id != '' && @request.data.name = 123") + * ` + "```" + `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") @@ -430,13 +762,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 Dao implements daos.Dao { - constructor(concurrentDB?: dbx.Builder, nonconcurrentDB?: dbx.Builder) +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 SubscriptionMessage implements subscriptions.Message { + constructor(options?: Partial) } // ------------------------------------------------------------------- @@ -474,26 +848,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 } // ------------------------------------------------------------------- @@ -509,11 +878,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 @@ -522,6 +889,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 } // ------------------------------------------------------------------- @@ -538,6 +920,40 @@ declare namespace $filesystem { let fileFromPath: filesystem.newFileFromPath let fileFromBytes: filesystem.newFileFromBytes let fileFromMultipart: filesystem.newFileFromMultipart + + /** + * Initializes a new S3-only filesystem instance + * (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it). + * + * Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will + * construct a local or S3 filesystem based on the configured application settings. + */ + let s3: filesystem.newS3 + + /** + * Initializes a new local-only filesystem instance + * (make sure to call ` + "`" + `close()` + "`" + ` after you are done working with it). + * + * Most users should prefer ` + "`" + `$app.newFilesystem()` + "`" + ` which will + * construct a local or S3 filesystem based on the configured application settings. + */ + let local: filesystem.newLocal + + /** + * fileFromURL creates a new File from the provided url by + * 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 } // ------------------------------------------------------------------- @@ -551,21 +967,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 } // ------------------------------------------------------------------- @@ -579,150 +995,61 @@ 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 + /** + * Legacy alias for $os.cmd(). + */ + let exec: exec.command + + /** + * 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 + + /** + * Args hold the command-line arguments, starting with the program name. + */ + let args: Array + + 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 } // ------------------------------------------------------------------- // formsBinds // ------------------------------------------------------------------- -interface AdminLoginForm extends forms.AdminLogin{} // merge -/** - * @inheritDoc - * @group PocketBase - */ -declare class AdminLoginForm implements forms.AdminLogin { - constructor(app: core.App) -} - -interface AdminPasswordResetConfirmForm extends forms.AdminPasswordResetConfirm{} // merge -/** - * @inheritDoc - * @group PocketBase - */ -declare class AdminPasswordResetConfirmForm implements forms.AdminPasswordResetConfirm { - constructor(app: core.App) -} - -interface AdminPasswordResetRequestForm extends forms.AdminPasswordResetRequest{} // merge -/** - * @inheritDoc - * @group PocketBase - */ -declare class AdminPasswordResetRequestForm implements forms.AdminPasswordResetRequest { - constructor(app: core.App) -} - -interface AdminUpsertForm extends forms.AdminUpsert{} // merge -/** - * @inheritDoc - * @group PocketBase - */ -declare class AdminUpsertForm implements forms.AdminUpsert { - constructor(app: core.App, admin: models.Admin) -} - interface AppleClientSecretCreateForm extends forms.AppleClientSecretCreate{} // merge /** * @inheritDoc * @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 @@ -731,34 +1058,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 @@ -767,7 +1067,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 @@ -776,60 +1076,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) } @@ -842,27 +1162,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)) + * ` + "```" + ` + */ + 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 + + /** + * 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). */ - export function staticDirectoryHandler(dir: string, indexFallback: boolean): echo.HandlerFunc - - 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 + 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. * @@ -876,29 +1231,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, }; } @@ -914,8 +1277,8 @@ 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; ` @@ -926,13 +1289,14 @@ func main() { gen := tygoja.New(tygoja.Config{ Packages: map[string][]string{ - "github.com/go-ozzo/ozzo-validation/v4": {"Error"}, + "github.com/pocketbase/ozzo-validation/v4": {"Error"}, "github.com/pocketbase/dbx": {"*"}, "github.com/pocketbase/pocketbase/tools/security": {"*"}, "github.com/pocketbase/pocketbase/tools/filesystem": {"*"}, "github.com/pocketbase/pocketbase/tools/template": {"*"}, - "github.com/pocketbase/pocketbase/tokens": {"*"}, + "github.com/pocketbase/pocketbase/mails": {"*"}, "github.com/pocketbase/pocketbase/apis": {"*"}, + "github.com/pocketbase/pocketbase/core": {"*"}, "github.com/pocketbase/pocketbase/forms": {"*"}, "github.com/pocketbase/pocketbase": {"*"}, "path/filepath": {"*"}, @@ -946,23 +1310,24 @@ func main() { return mapper.MethodName(nil, reflect.Method{Name: s}) }, TypeMappings: map[string]string{ - "crypto.*": "any", - "acme.*": "any", - "autocert.*": "any", - "driver.*": "any", - "reflect.*": "any", - "fmt.*": "any", - "rand.*": "any", - "tls.*": "any", - "asn1.*": "any", - "pkix.*": "any", - "x509.*": "any", - "pflag.*": "any", - "flag.*": "any", - "log.*": "any", - "http.Client": "any", + "crypto.*": "any", + "acme.*": "any", + "autocert.*": "any", + "driver.*": "any", + "reflect.*": "any", + "fmt.*": "any", + "rand.*": "any", + "tls.*": "any", + "asn1.*": "any", + "pkix.*": "any", + "x509.*": "any", + "pflag.*": "any", + "flag.*": "any", + "log.*": "any", + "http.Client": "any", + "mail.Address": "{ address: string; name?: string; }", // prevents the LSP to complain in case no name is provided }, - Indent: " ", // use only a single space to reduce slight the size + Indent: " ", // use only a single space to reduce slightly the size WithPackageFunctions: true, Heading: declarations, }) @@ -977,6 +1342,16 @@ func main() { log.Fatal("Failed to get the current docs directory") } + // replace the original app interfaces with their non-"on*"" hooks equivalents + result = strings.ReplaceAll(result, "core.App", "CoreApp") + result = strings.ReplaceAll(result, "pocketbase.PocketBase", "PocketBase") + result = strings.ReplaceAll(result, "ORIGINAL_CORE_APP", "core.App") + result = strings.ReplaceAll(result, "ORIGINAL_POCKETBASE", "pocketbase.PocketBase") + + // prepend a timestamp with the generation time + // so that it can be compared without reading the entire file + result = fmt.Sprintf("// %d\n%s", time.Now().Unix(), result) + parentDir := filepath.Dir(filename) typesFile := filepath.Join(parentDir, "generated", "types.d.ts") @@ -988,7 +1363,7 @@ func main() { func hooksDeclarations() string { var result strings.Builder - excluded := []string{"OnBeforeServe"} + excluded := []string{"OnServe"} appType := reflect.TypeOf(struct{ core.App }{}) totalMethods := appType.NumMethod() @@ -1002,7 +1377,7 @@ func hooksDeclarations() string { withTags := strings.HasPrefix(hookType.String(), "*hook.TaggedHook") - addMethod, ok := hookType.MethodByName("Add") + addMethod, ok := hookType.MethodByName("BindFunc") if !ok { continue } diff --git a/plugins/jsvm/jsvm.go b/plugins/jsvm/jsvm.go index 6fa1c82317..85b196d6e8 100644 --- a/plugins/jsvm/jsvm.go +++ b/plugins/jsvm/jsvm.go @@ -1,16 +1,21 @@ // Package jsvm implements pluggable utilities for binding a JS goja runtime // to the PocketBase instance (loading migrations, attaching to app hooks, etc.). // +// The package also exports several reusable bindings so that users +// can utilize them as part of their own custom goja runtime setup. +// // Example: // // jsvm.MustRegister(app, jsvm.Config{ -// WatchHooks: true, +// HooksWatch: true, // }) package jsvm import ( + "bytes" "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -20,25 +25,45 @@ import ( "time" "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/buffer" "github.com/dop251/goja_nodejs/console" "github.com/dop251/goja_nodejs/process" "github.com/dop251/goja_nodejs/require" "github.com/fatih/color" "github.com/fsnotify/fsnotify" - "github.com/labstack/echo/v5" - "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" - m "github.com/pocketbase/pocketbase/migrations" "github.com/pocketbase/pocketbase/plugins/jsvm/internal/types/generated" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/tools/template" ) -const ( - typesFileName = "types.d.ts" -) +const typesFileName = "types.d.ts" + +var defaultScriptPath = "pb.js" + +func init() { + // For backward compatibility and consistency with the Go exposed + // methods that operate with relative paths (e.g. `$os.writeFile`), + // we define the "current JS module" as if it is a file in the current working directory + // (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs"). + // + // This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95). + cwd, err := os.Getwd() + if err != nil { + // truly rare case, log just for debug purposes + color.Yellow("Failed to retrieve the current working directory: %v", err) + } else { + defaultScriptPath = filepath.Join(cwd, defaultScriptPath) + } +} // Config defines the config options of the jsvm plugin. type Config struct { + // OnInit is an optional function that will be called + // after a JS runtime is initialized, allowing you to + // attach custom Go variables and functions. + OnInit func(vm *goja.Runtime) + // HooksWatch enables auto app restarts when a JS app hook file changes. // // Note that currently the application cannot be automatically restarted on Windows @@ -58,10 +83,10 @@ type Config struct { HooksFilesPattern string // HooksPoolSize specifies how many goja.Runtime instances to prewarm - // and keep for the JS app hooks gorotines execution. + // and keep for the JS app hooks goroutine execution to reuse. // - // Zero or negative value means that it will create a new goja.Runtime - // on every fired goroutine. + // Zero or negative value means that no pool will be maintained and + // instead it will create a new goja.Runtime on every fired goroutine. HooksPoolSize int // MigrationsDir specifies the JS migrations directory. @@ -77,6 +102,9 @@ type Config struct { // TypeScript declarations file. // // If not set it fallbacks to "pb_data". + // + // Note: Avoid using the same directory as the HooksDir when HooksWatch is enabled + // to prevent unnecessary app restarts when the types file is initially created. TypesDir string } @@ -85,7 +113,12 @@ type Config struct { // // Example usage: // -// jsvm.MustRegister(app, jsvm.Config{}) +// jsvm.MustRegister(app, jsvm.Config{ +// OnInit: func(vm *goja.Runtime) { +// // register custom bindings +// vm.Set("myCustomVar", 123) +// }, +// }) func MustRegister(app core.App, config Config) { if err := Register(app, config); err != nil { panic(err) @@ -116,11 +149,16 @@ func Register(app core.App, config Config) error { p.config.TypesDir = app.DataDir() } - p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { - // always update the app types on start to ensure that - // the user has the latest generated declarations - if err := p.saveTypesFile(); err != nil { - color.Yellow("Unable to save app types file: %v", err) + p.app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error { + err := e.Next() + if err != nil { + return err + } + + // ensure that the user has the latest types declaration + err = p.refreshTypesFile() + if err != nil { + color.Yellow("Unable to refresh app types file: %v", err) } return nil @@ -150,26 +188,64 @@ func (p *plugin) registerMigrations() error { return err } + absHooksDir, err := filepath.Abs(p.config.HooksDir) + if err != nil { + return err + } + registry := new(require.Registry) // this can be shared by multiple runtimes + templateRegistry := template.NewRegistry() for file, content := range files { vm := goja.New() + registry.Enable(vm) console.Enable(vm) process.Enable(vm) - baseBinds(vm) - dbxBinds(vm) - tokensBinds(vm) - securityBinds(vm) - osBinds(vm) - filepathBinds(vm) - httpClientBinds(vm) - - vm.Set("migrate", func(up, down func(db dbx.Builder) error) { - m.AppMigrations.Register(up, down, file) + buffer.Enable(vm) + + BindCore(vm) + BindDbx(vm) + BindSecurity(vm) + BindOS(vm) + BindFilepath(vm) + BindHTTP(vm) + BindFilesystem(vm) + BindForms(vm) + BindMails(vm) + + vm.Set("$template", templateRegistry) + vm.Set("__hooks", absHooksDir) + + vm.Set("migrate", func(up, down func(txApp core.App) error) { + // note: safe wrap to capture any eventual panic and to allow + // the error message to print the migration filename + core.AppMigrations.Register( + func(txApp core.App) error { + return routine.SafeWrap(func() error { + if up == nil { + return nil + } + return up(txApp) + })() + }, + func(txApp core.App) error { + return routine.SafeWrap(func() error { + if down == nil { + return nil + } + return down(txApp) + })() + }, + file, + ) }) - _, err := vm.RunString(string(content)) + if p.config.OnInit != nil { + p.config.OnInit(vm) + } + + _, err := vm.RunScript(defaultScriptPath, string(content)) if err != nil { return fmt.Errorf("failed to run migration %s: %w", file, err) } @@ -206,7 +282,7 @@ func (p *plugin) registerHooks() error { // initialize the hooks dir watcher if p.config.HooksWatch { if err := p.watchHooks(); err != nil { - return err + color.Yellow("Unable to init hooks watcher: %v", err) } } @@ -220,9 +296,10 @@ func (p *plugin) registerHooks() error { return err } - p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { - e.Router.HTTPErrorHandler = p.normalizeServeExceptions(e.Router.HTTPErrorHandler) - return nil + p.app.OnServe().BindFunc(func(e *core.ServeEvent) error { + e.Router.BindFunc(p.normalizeServeExceptions) + + return e.Next() }) // safe to be shared across multiple vms @@ -233,22 +310,26 @@ func (p *plugin) registerHooks() error { requireRegistry.Enable(vm) console.Enable(vm) process.Enable(vm) - - baseBinds(vm) - dbxBinds(vm) - filesystemBinds(vm) - tokensBinds(vm) - securityBinds(vm) - osBinds(vm) - filepathBinds(vm) - httpClientBinds(vm) - formsBinds(vm) - apisBinds(vm) - mailsBinds(vm) + buffer.Enable(vm) + + BindCore(vm) + BindDbx(vm) + BindSecurity(vm) + BindOS(vm) + BindFilepath(vm) + BindHTTP(vm) + BindFilesystem(vm) + BindForms(vm) + BindMails(vm) + BindApis(vm) vm.Set("$app", p.app) vm.Set("$template", templateRegistry) vm.Set("__hooks", absHooksDir) + + if p.config.OnInit != nil { + p.config.OnInit(vm) + } } // initiliaze the executor vms @@ -269,7 +350,7 @@ func (p *plugin) registerHooks() error { func() { defer func() { if err := recover(); err != nil { - fmtErr := fmt.Errorf("Failed to execute %s:\n - %v", file, err) + fmtErr := fmt.Errorf("failed to execute %s:\n - %v", file, err) if p.config.HooksWatch { color.Red("%v", fmtErr) @@ -279,7 +360,7 @@ func (p *plugin) registerHooks() error { } }() - _, err := loader.RunString(string(content)) + _, err := loader.RunScript(defaultScriptPath, string(content)) if err != nil { panic(err) } @@ -289,32 +370,17 @@ func (p *plugin) registerHooks() error { return nil } -// normalizeExceptions wraps the provided error handler and returns a new one -// with extracted goja exception error value for consistency when throwing or returning errors. -func (p *plugin) normalizeServeExceptions(oldErrorHandler echo.HTTPErrorHandler) echo.HTTPErrorHandler { - return func(c echo.Context, err error) { - defer func() { - oldErrorHandler(c, err) - }() - - if err == nil || c.Response().Committed { - return // no error or already committed - } +// normalizeExceptions registers a global error handler that +// wraps the extracted goja exception error value for consistency +// when throwing or returning errors. +func (p *plugin) normalizeServeExceptions(e *core.RequestEvent) error { + err := e.Next() - jsException, ok := err.(*goja.Exception) - if !ok { - return // 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 - } - } + if err == nil || e.Written() { + return err // no error or already committed } + + return normalizeException(err) } // watchHooks initializes a hooks file watcher that will restart the @@ -322,13 +388,23 @@ func (p *plugin) normalizeServeExceptions(oldErrorHandler echo.HTTPErrorHandler) // // This method does nothing if the hooks directory is missing. func (p *plugin) watchHooks() error { - if _, err := os.Stat(p.config.HooksDir); err != nil { + watchDir := p.config.HooksDir + + hooksDirInfo, err := os.Lstat(p.config.HooksDir) + if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil // no hooks dir to watch } return err } + if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink { + watchDir, err = filepath.EvalSymlinks(p.config.HooksDir) + if err != nil { + return fmt.Errorf("failed to resolve hooksDir symlink: %w", err) + } + } + watcher, err := fsnotify.NewWatcher() if err != nil { return err @@ -343,16 +419,16 @@ func (p *plugin) watchHooks() error { } } - p.app.OnTerminate().Add(func(e *core.TerminateEvent) error { + p.app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { watcher.Close() stopDebounceTimer() - return nil + return e.Next() }) // start listening for events. - go func() { + routine.FireAndForget(func() { defer stopDebounceTimer() for { @@ -367,7 +443,7 @@ func (p *plugin) watchHooks() error { debounceTimer = time.AfterFunc(50*time.Millisecond, func() { // app restart is currently not supported on Windows if runtime.GOOS == "windows" { - color.Yellow("File %s changed, please restart the app", event.Name) + color.Yellow("File %s changed, please restart the app manually", event.Name) } else { color.Yellow("File %s changed, restarting...", event.Name) if err := p.app.Restart(); err != nil { @@ -382,17 +458,22 @@ func (p *plugin) watchHooks() error { color.Red("Watch error:", err) } } - }() + }) // add directories to watch // // @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18) - dirsErr := filepath.Walk(p.config.HooksDir, func(path string, info fs.FileInfo, err error) error { - // ignore hidden directories and node_modules - if !info.IsDir() || info.Name() == "node_modules" || strings.HasPrefix(info.Name(), ".") { + dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error { + // ignore access failures and non-dir entries + if err != nil || !entry.IsDir() { return nil } + // skip traversing hidden directories and node_modules + if strings.HasPrefix(entry.Name(), ".") || entry.Name() == "node_modules" { + return filepath.SkipDir + } + return watcher.Add(path) }) if dirsErr != nil { @@ -423,8 +504,8 @@ func (p *plugin) relativeTypesPath(basepath string) string { return rel } -// saveTypesFile saves the embedded TS declarations as a file on the disk. -func (p *plugin) saveTypesFile() error { +// refreshTypesFile saves the embedded TS declarations as a file on the disk. +func (p *plugin) refreshTypesFile() error { fullPath := p.fullTypesPath() // ensure that the types directory exists @@ -439,11 +520,20 @@ func (p *plugin) saveTypesFile() error { return err } - if err := os.WriteFile(fullPath, data, 0644); err != nil { - return err + // read the first timestamp line of the old file (if exists) and compare it to the embedded one + // (note: ignore errors to allow always overwriting the file if it is invalid) + existingFile, err := os.Open(fullPath) + if err == nil { + timestamp := make([]byte, 13) + io.ReadFull(existingFile, timestamp) + existingFile.Close() + + if len(data) >= len(timestamp) && bytes.Equal(data[:13], timestamp) { + return nil // nothing new to save + } } - return nil + return os.WriteFile(fullPath, data, 0644) } // prependToEmptyFile prepends the specified text to an empty file. diff --git a/plugins/jsvm/mapper.go b/plugins/jsvm/mapper.go index 27f3543185..d9c16bb0c2 100644 --- a/plugins/jsvm/mapper.go +++ b/plugins/jsvm/mapper.go @@ -29,7 +29,13 @@ func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string { return convertGoToJSName(m.Name) } +var nameExceptions = map[string]string{"OAuth2": "oauth2"} + func convertGoToJSName(name string) string { + if v, ok := nameExceptions[name]; ok { + return v + } + startUppercase := make([]rune, 0, len(name)) for _, c := range name { diff --git a/plugins/jsvm/mapper_test.go b/plugins/jsvm/mapper_test.go index 4ddfd6286b..0cc020ebe8 100644 --- a/plugins/jsvm/mapper_test.go +++ b/plugins/jsvm/mapper_test.go @@ -25,6 +25,7 @@ func TestFieldMapper(t *testing.T) { {"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"}, {"OIDCMap", "oidcMap"}, {"MD5", "md5"}, + {"OAuth2", "oauth2"}, } for i, s := range scenarios { diff --git a/plugins/jsvm/pool.go b/plugins/jsvm/pool.go index e0174c5e87..e330609edb 100644 --- a/plugins/jsvm/pool.go +++ b/plugins/jsvm/pool.go @@ -20,6 +20,10 @@ type vmsPool struct { // newPool creates a new pool with pre-warmed vms generated from the specified factory. func newPool(size int, factory func() *goja.Runtime) *vmsPool { + if size < 0 { + size = 0 + } + pool := &vmsPool{ factory: factory, items: make([]*poolItem, size), diff --git a/plugins/migratecmd/automigrate.go b/plugins/migratecmd/automigrate.go index b65f67c23a..a022a4e3c6 100644 --- a/plugins/migratecmd/automigrate.go +++ b/plugins/migratecmd/automigrate.go @@ -6,134 +6,101 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/migrate" ) -const collectionsCacheKey = "migratecmd_collections" - -// onCollectionChange handles the automigration snapshot generation on -// collection change event (create/update/delete). -func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { - return func(e *core.ModelEvent) error { - if e.Model.TableName() != "_collections" { - return nil // not a collection - } - - // @todo replace with the OldModel when added to the ModelEvent - oldCollections, err := p.getCachedCollections() +// automigrateOnCollectionChange handles the automigration snapshot +// generation on collection change request event (create/update/delete). +func (p *plugin) automigrateOnCollectionChange(e *core.CollectionRequestEvent) error { + var err error + var old *core.Collection + if !e.Collection.IsNew() { + old, err = e.App.FindCollectionByNameOrId(e.Collection.Id) if err != nil { return err } + } - old := oldCollections[e.Model.GetId()] + err = e.Next() + if err != nil { + return err + } - new, err := p.app.Dao().FindCollectionByNameOrId(e.Model.GetId()) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } + new, err := p.app.FindCollectionByNameOrId(e.Collection.Id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } - var template string - var templateErr error - if p.config.TemplateLang == TemplateLangJS { - template, templateErr = p.jsDiffTemplate(new, old) - } else { - template, templateErr = p.goDiffTemplate(new, old) - } - if templateErr != nil { - if errors.Is(templateErr, emptyTemplateErr) { - return nil // no changes - } - return fmt.Errorf("failed to resolve template: %w", templateErr) - } + // for now exclude OAuth2 configs from the migration + if old != nil && old.IsAuth() { + old.OAuth2.Providers = nil + } + if new != nil && new.IsAuth() { + new.OAuth2.Providers = nil + } - var action string - switch { - case new == nil: - action = "deleted_" + old.Name - case old == nil: - action = "created_" + new.Name - default: - action = "updated_" + old.Name + var template string + var templateErr error + if p.config.TemplateLang == TemplateLangJS { + template, templateErr = p.jsDiffTemplate(new, old) + } else { + template, templateErr = p.goDiffTemplate(new, old) + } + if templateErr != nil { + if errors.Is(templateErr, ErrEmptyTemplate) { + return nil // no changes } - - name := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), action, p.config.TemplateLang) - filePath := filepath.Join(p.config.Dir, name) - - return p.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { - // insert the migration entry - _, err := txDao.DB().Insert(migrate.DefaultMigrationsTable, dbx.Params{ - "file": name, - // use microseconds for more granular applied time in case - // multiple collection changes happens at the ~exact time - "applied": time.Now().UnixMicro(), - }).Execute() - if err != nil { - return err - } - - // ensure that the local migrations dir exist - if err := os.MkdirAll(p.config.Dir, os.ModePerm); err != nil { - return fmt.Errorf("failed to create migration dir: %w", err) - } - - if err := os.WriteFile(filePath, []byte(template), 0644); err != nil { - return fmt.Errorf("failed to save automigrate file: %w", err) - } - - p.updateSingleCachedCollection(new, old) - - return nil - }) + return fmt.Errorf("failed to resolve template: %w", templateErr) } -} - -func (p *plugin) updateSingleCachedCollection(new, old *models.Collection) { - cached, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection) + var action string switch { case new == nil: - delete(cached, old.Id) + action = "deleted_" + normalizeCollectionName(old.Name) + case old == nil: + action = "created_" + normalizeCollectionName(new.Name) default: - cached[new.Id] = new + action = "updated_" + normalizeCollectionName(old.Name) } - p.app.Cache().Set(collectionsCacheKey, cached) -} - -func (p *plugin) refreshCachedCollections() error { - if p.app.Dao() == nil { - return errors.New("app is not initialized yet") - } - - var collections []*models.Collection - if err := p.app.Dao().CollectionQuery().All(&collections); err != nil { - return err - } + name := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), action, p.config.TemplateLang) + filePath := filepath.Join(p.config.Dir, name) + + return p.app.RunInTransaction(func(txApp core.App) error { + // insert the migration entry + _, err := txApp.DB().Insert(core.DefaultMigrationsTable, dbx.Params{ + "file": name, + // use microseconds for more granular applied time in case + // multiple collection changes happens at the ~exact time + "applied": time.Now().UnixMicro(), + }).Execute() + if err != nil { + return err + } - cached := map[string]*models.Collection{} - for _, c := range collections { - cached[c.Id] = c - } + // ensure that the local migrations dir exist + if err := os.MkdirAll(p.config.Dir, os.ModePerm); err != nil { + return fmt.Errorf("failed to create migration dir: %w", err) + } - p.app.Cache().Set(collectionsCacheKey, cached) + if err := os.WriteFile(filePath, []byte(template), 0644); err != nil { + return fmt.Errorf("failed to save automigrate file: %w", err) + } - return nil + return nil + }) } -func (p *plugin) getCachedCollections() (map[string]*models.Collection, error) { - if !p.app.Cache().Has(collectionsCacheKey) { - if err := p.refreshCachedCollections(); err != nil { - return nil, err - } +func normalizeCollectionName(name string) string { + // adds an extra "_" suffix to the name in case the collection ends + // with "test" to prevent accidentally resulting in "_test.go"/"_test.js" files + if strings.HasSuffix(strings.ToLower(name), "test") { + name += "_" } - result, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection) - - return result, nil + return name } diff --git a/plugins/migratecmd/migratecmd.go b/plugins/migratecmd/migratecmd.go index c4bf1c6dcc..6d426ea8d4 100644 --- a/plugins/migratecmd/migratecmd.go +++ b/plugins/migratecmd/migratecmd.go @@ -16,18 +16,16 @@ package migratecmd import ( + "errors" "fmt" "os" "path" "path/filepath" "time" - "github.com/AlecAivazis/survey/v2" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/inflector" - "github.com/pocketbase/pocketbase/tools/migrate" + "github.com/pocketbase/pocketbase/tools/osutils" "github.com/spf13/cobra" ) @@ -82,22 +80,9 @@ func Register(app core.App, rootCmd *cobra.Command, config Config) error { // watch for collection changes if p.config.Automigrate { - // refresh the cache right after app bootstap - p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { - p.refreshCachedCollections() - return nil - }) - - // refresh the cache to ensure that it constains the latest changes - // when migrations are applied on server start - p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error { - p.refreshCachedCollections() - return nil - }) - - p.app.OnModelAfterCreate().Add(p.afterCollectionChange()) - p.app.OnModelAfterUpdate().Add(p.afterCollectionChange()) - p.app.OnModelAfterDelete().Add(p.afterCollectionChange()) + p.app.OnCollectionCreateRequest().BindFunc(p.automigrateOnCollectionChange) + p.app.OnCollectionUpdateRequest().BindFunc(p.automigrateOnCollectionChange) + p.app.OnCollectionDeleteRequest().BindFunc(p.automigrateOnCollectionChange) } return nil @@ -118,13 +103,11 @@ func (p *plugin) createCommand() *cobra.Command { ` command := &cobra.Command{ - Use: "migrate", - Short: "Executes app DB migration scripts", - Long: cmdDesc, - ValidArgs: []string{"up", "down", "create", "collections"}, - // prevents printing the error log twice - SilenceErrors: true, - SilenceUsage: true, + Use: "migrate", + Short: "Executes app DB migration scripts", + Long: cmdDesc, + ValidArgs: []string{"up", "down", "create", "collections"}, + SilenceUsage: true, RunE: func(command *cobra.Command, args []string) error { cmd := "" if len(args) > 0 { @@ -141,10 +124,12 @@ func (p *plugin) createCommand() *cobra.Command { return err } default: - runner, err := migrate.NewRunner(p.app.DB(), migrations.AppMigrations) - if err != nil { - return err - } + // note: system migrations are always applied as part of the bootstrap process + var list = core.MigrationsList{} + list.Copy(core.SystemMigrations) + list.Copy(core.AppMigrations) + + runner := core.NewMigrationsRunner(p.app, list) if err := runner.Run(args...); err != nil { return err @@ -160,7 +145,7 @@ func (p *plugin) createCommand() *cobra.Command { func (p *plugin) migrateCreateHandler(template string, args []string, interactive bool) (string, error) { if len(args) < 1 { - return "", fmt.Errorf("Missing migration file name") + return "", errors.New("missing migration file name") } name := args[0] @@ -171,11 +156,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv resultFilePath := path.Join(dir, filename) if interactive { - confirm := false - prompt := &survey.Confirm{ - Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), - } - survey.AskOne(prompt, &confirm) + confirm := osutils.YesNoPrompt(fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), false) if !confirm { fmt.Println("The command has been cancelled") return "", nil @@ -191,7 +172,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv template, templateErr = p.goBlankTemplate() } if templateErr != nil { - return "", fmt.Errorf("Failed to resolve create template: %v\n", templateErr) + return "", fmt.Errorf("failed to resolve create template: %v", templateErr) } } @@ -202,7 +183,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv // save the migration file if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil { - return "", fmt.Errorf("Failed to save migration file %q: %v\n", resultFilePath, err) + return "", fmt.Errorf("failed to save migration file %q: %v", resultFilePath, err) } if interactive { @@ -213,12 +194,13 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv } func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (string, error) { - createArgs := []string{"collections_snapshot"} + createArgs := make([]string, 0, len(args)+1) + createArgs = append(createArgs, "collections_snapshot") createArgs = append(createArgs, args...) - collections := []*models.Collection{} - if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil { - return "", fmt.Errorf("Failed to fetch migrations list: %v", err) + collections := []*core.Collection{} + if err := p.app.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil { + return "", fmt.Errorf("failed to fetch migrations list: %v", err) } var template string @@ -229,7 +211,7 @@ func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (str template, templateErr = p.goSnapshotTemplate(collections) } if templateErr != nil { - return "", fmt.Errorf("Failed to resolve template: %v", templateErr) + return "", fmt.Errorf("failed to resolve template: %v", templateErr) } return p.migrateCreateHandler(template, createArgs, interactive) diff --git a/plugins/migratecmd/migratecmd_test.go b/plugins/migratecmd/migratecmd_test.go index 04b0c68765..2c0d95e961 100644 --- a/plugins/migratecmd/migratecmd_test.go +++ b/plugins/migratecmd/migratecmd_test.go @@ -3,18 +3,20 @@ package migratecmd_test import ( "os" "path/filepath" + "regexp" "strings" "testing" - "github.com/pocketbase/pocketbase/daos" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/types" ) func TestAutomigrateCollectionCreate(t *testing.T) { + t.Parallel() + scenarios := []struct { lang string expectedTemplate string @@ -23,41 +25,172 @@ func TestAutomigrateCollectionCreate(t *testing.T) { migratecmd.TemplateLangJS, ` /// -migrate((db) => { +migrate((app) => { const collection = new Collection({ - "id": "new_id", - "created": "2022-01-01 00:00:00.000Z", - "updated": "2022-01-01 00:00:00.000Z", - "name": "new_name", - "type": "auth", - "system": true, - "schema": [], + "authAlert": { + "emailTemplate": { + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Login from a new location" + }, + "enabled": true + }, + "authRule": "", + "authToken": { + "duration": 432000 + }, + "confirmEmailChangeTemplate": { + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Confirm your {APP_NAME} new email address" + }, + "createRule": null, + "deleteRule": null, + "emailChangeToken": { + "duration": 1800 + }, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text@TEST_RANDOM", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password@TEST_RANDOM", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text@TEST_RANDOM", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": [], + "help": "", + "hidden": false, + "id": "email@TEST_RANDOM", + "name": "email", + "onlyDomains": [], + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + } + ], + "fileToken": { + "duration": 180 + }, + "id": "@TEST_RANDOM", "indexes": [ - "create index test on new_name (id)" + "create index test on new_name (id)", + "CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `tokenKey` + "`" + `)", + "CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''" ], - "listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0", - "viewRule": "id = \"1\"", - "createRule": null, + "listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0", + "manageRule": "1 != 2", + "mfa": { + "duration": 600, + "enabled": false, + "rule": "" + }, + "name": "new_name", + "oauth2": { + "enabled": false, + "mappedFields": { + "avatarURL": "", + "id": "", + "name": "", + "username": "" + } + }, + "otp": { + "duration": 180, + "emailTemplate": { + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "OTP for {APP_NAME}" + }, + "enabled": false, + "length": 8 + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "passwordResetToken": { + "duration": 1800 + }, + "resetPasswordTemplate": { + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Reset your {APP_NAME} password" + }, + "system": true, + "type": "auth", "updateRule": null, - "deleteRule": null, - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - } + "verificationTemplate": { + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

If you didn't recently register, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Verify your {APP_NAME} email" + }, + "verificationToken": { + "duration": 86400 + }, + "viewRule": "id = \"1\"" }); - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("new_id"); + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("@TEST_RANDOM"); - return dao.deleteCollection(collection); + return app.delete(collection); }) `, }, @@ -67,127 +200,272 @@ migrate((db) => { package _test_migrations import ( - "encoding/json" + "encoding/json/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" ) func init() { - m.Register(func(db dbx.Builder) error { + m.Register(func(app core.App) error { jsonData := ` + "`" + `{ - "id": "new_id", - "created": "2022-01-01 00:00:00.000Z", - "updated": "2022-01-01 00:00:00.000Z", - "name": "new_name", - "type": "auth", - "system": true, - "schema": [], + "authAlert": { + "emailTemplate": { + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Login from a new location" + }, + "enabled": true + }, + "authRule": "", + "authToken": { + "duration": 432000 + }, + "confirmEmailChangeTemplate": { + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Confirm your {APP_NAME} new email address" + }, + "createRule": null, + "deleteRule": null, + "emailChangeToken": { + "duration": 1800 + }, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text@TEST_RANDOM", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password@TEST_RANDOM", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text@TEST_RANDOM", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": [], + "help": "", + "hidden": false, + "id": "email@TEST_RANDOM", + "name": "email", + "onlyDomains": [], + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + } + ], + "fileToken": { + "duration": 180 + }, + "id": "@TEST_RANDOM", "indexes": [ - "create index test on new_name (id)" + "create index test on new_name (id)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''" ], - "listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `", - "viewRule": "id = \"1\"", - "createRule": null, + "listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0", + "manageRule": "1 != 2", + "mfa": { + "duration": 600, + "enabled": false, + "rule": "" + }, + "name": "new_name", + "oauth2": { + "enabled": false, + "mappedFields": { + "avatarURL": "", + "id": "", + "name": "", + "username": "" + } + }, + "otp": { + "duration": 180, + "emailTemplate": { + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "OTP for {APP_NAME}" + }, + "enabled": false, + "length": 8 + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "passwordResetToken": { + "duration": 1800 + }, + "resetPasswordTemplate": { + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Reset your {APP_NAME} password" + }, + "system": true, + "type": "auth", "updateRule": null, - "deleteRule": null, - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - } + "verificationTemplate": { + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

If you didn't recently register, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Verify your {APP_NAME} email" + }, + "verificationToken": { + "duration": 86400 + }, + "viewRule": "id = \"1\"" }` + "`" + ` - collection := &models.Collection{} + collection := &core.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } - return daos.New(db).SaveCollection(collection) - }, func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId("new_id") + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM") if err != nil { return err } - return dao.DeleteCollection(collection) + return app.Delete(collection) }) } `, }, } - for i, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") - - migratecmd.MustRegister(app, nil, migratecmd.Config{ - TemplateLang: s.lang, - Automigrate: true, - Dir: migrationsDir, - }) - - // @todo remove after collections cache is replaced - app.Bootstrap() - - collection := &models.Collection{} - collection.Id = "new_id" - collection.Name = "new_name" - collection.Type = models.CollectionTypeAuth - collection.System = true - collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") - collection.Updated = collection.Created - collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") - collection.ViewRule = types.Pointer(`id = "1"`) - collection.Indexes = types.JsonArray[string]{"create index test on new_name (id)"} - collection.SetOptions(models.CollectionAuthOptions{ - ManageRule: types.Pointer("created > 0"), - MinPasswordLength: 20, - }) - collection.MarkAsNew() - - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save collection, got %v", i, err) - } - - files, err := os.ReadDir(migrationsDir) - if err != nil { - t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) - } + for _, s := range scenarios { + t.Run(s.lang, func(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + migratecmd.MustRegister(app, nil, migratecmd.Config{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + + app.Bootstrap() + + collection := core.NewAuthCollection("new_name") + collection.System = true + collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.Indexes = types.JSONArray[string]{"create index test on new_name (id)"} + collection.ManageRule = types.Pointer("1 != 2") + // should be ignored + collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}} + testSecret := strings.Repeat("a", 30) + collection.AuthToken.Secret = testSecret + collection.FileToken.Secret = testSecret + collection.EmailChangeToken.Secret = testSecret + collection.PasswordResetToken.Secret = testSecret + collection.VerificationToken.Secret = testSecret + + // save the newly created dummy collection (with mock request event) + event := new(core.CollectionRequestEvent) + event.RequestEvent = &core.RequestEvent{} + event.App = app + event.Collection = collection + err := app.OnCollectionCreateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error { + return e.App.Save(e.Collection) + }) + if err != nil { + t.Fatalf("Failed to save the created dummy collection, got: %v", err) + } - if total := len(files); total != 1 { - t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) - } + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("Expected migrationsDir to be created, got %v", err) + } - expectedName := "_created_new_name." + s.lang - if !strings.Contains(files[0].Name(), expectedName) { - t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name()) - } + if total := len(files); total != 1 { + t.Fatalf("Expected 1 file to be generated, got %d: %v", total, files) + } - fullPath := filepath.Join(migrationsDir, files[0].Name()) - content, err := os.ReadFile(fullPath) - if err != nil { - t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) - } + expectedName := "_created_new_name." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } - if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { - t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) - } + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("Failed to read the generated migration file: %v", err) + } + contentStr := strings.TrimSpace(string(content)) + + // replace @TEST_RANDOM placeholder with a regex pattern + expectedTemplate := strings.ReplaceAll( + "^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$", + "@TEST_RANDOM", + `\w+`, + ) + if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) { + t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr) + } + }) } } func TestAutomigrateCollectionDelete(t *testing.T) { + t.Parallel() + scenarios := []struct { lang string expectedTemplate string @@ -196,41 +474,172 @@ func TestAutomigrateCollectionDelete(t *testing.T) { migratecmd.TemplateLangJS, ` /// -migrate((db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("test123"); +migrate((app) => { + const collection = app.findCollectionByNameOrId("@TEST_RANDOM"); - return dao.deleteCollection(collection); -}, (db) => { + return app.delete(collection); +}, (app) => { const collection = new Collection({ - "id": "test123", - "created": "2022-01-01 00:00:00.000Z", - "updated": "2022-01-01 00:00:00.000Z", - "name": "test456", - "type": "auth", - "system": false, - "schema": [], + "authAlert": { + "emailTemplate": { + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Login from a new location" + }, + "enabled": true + }, + "authRule": "", + "authToken": { + "duration": 432000 + }, + "confirmEmailChangeTemplate": { + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Confirm your {APP_NAME} new email address" + }, + "createRule": null, + "deleteRule": null, + "emailChangeToken": { + "duration": 1800 + }, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text@TEST_RANDOM", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password@TEST_RANDOM", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text@TEST_RANDOM", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": [], + "help": "", + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": [], + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + } + ], + "fileToken": { + "duration": 180 + }, + "id": "@TEST_RANDOM", "indexes": [ - "create index test on test456 (id)" + "create index test on test123 (id)", + "CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)", + "CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''" ], - "listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0", - "viewRule": "id = \"1\"", - "createRule": null, + "listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0", + "manageRule": "1 != 2", + "mfa": { + "duration": 600, + "enabled": false, + "rule": "" + }, + "name": "test123", + "oauth2": { + "enabled": false, + "mappedFields": { + "avatarURL": "", + "id": "", + "name": "", + "username": "" + } + }, + "otp": { + "duration": 180, + "emailTemplate": { + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "OTP for {APP_NAME}" + }, + "enabled": false, + "length": 8 + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "passwordResetToken": { + "duration": 1800 + }, + "resetPasswordTemplate": { + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Reset your {APP_NAME} password" + }, + "system": false, + "type": "auth", "updateRule": null, - "deleteRule": null, - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - } + "verificationTemplate": { + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

If you didn't recently register, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Verify your {APP_NAME} email" + }, + "verificationToken": { + "duration": 86400 + }, + "viewRule": "id = \"1\"" }); - return Dao(db).saveCollection(collection); + return app.save(collection); }) `, }, @@ -240,133 +649,267 @@ migrate((db) => { package _test_migrations import ( - "encoding/json" + "encoding/json/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" ) func init() { - m.Register(func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId("test123") + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM") if err != nil { return err } - return dao.DeleteCollection(collection) - }, func(db dbx.Builder) error { + return app.Delete(collection) + }, func(app core.App) error { jsonData := ` + "`" + `{ - "id": "test123", - "created": "2022-01-01 00:00:00.000Z", - "updated": "2022-01-01 00:00:00.000Z", - "name": "test456", - "type": "auth", - "system": false, - "schema": [], + "authAlert": { + "emailTemplate": { + "body": "

Hello,

\n

We noticed a login to your {APP_NAME} account from a new location:

\n

{ALERT_INFO}

\n

If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.

\n

If this was you, you may disregard this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Login from a new location" + }, + "enabled": true + }, + "authRule": "", + "authToken": { + "duration": 432000 + }, + "confirmEmailChangeTemplate": { + "body": "

Hello,

\n

Click on the button below to confirm your new email address.

\n

\n Confirm new email\n

\n

If you didn't ask to change your email address, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Confirm your {APP_NAME} new email address" + }, + "createRule": null, + "deleteRule": null, + "emailChangeToken": { + "duration": 1800 + }, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text@TEST_RANDOM", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cost": 0, + "help": "", + "hidden": true, + "id": "password@TEST_RANDOM", + "max": 0, + "min": 8, + "name": "password", + "pattern": "", + "presentable": false, + "required": true, + "system": true, + "type": "password" + }, + { + "autogeneratePattern": "[a-zA-Z0-9]{50}", + "help": "", + "hidden": true, + "id": "text@TEST_RANDOM", + "max": 60, + "min": 30, + "name": "tokenKey", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": true, + "type": "text" + }, + { + "exceptDomains": [], + "help": "", + "hidden": false, + "id": "email3885137012", + "name": "email", + "onlyDomains": [], + "presentable": false, + "required": true, + "system": true, + "type": "email" + }, + { + "help": "", + "hidden": false, + "id": "bool@TEST_RANDOM", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + }, + { + "help": "", + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": true, + "type": "bool" + } + ], + "fileToken": { + "duration": 180 + }, + "id": "@TEST_RANDOM", "indexes": [ - "create index test on test456 (id)" + "create index test on test123 (id)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''" ], - "listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `", - "viewRule": "id = \"1\"", - "createRule": null, + "listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0", + "manageRule": "1 != 2", + "mfa": { + "duration": 600, + "enabled": false, + "rule": "" + }, + "name": "test123", + "oauth2": { + "enabled": false, + "mappedFields": { + "avatarURL": "", + "id": "", + "name": "", + "username": "" + } + }, + "otp": { + "duration": 180, + "emailTemplate": { + "body": "

Hello,

\n

Your one-time password is: {OTP}

\n

If you didn't ask for the one-time password, you can ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "OTP for {APP_NAME}" + }, + "enabled": false, + "length": 8 + }, + "passwordAuth": { + "enabled": true, + "identityFields": [ + "email" + ] + }, + "passwordResetToken": { + "duration": 1800 + }, + "resetPasswordTemplate": { + "body": "

Hello,

\n

Click on the button below to reset your password.

\n

\n Reset password\n

\n

If you didn't ask to reset your password, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Reset your {APP_NAME} password" + }, + "system": false, + "type": "auth", "updateRule": null, - "deleteRule": null, - "options": { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - } + "verificationTemplate": { + "body": "

Hello,

\n

Thank you for joining us at {APP_NAME}.

\n

Click on the button below to verify your email address.

\n

\n Verify\n

\n

If you didn't recently register, please ignore this email.

\n

\n Thanks,
\n {APP_NAME} team\n

", + "subject": "Verify your {APP_NAME} email" + }, + "verificationToken": { + "duration": 86400 + }, + "viewRule": "id = \"1\"" }` + "`" + ` - collection := &models.Collection{} + collection := &core.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } - return daos.New(db).SaveCollection(collection) + return app.Save(collection) }) } `, }, } - for i, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") - - migratecmd.MustRegister(app, nil, migratecmd.Config{ - TemplateLang: s.lang, - Automigrate: true, - Dir: migrationsDir, - }) - - // create dummy collection - collection := &models.Collection{} - collection.Id = "test123" - collection.Name = "test456" - collection.Type = models.CollectionTypeAuth - collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") - collection.Updated = collection.Created - collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0") - collection.ViewRule = types.Pointer(`id = "1"`) - collection.Indexes = types.JsonArray[string]{"create index test on test456 (id)"} - collection.SetOptions(models.CollectionAuthOptions{ - ManageRule: types.Pointer("created > 0"), - MinPasswordLength: 20, - }) - collection.MarkAsNew() - - // use different dao to avoid triggering automigrate while saving the dummy collection - if err := daos.New(app.DB()).SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err) - } - - // @todo remove after collections cache is replaced - app.Bootstrap() - - // delete the newly created dummy collection - if err := app.Dao().DeleteCollection(collection); err != nil { - t.Fatalf("[%d] Failed to delete dummy collection, got %v", i, err) - } + for _, s := range scenarios { + t.Run(s.lang, func(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + // create dummy collection + collection := core.NewAuthCollection("test123") + collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.Indexes = types.JSONArray[string]{"create index test on test123 (id)"} + collection.ManageRule = types.Pointer("1 != 2") + if err := app.Save(collection); err != nil { + t.Fatalf("Failed to save dummy collection, got: %v", err) + } - files, err := os.ReadDir(migrationsDir) - if err != nil { - t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) - } + migratecmd.MustRegister(app, nil, migratecmd.Config{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + + app.Bootstrap() + + // delete the newly created dummy collection (with mock request event) + event := new(core.CollectionRequestEvent) + event.RequestEvent = &core.RequestEvent{} + event.App = app + event.Collection = collection + err := app.OnCollectionDeleteRequest().Trigger(event, func(e *core.CollectionRequestEvent) error { + return e.App.Delete(e.Collection) + }) + if err != nil { + t.Fatalf("Failed to delete dummy collection, got: %v", err) + } - if total := len(files); total != 1 { - t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) - } + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("Expected migrationsDir to be created, got: %v", err) + } - expectedName := "_deleted_test456." + s.lang - if !strings.Contains(files[0].Name(), expectedName) { - t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name()) - } + if total := len(files); total != 1 { + t.Fatalf("Expected 1 file to be generated, got %d", total) + } - fullPath := filepath.Join(migrationsDir, files[0].Name()) - content, err := os.ReadFile(fullPath) - if err != nil { - t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) - } + expectedName := "_deleted_test123." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } - if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { - t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) - } + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("Failed to read the generated migration file: %v", err) + } + contentStr := strings.TrimSpace(string(content)) + + // replace @TEST_RANDOM placeholder with a regex pattern + expectedTemplate := strings.ReplaceAll( + "^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$", + "@TEST_RANDOM", + `\w+`, + ) + if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) { + t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr) + } + }) } } func TestAutomigrateCollectionUpdate(t *testing.T) { + t.Parallel() + scenarios := []struct { lang string expectedTemplate string @@ -375,114 +918,121 @@ func TestAutomigrateCollectionUpdate(t *testing.T) { migratecmd.TemplateLangJS, ` /// -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("test123") - - collection.name = "test456_update" - collection.type = "base" - collection.listRule = "@request.auth.id != ''" - collection.createRule = "id = \"nil_update\"" - collection.updateRule = "id = \"2_update\"" - collection.deleteRule = null - collection.options = {} - collection.indexes = [ - "create index test1 on test456_update (f1_name)" - ] - - // remove - collection.schema.removeField("f3_id") - - // add - collection.schema.addField(new SchemaField({ - "system": false, +migrate((app) => { + const collection = app.findCollectionByNameOrId("@TEST_RANDOM") + + // update collection data + unmarshal({ + "createRule": "id = \"nil_update\"", + "deleteRule": null, + "fileToken": { + "duration": 10 + }, + "indexes": [ + "create index test1 on test123_update (f1_name)", + "CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `tokenKey` + "`" + `)", + "CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''" + ], + "listRule": "@request.auth.id != ''", + "name": "test123_update", + "oauth2": { + "enabled": true + }, + "updateRule": "id = \"2_update\"" + }, collection) + + // remove field + collection.fields.removeById("f3_id") + + // add field + collection.fields.addAt(8, new Field({ + "autogeneratePattern": "", + "help": "", + "hidden": false, "id": "f4_id", + "max": 0, + "min": 0, "name": "f4_name", - "type": "text", - "required": false, + "pattern": "` + "`" + `test backtick` + "`" + `123", "presentable": false, - "unique": false, - "options": { - "min": null, - "max": null, - "pattern": "` + "`" + `test backtick` + "`" + `123" - } + "primaryKey": false, + "required": false, + "system": false, + "type": "text" })) - // update - collection.schema.addField(new SchemaField({ - "system": false, + // update field + collection.fields.addAt(7, new Field({ + "help": "", + "hidden": false, "id": "f2_id", + "max": null, + "min": 10, "name": "f2_name_new", - "type": "number", - "required": false, + "onlyInt": false, "presentable": false, - "unique": true, - "options": { - "min": 10, - "max": null, - "noDecimal": false - } + "required": false, + "system": false, + "type": "number" })) - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("test123") - - collection.name = "test456" - collection.type = "auth" - collection.listRule = "@request.auth.id != '' && created > 0" - collection.createRule = null - collection.updateRule = "id = \"2\"" - collection.deleteRule = "id = \"3\"" - collection.options = { - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - } - collection.indexes = [ - "create index test1 on test456 (f1_name)" - ] - - // add - collection.schema.addField(new SchemaField({ - "system": false, + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("@TEST_RANDOM") + + // update collection data + unmarshal({ + "createRule": null, + "deleteRule": "id = \"3\"", + "fileToken": { + "duration": 180 + }, + "indexes": [ + "create index test1 on test123 (f1_name)", + "CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)", + "CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''" + ], + "listRule": "@request.auth.id != '' && 1 != 2", + "name": "test123", + "oauth2": { + "enabled": false + }, + "updateRule": "id = \"2\"" + }, collection) + + // add field + collection.fields.addAt(8, new Field({ + "help": "", + "hidden": false, "id": "f3_id", "name": "f3_name", - "type": "bool", - "required": false, "presentable": false, - "unique": false, - "options": {} + "required": false, + "system": false, + "type": "bool" })) - // remove - collection.schema.removeField("f4_id") + // remove field + collection.fields.removeById("f4_id") - // update - collection.schema.addField(new SchemaField({ - "system": false, + // update field + collection.fields.addAt(7, new Field({ + "help": "", + "hidden": false, "id": "f2_id", + "max": null, + "min": 10, "name": "f2_name", - "type": "number", - "required": false, + "onlyInt": false, "presentable": false, - "unique": true, - "options": { - "min": 10, - "max": null, - "noDecimal": false - } + "required": false, + "system": false, + "type": "number" })) - return dao.saveCollection(collection) + return app.save(collection) }) + `, }, { @@ -491,278 +1041,271 @@ migrate((db) => { package _test_migrations import ( - "encoding/json" + "encoding/json/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/types" ) func init() { - m.Register(func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId("test123") + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM") if err != nil { return err } - collection.Name = "test456_update" - - collection.Type = "base" - - collection.ListRule = types.Pointer("@request.auth.id != ''") - - collection.CreateRule = types.Pointer("id = \"nil_update\"") - - collection.UpdateRule = types.Pointer("id = \"2_update\"") - - collection.DeleteRule = nil - - options := map[string]any{} - json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options) - collection.SetOptions(options) - - json.Unmarshal([]byte(` + "`" + `[ - "create index test1 on test456_update (f1_name)" - ]` + "`" + `), &collection.Indexes) + // update collection data + if err := json.Unmarshal([]byte(` + "`" + `{ + "createRule": "id = \"nil_update\"", + "deleteRule": null, + "fileToken": { + "duration": 10 + }, + "indexes": [ + "create index test1 on test123_update (f1_name)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''" + ], + "listRule": "@request.auth.id != ''", + "name": "test123_update", + "oauth2": { + "enabled": true + }, + "updateRule": "id = \"2_update\"" + }` + "`" + `), &collection); err != nil { + return err + } - // remove - collection.Schema.RemoveField("f3_id") + // remove field + collection.Fields.RemoveById("f3_id") - // add - new_f4_name := &schema.SchemaField{} - json.Unmarshal([]byte(` + "`" + `{ - "system": false, + // add field + if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ + "autogeneratePattern": "", + "help": "", + "hidden": false, "id": "f4_id", + "max": 0, + "min": 0, "name": "f4_name", - "type": "text", - "required": false, + "pattern": "` + "` + \"`\" + `" + `test backtick` + "` + \"`\" + `" + `123", "presentable": false, - "unique": false, - "options": { - "min": null, - "max": null, - "pattern": ` + "\"` + \"`\" + `test backtick` + \"`\" + `123\"" + ` - } - }` + "`" + `), new_f4_name) - collection.Schema.AddField(new_f4_name) - - // update - edit_f2_name_new := &schema.SchemaField{} - json.Unmarshal([]byte(` + "`" + `{ + "primaryKey": false, + "required": false, "system": false, + "type": "text" + }` + "`" + `)); err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ + "help": "", + "hidden": false, "id": "f2_id", + "max": null, + "min": 10, "name": "f2_name_new", - "type": "number", - "required": false, + "onlyInt": false, "presentable": false, - "unique": true, - "options": { - "min": 10, - "max": null, - "noDecimal": false - } - }` + "`" + `), edit_f2_name_new) - collection.Schema.AddField(edit_f2_name_new) - - return dao.SaveCollection(collection) - }, func(db dbx.Builder) error { - dao := daos.New(db); + "required": false, + "system": false, + "type": "number" + }` + "`" + `)); err != nil { + return err + } - collection, err := dao.FindCollectionByNameOrId("test123") + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM") if err != nil { return err } - collection.Name = "test456" - - collection.Type = "auth" - - collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0") - - collection.CreateRule = nil - - collection.UpdateRule = types.Pointer("id = \"2\"") - - collection.DeleteRule = types.Pointer("id = \"3\"") - - options := map[string]any{} - json.Unmarshal([]byte(` + "`" + `{ - "allowEmailAuth": false, - "allowOAuth2Auth": false, - "allowUsernameAuth": false, - "exceptEmailDomains": null, - "manageRule": "created > 0", - "minPasswordLength": 20, - "onlyEmailDomains": null, - "requireEmail": false - }` + "`" + `), &options) - collection.SetOptions(options) - - json.Unmarshal([]byte(` + "`" + `[ - "create index test1 on test456 (f1_name)" - ]` + "`" + `), &collection.Indexes) + // update collection data + if err := json.Unmarshal([]byte(` + "`" + `{ + "createRule": null, + "deleteRule": "id = \"3\"", + "fileToken": { + "duration": 180 + }, + "indexes": [ + "create index test1 on test123 (f1_name)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)", + "CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''" + ], + "listRule": "@request.auth.id != '' && 1 != 2", + "name": "test123", + "oauth2": { + "enabled": false + }, + "updateRule": "id = \"2\"" + }` + "`" + `), &collection); err != nil { + return err + } - // add - del_f3_name := &schema.SchemaField{} - json.Unmarshal([]byte(` + "`" + `{ - "system": false, + // add field + if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{ + "help": "", + "hidden": false, "id": "f3_id", "name": "f3_name", - "type": "bool", - "required": false, "presentable": false, - "unique": false, - "options": {} - }` + "`" + `), del_f3_name) - collection.Schema.AddField(del_f3_name) + "required": false, + "system": false, + "type": "bool" + }` + "`" + `)); err != nil { + return err + } - // remove - collection.Schema.RemoveField("f4_id") + // remove field + collection.Fields.RemoveById("f4_id") - // update - edit_f2_name_new := &schema.SchemaField{} - json.Unmarshal([]byte(` + "`" + `{ - "system": false, + // update field + if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{ + "help": "", + "hidden": false, "id": "f2_id", + "max": null, + "min": 10, "name": "f2_name", - "type": "number", - "required": false, + "onlyInt": false, "presentable": false, - "unique": true, - "options": { - "min": 10, - "max": null, - "noDecimal": false - } - }` + "`" + `), edit_f2_name_new) - collection.Schema.AddField(edit_f2_name_new) + "required": false, + "system": false, + "type": "number" + }` + "`" + `)); err != nil { + return err + } - return dao.SaveCollection(collection) + return app.Save(collection) }) } `, }, } - for i, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") - - migratecmd.MustRegister(app, nil, migratecmd.Config{ - TemplateLang: s.lang, - Automigrate: true, - Dir: migrationsDir, - }) - - // create dummy collection - collection := &models.Collection{} - collection.Id = "test123" - collection.Name = "test456" - collection.Type = models.CollectionTypeAuth - collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z") - collection.Updated = collection.Created - collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0") - collection.ViewRule = types.Pointer(`id = "1"`) - collection.UpdateRule = types.Pointer(`id = "2"`) - collection.CreateRule = nil - collection.DeleteRule = types.Pointer(`id = "3"`) - collection.Indexes = types.JsonArray[string]{"create index test1 on test456 (f1_name)"} - collection.SetOptions(models.CollectionAuthOptions{ - ManageRule: types.Pointer("created > 0"), - MinPasswordLength: 20, - }) - collection.MarkAsNew() - collection.Schema.AddField(&schema.SchemaField{ - Id: "f1_id", - Name: "f1_name", - Type: schema.FieldTypeText, - Required: true, - }) - collection.Schema.AddField(&schema.SchemaField{ - Id: "f2_id", - Name: "f2_name", - Type: schema.FieldTypeNumber, - Unique: true, - Options: &schema.NumberOptions{ - Min: types.Pointer(10.0), - }, - }) - collection.Schema.AddField(&schema.SchemaField{ - Id: "f3_id", - Name: "f3_name", - Type: schema.FieldTypeBool, - }) - - // use different dao to avoid triggering automigrate while saving the dummy collection - if err := daos.New(app.DB()).SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err) - } + for _, s := range scenarios { + t.Run(s.lang, func(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + + // create dummy collection + collection := core.NewAuthCollection("test123") + collection.ListRule = types.Pointer("@request.auth.id != '' && 1 != 2") + collection.ViewRule = types.Pointer(`id = "1"`) + collection.UpdateRule = types.Pointer(`id = "2"`) + collection.CreateRule = nil + collection.DeleteRule = types.Pointer(`id = "3"`) + collection.Indexes = types.JSONArray[string]{"create index test1 on test123 (f1_name)"} + collection.ManageRule = types.Pointer("1 != 2") + collection.Fields.Add(&core.TextField{ + Id: "f1_id", + Name: "f1_name", + Required: true, + }) + collection.Fields.Add(&core.NumberField{ + Id: "f2_id", + Name: "f2_name", + Min: types.Pointer(10.0), + }) + collection.Fields.Add(&core.BoolField{ + Id: "f3_id", + Name: "f3_name", + }) + + if err := app.Save(collection); err != nil { + t.Fatalf("Failed to save dummy collection, got %v", err) + } - // @todo remove after collections cache is replaced - app.Bootstrap() - - collection.Name = "test456_update" - collection.Type = models.CollectionTypeBase - collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`) - collection.ListRule = types.Pointer("@request.auth.id != ''") - collection.ViewRule = types.Pointer(`id = "1"`) // no change - collection.UpdateRule = types.Pointer(`id = "2_update"`) - collection.CreateRule = types.Pointer(`id = "nil_update"`) - collection.DeleteRule = nil - collection.Indexes = types.JsonArray[string]{ - "create index test1 on test456_update (f1_name)", - } - collection.NormalizeOptions() - collection.Schema.RemoveField("f3_id") - collection.Schema.AddField(&schema.SchemaField{ - Id: "f4_id", - Name: "f4_name", - Type: schema.FieldTypeText, - Options: &schema.TextOptions{ + // init plugin + migratecmd.MustRegister(app, nil, migratecmd.Config{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + app.Bootstrap() + + // update the dummy collection + collection.Name = "test123_update" + collection.ListRule = types.Pointer("@request.auth.id != ''") + collection.ViewRule = types.Pointer(`id = "1"`) // no change + collection.UpdateRule = types.Pointer(`id = "2_update"`) + collection.CreateRule = types.Pointer(`id = "nil_update"`) + collection.DeleteRule = nil + collection.Indexes = types.JSONArray[string]{ + "create index test1 on test123_update (f1_name)", + } + collection.Fields.RemoveById("f3_id") + collection.Fields.Add(&core.TextField{ + Id: "f4_id", + Name: "f4_name", Pattern: "`test backtick`123", - }, - }) - f := collection.Schema.GetFieldById("f2_id") - f.Name = "f2_name_new" - - // save the changes and trigger automigrate - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save dummy collection changes, got %v", i, err) - } - - files, err := os.ReadDir(migrationsDir) - if err != nil { - t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err) - } + }) + f := collection.Fields.GetById("f2_id") + f.SetName("f2_name_new") + collection.OAuth2.Enabled = true + collection.FileToken.Duration = 10 + // should be ignored + collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}} + testSecret := strings.Repeat("b", 30) + collection.AuthToken.Secret = testSecret + collection.FileToken.Secret = testSecret + collection.EmailChangeToken.Secret = testSecret + collection.PasswordResetToken.Secret = testSecret + collection.VerificationToken.Secret = testSecret + + // save the changes and trigger automigrate (with mock request event) + event := new(core.CollectionRequestEvent) + event.RequestEvent = &core.RequestEvent{} + event.App = app + event.Collection = collection + err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error { + return e.App.Save(e.Collection) + }) + if err != nil { + t.Fatalf("Failed to save dummy collection changes, got %v", err) + } - if total := len(files); total != 1 { - t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total) - } + files, err := os.ReadDir(migrationsDir) + if err != nil { + t.Fatalf("Expected migrationsDir to be created, got: %v", err) + } - expectedName := "_updated_test456." + s.lang - if !strings.Contains(files[0].Name(), expectedName) { - t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name()) - } + if total := len(files); total != 1 { + t.Fatalf("Expected 1 file to be generated, got %d", total) + } - fullPath := filepath.Join(migrationsDir, files[0].Name()) - content, err := os.ReadFile(fullPath) - if err != nil { - t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err) - } + expectedName := "_updated_test123." + s.lang + if !strings.Contains(files[0].Name(), expectedName) { + t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name()) + } - if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) { - t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v) - } + fullPath := filepath.Join(migrationsDir, files[0].Name()) + content, err := os.ReadFile(fullPath) + if err != nil { + t.Fatalf("Failed to read the generated migration file: %v", err) + } + contentStr := strings.TrimSpace(string(content)) + + // replace @TEST_RANDOM placeholder with a regex pattern + expectedTemplate := strings.ReplaceAll( + "^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$", + "@TEST_RANDOM", + `\w+`, + ) + if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) { + t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr) + } + }) } } func TestAutomigrateCollectionNoChanges(t *testing.T) { + t.Parallel() + scenarios := []struct { lang string }{ @@ -774,39 +1317,53 @@ func TestAutomigrateCollectionNoChanges(t *testing.T) { }, } - for i, s := range scenarios { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") + for _, s := range scenarios { + t.Run(s.lang, func(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() - migratecmd.MustRegister(app, nil, migratecmd.Config{ - TemplateLang: s.lang, - Automigrate: true, - Dir: migrationsDir, - }) + migrationsDir := filepath.Join(app.DataDir(), "_test_migrations") - // create dummy collection - collection := &models.Collection{} - collection.Name = "test123" - collection.Type = models.CollectionTypeAuth + // create dummy collection + collection := core.NewAuthCollection("test123") - // use different dao to avoid triggering automigrate while saving the dummy collection - if err := daos.New(app.DB()).SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err) - } - - // @todo remove after collections cache is replaced - app.Bootstrap() + if err := app.Save(collection); err != nil { + t.Fatalf("Failed to save dummy collection, got %v", err) + } - // resave without changes and trigger automigrate - if err := app.Dao().SaveCollection(collection); err != nil { - t.Fatalf("[%d] Failed to save dummy collection update, got %v", i, err) - } + // init plugin + migratecmd.MustRegister(app, nil, migratecmd.Config{ + TemplateLang: s.lang, + Automigrate: true, + Dir: migrationsDir, + }) + app.Bootstrap() + + // should be ignored + collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}} + testSecret := strings.Repeat("b", 30) + collection.AuthToken.Secret = testSecret + collection.FileToken.Secret = testSecret + collection.EmailChangeToken.Secret = testSecret + collection.PasswordResetToken.Secret = testSecret + collection.VerificationToken.Secret = testSecret + + // resave without other changes and trigger automigrate (with mock request event) + event := new(core.CollectionRequestEvent) + event.RequestEvent = &core.RequestEvent{} + event.App = app + event.Collection = collection + err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error { + return e.App.Save(e.Collection) + }) + if err != nil { + t.Fatalf("Failed to save dummy collection update, got %v", err) + } - files, _ := os.ReadDir(migrationsDir) - if total := len(files); total != 0 { - t.Fatalf("[%d] Expected 0 files to be generated, got %d", i, total) - } + files, _ := os.ReadDir(migrationsDir) + if total := len(files); total != 0 { + t.Fatalf("Expected 0 files to be generated, got %d", total) + } + }) } } diff --git a/plugins/migratecmd/templates.go b/plugins/migratecmd/templates.go index c21664e67c..179817aff4 100644 --- a/plugins/migratecmd/templates.go +++ b/plugins/migratecmd/templates.go @@ -2,14 +2,15 @@ package migratecmd import ( "bytes" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "fmt" "path/filepath" - "strconv" + "slices" "strings" - "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/core" ) const ( @@ -22,16 +23,16 @@ const ( jsTypesDirective = `/// ` + "\n" ) -var emptyTemplateErr = errors.New("empty template") +var ErrEmptyTemplate = errors.New("empty template") // ------------------------------------------------------------------- // JavaScript templates // ------------------------------------------------------------------- func (p *plugin) jsBlankTemplate() (string, error) { - const template = jsTypesDirective + `migrate((db) => { + const template = jsTypesDirective + `migrate((app) => { // add up queries... -}, (db) => { +}, (app) => { // add down queries... }) ` @@ -39,19 +40,30 @@ func (p *plugin) jsBlankTemplate() (string, error) { return template, nil } -func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collections, " ", " ") +func (p *plugin) jsSnapshotTemplate(collections []*core.Collection) (string, error) { + // unset timestamp fields + var collectionsData = make([]map[string]any, len(collections)) + for i, c := range collections { + data, err := toMap(c) + if err != nil { + return "", fmt.Errorf("failed to serialize %q into a map: %w", c.Name, err) + } + delete(data, "created") + delete(data, "updated") + deleteNestedMapKey(data, "oauth2", "providers") + collectionsData[i] = data + } + + jsonData, err := marhshalWithoutEscape(collectionsData, " ", " ") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %w", err) } - const template = jsTypesDirective + `migrate((db) => { + const template = jsTypesDirective + `migrate((app) => { const snapshot = %s; - const collections = snapshot.map((item) => new Collection(item)); - - return Dao(db).importCollections(collections, true, null); -}, (db) => { + return app.importCollections(snapshot, false); +}, (app) => { return null; }) ` @@ -59,49 +71,65 @@ func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, e return fmt.Sprintf(template, string(jsonData)), nil } -func (p *plugin) jsCreateTemplate(collection *models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collection, " ", " ") +func (p *plugin) jsCreateTemplate(collection *core.Collection) (string, error) { + // unset timestamp fields + collectionData, err := toMap(collection) if err != nil { - return "", fmt.Errorf("failed to serialize collections list: %w", err) + return "", err + } + delete(collectionData, "created") + delete(collectionData, "updated") + deleteNestedMapKey(collectionData, "oauth2", "providers") + + jsonData, err := marhshalWithoutEscape(collectionData, " ", " ") + if err != nil { + return "", fmt.Errorf("failed to serialize collection: %w", err) } - const template = jsTypesDirective + `migrate((db) => { + const template = jsTypesDirective + `migrate((app) => { const collection = new Collection(%s); - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId(%q); + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId(%q); - return dao.deleteCollection(collection); + return app.delete(collection); }) ` return fmt.Sprintf(template, string(jsonData), collection.Id), nil } -func (p *plugin) jsDeleteTemplate(collection *models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collection, " ", " ") +func (p *plugin) jsDeleteTemplate(collection *core.Collection) (string, error) { + // unset timestamp fields + collectionData, err := toMap(collection) + if err != nil { + return "", err + } + delete(collectionData, "created") + delete(collectionData, "updated") + deleteNestedMapKey(collectionData, "oauth2", "providers") + + jsonData, err := marhshalWithoutEscape(collectionData, " ", " ") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %w", err) } - const template = jsTypesDirective + `migrate((db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId(%q); + const template = jsTypesDirective + `migrate((app) => { + const collection = app.findCollectionByNameOrId(%q); - return dao.deleteCollection(collection); -}, (db) => { + return app.delete(collection); +}, (app) => { const collection = new Collection(%s); - return Dao(db).saveCollection(collection); + return app.save(collection); }) ` return fmt.Sprintf(template, collection.Id, string(jsonData)), nil } -func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) (string, error) { +func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (string, error) { if new == nil && old == nil { return "", errors.New("the diff template require at least one of the collection to be non-nil") } @@ -118,209 +146,144 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) downParts := []string{} varName := "collection" - if old.Name != new.Name { - upParts = append(upParts, fmt.Sprintf("%s.name = %q", varName, new.Name)) - downParts = append(downParts, fmt.Sprintf("%s.name = %q", varName, old.Name)) - } - - if old.Type != new.Type { - upParts = append(upParts, fmt.Sprintf("%s.type = %q", varName, new.Type)) - downParts = append(downParts, fmt.Sprintf("%s.type = %q", varName, old.Type)) - } - - if old.System != new.System { - upParts = append(upParts, fmt.Sprintf("%s.system = %t", varName, new.System)) - downParts = append(downParts, fmt.Sprintf("%s.system = %t", varName, old.System)) - } - - // --- - // note: strconv.Quote is used because %q converts the rule operators in unicode char codes - // --- - - formatRule := func(prop string, rule *string) string { - if rule == nil { - return fmt.Sprintf("%s.%s = null", varName, prop) - } - - return fmt.Sprintf("%s.%s = %s", varName, prop, strconv.Quote(*rule)) + newMap, err := toMap(new) + if err != nil { + return "", err } - if old.ListRule != new.ListRule { - oldRule := formatRule("listRule", old.ListRule) - newRule := formatRule("listRule", new.ListRule) - - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } + oldMap, err := toMap(old) + if err != nil { + return "", err } - if old.ViewRule != new.ViewRule { - oldRule := formatRule("viewRule", old.ViewRule) - newRule := formatRule("viewRule", new.ViewRule) - - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } - } + // non-fields + // ----------------------------------------------------------------- - if old.CreateRule != new.CreateRule { - oldRule := formatRule("createRule", old.CreateRule) - newRule := formatRule("createRule", new.CreateRule) + upDiff := diffMaps(oldMap, newMap, "fields", "created", "updated") + if len(upDiff) > 0 { + downDiff := diffMaps(newMap, oldMap, "fields", "created", "updated") - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) + rawUpDiff, err := marhshalWithoutEscape(upDiff, " ", " ") + if err != nil { + return "", err } - } - - if old.UpdateRule != new.UpdateRule { - oldRule := formatRule("updateRule", old.UpdateRule) - newRule := formatRule("updateRule", new.UpdateRule) - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) + rawDownDiff, err := marhshalWithoutEscape(downDiff, " ", " ") + if err != nil { + return "", err } - } - - if old.DeleteRule != new.DeleteRule { - oldRule := formatRule("deleteRule", old.DeleteRule) - newRule := formatRule("deleteRule", new.DeleteRule) - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } + upParts = append(upParts, "// update collection data") + upParts = append(upParts, fmt.Sprintf("unmarshal(%s, %s)", string(rawUpDiff), varName)+"\n") + // --- + downParts = append(downParts, "// update collection data") + downParts = append(downParts, fmt.Sprintf("unmarshal(%s, %s)", string(rawDownDiff), varName)+"\n") } - // Options - rawNewOptions, err := marhshalWithoutEscape(new.Options, " ", " ") - if err != nil { - return "", err - } - rawOldOptions, err := marhshalWithoutEscape(old.Options, " ", " ") - if err != nil { - return "", err - } - if !bytes.Equal(rawNewOptions, rawOldOptions) { - upParts = append(upParts, fmt.Sprintf("%s.options = %s", varName, rawNewOptions)) - downParts = append(downParts, fmt.Sprintf("%s.options = %s", varName, rawOldOptions)) - } + // fields + // ----------------------------------------------------------------- - // Indexes - rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, " ", " ") - if err != nil { - return "", err - } - rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, " ", " ") - if err != nil { - return "", err - } - if !bytes.Equal(rawNewIndexes, rawOldIndexes) { - upParts = append(upParts, fmt.Sprintf("%s.indexes = %s", varName, rawNewIndexes)) - downParts = append(downParts, fmt.Sprintf("%s.indexes = %s", varName, rawOldIndexes)) + oldFieldsSlice, ok := oldMap["fields"].([]any) + if !ok { + return "", errors.New(`oldMap["fields"] is not []any`) } - // ensure new line between regular and collection fields - if len(upParts) > 0 { - upParts[len(upParts)-1] += "\n" - } - if len(downParts) > 0 { - downParts[len(downParts)-1] += "\n" + newFieldsSlice, ok := newMap["fields"].([]any) + if !ok { + return "", errors.New(`newMap["fields"] is not []any`) } - // Schema - // ----------------------------------------------------------------- - // deleted fields - for _, oldField := range old.Schema.Fields() { - if new.Schema.GetFieldById(oldField.Id) != nil { + for i, oldField := range old.Fields { + if new.Fields.GetById(oldField.GetId()) != nil { continue // exist } - rawOldField, err := marhshalWithoutEscape(oldField, " ", " ") + rawOldField, err := marhshalWithoutEscape(oldFieldsSlice[i], " ", " ") if err != nil { return "", err } - upParts = append(upParts, "// remove") - upParts = append(upParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, oldField.Id)) + upParts = append(upParts, "// remove field") + upParts = append(upParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, oldField.GetId())) - downParts = append(downParts, "// add") - downParts = append(downParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawOldField)) + downParts = append(downParts, "// add field") + downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawOldField)) } // created fields - for _, newField := range new.Schema.Fields() { - if old.Schema.GetFieldById(newField.Id) != nil { + for i, newField := range new.Fields { + if old.Fields.GetById(newField.GetId()) != nil { continue // exist } - rawNewField, err := marhshalWithoutEscape(newField, " ", " ") + rawNewField, err := marhshalWithoutEscape(newFieldsSlice[i], " ", " ") if err != nil { return "", err } - upParts = append(upParts, "// add") - upParts = append(upParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawNewField)) + upParts = append(upParts, "// add field") + upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField)) - downParts = append(downParts, "// remove") - downParts = append(downParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, newField.Id)) + downParts = append(downParts, "// remove field") + downParts = append(downParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, newField.GetId())) } // modified fields - for _, newField := range new.Schema.Fields() { - oldField := old.Schema.GetFieldById(newField.Id) - if oldField == nil { - continue - } + // (note currently ignoring order-only changes as it comes with too many edge-cases) + for i, newField := range new.Fields { + var rawNewField, rawOldField []byte - rawNewField, err := marhshalWithoutEscape(newField, " ", " ") + rawNewField, err = marhshalWithoutEscape(newFieldsSlice[i], " ", " ") if err != nil { return "", err } - rawOldField, err := marhshalWithoutEscape(oldField, " ", " ") - if err != nil { - return "", err + var oldFieldIndex int + + for j, oldField := range old.Fields { + if oldField.GetId() == newField.GetId() { + rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], " ", " ") + if err != nil { + return "", err + } + oldFieldIndex = j + break + } } - if bytes.Equal(rawNewField, rawOldField) { - continue // no change + if rawOldField == nil || bytes.Equal(rawNewField, rawOldField) { + continue // new field or no change } - upParts = append(upParts, "// update") - upParts = append(upParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawNewField)) + upParts = append(upParts, "// update field") + upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField)) - downParts = append(downParts, "// update") - downParts = append(downParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawOldField)) + downParts = append(downParts, "// update field") + downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, oldFieldIndex, rawOldField)) } // ----------------------------------------------------------------- if len(upParts) == 0 && len(downParts) == 0 { - return "", emptyTemplateErr + return "", ErrEmptyTemplate } up := strings.Join(upParts, "\n ") down := strings.Join(downParts, "\n ") - const template = jsTypesDirective + `migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId(%q) + const template = jsTypesDirective + `migrate((app) => { + const collection = app.findCollectionByNameOrId(%q) %s - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId(%q) + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId(%q) %s - return dao.saveCollection(collection) + return app.save(collection) }) ` @@ -339,16 +302,16 @@ func (p *plugin) goBlankTemplate() (string, error) { const template = `package %s import ( - "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" ) func init() { - m.Register(func(db dbx.Builder) error { + m.Register(func(app core.App) error { // add up queries... return nil - }, func(db dbx.Builder) error { + }, func(app core.App) error { // add down queries... return nil @@ -359,8 +322,21 @@ func init() { return fmt.Sprintf(template, filepath.Base(p.config.Dir)), nil } -func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collections, "\t\t", "\t") +func (p *plugin) goSnapshotTemplate(collections []*core.Collection) (string, error) { + // unset timestamp fields + var collectionsData = make([]map[string]any, len(collections)) + for i, c := range collections { + data, err := toMap(c) + if err != nil { + return "", fmt.Errorf("failed to serialize %q into a map: %w", c.Name, err) + } + delete(data, "created") + delete(data, "updated") + deleteNestedMapKey(data, "oauth2", "providers") + collectionsData[i] = data + } + + jsonData, err := marhshalWithoutEscape(collectionsData, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %w", err) } @@ -368,25 +344,16 @@ func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, e const template = `package %s import ( - "encoding/json" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" ) func init() { - m.Register(func(db dbx.Builder) error { + m.Register(func(app core.App) error { jsonData := ` + "`%s`" + ` - collections := []*models.Collection{} - if err := json.Unmarshal([]byte(jsonData), &collections); err != nil { - return err - } - - return daos.New(db).ImportCollections(collections, true, nil) - }, func(db dbx.Builder) error { + return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false) + }, func(app core.App) error { return nil }) } @@ -398,8 +365,17 @@ func init() { ), nil } -func (p *plugin) goCreateTemplate(collection *models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t") +func (p *plugin) goCreateTemplate(collection *core.Collection) (string, error) { + // unset timestamp fields + collectionData, err := toMap(collection) + if err != nil { + return "", err + } + delete(collectionData, "created") + delete(collectionData, "updated") + deleteNestedMapKey(collectionData, "oauth2", "providers") + + jsonData, err := marhshalWithoutEscape(collectionData, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %w", err) } @@ -407,33 +383,29 @@ func (p *plugin) goCreateTemplate(collection *models.Collection) (string, error) const template = `package %s import ( - "encoding/json" + "encoding/json/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" ) func init() { - m.Register(func(db dbx.Builder) error { + m.Register(func(app core.App) error { jsonData := ` + "`%s`" + ` - collection := &models.Collection{} + collection := &core.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } - return daos.New(db).SaveCollection(collection) - }, func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId(%q) + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId(%q) if err != nil { return err } - return dao.DeleteCollection(collection) + return app.Delete(collection) }) } ` @@ -446,8 +418,17 @@ func init() { ), nil } -func (p *plugin) goDeleteTemplate(collection *models.Collection) (string, error) { - jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t") +func (p *plugin) goDeleteTemplate(collection *core.Collection) (string, error) { + // unset timestamp fields + collectionData, err := toMap(collection) + if err != nil { + return "", err + } + delete(collectionData, "created") + delete(collectionData, "updated") + deleteNestedMapKey(collectionData, "oauth2", "providers") + + jsonData, err := marhshalWithoutEscape(collectionData, "\t\t", "\t") if err != nil { return "", fmt.Errorf("failed to serialize collections list: %w", err) } @@ -455,33 +436,29 @@ func (p *plugin) goDeleteTemplate(collection *models.Collection) (string, error) const template = `package %s import ( - "encoding/json" + "encoding/json/v2" - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - "github.com/pocketbase/pocketbase/models" ) func init() { - m.Register(func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId(%q) + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId(%q) if err != nil { return err } - return dao.DeleteCollection(collection) - }, func(db dbx.Builder) error { + return app.Delete(collection) + }, func(app core.App) error { jsonData := ` + "`%s`" + ` - collection := &models.Collection{} + collection := &core.Collection{} if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { return err } - return daos.New(db).SaveCollection(collection) + return app.Save(collection) }) } ` @@ -494,7 +471,7 @@ func init() { ), nil } -func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) (string, error) { +func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (string, error) { if new == nil && old == nil { return "", errors.New("the diff template require at least one of the collection to be non-nil") } @@ -510,200 +487,128 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) upParts := []string{} downParts := []string{} varName := "collection" - if old.Name != new.Name { - upParts = append(upParts, fmt.Sprintf("%s.Name = %q\n", varName, new.Name)) - downParts = append(downParts, fmt.Sprintf("%s.Name = %q\n", varName, old.Name)) - } - - if old.Type != new.Type { - upParts = append(upParts, fmt.Sprintf("%s.Type = %q\n", varName, new.Type)) - downParts = append(downParts, fmt.Sprintf("%s.Type = %q\n", varName, old.Type)) - } - if old.System != new.System { - upParts = append(upParts, fmt.Sprintf("%s.System = %t\n", varName, new.System)) - downParts = append(downParts, fmt.Sprintf("%s.System = %t\n", varName, old.System)) + newMap, err := toMap(new) + if err != nil { + return "", err } - // --- - // note: strconv.Quote is used because %q converts the rule operators in unicode char codes - // --- - - formatRule := func(prop string, rule *string) string { - if rule == nil { - return fmt.Sprintf("%s.%s = nil\n", varName, prop) - } - - return fmt.Sprintf("%s.%s = types.Pointer(%s)\n", varName, prop, strconv.Quote(*rule)) + oldMap, err := toMap(old) + if err != nil { + return "", err } - if old.ListRule != new.ListRule { - oldRule := formatRule("ListRule", old.ListRule) - newRule := formatRule("ListRule", new.ListRule) - - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } - } + // non-fields + // ----------------------------------------------------------------- - if old.ViewRule != new.ViewRule { - oldRule := formatRule("ViewRule", old.ViewRule) - newRule := formatRule("ViewRule", new.ViewRule) + upDiff := diffMaps(oldMap, newMap, "fields", "created", "updated") + if len(upDiff) > 0 { + downDiff := diffMaps(newMap, oldMap, "fields", "created", "updated") - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) + rawUpDiff, err := marhshalWithoutEscape(upDiff, "\t\t", "\t") + if err != nil { + return "", err } - } - - if old.CreateRule != new.CreateRule { - oldRule := formatRule("CreateRule", old.CreateRule) - newRule := formatRule("CreateRule", new.CreateRule) - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) + rawDownDiff, err := marhshalWithoutEscape(downDiff, "\t\t", "\t") + if err != nil { + return "", err } - } - if old.UpdateRule != new.UpdateRule { - oldRule := formatRule("UpdateRule", old.UpdateRule) - newRule := formatRule("UpdateRule", new.UpdateRule) - - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } + upParts = append(upParts, "// update collection data") + upParts = append(upParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s)", escapeBacktick(string(rawUpDiff)), varName))) + // --- + downParts = append(downParts, "// update collection data") + downParts = append(downParts, goErrIf(fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s)", escapeBacktick(string(rawDownDiff)), varName))) } - if old.DeleteRule != new.DeleteRule { - oldRule := formatRule("DeleteRule", old.DeleteRule) - newRule := formatRule("DeleteRule", new.DeleteRule) - - if oldRule != newRule { - upParts = append(upParts, newRule) - downParts = append(downParts, oldRule) - } - } + // fields + // ----------------------------------------------------------------- - // Options - rawNewOptions, err := marhshalWithoutEscape(new.Options, "\t\t", "\t") - if err != nil { - return "", err - } - rawOldOptions, err := marhshalWithoutEscape(old.Options, "\t\t", "\t") - if err != nil { - return "", err - } - if !bytes.Equal(rawNewOptions, rawOldOptions) { - upParts = append(upParts, "options := map[string]any{}") - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawNewOptions)))) - upParts = append(upParts, fmt.Sprintf("%s.SetOptions(options)\n", varName)) - // --- - downParts = append(downParts, "options := map[string]any{}") - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawOldOptions)))) - downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName)) + oldFieldsSlice, ok := oldMap["fields"].([]any) + if !ok { + return "", errors.New(`oldMap["fields"] is not []any`) } - // Indexes - rawNewIndexes, err := marhshalWithoutEscape(new.Indexes, "\t\t", "\t") - if err != nil { - return "", err - } - rawOldIndexes, err := marhshalWithoutEscape(old.Indexes, "\t\t", "\t") - if err != nil { - return "", err - } - if !bytes.Equal(rawNewIndexes, rawOldIndexes) { - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawNewIndexes)), varName)) - // --- - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &%s.Indexes)\n", escapeBacktick(string(rawOldIndexes)), varName)) + newFieldsSlice, ok := newMap["fields"].([]any) + if !ok { + return "", errors.New(`newMap["fields"] is not []any`) } - // Schema - // --------------------------------------------------------------- // deleted fields - for _, oldField := range old.Schema.Fields() { - if new.Schema.GetFieldById(oldField.Id) != nil { + for i, oldField := range old.Fields { + if new.Fields.GetById(oldField.GetId()) != nil { continue // exist } - rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t") + rawOldField, err := marhshalWithoutEscape(oldFieldsSlice[i], "\t\t", "\t") if err != nil { return "", err } - fieldVar := fmt.Sprintf("del_%s", oldField.Name) + upParts = append(upParts, "// remove field") + upParts = append(upParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, oldField.GetId())) - upParts = append(upParts, "// remove") - upParts = append(upParts, fmt.Sprintf("%s.Schema.RemoveField(%q)\n", varName, oldField.Id)) - - downParts = append(downParts, "// add") - downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)) - downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) + downParts = append(downParts, "// add field") + downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawOldField))))) } // created fields - for _, newField := range new.Schema.Fields() { - if old.Schema.GetFieldById(newField.Id) != nil { + for i, newField := range new.Fields { + if old.Fields.GetById(newField.GetId()) != nil { continue // exist } - rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t") + rawNewField, err := marhshalWithoutEscape(newFieldsSlice[i], "\t\t", "\t") if err != nil { return "", err } - fieldVar := fmt.Sprintf("new_%s", newField.Name) - - upParts = append(upParts, "// add") - upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)) - upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) + upParts = append(upParts, "// add field") + upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField))))) - downParts = append(downParts, "// remove") - downParts = append(downParts, fmt.Sprintf("%s.Schema.RemoveField(%q)\n", varName, newField.Id)) + downParts = append(downParts, "// remove field") + downParts = append(downParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, newField.GetId())) } // modified fields - for _, newField := range new.Schema.Fields() { - oldField := old.Schema.GetFieldById(newField.Id) - if oldField == nil { - continue - } + // (note currently ignoring order-only changes as it comes with too many edge-cases) + for i, newField := range new.Fields { + var rawNewField, rawOldField []byte - rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t") + rawNewField, err = marhshalWithoutEscape(newFieldsSlice[i], "\t\t", "\t") if err != nil { return "", err } - rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t") - if err != nil { - return "", err - } + var oldFieldIndex int - if bytes.Equal(rawNewField, rawOldField) { - continue // no change + for j, oldField := range old.Fields { + if oldField.GetId() == newField.GetId() { + rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], "\t\t", "\t") + if err != nil { + return "", err + } + oldFieldIndex = j + break + } } - fieldVar := fmt.Sprintf("edit_%s", newField.Name) + if rawOldField == nil || bytes.Equal(rawNewField, rawOldField) { + continue // new field or no change + } - upParts = append(upParts, "// update") - upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar)) - upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) + upParts = append(upParts, "// update field") + upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField))))) - downParts = append(downParts, "// update") - downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar)) - downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar)) - downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar)) + downParts = append(downParts, "// update field") + downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, oldFieldIndex, escapeBacktick(string(rawOldField))))) } + // --------------------------------------------------------------- if len(upParts) == 0 && len(downParts) == 0 { - return "", emptyTemplateErr + return "", ErrEmptyTemplate } up := strings.Join(upParts, "\n\t\t") @@ -716,20 +621,11 @@ func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) if strings.Contains(combined, "json.Unmarshal(") || strings.Contains(combined, "json.Marshal(") { - imports += "\n\t\"encoding/json\"\n" + imports += "\n\t\"encoding/json/v2\"\n" } - imports += "\n\t\"github.com/pocketbase/dbx\"" - imports += "\n\t\"github.com/pocketbase/pocketbase/daos\"" + imports += "\n\t\"github.com/pocketbase/pocketbase/core\"" imports += "\n\tm \"github.com/pocketbase/pocketbase/migrations\"" - - if strings.Contains(combined, "schema.SchemaField{") { - imports += "\n\t\"github.com/pocketbase/pocketbase/models/schema\"" - } - - if strings.Contains(combined, "types.Pointer(") { - imports += "\n\t\"github.com/pocketbase/pocketbase/tools/types\"" - } // --- const template = `package %s @@ -738,28 +634,24 @@ import (%s ) func init() { - m.Register(func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId(%q) + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId(%q) if err != nil { return err } %s - return dao.SaveCollection(collection) - }, func(db dbx.Builder) error { - dao := daos.New(db); - - collection, err := dao.FindCollectionByNameOrId(%q) + return app.Save(collection) + }, func(app core.App) error { + collection, err := app.FindCollectionByNameOrId(%q) if err != nil { return err } %s - return dao.SaveCollection(collection) + return app.Save(collection) }) } ` @@ -774,20 +666,97 @@ func init() { } func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) { - raw, err := json.MarshalIndent(v, prefix, indent) + return json.Marshal(v, + jsontext.WithIndentPrefix(prefix), + jsontext.WithIndent(indent), + json.Deterministic(true), + ) +} + +func escapeBacktick(v string) string { + return strings.ReplaceAll(v, "`", "` + \"`\" + `") +} + +func goErrIf(v string) string { + return "if err := " + v + "; err != nil {\n\t\t\treturn err\n\t\t}\n" +} + +func toMap(v any) (map[string]any, error) { + raw, err := json.Marshal(v) if err != nil { return nil, err } - // unescape escaped unicode characters - unescaped, err := strconv.Unquote(strings.ReplaceAll(strconv.Quote(string(raw)), `\\u`, `\u`)) + result := map[string]any{} + + err = json.Unmarshal(raw, &result) if err != nil { return nil, err } - return []byte(unescaped), nil + return result, nil } -func escapeBacktick(v string) string { - return strings.ReplaceAll(v, "`", "` + \"`\" + `") +func diffMaps(old, new map[string]any, excludeKeys ...string) map[string]any { + diff := map[string]any{} + + for k, vNew := range new { + if slices.Contains(excludeKeys, k) { + continue + } + + vOld, ok := old[k] + if !ok { + // new field + diff[k] = vNew + continue + } + + // compare the serialized version of the values in case of slice or other custom type + rawOld, _ := json.Marshal(vOld, json.Deterministic(true)) + rawNew, _ := json.Marshal(vNew, json.Deterministic(true)) + + if !bytes.Equal(rawOld, rawNew) { + // if both are maps add recursively only the changed fields + vOldMap, ok1 := vOld.(map[string]any) + vNewMap, ok2 := vNew.(map[string]any) + if ok1 && ok2 { + subDiff := diffMaps(vOldMap, vNewMap) + if len(subDiff) > 0 { + diff[k] = subDiff + } + } else { + diff[k] = vNew + } + } + } + + // unset missing fields + for k := range old { + if _, ok := diff[k]; ok || slices.Contains(excludeKeys, k) { + continue // already added + } + + if _, ok := new[k]; !ok { + diff[k] = nil + } + } + + return diff +} + +func deleteNestedMapKey(data map[string]any, parts ...string) { + if len(parts) == 0 { + return + } + + if len(parts) == 1 { + delete(data, parts[0]) + return + } + + v, ok := data[parts[0]].(map[string]any) + if ok { + deleteNestedMapKey(v, parts[1:]...) + } } diff --git a/pocketbase.go b/pocketbase.go index dcfae07cd6..dbf8fcc807 100644 --- a/pocketbase.go +++ b/pocketbase.go @@ -1,17 +1,24 @@ package pocketbase import ( + "errors" + "io" "os" "os/signal" "path/filepath" "strings" "syscall" + "time" - "github.com/fatih/color" "github.com/pocketbase/pocketbase/cmd" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/osutils" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/spf13/cobra" + + _ "github.com/pocketbase/pocketbase/migrations" ) var _ core.App = (*PocketBase)(nil) @@ -19,21 +26,17 @@ var _ core.App = (*PocketBase)(nil) // Version of PocketBase var Version = "(untracked)" -// appWrapper serves as a private core.App instance wrapper. -type appWrapper struct { - core.App -} - // 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 PocketBase struct { - *appWrapper + core.App - debugFlag bool + devFlag bool dataDirFlag string encryptionEnvFlag string + queryTimeout int hideStartBanner bool // RootCmd is the main console command @@ -42,34 +45,36 @@ type PocketBase struct { // Config is the PocketBase initialization config struct. type Config struct { + // hide the default console server info on app startup + HideStartBanner bool + // optional default values for the console flags - DefaultDebug bool + DefaultDev bool DefaultDataDir string // if not set, it will fallback to "./pb_data" DefaultEncryptionEnv string - - // hide the default console server info on app startup - HideStartBanner bool + DefaultQueryTimeout time.Duration // default to core.DefaultQueryTimeout (in seconds) // optional DB configurations - DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns - DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns - LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns - LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns + DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns + DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns + AuxMaxOpenConns int // default to core.DefaultAuxMaxOpenConns + AuxMaxIdleConns int // default to core.DefaultAuxMaxIdleConns + DBConnect core.DBConnectFunc // default to core.dbConnect } // New creates a new PocketBase instance with the default configuration. -// Use [NewWithConfig()] if you want to provide a custom configuration. +// Use [NewWithConfig] if you want to provide a custom configuration. // // 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()]. +// Everything will be initialized when [PocketBase.Start] is executed. +// If you want to initialize the application before calling [PocketBase.Start], +// then you'll have to manually call [PocketBase.Bootstrap]. func New() *PocketBase { _, isUsingGoRun := inspectRuntime() return NewWithConfig(Config{ - DefaultDebug: isUsingGoRun, + DefaultDev: isUsingGoRun, }) } @@ -77,9 +82,9 @@ func New() *PocketBase { // // 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()]. +// Everything will be initialized when [PocketBase.Start] is executed. +// If you want to initialize the application before calling [PocketBase.Start], +// then you'll have to manually call [PocketBase.Bootstrap]. func NewWithConfig(config Config) *PocketBase { // initialize a default data directory based on the executable baseDir if config.DefaultDataDir == "" { @@ -87,10 +92,16 @@ func NewWithConfig(config Config) *PocketBase { config.DefaultDataDir = filepath.Join(baseDir, "pb_data") } + if config.DefaultQueryTimeout == 0 { + config.DefaultQueryTimeout = core.DefaultQueryTimeout + } + + executableName := filepath.Base(os.Args[0]) + pb := &PocketBase{ RootCmd: &cobra.Command{ - Use: filepath.Base(os.Args[0]), - Short: "PocketBase CLI", + Use: executableName, + Short: executableName + " CLI", Version: Version, FParseErrWhitelist: cobra.FParseErrWhitelist{ UnknownFlags: true, @@ -100,38 +111,62 @@ func NewWithConfig(config Config) *PocketBase { DisableDefaultCmd: true, }, }, - debugFlag: config.DefaultDebug, + devFlag: config.DefaultDev, dataDirFlag: config.DefaultDataDir, encryptionEnvFlag: config.DefaultEncryptionEnv, hideStartBanner: config.HideStartBanner, } + // don't write command errors to the stderr because the error is + // propagated back to the app.Start() and could result in duplication + pb.RootCmd.SetErr(&nopWrite{}) + // parse base flags // (errors are ignored, since the full flags parsing happens on Execute()) pb.eagerParseFlags(&config) // initialize the app instance - pb.appWrapper = &appWrapper{core.NewBaseApp(core.BaseAppConfig{ + pb.App = core.NewBaseApp(core.BaseAppConfig{ + IsDev: pb.devFlag, DataDir: pb.dataDirFlag, EncryptionEnv: pb.encryptionEnvFlag, - IsDebug: pb.debugFlag, + QueryTimeout: time.Duration(pb.queryTimeout) * time.Second, DataMaxOpenConns: config.DataMaxOpenConns, DataMaxIdleConns: config.DataMaxIdleConns, - LogsMaxOpenConns: config.LogsMaxOpenConns, - LogsMaxIdleConns: config.LogsMaxIdleConns, - })} + AuxMaxOpenConns: config.AuxMaxOpenConns, + AuxMaxIdleConns: config.AuxMaxIdleConns, + DBConnect: config.DBConnect, + }) // hide the default help command (allow only `--help` flag) pb.RootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + // https://github.com/pocketbase/pocketbase/issues/6136 + pb.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{ + Id: ModerncDepsCheckHookId, + Func: func(be *core.BootstrapEvent) error { + if err := be.Next(); err != nil { + return err + } + + // run separately to avoid blocking + app := be.App + routine.FireAndForget(func() { + checkModerncDeps(app) + }) + + return nil + }, + }) + return pb } // Start starts the application, aka. registers the default system -// commands (serve, migrate, version) and executes pb.RootCmd. +// commands (serve, superuser, version) and executes pb.RootCmd. func (pb *PocketBase) Start() error { // register system commands - pb.RootCmd.AddCommand(cmd.NewAdminCommand(pb)) + pb.RootCmd.AddCommand(cmd.NewSuperuserCommand(pb)) pb.RootCmd.AddCommand(cmd.NewServeCommand(pb, !pb.hideStartBanner)) return pb.Execute() @@ -149,33 +184,33 @@ func (pb *PocketBase) Execute() error { } } - done := make(chan bool, 1) + execCh := make(chan error, 1) + sigCh := make(chan os.Signal, 1) // listen for interrupt signal to gracefully shutdown the application - go func() { - sigch := make(chan os.Signal, 1) - signal.Notify(sigch, os.Interrupt, syscall.SIGTERM) - <-sigch - done <- true - }() + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) // execute the root command go func() { - if err := pb.RootCmd.Execute(); err != nil { - // @todo replace with db log once generalized logs are added - // and maybe consider reorganizing the code to return os.Exit(1) - // (note may need to update the existing commands to not silence errors) - color.Red(err.Error()) - } - - done <- true + execCh <- routine.SafeWrap(pb.RootCmd.Execute)() }() - <-done + // wait for either an OS signal or the command to complete + var execErr error + select { + case <-sigCh: + case execErr = <-execCh: + } + + signal.Stop(sigCh) - // trigger app cleanups - return pb.OnTerminate().Trigger(&core.TerminateEvent{ - App: pb, + // trigger cleanups + // + // @todo consider skipping and just call the finalizer in case OnTerminate was already invoked manually? + event := new(core.TerminateEvent) + event.App = pb + return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error { + return errors.Join(e.App.ClearBootstrap(), execErr) }) } @@ -197,10 +232,17 @@ func (pb *PocketBase) eagerParseFlags(config *Config) error { ) pb.RootCmd.PersistentFlags().BoolVar( - &pb.debugFlag, - "debug", - config.DefaultDebug, - "enable debug mode, aka. showing more detailed logs", + &pb.devFlag, + "dev", + config.DefaultDev, + "enable dev mode, aka. printing logs and sql statements to the console", + ) + + pb.RootCmd.PersistentFlags().IntVar( + &pb.queryTimeout, + "queryTimeout", + int(config.DefaultQueryTimeout.Seconds()), + "the default SELECT queries timeout in seconds", ) return pb.RootCmd.ParseFlags(os.Args[1:]) @@ -250,8 +292,11 @@ func (pb *PocketBase) skipBootstrap() bool { } // inspectRuntime tries to find the base executable directory and how it was run. +// +// note: we are using os.Args[0] and not os.Executable() since it could +// break existing aliased binaries (eg. the community maintained homebrew package) func inspectRuntime() (baseDir string, withGoRun bool) { - if strings.HasPrefix(os.Args[0], os.TempDir()) { + if osutils.IsProbablyGoRun() { // probably ran with go run withGoRun = true baseDir, _ = os.Getwd() @@ -262,3 +307,11 @@ func inspectRuntime() (baseDir string, withGoRun bool) { } return } + +var _ io.Writer = (*nopWrite)(nil) + +type nopWrite struct{} + +func (w *nopWrite) Write(p []byte) (n int, err error) { + return +} diff --git a/pocketbase_test.go b/pocketbase_test.go index f24772ae3c..101883e1e0 100644 --- a/pocketbase_test.go +++ b/pocketbase_test.go @@ -36,8 +36,8 @@ func TestNew(t *testing.T) { t.Fatal("Expected RootCmd to be initialized, got nil") } - if app.appWrapper == nil { - t.Fatal("Expected appWrapper to be initialized, got nil") + if app.App == nil { + t.Fatal("Expected App to be initialized, got nil") } if app.DataDir() != "test_dir" { @@ -45,17 +45,12 @@ func TestNew(t *testing.T) { } if app.EncryptionEnv() != "test_encryption_env" { - t.Fatalf("Expected app.DataDir() test_encryption_env, got %q", app.EncryptionEnv()) - } - - if app.IsDebug() != true { - t.Fatal("Expected app.IsDebug() true, got false") + t.Fatalf("Expected app.EncryptionEnv() test_encryption_env, got %q", app.EncryptionEnv()) } } func TestNewWithConfig(t *testing.T) { app := NewWithConfig(Config{ - DefaultDebug: true, DefaultDataDir: "test_dir", DefaultEncryptionEnv: "test_encryption_env", HideStartBanner: true, @@ -69,8 +64,8 @@ func TestNewWithConfig(t *testing.T) { t.Fatal("Expected RootCmd to be initialized, got nil") } - if app.appWrapper == nil { - t.Fatal("Expected appWrapper to be initialized, got nil") + if app.App == nil { + t.Fatal("Expected App to be initialized, got nil") } if app.hideStartBanner != true { @@ -82,11 +77,7 @@ func TestNewWithConfig(t *testing.T) { } if app.EncryptionEnv() != "test_encryption_env" { - t.Fatalf("Expected app.DataDir() %q, got %q", "test_encryption_env", app.EncryptionEnv()) - } - - if app.IsDebug() != true { - t.Fatal("Expected app.IsDebug() true, got false") + t.Fatalf("Expected app.EncryptionEnv() %q, got %q", "test_encryption_env", app.EncryptionEnv()) } } @@ -109,7 +100,6 @@ func TestNewWithConfigAndFlags(t *testing.T) { ) app := NewWithConfig(Config{ - DefaultDebug: true, DefaultDataDir: "test_dir", DefaultEncryptionEnv: "test_encryption_env", HideStartBanner: true, @@ -123,8 +113,8 @@ func TestNewWithConfigAndFlags(t *testing.T) { t.Fatal("Expected RootCmd to be initialized, got nil") } - if app.appWrapper == nil { - t.Fatal("Expected appWrapper to be initialized, got nil") + if app.App == nil { + t.Fatal("Expected App to be initialized, got nil") } if app.hideStartBanner != true { @@ -136,11 +126,7 @@ func TestNewWithConfigAndFlags(t *testing.T) { } if app.EncryptionEnv() != "test_encryption_env_flag" { - t.Fatalf("Expected app.DataDir() %q, got %q", "test_encryption_env_flag", app.EncryptionEnv()) - } - - if app.IsDebug() != false { - t.Fatal("Expected app.IsDebug() false, got true") + t.Fatalf("Expected app.EncryptionEnv() %q, got %q", "test_encryption_env_flag", app.EncryptionEnv()) } } diff --git a/resolvers/multi_match_subquery.go b/resolvers/multi_match_subquery.go deleted file mode 100644 index 5169903ae6..0000000000 --- a/resolvers/multi_match_subquery.go +++ /dev/null @@ -1,70 +0,0 @@ -package resolvers - -import ( - "fmt" - "strings" - - "github.com/pocketbase/dbx" -) - -var _ dbx.Expression = (*multiMatchSubquery)(nil) - -// join defines the specification for a single SQL JOIN clause. -type join struct { - tableName string - tableAlias string - on dbx.Expression -} - -// multiMatchSubquery defines a record multi-match subquery expression. -type multiMatchSubquery struct { - baseTableAlias string - fromTableName string - fromTableAlias string - valueIdentifier string - joins []*join - params dbx.Params -} - -// Build converts the expression into a SQL fragment. -// -// Implements [dbx.Expression] interface. -func (m *multiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string { - if m.baseTableAlias == "" || m.fromTableName == "" || m.fromTableAlias == "" { - return "0=1" - } - - if params == nil { - params = m.params - } else { - // merge by updating the parent params - for k, v := range m.params { - params[k] = v - } - } - - var mergedJoins strings.Builder - for i, j := range m.joins { - if i > 0 { - mergedJoins.WriteString(" ") - } - mergedJoins.WriteString("LEFT JOIN ") - mergedJoins.WriteString(db.QuoteTableName(j.tableName)) - mergedJoins.WriteString(" ") - mergedJoins.WriteString(db.QuoteTableName(j.tableAlias)) - if j.on != nil { - mergedJoins.WriteString(" ON ") - mergedJoins.WriteString(j.on.Build(db, params)) - } - } - - return fmt.Sprintf( - `SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`, - db.QuoteColumnName(m.valueIdentifier), - db.QuoteTableName(m.fromTableName), - db.QuoteTableName(m.fromTableAlias), - mergedJoins.String(), - db.QuoteColumnName(m.fromTableAlias+".id"), - db.QuoteColumnName(m.baseTableAlias+".id"), - ) -} diff --git a/resolvers/record_field_resolve_runner.go b/resolvers/record_field_resolve_runner.go deleted file mode 100644 index d3bede86db..0000000000 --- a/resolvers/record_field_resolve_runner.go +++ /dev/null @@ -1,610 +0,0 @@ -package resolvers - -import ( - "encoding/json" - "fmt" - "strconv" - "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/search" - "github.com/pocketbase/pocketbase/tools/security" -) - -// 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 - allowHiddenFields bool // indicates whether hidden fields (eg. email) should be allowed without extra conditions - nullifyMisingField bool // indicating whether to return null on missing field or return an error - withMultiMatch bool // indicates whether to attach a multiMatchSubquery condition to the ResolverResult - multiMatchActiveTableAlias string // the last used multi-match table alias - multiMatch *multiMatchSubquery // the multi-match subquery expression generated from the fieldName -} - -func (r *runner) run() (*search.ResolverResult, error) { - if r.used { - return nil, fmt.Errorf("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" { - if r.resolver.requestInfo == nil { - return &search.ResolverResult{Identifier: "NULL"}, nil - } - - if strings.HasPrefix(r.fieldName, "@request.auth.") { - return r.processRequestAuthField() - } - - if strings.HasPrefix(r.fieldName, "@request.data.") && len(r.activeProps) > 2 { - name, modifier, err := splitModifier(r.activeProps[2]) - if err != nil { - return nil, err - } - - dataField := r.resolver.baseCollection.Schema.GetFieldByName(name) - if dataField == nil { - return r.resolver.resolveStaticRequestField(r.activeProps[1:]...) - } - - dataField.InitOptions() - - // check for data relation field - if dataField.Type == schema.FieldTypeRelation && len(r.activeProps) > 3 { - return r.processRequestInfoRelationField(dataField) - } - - // check for select:each field - if modifier == eachModifier && dataField.Type == schema.FieldTypeSelect && len(r.activeProps) == 3 { - return r.processRequestInfoSelectEachModifier(dataField) - } - - // check for data arrayble fields ":length" modifier - if modifier == lengthModifier && list.ExistInSlice(dataField.Type, schema.ArraybleFieldTypes()) && len(r.activeProps) == 3 { - return r.processRequestInfoLengthModifier(dataField) - } - } - - // 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 - r.activeTableAlias = inflector.Columnify(r.activeCollectionName) - - r.allowHiddenFields = r.resolver.allowHiddenFields - // always allow hidden fields since the @.* filter is a system one - if r.activeProps[0] == "@collection" || r.activeProps[0] == "@request" { - r.allowHiddenFields = true - } - - // 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 = &multiMatchSubquery{ - baseTableAlias: 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) - } - - collection, err := r.resolver.loadCollection(r.activeProps[1]) - 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 - r.activeTableAlias = inflector.Columnify("__collection_" + r.activeCollectionName) - - r.withMultiMatch = true - - // join the collection to the main query - r.resolver.registerJoin(inflector.Columnify(collection.Name), r.activeTableAlias, nil) - - // join the collection to the multi-match subquery - r.multiMatchActiveTableAlias = "__mm" + r.activeTableAlias - r.multiMatch.joins = append(r.multiMatch.joins, &join{ - tableName: inflector.Columnify(collection.Name), - tableAlias: r.multiMatchActiveTableAlias, - }) - - // 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) { - // plain auth field - // --- - if list.ExistInSlice(r.fieldName, plainRequestAuthFields) { - return r.resolver.resolveStaticRequestField(r.activeProps[1:]...) - } - - // resolve the auth collection field - // --- - if r.resolver.requestInfo == nil || r.resolver.requestInfo.AuthRecord == nil || r.resolver.requestInfo.AuthRecord.Collection() == nil { - return &search.ResolverResult{Identifier: "NULL"}, nil - } - - collection := r.resolver.requestInfo.AuthRecord.Collection() - r.resolver.loadedCollections = append(r.resolver.loadedCollections, collection) - - r.activeCollectionName = collection.Name - r.activeTableAlias = "__auth_" + inflector.Columnify(r.activeCollectionName) - - // join the auth collection to the main query - r.resolver.registerJoin( - inflector.Columnify(r.activeCollectionName), - r.activeTableAlias, - dbx.HashExp{ - // aka. __auth_users.id = :userId - (r.activeTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id, - }, - ) - - // join the auth collection to the multi-match subquery - r.multiMatchActiveTableAlias = "__mm_" + r.activeTableAlias - r.multiMatch.joins = append( - r.multiMatch.joins, - &join{ - tableName: inflector.Columnify(r.activeCollectionName), - tableAlias: r.multiMatchActiveTableAlias, - on: dbx.HashExp{ - (r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id, - }, - }, - ) - - // leave only the auth relation fields - // aka. @request.auth.fieldA.fieldB -> fieldA.fieldB - r.activeProps = r.activeProps[2:] - - return r.processActiveProps() -} - -func (r *runner) processRequestInfoLengthModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) { - dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name]) - - result := &search.ResolverResult{ - Identifier: fmt.Sprintf("%d", len(dataItems)), - } - - return result, nil -} - -func (r *runner) processRequestInfoSelectEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) { - options, ok := dataField.Options.(*schema.SelectOptions) - if !ok { - return nil, fmt.Errorf("failed to initialize field %q options", dataField.Name) - } - - dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name]) - rawJson, err := json.Marshal(dataItems) - if err != nil { - return nil, fmt.Errorf("cannot marshalize the data select item for field %q", r.activeProps[2]) - } - - placeholder := "dataSelect" + security.PseudorandomString(4) - cleanFieldName := inflector.Columnify(dataField.Name) - jeTable := fmt.Sprintf("json_each({:%s})", placeholder) - jeAlias := "__dataSelect_" + cleanFieldName + "_je" - r.resolver.registerJoin(jeTable, jeAlias, nil) - - result := &search.ResolverResult{ - Identifier: fmt.Sprintf("[[%s.value]]", jeAlias), - Params: dbx.Params{placeholder: rawJson}, - } - - if options.MaxSelect != 1 { - 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, &join{ - tableName: jeTable2, - tableAlias: jeAlias2, - }) - r.multiMatch.params[placeholder2] = rawJson - r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2) - - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil -} - -func (r *runner) processRequestInfoRelationField(dataField *schema.SchemaField) (*search.ResolverResult, error) { - options, ok := dataField.Options.(*schema.RelationOptions) - if !ok { - return nil, fmt.Errorf("failed to initialize data field %q options", dataField.Name) - } - - dataRelCollection, err := r.resolver.loadCollection(options.CollectionId) - if err != nil { - return nil, fmt.Errorf("failed to load collection %q from data field %q", options.CollectionId, dataField.Name) - } - - var dataRelIds []string - if r.resolver.requestInfo != nil && len(r.resolver.requestInfo.Data) != 0 { - dataRelIds = list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name]) - } - if len(dataRelIds) == 0 { - return &search.ResolverResult{Identifier: "NULL"}, nil - } - - r.activeCollectionName = dataRelCollection.Name - r.activeTableAlias = inflector.Columnify("__data_" + dataRelCollection.Name) - - // join the data rel collection to the main collection - r.resolver.registerJoin( - inflector.Columnify(r.activeCollectionName), - r.activeTableAlias, - dbx.In( - fmt.Sprintf("[[%s.id]]", inflector.Columnify(r.activeTableAlias)), - list.ToInterfaceSlice(dataRelIds)..., - ), - ) - - if options.MaxSelect == nil || *options.MaxSelect != 1 { - r.withMultiMatch = true - } - - // join the data rel collection to the multi-match subquery - r.multiMatchActiveTableAlias = inflector.Columnify("__data_mm_" + dataRelCollection.Name) - r.multiMatch.joins = append( - r.multiMatch.joins, - &join{ - tableName: inflector.Columnify(r.activeCollectionName), - tableAlias: r.multiMatchActiveTableAlias, - on: dbx.In(r.multiMatchActiveTableAlias+".id", list.ToInterfaceSlice(dataRelIds)...), - }, - ) - - // leave only the data relation fields - // aka. @request.data.someRel.fieldA.fieldB -> fieldA.fieldB - r.activeProps = r.activeProps[3:] - - return r.processActiveProps() -} - -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 { - // system field, aka. internal model prop - // (always available but not part of the collection schema) - // ------------------------------------------------------- - if list.ExistInSlice(prop, resolvableSystemFieldNames(collection)) { - result := &search.ResolverResult{ - Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, inflector.Columnify(prop)), - } - - // allow querying only auth records with emails marked as public - if prop == schema.FieldNameEmail && !r.allowHiddenFields { - result.AfterBuild = func(expr dbx.Expression) dbx.Expression { - return dbx.Enclose(dbx.And(expr, dbx.NewExp(fmt.Sprintf( - "[[%s.%s]] = TRUE", - r.activeTableAlias, - schema.FieldNameEmailVisibility, - )))) - } - } - - if r.withMultiMatch { - r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, inflector.Columnify(prop)) - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil - } - - name, modifier, err := splitModifier(prop) - if err != nil { - return nil, err - } - - field := collection.Schema.GetFieldByName(name) - if field == nil { - if r.nullifyMisingField { - return &search.ResolverResult{Identifier: "NULL"}, nil - } - return nil, fmt.Errorf("unknown field %q", name) - } - - cleanFieldName := inflector.Columnify(field.Name) - - // arrayble fields ":length" modifier - // ------------------------------------------------------- - if modifier == lengthModifier && list.ExistInSlice(field.Type, schema.ArraybleFieldTypes()) { - jePair := r.activeTableAlias + "." + cleanFieldName - - result := &search.ResolverResult{ - Identifier: jsonArrayLength(jePair), - } - - if r.withMultiMatch { - jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName - r.multiMatch.valueIdentifier = jsonArrayLength(jePair2) - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil - } - - // select field with ":each" modifier - // ------------------------------------------------------- - if field.Type == schema.FieldTypeSelect && modifier == eachModifier { - jePair := r.activeTableAlias + "." + cleanFieldName - jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je" - r.resolver.registerJoin(jsonEach(jePair), jeAlias, nil) - - result := &search.ResolverResult{ - Identifier: fmt.Sprintf("[[%s.value]]", jeAlias), - } - - field.InitOptions() - options, ok := field.Options.(*schema.SelectOptions) - if !ok { - return nil, fmt.Errorf("failed to initialize field %q options", prop) - } - - if options.MaxSelect != 1 { - r.withMultiMatch = true - } - - if r.withMultiMatch { - jePair2 := r.multiMatchActiveTableAlias + "." + cleanFieldName - jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je" - - r.multiMatch.joins = append(r.multiMatch.joins, &join{ - tableName: jsonEach(jePair2), - tableAlias: jeAlias2, - }) - r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.value]]", jeAlias2) - - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil - } - - // default - // ------------------------------------------------------- - result := &search.ResolverResult{ - Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName), - } - - if r.withMultiMatch { - r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName) - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil - } - - field := collection.Schema.GetFieldByName(prop) - if field == nil { - if r.nullifyMisingField { - return &search.ResolverResult{Identifier: "NULL"}, nil - } - return nil, fmt.Errorf("unknown field %q", prop) - } - - // check if it is a json field - if field.Type == schema.FieldTypeJson { - var jsonPath strings.Builder - jsonPath.WriteString("$") - for _, p := range r.activeProps[i+1:] { - if _, err := strconv.Atoi(p); err == nil { - jsonPath.WriteString("[") - jsonPath.WriteString(inflector.Columnify(p)) - jsonPath.WriteString("]") - } else { - jsonPath.WriteString(".") - jsonPath.WriteString(inflector.Columnify(p)) - } - } - - result := &search.ResolverResult{ - Identifier: fmt.Sprintf( - "JSON_EXTRACT([[%s.%s]], '%s')", - r.activeTableAlias, - inflector.Columnify(prop), - jsonPath.String(), - ), - } - - if r.withMultiMatch { - r.multiMatch.valueIdentifier = fmt.Sprintf( - "JSON_EXTRACT([[%s.%s]], '%s')", - r.multiMatchActiveTableAlias, - inflector.Columnify(prop), - jsonPath.String(), - ) - result.MultiMatchSubQuery = r.multiMatch - } - - return result, nil - } - - // check if it is a relation field - if field.Type != schema.FieldTypeRelation { - return nil, fmt.Errorf("field %q is not a valid relation", prop) - } - - // join the relation to the main query - // --- - field.InitOptions() - options, ok := field.Options.(*schema.RelationOptions) - if !ok { - return nil, fmt.Errorf("failed to initialize field %q options", prop) - } - - relCollection, relErr := r.resolver.loadCollection(options.CollectionId) - if relErr != nil { - return nil, fmt.Errorf("failed to find field %q collection", prop) - } - - cleanFieldName := inflector.Columnify(field.Name) - prefixedFieldName := r.activeTableAlias + "." + cleanFieldName - newTableAlias := r.activeTableAlias + "_" + cleanFieldName - newCollectionName := relCollection.Name - - if !options.IsMultiple() { - r.resolver.registerJoin( - inflector.Columnify(newCollectionName), - newTableAlias, - dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias, prefixedFieldName)), - ) - } else { - jeAlias := r.activeTableAlias + "_" + cleanFieldName + "_je" - r.resolver.registerJoin(jsonEach(prefixedFieldName), jeAlias, nil) - r.resolver.registerJoin( - inflector.Columnify(newCollectionName), - newTableAlias, - dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s.value]]", newTableAlias, jeAlias)), - ) - } - - r.activeCollectionName = newCollectionName - r.activeTableAlias = newTableAlias - // --- - - // join the relation to the multi-match subquery - // --- - if options.IsMultiple() { - r.withMultiMatch = true - } - - newTableAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName - prefixedFieldName2 := r.multiMatchActiveTableAlias + "." + cleanFieldName - - if !options.IsMultiple() { - r.multiMatch.joins = append( - r.multiMatch.joins, - &join{ - tableName: inflector.Columnify(newCollectionName), - tableAlias: newTableAlias2, - on: dbx.NewExp(fmt.Sprintf("[[%s.id]] = [[%s]]", newTableAlias2, prefixedFieldName2)), - }, - ) - } else { - jeAlias2 := r.multiMatchActiveTableAlias + "_" + cleanFieldName + "_je" - r.multiMatch.joins = append( - r.multiMatch.joins, - &join{ - tableName: jsonEach(prefixedFieldName2), - tableAlias: jeAlias2, - }, - &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 jsonArrayLength(tableColumnPair string) string { - return fmt.Sprintf( - // note: the case is used to normalize value access for single and multiple relations. - `json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`, - tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair, - ) -} - -func jsonEach(tableColumnPair string) string { - return fmt.Sprintf( - // note: the case is used to normalize value access for single and multiple relations. - `json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END)`, - tableColumnPair, tableColumnPair, tableColumnPair, - ) -} - -func resolvableSystemFieldNames(collection *models.Collection) []string { - result := schema.BaseModelFieldNames() - - if collection.IsAuth() { - result = append( - result, - schema.FieldNameUsername, - schema.FieldNameVerified, - schema.FieldNameEmailVisibility, - schema.FieldNameEmail, - ) - } - - return result -} diff --git a/resolvers/record_field_resolver.go b/resolvers/record_field_resolver.go deleted file mode 100644 index f938c66445..0000000000 --- a/resolvers/record_field_resolver.go +++ /dev/null @@ -1,296 +0,0 @@ -package resolvers - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/tools/search" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" -) - -// filter modifiers -const ( - eachModifier string = "each" - issetModifier string = "isset" - lengthModifier string = "length" -) - -// list of auth filter fields that don't require join with the auth -// collection or any other extra checks to be resolved. -var plainRequestAuthFields = []string{ - "@request.auth." + schema.FieldNameId, - "@request.auth." + schema.FieldNameCollectionId, - "@request.auth." + schema.FieldNameCollectionName, - "@request.auth." + schema.FieldNameUsername, - "@request.auth." + schema.FieldNameEmail, - "@request.auth." + schema.FieldNameEmailVisibility, - "@request.auth." + schema.FieldNameVerified, - "@request.auth." + schema.FieldNameCreated, - "@request.auth." + schema.FieldNameUpdated, -} - -// ensure that `search.FieldResolver` interface is implemented -var _ search.FieldResolver = (*RecordFieldResolver)(nil) - -// CollectionsFinder defines a common interface for retrieving -// collections and other related models. -// -// The interface at the moment is primarily used to avoid circular -// dependency with the daos.Dao package. -type CollectionsFinder interface { - FindCollectionByNameOrId(collectionNameOrId string) (*models.Collection, error) -} - -// RecordFieldResolver defines a custom search resolver struct for -// managing Record model search fields. -// -// Usually used together with `search.Provider`. -// Example: -// -// resolver := resolvers.NewRecordFieldResolver( -// app.Dao(), -// myCollection, -// &models.RequestInfo{...}, -// true, -// ) -// provider := search.NewProvider(resolver) -// ... -type RecordFieldResolver struct { - dao CollectionsFinder - baseCollection *models.Collection - allowHiddenFields bool - allowedFields []string - loadedCollections []*models.Collection - joins []*join // we cannot use a map because the insertion order is not preserved - requestInfo *models.RequestInfo - staticRequestInfo map[string]any -} - -// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. -func NewRecordFieldResolver( - dao CollectionsFinder, - baseCollection *models.Collection, - requestInfo *models.RequestInfo, - allowHiddenFields bool, -) *RecordFieldResolver { - r := &RecordFieldResolver{ - dao: dao, - baseCollection: baseCollection, - requestInfo: requestInfo, - allowHiddenFields: allowHiddenFields, - joins: []*join{}, - loadedCollections: []*models.Collection{baseCollection}, - allowedFields: []string{ - `^\w+[\w\.\:]*$`, - `^\@request\.method$`, - `^\@request\.auth\.[\w\.\:]*\w+$`, - `^\@request\.data\.[\w\.\:]*\w+$`, - `^\@request\.query\.[\w\.\:]*\w+$`, - `^\@request\.headers\.\w+$`, - `^\@collection\.\w+\.[\w\.\:]*\w+$`, - }, - } - - r.staticRequestInfo = map[string]any{} - if r.requestInfo != nil { - r.staticRequestInfo["method"] = r.requestInfo.Method - r.staticRequestInfo["query"] = r.requestInfo.Query - r.staticRequestInfo["headers"] = r.requestInfo.Headers - r.staticRequestInfo["data"] = r.requestInfo.Data - r.staticRequestInfo["auth"] = nil - if r.requestInfo.AuthRecord != nil { - r.requestInfo.AuthRecord.IgnoreEmailVisibility(true) - r.staticRequestInfo["auth"] = r.requestInfo.AuthRecord.PublicExport() - r.requestInfo.AuthRecord.IgnoreEmailVisibility(false) - } - } - - return r -} - -// UpdateQuery implements `search.FieldResolver` interface. -// -// Conditionally updates the provided search query based on the -// resolved fields (eg. dynamically joining relations). -func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error { - if len(r.joins) > 0 { - query.Distinct(true) - - for _, join := range r.joins { - query.LeftJoin( - (join.tableName + " " + join.tableAlias), - join.on, - ) - } - } - - return nil -} - -// Resolve implements `search.FieldResolver` interface. -// -// Example of some resolvable fieldName formats: -// -// id -// someSelect.each -// project.screen.status -// @request.status -// @request.query.filter -// @request.headers.x_token -// @request.auth.someRelation.name -// @request.data.someRelation.name -// @request.data.someField -// @request.data.someSelect:each -// @request.data.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, fmt.Errorf("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 := extractNestedMapVal(r.staticRequestInfo, path...) - - 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: - return &search.ResolverResult{Identifier: "NULL"}, nil - 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.Schema.GetFieldByName(path[len(path)-1]) - if field != nil && field.Type == schema.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 exampe 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) - if jsonErr == nil { - val = string(encoded) - } - } - - resultVal = val - } - - placeholder := "f" + security.PseudorandomString(5) - - return &search.ResolverResult{ - Identifier: "{:" + placeholder + "}", - Params: dbx.Params{placeholder: resultVal}, - }, nil -} - -func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) { - // return already loaded - for _, collection := range r.loadedCollections { - if collection.Id == collectionNameOrId || strings.EqualFold(collection.Name, collectionNameOrId) { - return collection, nil - } - } - - // load collection - collection, err := r.dao.FindCollectionByNameOrId(collectionNameOrId) - if err != nil { - return nil, err - } - r.loadedCollections = append(r.loadedCollections, collection) - - return collection, nil -} - -func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) { - join := &join{ - tableName: tableName, - tableAlias: tableAlias, - on: on, - } - - // replace existing join - for i, j := range r.joins { - if j.tableAlias == join.tableAlias { - r.joins[i] = join - return - } - } - - // register new join - r.joins = append(r.joins, join) -} - -func extractNestedMapVal(m map[string]any, keys ...string) (any, error) { - if len(keys) == 0 { - return nil, fmt.Errorf("at least one key should be provided") - } - - 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 - } - - if m, ok = result.(map[string]any); !ok { - return nil, fmt.Errorf("expected map, got %#v", result) - } - - return extractNestedMapVal(m, 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: - return parts[0], parts[1], nil - } - - return "", "", fmt.Errorf("unknown modifier in %q", combined) -} diff --git a/resolvers/record_field_resolver_test.go b/resolvers/record_field_resolver_test.go deleted file mode 100644 index be28996c0c..0000000000 --- a/resolvers/record_field_resolver_test.go +++ /dev/null @@ -1,537 +0,0 @@ -package resolvers_test - -import ( - "encoding/json" - "regexp" - "strings" - "testing" - - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/resolvers" - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/search" -) - -func TestRecordFieldResolverUpdateQuery(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - - requestInfo := &models.RequestInfo{ - Headers: map[string]any{ - "a": "123", - "b": "456", - }, - Query: map[string]any{ - "a": nil, - "b": 123, - }, - Data: map[string]any{ - "a": nil, - "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"}, - }, - AuthRecord: authRecord, - } - - scenarios := []struct { - name string - collectionIdOrName string - rule string - allowHiddenFields bool - expectQuery string - }{ - { - "non 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]] != {:TEST} OR [[demo4.title]] LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] NOT LIKE {:TEST} ESCAPE '\\' OR [[demo4.title]] > {:TEST} OR [[demo4.title]] >= {:TEST} OR [[demo4.title]] < {:TEST} OR [[demo4.title]] <= {:TEST})", - }, - { - "non relation field (with all opt/any operators)", - "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]] != {: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})", - }, - { - "incomplete rel", - "demo4", - "self_rel_one > true", - false, - "SELECT `demo4`.* FROM `demo4` WHERE [[demo4.self_rel_one]] > 1", - }, - { - "single rel (self rel)", - "demo4", - "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_self_rel_one.title]] > 1", - }, - { - "single rel (other collection)", - "demo4", - "rel_one_cascade.title > 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.title]] > 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 rels (opt/any operator)", - "demo4", - "self_rel_many.self_rel_one ?> true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] WHERE [[demo4_self_rel_many.self_rel_one]] > 1", - }, - { - "nested incomplete rels (multi-match operator)", - "demo4", - "self_rel_many.self_rel_one > true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 json_valid([[__mm_demo4.self_rel_many]]) 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`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))", - }, - { - "nested complete rels (opt/any operator)", - "demo4", - "self_rel_many.self_rel_one.title ?> true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 rels (multi-match operator)", - "demo4", - "self_rel_many.self_rel_one.title > true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 json_valid([[__mm_demo4.self_rel_many]]) 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)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))", - }, - { - "repeated nested rels (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 json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) 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) `demo4_self_rel_many_self_rel_one_self_rel_many_je` 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]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.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 rels (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 json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) 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) `demo4_self_rel_many_self_rel_one_self_rel_many_je` 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]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.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 json_valid([[__mm_demo4.self_rel_many]]) 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 json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) 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)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))", - }, - { - "multiple rels (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 json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 JSON_EXTRACT([[demo4_self_rel_one.json_object]], '$.a') > 1)", - }, - { - "multiple rels (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 json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.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 json_valid([[__mm_demo4.self_rel_many]]) 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 JSON_EXTRACT([[demo4_self_rel_one.json_object]], '$.a') > 1)", - }, - { - "@collection join (opt/any operators)", - "demo4", - "@collection.demo1.text ?> true || @collection.demo2.active ?> true || @collection.demo1.file_one ?> true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ([[__collection_demo1.text]] > 1 OR [[__collection_demo2.active]] > 1 OR [[__collection_demo1.file_one]] > 1)", - }, - { - "@collection join (multi-match operators)", - "demo4", - "@collection.demo1.text > true || @collection.demo2.active > true || @collection.demo1.file_one > true", - false, - "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`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) 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`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) 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`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))", - }, - { - "@request.auth fields", - "demo4", - "@request.auth.id > true || @request.auth.username > true || @request.auth.rel.title > true || @request.data.demo > true", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__auth_users` ON `__auth_users`.`id`={:TEST} LEFT JOIN `demo2` `__auth_users_rel` ON [[__auth_users_rel.id]] = [[__auth_users.rel]] WHERE ({:TEST} > 1 OR {:TEST} > 1 OR [[__auth_users_rel.title]] > 1 OR NULL > 1)", - }, - { - "hidden field with system filters (multi-match and ignore emailVisibility)", - "demo4", - "@collection.users.email > true || @request.auth.email > true", - false, - "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 ([[__smTEST.multiMatchValue]] IS NULL))))) OR {:TEST} > 1)", - }, - { - "hidden field (add emailVisibility)", - "users", - "id > true || email > true", - false, - "SELECT `users`.* FROM `users` WHERE ([[users.id]] > 1 OR (([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)))", - }, - { - "hidden field (force ignore emailVisibility)", - "users", - "email > true", - true, - "SELECT `users`.* FROM `users` WHERE [[users.email]] > 1", - }, - { - "isset key", - "demo1", - "@request.data.a:isset > true ||" + - "@request.data.b:isset > true ||" + - "@request.data.c:isset > true ||" + - "@request.query.a:isset > true ||" + - "@request.query.b:isset > true ||" + - "@request.query.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)", - }, - { - "@request.data.rel.* fields", - "demo1", - "@request.data.rel_one > true &&" + - "@request.data.rel_one.text > true &&" + - "@request.data.rel_many > true &&" + - "@request.data.rel_many.email != 'test' &&" + - "@request.data.rel_many.url ?= 'test' &&" + - "@request.data.rel_many.avatar ~ 'test'", - false, - "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `demo1` `__data_demo1` ON [[__data_demo1.id]]={:TEST} LEFT JOIN `users` `__data_users` ON [[__data_users.id]] IN ({:TEST}, {:TEST}) WHERE ({:TEST} > 1 AND [[__data_demo1.text]] > 1 AND {:TEST} > 1 AND (([[__data_users.email]] != {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__data_mm_users.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users` ON `__data_mm_users`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] != {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND '' = {:TEST} AND (([[__data_users.avatar]] LIKE {:TEST} ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__data_mm_users.avatar]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users` ON `__data_mm_users`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] LIKE {:TEST} ESCAPE '\\')) OR ([[__smTEST.multiMatchValue]] IS NULL))))))", - }, - { - "@request.data.select:each fields", - "demo1", - "@request.data.select_one = 'test' &&" + - "@request.data.select_one:each != 'test' &&" + - "@request.data.select_one:each ?= 'test' &&" + - "@request.data.select_many ~ 'test' &&" + - "@request.data.select_many:each = 'test' &&" + - "@request.data.select_many:each ?< true", - false, - "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:TEST}) `__dataSelect_select_one_je` LEFT JOIN json_each({:TEST}) `__dataSelect_select_many_je` WHERE ('' = {:TEST} AND [[__dataSelect_select_one_je.value]] != {:TEST} AND [[__dataSelect_select_one_je.value]] = {:TEST} AND {:TEST} LIKE {:TEST} ESCAPE '\\' AND (([[__dataSelect_select_many_je.value]] = {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataSelect_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:TEST}) `__mm__dataSelect_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = {:TEST})))) AND [[__dataSelect_select_many_je.value]] < 1)", - }, - { - "regular select:each fields", - "demo1", - "select_one = 'test' &&" + - "select_one:each != 'test' &&" + - "select_one:each ?> true &&" + - "select_many ~ 'test' &&" + - "select_many:each = 'test' &&" + - "select_many:each ?> true", - false, - "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` WHERE ([[demo1.select_one]] = {:TEST} AND [[demo1_select_one_je.value]] != {:TEST} AND [[demo1_select_one_je.value]] > 1 AND [[demo1.select_many]] LIKE {:TEST} ESCAPE '\\' AND (([[demo1_select_many_je.value]] = {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = {:TEST})))) AND [[demo1_select_many_je.value]] > 1)", - }, - { - "select:each vs select:each", - "demo1", - "select_one:each != select_many:each &&" + - "select_many:each > select_one:each &&" + - "select_many:each ?< select_one:each &&" + - "select_many:each = @request.data.select_many:each", - false, - "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each({:dataSelectTEST}) `__dataSelect_select_many_je` WHERE (((COALESCE([[demo1_select_one_je.value]], '') != COALESCE([[demo1_select_many_je.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT (COALESCE([[demo1_select_one_je.value]], '') != COALESCE([[__smTEST.multiMatchValue]], ''))) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND (([[demo1_select_many_je.value]] > [[demo1_select_one_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > [[demo1_select_one_je.value]])) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_select_many_je.value]] < [[demo1_select_one_je.value]] AND (([[demo1_select_many_je.value]] = [[__dataSelect_select_many_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm__dataSelect_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataSelectTEST}) `__mm__dataSelect_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))", - }, - { - "mixed multi-match vs multi-match", - "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.email > @request.data.rel_many.email", - false, - "SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.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` ON [[__data_users.id]] IN ({:TEST}, {:TEST}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') != 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 json_valid([[__mm_demo1.rel_many]]) 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 json_valid([[__mm_demo1.rel_many]]) 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]], '') != COALESCE([[__mrTEST.multiMatchValue]], ''))) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL))))) 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 json_valid([[__mm_demo1.rel_many]]) 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 '\\')) OR ([[__smTEST.multiMatchValue]] IS NULL))))) 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 json_valid([[__mm_demo1.rel_many]]) 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.email]] > [[__data_users.email]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) 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 [[__data_mm_users.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users` ON `__data_mm_users`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE ((NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]])) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL)))))) AND ([[demo1_rel_many.emailVisibility]] = TRUE)))", - }, - { - "@request.data.arrayable:length fields", - "demo1", - "@request.data.select_one:length > 1 &&" + - "@request.data.select_one:length ?> 2 &&" + - "@request.data.select_many:length < 3 &&" + - "@request.data.select_many:length ?> 4 &&" + - "@request.data.rel_one:length = 5 &&" + - "@request.data.rel_one:length ?= 6 &&" + - "@request.data.rel_many:length != 7 &&" + - "@request.data.rel_many:length ?!= 8 &&" + - "@request.data.file_one:length = 9 &&" + - "@request.data.file_one:length ?= 0 &&" + - "@request.data.file_many:length != 1 &&" + - "@request.data.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 != {:TEST} AND 2 != {:TEST} AND 1 = {:TEST} AND 1 = {:TEST} AND 3 != {:TEST} AND 3 != {:TEST})", - }, - { - "regular arrayable:length fields", - "demo4", - "@request.data.self_rel_one.self_rel_many:length > 1 &&" + - "@request.data.self_rel_one.self_rel_many:length ?> 2 &&" + - "@request.data.rel_many_cascade.files:length ?< 3 &&" + - "@request.data.rel_many_cascade.files:length < 4 &&" + - "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", - false, - "SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4` ON [[__data_demo4.id]]={:TEST} LEFT JOIN `demo3` `__data_demo3` ON [[__data_demo3.id]] IN ({:TEST}, {:TEST}) LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE (CASE WHEN [[__data_demo4.self_rel_many]] = '' OR [[__data_demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE (CASE WHEN [[__data_demo4.self_rel_many]] = '' OR [[__data_demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE (CASE WHEN [[__data_demo3.files]] = '' OR [[__data_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE (CASE WHEN [[__data_demo3.files]] = '' OR [[__data_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3.files]]) THEN [[__data_mm_demo3.files]] ELSE (CASE WHEN [[__data_mm_demo3.files]] = '' OR [[__data_mm_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3` ON `__data_mm_demo3`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] < {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) 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 json_valid([[demo4_self_rel_one.self_rel_many]]) 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 json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) 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) != {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) 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 json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) 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]] != {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) 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) != {:TEST})", - }, - { - "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 ((JSON_EXTRACT([[demo4.json_object]], '$.a.b') = '' OR JSON_EXTRACT([[demo4.json_object]], '$.a.b') IS NULL) AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) 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} AND JSON_EXTRACT([[demo4.json_object]], '$.a.b') > {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) 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})", - }, - } - - for _, s := range scenarios { - collection, err := app.Dao().FindCollectionByNameOrId(s.collectionIdOrName) - if err != nil { - t.Fatalf("[%s] Failed to load collection %s: %v", s.name, s.collectionIdOrName, err) - } - - query := app.Dao().RecordQuery(collection) - - r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestInfo, s.allowHiddenFields) - - expr, err := search.FilterData(s.rule).BuildExpr(r) - if err != nil { - t.Fatalf("[%s] BuildExpr failed with error %v", s.name, err) - } - - if err := r.UpdateQuery(query); err != nil { - t.Fatalf("[%s] UpdateQuery failed with error %v", s.name, err) - } - - 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("[%s] Expected query\n %v \ngot:\n %v", s.name, expectQuery, rawQuery) - } - } -} - -func TestRecordFieldResolverResolveSchemaFields(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo4") - if err != nil { - t.Fatal(err) - } - - authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - - requestInfo := &models.RequestInfo{ - AuthRecord: authRecord, - } - - r := resolvers.NewRecordFieldResolver(app.Dao(), 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]]"}, - // json_extract - {"json_array.0", false, "JSON_EXTRACT([[demo4.json_array]], '$[0]')"}, - {"json_object.a.b.c", false, "JSON_EXTRACT([[demo4.json_object]], '$.a.b.c')"}, - // @request.auth relation join: - {"@request.auth.rel", false, "[[__auth_users.rel]]"}, - {"@request.auth.rel.title", false, "[[__auth_users_rel.title]]"}, - // @collection fieds: - {"@collect", true, ""}, - {"collection.demo4.title", true, ""}, - {"@collection", true, ""}, - {"@collection.unknown", true, ""}, - {"@collection.demo2", true, ""}, - {"@collection.demo2.", true, ""}, - {"@collection.demo2.title", false, "[[__collection_demo2.title]]"}, - {"@collection.demo4.title", false, "[[__collection_demo4.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 { - r, err := r.Resolve(s.fieldName) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%q) Expected hasErr %v, got %v (%v)", s.fieldName, s.expectError, hasErr, err) - continue - } - - if hasErr { - continue - } - - if r.Identifier != s.expectName { - t.Errorf("(%q) Expected r.Identifier %q, got %q", s.fieldName, s.expectName, r.Identifier) - } - - // params should be empty for non @request fields - if len(r.Params) != 0 { - t.Errorf("(%q) Expected 0 r.Params, got %v", s.fieldName, r.Params) - } - } -} - -func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - collection, err := app.Dao().FindCollectionByNameOrId("demo1") - if err != nil { - t.Fatal(err) - } - - authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33") - if err != nil { - t.Fatal(err) - } - - requestInfo := &models.RequestInfo{ - Method: "get", - Query: map[string]any{ - "a": 123, - }, - Data: map[string]any{ - "number": "10", - "number_unknown": "20", - "b": 456, - "c": map[string]int{"sub": 1}, - }, - Headers: map[string]any{ - "d": "789", - }, - AuthRecord: authRecord, - } - - r := resolvers.NewRecordFieldResolver(app.Dao(), 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.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", true, ``}, - {"@request.data", true, ``}, - {"@request.data.b", false, `456`}, - {"@request.data.number", false, `10`}, // number field normalization - {"@request.data.number_unknown", false, `"20"`}, // no numeric normalizations for unknown fields - {"@request.data.b.missing", false, ``}, - {"@request.data.c", false, `"{\"sub\":1}"`}, - {"@request.auth", true, ""}, - {"@request.auth.id", false, `"4q1xlclmfloku33"`}, - {"@request.auth.email", false, `"test@example.com"`}, - {"@request.auth.username", false, `"users75657"`}, - {"@request.auth.verified", false, `false`}, - {"@request.auth.emailVisibility", false, `false`}, - {"@request.auth.missing", false, `NULL`}, - } - - for i, s := range scenarios { - r, err := r.Resolve(s.fieldName) - - 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 - } - - // missing key - // --- - if len(r.Params) == 0 { - if r.Identifier != "NULL" { - t.Errorf("(%d) Expected 0 placeholder parameters for %v, got %v", i, r.Identifier, r.Params) - } - continue - } - - // existing key - // --- - if len(r.Params) != 1 { - t.Errorf("(%d) Expected 1 placeholder parameter for %v, got %v", i, r.Identifier, r.Params) - continue - } - - var paramName string - var paramValue any - for k, v := range r.Params { - paramName = k - paramValue = v - } - - if r.Identifier != ("{:" + paramName + "}") { - t.Errorf("(%d) Expected parameter r.Identifier %q, got %q", i, paramName, r.Identifier) - } - - encodedParamValue, _ := json.Marshal(paramValue) - if string(encodedParamValue) != s.expectParamValue { - t.Errorf("(%d) Expected r.Params %v for %v, got %v", i, s.expectParamValue, r.Identifier, string(encodedParamValue)) - } - } -} diff --git a/resolvers/resolvers.go b/resolvers/resolvers.go deleted file mode 100644 index 8c045a8979..0000000000 --- a/resolvers/resolvers.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package resolvers contains custom search.FieldResolver implementations. -package resolvers diff --git a/tests/api.go b/tests/api.go index 057ea4c37f..f979fc1712 100644 --- a/tests/api.go +++ b/tests/api.go @@ -3,57 +3,170 @@ package tests import ( "bytes" "context" - "encoding/json" + "encoding/json/jsontext" "fmt" "io" + "maps" "net/http" "net/http/httptest" "strings" "testing" "time" - "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/hook" ) // ApiScenario defines a single api request test case/scenario. type ApiScenario struct { - Name string - Method string - Url string - Body io.Reader - RequestHeaders map[string]string + // Name is the test name. + Name string + + // Method is the HTTP method of the test request to use. + Method string + + // URL is the url/path of the endpoint you want to test. + URL string + + // Body specifies the body to send with the request. + // + // For example: + // + // strings.NewReader(`{"title":"abc"}`) + Body io.Reader + + // Headers specifies the headers to send with the request (e.g. "Authorization": "abc") + Headers map[string]string // Delay adds a delay before checking the expectations usually // to ensure that all fired non-awaited go routines have finished Delay time.Duration + // Timeout specifies how long to wait before cancelling the request context. + // + // A zero or negative value means that there will be no timeout. + Timeout time.Duration + + // DisableTestAppCleanup disables the builtin TestApp cleanup at + // the end of the ApiScenario execution. + // + // This option works only when explicit TestAppFactory is specified + // and means that the developer is responsible to do the necessary + // after test cleanup on their own (e.g. by manually calling testApp.Cleanup()). + DisableTestAppCleanup bool + // expectations - // --- - ExpectedStatus int - ExpectedContent []string + // --------------------------------------------------------------- + + // ExpectedStatus specifies the expected response HTTP status code. + ExpectedStatus int + + // List of keywords that MUST exist in the response body. + // + // Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty. + // Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204). + ExpectedContent []string + + // List of keywords that MUST NOT exist in the response body. + // + // Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty. + // Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204). NotExpectedContent []string - ExpectedEvents map[string]int + + // List of hook events to check whether they were fired or not. + // + // You can use the wildcard "*" event key if you want to ensure + // that no other hook events except those listed have been fired. + // + // For example: + // + // map[string]int{ "*": 0 } // no hook events were fired + // map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired + // map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times. + ExpectedEvents map[string]int // test hooks - // --- - TestAppFactory func(t *testing.T) *TestApp - BeforeTestFunc func(t *testing.T, app *TestApp, e *echo.Echo) - AfterTestFunc func(t *testing.T, app *TestApp, res *http.Response) + // --------------------------------------------------------------- + + TestAppFactory func(t testing.TB) *TestApp + BeforeTestFunc func(t testing.TB, app *TestApp, e *core.ServeEvent) + AfterTestFunc func(t testing.TB, app *TestApp, res *http.Response) } // Test executes the test scenario. +// +// Example: +// +// func TestListExample(t *testing.T) { +// scenario := tests.ApiScenario{ +// Name: "list example collection", +// Method: http.MethodGet, +// URL: "/api/collections/example/records", +// ExpectedStatus: 200, +// ExpectedContent: []string{ +// `"totalItems":3`, +// `"id":"0yxhwia2amd8gec"`, +// `"id":"achvryl401bhse3"`, +// `"id":"llvuca81nly1qls"`, +// }, +// ExpectedEvents: map[string]int{ +// "OnRecordsListRequest": 1, +// "OnRecordEnrich": 3, +// }, +// } +// +// scenario.Test(t) +// } func (scenario *ApiScenario) Test(t *testing.T) { + t.Run(scenario.normalizedName(), func(t *testing.T) { + scenario.test(t) + }) +} + +// Benchmark benchmarks the test scenario. +// +// Example: +// +// func BenchmarkListExample(b *testing.B) { +// scenario := tests.ApiScenario{ +// Name: "list example collection", +// Method: http.MethodGet, +// URL: "/api/collections/example/records", +// ExpectedStatus: 200, +// ExpectedContent: []string{ +// `"totalItems":3`, +// `"id":"0yxhwia2amd8gec"`, +// `"id":"achvryl401bhse3"`, +// `"id":"llvuca81nly1qls"`, +// }, +// ExpectedEvents: map[string]int{ +// "OnRecordsListRequest": 1, +// "OnRecordEnrich": 3, +// }, +// } +// +// scenario.Benchmark(b) +// } +func (scenario *ApiScenario) Benchmark(b *testing.B) { + b.Run(scenario.normalizedName(), func(b *testing.B) { + for i := 0; i < b.N; i++ { + scenario.test(b) + } + }) +} + +func (scenario *ApiScenario) normalizedName() string { var name = scenario.Name + if name == "" { - name = fmt.Sprintf("%s:%s", scenario.Method, scenario.Url) + name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL) } - t.Run(name, scenario.test) + return name } -func (scenario *ApiScenario) test(t *testing.T) { +func (scenario *ApiScenario) test(t testing.TB) { var testApp *TestApp if scenario.TestAppFactory != nil { testApp = scenario.TestAppFactory(t) @@ -67,114 +180,141 @@ func (scenario *ApiScenario) test(t *testing.T) { t.Fatalf("Failed to initialize the test app instance: %v", testAppErr) } } - defer testApp.Cleanup() - e, err := apis.InitApi(testApp) + // https://github.com/pocketbase/pocketbase/discussions/7267 + if scenario.TestAppFactory == nil || !scenario.DisableTestAppCleanup { + defer testApp.Cleanup() + } + + 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 - testApp.OnBeforeServe().Trigger(&core.ServeEvent{ - App: testApp, - Router: e, - }) + serveEvent := new(core.ServeEvent) + serveEvent.App = testApp + serveEvent.Router = baseRouter - if scenario.BeforeTestFunc != nil { - scenario.BeforeTestFunc(t, testApp, e) - } - - recorder := httptest.NewRecorder() - req := httptest.NewRequest(scenario.Method, scenario.Url, scenario.Body) - - // add middleware to timeout long-running requests (eg. keep-alive routes) - e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx, cancelFunc := context.WithTimeout(c.Request().Context(), 100*time.Millisecond) - defer cancelFunc() - c.SetRequest(c.Request().Clone(ctx)) - return next(c) + serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { + if scenario.BeforeTestFunc != nil { + scenario.BeforeTestFunc(t, testApp, e) } - }) - // set default header - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + // reset the event counters in case a hook was triggered from a before func (eg. db save) + testApp.ResetEventCalls() - // set scenario headers - for k, v := range scenario.RequestHeaders { - req.Header.Set(k, v) - } + // add middleware to timeout long-running requests (eg. keep-alive routes) + e.Router.Bind(&hook.Handler[*core.RequestEvent]{ + Func: func(re *core.RequestEvent) error { + slowTimer := time.AfterFunc(3*time.Second, func() { + t.Logf("[WARN] Long running test %q", scenario.Name) + }) + defer slowTimer.Stop() - // execute request - e.ServeHTTP(recorder, req) + if scenario.Timeout > 0 { + ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout) + defer cancelFunc() + re.Request = re.Request.Clone(ctx) + } - res := recorder.Result() + return re.Next() + }, + Priority: -9999, + }) - if res.StatusCode != scenario.ExpectedStatus { - t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode) - } + recorder := httptest.NewRecorder() - if scenario.Delay > 0 { - time.Sleep(scenario.Delay) - } + req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body) - if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 { - if len(recorder.Body.Bytes()) != 0 { - t.Errorf("Expected empty body, got \n%v", recorder.Body.String()) + // set default header + req.Header.Set("content-type", "application/json") + + // set scenario headers + for k, v := range scenario.Headers { + // trim whitespaces for consistency with the net/http request parsing + req.Header.Set(k, strings.TrimSpace(v)) } - } else { - // normalize json response format - buffer := new(bytes.Buffer) - err := json.Compact(buffer, recorder.Body.Bytes()) - var normalizedBody string + + // execute request + mux, err := e.Router.BuildMux() if err != nil { - // not a json... - normalizedBody = recorder.Body.String() - } else { - normalizedBody = buffer.String() + t.Fatalf("Failed to build router mux: %v", err) } + mux.ServeHTTP(recorder, req) - for _, item := range scenario.ExpectedContent { - if !strings.Contains(normalizedBody, item) { - t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody) - break - } - } + res := recorder.Result() - for _, item := range scenario.NotExpectedContent { - if strings.Contains(normalizedBody, item) { - t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody) - break - } + if res.StatusCode != scenario.ExpectedStatus { + t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode) } - } - // to minimize the breaking changes we always expect the error - // events to be called on API error - if res.StatusCode >= 400 { - if scenario.ExpectedEvents == nil { - scenario.ExpectedEvents = map[string]int{} + // @todo consider removing in favour of synctest.Wait() + if scenario.Delay > 0 { + time.Sleep(scenario.Delay) } - if _, ok := scenario.ExpectedEvents["OnBeforeApiError"]; !ok { - scenario.ExpectedEvents["OnBeforeApiError"] = 1 + + if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 { + if len(recorder.Body.Bytes()) != 0 { + t.Errorf("Expected empty body, got \n%v", recorder.Body.String()) + } + } else { + // normalize json response format + var normalizedBody string + + buf := new(bytes.Buffer) + enc := jsontext.NewEncoder(buf) + err := enc.WriteValue(recorder.Body.Bytes()) + if err != nil { + // not a json... + normalizedBody = recorder.Body.String() + } else { + normalizedBody = buf.String() + } + + for _, item := range scenario.ExpectedContent { + if !strings.Contains(normalizedBody, item) { + t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody) + break + } + } + + for _, item := range scenario.NotExpectedContent { + if strings.Contains(normalizedBody, item) { + t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody) + break + } + } } - if _, ok := scenario.ExpectedEvents["OnAfterApiError"]; !ok { - scenario.ExpectedEvents["OnAfterApiError"] = 1 + + remainingEvents := maps.Clone(testApp.EventCalls) + + var noOtherEventsShouldRemain bool + for event, expectedNum := range scenario.ExpectedEvents { + if event == "*" && expectedNum <= 0 { + noOtherEventsShouldRemain = true + continue + } + + actualNum := remainingEvents[event] + if actualNum != expectedNum { + t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum) + } + + delete(remainingEvents, event) } - } - if len(testApp.EventCalls) > len(scenario.ExpectedEvents) { - t.Errorf("Expected events %v, got %v", scenario.ExpectedEvents, testApp.EventCalls) - } + if noOtherEventsShouldRemain && len(remainingEvents) > 0 { + t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls) + } - for event, expectedCalls := range scenario.ExpectedEvents { - actualCalls := testApp.EventCalls[event] - if actualCalls != expectedCalls { - t.Errorf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls) + if scenario.AfterTestFunc != nil { + scenario.AfterTestFunc(t, testApp, res) } - } - if scenario.AfterTestFunc != nil { - scenario.AfterTestFunc(t, testApp, res) + return nil + }) + if serveErr != nil { + t.Fatalf("Failed to trigger app serve hook: %v", serveErr) } } diff --git a/tests/app.go b/tests/app.go index 4c5e101ce9..2cf6ab3d51 100644 --- a/tests/app.go +++ b/tests/app.go @@ -10,7 +10,9 @@ import ( "sync" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/hook" + + _ "github.com/pocketbase/pocketbase/migrations" ) // TestApp is a wrapper app instance used for testing. @@ -21,11 +23,6 @@ type TestApp struct { // EventCalls defines a map to inspect which app events // (and how many times) were triggered. - // - // The following events are not counted because they execute always: - // - OnBeforeBootstrap - // - OnAfterBootstrap - // - OnBeforeServe EventCalls map[string]int TestMailer *TestMailer @@ -36,24 +33,22 @@ type TestApp struct { // // After this call, the app instance shouldn't be used anymore. func (t *TestApp) Cleanup() { - t.ResetEventCalls() - t.ResetBootstrapState() + event := new(core.TerminateEvent) + event.App = t + + t.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error { + t.ClearBootstrap() + t.TestMailer.Reset() + t.ResetEventCalls() + + return e.Next() + }) if t.DataDir() != "" { os.RemoveAll(t.DataDir()) } } -// NewMailClient initializes test app mail client. -func (t *TestApp) NewMailClient() mailer.Mailer { - t.mux.Lock() - defer t.mux.Unlock() - - t.TestMailer.Reset() - - return t.TestMailer -} - // ResetEventCalls resets the EventCalls counter. func (t *TestApp) ResetEventCalls() { t.mux.Lock() @@ -62,7 +57,7 @@ func (t *TestApp) ResetEventCalls() { t.EventCalls = make(map[string]int) } -func (t *TestApp) registerEventCall(name string) error { +func (t *TestApp) registerEventCall(name string) { t.mux.Lock() defer t.mux.Unlock() @@ -71,34 +66,47 @@ func (t *TestApp) registerEventCall(name string) error { } t.EventCalls[name]++ - - return nil } // NewTestApp creates and initializes a test application instance. // -// It is the caller's responsibility to call `app.Cleanup()` -// when the app is no longer needed. +// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed. func NewTestApp(optTestDataDir ...string) (*TestApp, error) { var testDataDir string - if len(optTestDataDir) == 0 || optTestDataDir[0] == "" { + if len(optTestDataDir) > 0 { + testDataDir = optTestDataDir[0] + } + + return NewTestAppWithConfig(core.BaseAppConfig{ + DataDir: testDataDir, + EncryptionEnv: "pb_test_env", + }) +} + +// NewTestAppWithConfig creates and initializes a test application instance +// from the provided config. +// +// If config.DataDir is not set it fallbacks to the default internal test data directory. +// +// config.DataDir is cloned for each new test application instance. +// +// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed. +func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) { + if config.DataDir == "" { // fallback to the default test data directory _, currentFile, _, _ := runtime.Caller(0) - testDataDir = filepath.Join(path.Dir(currentFile), "data") - } else { - testDataDir = optTestDataDir[0] + config.DataDir = filepath.Join(path.Dir(currentFile), "data") } - tempDir, err := TempDirClone(testDataDir) + tempDir, err := TempDirClone(config.DataDir) if err != nil { return nil, err } - app := core.NewBaseApp(core.BaseAppConfig{ - DataDir: tempDir, - EncryptionEnv: "pb_test_env", - IsDebug: false, - }) + // replace with the clone + config.DataDir = tempDir + + app := core.NewBaseApp(config) // load data dir and db connections if err := app.Bootstrap(); err != nil { @@ -106,10 +114,15 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { } // ensure that the Dao and DB configurations are properly loaded - if _, err := app.Dao().DB().NewQuery("Select 1").Execute(); err != nil { + if _, err := app.DB().NewQuery("Select 1").Execute(); err != nil { + return nil, err + } + if _, err := app.AuxDB().NewQuery("Select 1").Execute(); err != nil { return nil, err } - if _, err := app.LogsDao().DB().NewQuery("Select 1").Execute(); err != nil { + + // apply any missing migrations + if err := app.RunAllMigrations(); err != nil { return nil, err } @@ -123,344 +136,696 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { TestMailer: &TestMailer{}, } - t.OnBeforeApiError().Add(func(e *core.ApiErrorEvent) error { - return t.registerEventCall("OnBeforeApiError") - }) - - t.OnAfterApiError().Add(func(e *core.ApiErrorEvent) error { - return t.registerEventCall("OnAfterApiError") + t.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{ + Func: func(e *core.BootstrapEvent) error { + t.registerEventCall("OnBootstrap") + return e.Next() + }, + Priority: -99999, }) - t.OnModelBeforeCreate().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelBeforeCreate") + t.OnBootstrapClear().Bind(&hook.Handler[*core.BootstrapEvent]{ + Func: func(e *core.BootstrapEvent) error { + t.registerEventCall("OnBootstrapClear") + return e.Next() + }, + Priority: -99999, }) - t.OnModelAfterCreate().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelAfterCreate") + t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{ + Func: func(e *core.ServeEvent) error { + t.registerEventCall("OnServe") + e.InstallerFunc = nil // https://github.com/pocketbase/pocketbase/discussions/7202 + return e.Next() + }, + Priority: -99999, }) - t.OnModelBeforeUpdate().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelBeforeUpdate") + t.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{ + Func: func(e *core.TerminateEvent) error { + t.registerEventCall("OnTerminate") + return e.Next() + }, + Priority: -99999, }) - t.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelAfterUpdate") + t.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{ + Func: func(e *core.BackupEvent) error { + t.registerEventCall("OnBackupCreate") + return e.Next() + }, + Priority: -99999, }) - t.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelBeforeDelete") + t.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{ + Func: func(e *core.BackupEvent) error { + t.registerEventCall("OnBackupRestore") + return e.Next() + }, + Priority: -99999, }) - t.OnModelAfterDelete().Add(func(e *core.ModelEvent) error { - return t.registerEventCall("OnModelAfterDelete") + t.OnModelCreate().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelCreate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordsListRequest().Add(func(e *core.RecordsListEvent) error { - return t.registerEventCall("OnRecordsListRequest") + t.OnModelCreateExecute().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelCreateExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordViewRequest().Add(func(e *core.RecordViewEvent) error { - return t.registerEventCall("OnRecordViewRequest") + t.OnModelAfterCreateSuccess().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelAfterCreateSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeCreateRequest().Add(func(e *core.RecordCreateEvent) error { - return t.registerEventCall("OnRecordBeforeCreateRequest") + t.OnModelAfterCreateError().Bind(&hook.Handler[*core.ModelErrorEvent]{ + Func: func(e *core.ModelErrorEvent) error { + t.registerEventCall("OnModelAfterCreateError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error { - return t.registerEventCall("OnRecordAfterCreateRequest") + t.OnModelUpdate().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelUpdate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeUpdateRequest().Add(func(e *core.RecordUpdateEvent) error { - return t.registerEventCall("OnRecordBeforeUpdateRequest") + t.OnModelUpdateExecute().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelUpdateExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error { - return t.registerEventCall("OnRecordAfterUpdateRequest") + t.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelAfterUpdateSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeDeleteRequest().Add(func(e *core.RecordDeleteEvent) error { - return t.registerEventCall("OnRecordBeforeDeleteRequest") + t.OnModelAfterUpdateError().Bind(&hook.Handler[*core.ModelErrorEvent]{ + Func: func(e *core.ModelErrorEvent) error { + t.registerEventCall("OnModelAfterUpdateError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error { - return t.registerEventCall("OnRecordAfterDeleteRequest") + t.OnModelValidate().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelValidate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAuthRequest().Add(func(e *core.RecordAuthEvent) error { - return t.registerEventCall("OnRecordAuthRequest") + t.OnModelDelete().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelDelete") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error { - return t.registerEventCall("OnRecordBeforeAuthWithPasswordRequest") + t.OnModelDeleteExecute().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelDeleteExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error { - return t.registerEventCall("OnRecordAfterAuthWithPasswordRequest") + t.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{ + Func: func(e *core.ModelEvent) error { + t.registerEventCall("OnModelAfterDeleteSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeAuthWithOAuth2Request().Add(func(e *core.RecordAuthWithOAuth2Event) error { - return t.registerEventCall("OnRecordBeforeAuthWithOAuth2Request") + t.OnModelAfterDeleteError().Bind(&hook.Handler[*core.ModelErrorEvent]{ + Func: func(e *core.ModelErrorEvent) error { + t.registerEventCall("OnModelAfterDeleteError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterAuthWithOAuth2Request().Add(func(e *core.RecordAuthWithOAuth2Event) error { - return t.registerEventCall("OnRecordAfterAuthWithOAuth2Request") + t.OnRecordEnrich().Bind(&hook.Handler[*core.RecordEnrichEvent]{ + Func: func(e *core.RecordEnrichEvent) error { + t.registerEventCall("OnRecordEnrich") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error { - return t.registerEventCall("OnRecordBeforeAuthRefreshRequest") + t.OnRecordValidate().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordValidate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error { - return t.registerEventCall("OnRecordAfterAuthRefreshRequest") + t.OnRecordCreate().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordCreate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error { - return t.registerEventCall("OnRecordBeforeRequestPasswordResetRequest") + t.OnRecordCreateExecute().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordCreateExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error { - return t.registerEventCall("OnRecordAfterRequestPasswordResetRequest") + t.OnRecordAfterCreateSuccess().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordAfterCreateSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error { - return t.registerEventCall("OnRecordBeforeConfirmPasswordResetRequest") + t.OnRecordAfterCreateError().Bind(&hook.Handler[*core.RecordErrorEvent]{ + Func: func(e *core.RecordErrorEvent) error { + t.registerEventCall("OnRecordAfterCreateError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error { - return t.registerEventCall("OnRecordAfterConfirmPasswordResetRequest") + t.OnRecordUpdate().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordUpdate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error { - return t.registerEventCall("OnRecordBeforeRequestVerificationRequest") + t.OnRecordUpdateExecute().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordUpdateExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error { - return t.registerEventCall("OnRecordAfterRequestVerificationRequest") + t.OnRecordAfterUpdateSuccess().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordAfterUpdateSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error { - return t.registerEventCall("OnRecordBeforeConfirmVerificationRequest") + t.OnRecordAfterUpdateError().Bind(&hook.Handler[*core.RecordErrorEvent]{ + Func: func(e *core.RecordErrorEvent) error { + t.registerEventCall("OnRecordAfterUpdateError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error { - return t.registerEventCall("OnRecordAfterConfirmVerificationRequest") + t.OnRecordDelete().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordDelete") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error { - return t.registerEventCall("OnRecordBeforeRequestEmailChangeRequest") + t.OnRecordDeleteExecute().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordDeleteExecute") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error { - return t.registerEventCall("OnRecordAfterRequestEmailChangeRequest") + t.OnRecordAfterDeleteSuccess().Bind(&hook.Handler[*core.RecordEvent]{ + Func: func(e *core.RecordEvent) error { + t.registerEventCall("OnRecordAfterDeleteSuccess") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordBeforeConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error { - return t.registerEventCall("OnRecordBeforeConfirmEmailChangeRequest") + t.OnRecordAfterDeleteError().Bind(&hook.Handler[*core.RecordErrorEvent]{ + Func: func(e *core.RecordErrorEvent) error { + t.registerEventCall("OnRecordAfterDeleteError") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error { - return t.registerEventCall("OnRecordAfterConfirmEmailChangeRequest") + t.OnCollectionValidate().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionValidate") + return e.Next() + }, + Priority: -99999, }) - t.OnRecordListExternalAuthsRequest().Add(func(e *core.RecordListExternalAuthsEvent) error { - return t.registerEventCall("OnRecordListExternalAuthsRequest") - }) + t.OnCollectionCreate().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionCreate") + return e.Next() + }, + Priority: -99999, + }) - t.OnRecordBeforeUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error { - return t.registerEventCall("OnRecordBeforeUnlinkExternalAuthRequest") - }) + t.OnCollectionCreateExecute().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionCreateExecute") + return e.Next() + }, + Priority: -99999, + }) - t.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error { - return t.registerEventCall("OnRecordAfterUnlinkExternalAuthRequest") - }) + t.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionAfterCreateSuccess") + return e.Next() + }, + Priority: -99999, + }) - t.OnMailerBeforeAdminResetPasswordSend().Add(func(e *core.MailerAdminEvent) error { - return t.registerEventCall("OnMailerBeforeAdminResetPasswordSend") - }) + t.OnCollectionAfterCreateError().Bind(&hook.Handler[*core.CollectionErrorEvent]{ + Func: func(e *core.CollectionErrorEvent) error { + t.registerEventCall("OnCollectionAfterCreateError") + return e.Next() + }, + Priority: -99999, + }) - t.OnMailerAfterAdminResetPasswordSend().Add(func(e *core.MailerAdminEvent) error { - return t.registerEventCall("OnMailerAfterAdminResetPasswordSend") - }) + t.OnCollectionUpdate().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionUpdate") + return e.Next() + }, + Priority: -99999, + }) - t.OnMailerBeforeRecordResetPasswordSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerBeforeRecordResetPasswordSend") - }) + t.OnCollectionUpdateExecute().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionUpdateExecute") + return e.Next() + }, + Priority: -99999, + }) - t.OnMailerAfterRecordResetPasswordSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerAfterRecordResetPasswordSend") + t.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionAfterUpdateSuccess") + return e.Next() + }, + Priority: -99999, + }) + + t.OnCollectionAfterUpdateError().Bind(&hook.Handler[*core.CollectionErrorEvent]{ + Func: func(e *core.CollectionErrorEvent) error { + t.registerEventCall("OnCollectionAfterUpdateError") + return e.Next() + }, + Priority: -99999, + }) + + t.OnCollectionDelete().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionDelete") + return e.Next() + }, + Priority: -99999, + }) + + t.OnCollectionDeleteExecute().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionDeleteExecute") + return e.Next() + }, + Priority: -99999, + }) + + t.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{ + Func: func(e *core.CollectionEvent) error { + t.registerEventCall("OnCollectionAfterDeleteSuccess") + return e.Next() + }, + Priority: -99999, + }) + + t.OnCollectionAfterDeleteError().Bind(&hook.Handler[*core.CollectionErrorEvent]{ + Func: func(e *core.CollectionErrorEvent) error { + t.registerEventCall("OnCollectionAfterDeleteError") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerSend().Bind(&hook.Handler[*core.MailerEvent]{ + Func: func(e *core.MailerEvent) error { + if t.TestMailer == nil { + t.TestMailer = &TestMailer{} + } + e.Mailer = t.TestMailer + t.registerEventCall("OnMailerSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerRecordAuthAlertSend().Bind(&hook.Handler[*core.MailerRecordEvent]{ + Func: func(e *core.MailerRecordEvent) error { + t.registerEventCall("OnMailerRecordAuthAlertSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerRecordPasswordResetSend().Bind(&hook.Handler[*core.MailerRecordEvent]{ + Func: func(e *core.MailerRecordEvent) error { + t.registerEventCall("OnMailerRecordPasswordResetSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerRecordVerificationSend().Bind(&hook.Handler[*core.MailerRecordEvent]{ + Func: func(e *core.MailerRecordEvent) error { + t.registerEventCall("OnMailerRecordVerificationSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerRecordEmailChangeSend().Bind(&hook.Handler[*core.MailerRecordEvent]{ + Func: func(e *core.MailerRecordEvent) error { + t.registerEventCall("OnMailerRecordEmailChangeSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnMailerRecordOTPSend().Bind(&hook.Handler[*core.MailerRecordEvent]{ + Func: func(e *core.MailerRecordEvent) error { + t.registerEventCall("OnMailerRecordOTPSend") + return e.Next() + }, + Priority: -99999, + }) + + // t.OnFilesystemDelete().Bind(&hook.Handler[*core.FilesystemDeleteEvent]{ + // Func: func(e *core.FilesystemDeleteEvent) error { + // t.registerEventCall("OnFilesystemDelete") + // return e.Next() + // }, + // Priority: -99999, + // }) + // t.OnFilesystemNewWriter().Bind(&hook.Handler[*core.FilesystemNewWriterEvent]{ + // Func: func(e *core.FilesystemNewWriterEvent) error { + // t.registerEventCall("OnFilesystemNewWriter") + // return e.Next() + // }, + // Priority: -99999, + // }) + + t.OnRealtimeConnectRequest().Bind(&hook.Handler[*core.RealtimeConnectRequestEvent]{ + Func: func(e *core.RealtimeConnectRequestEvent) error { + t.registerEventCall("OnRealtimeConnectRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRealtimeMessageSend().Bind(&hook.Handler[*core.RealtimeMessageEvent]{ + Func: func(e *core.RealtimeMessageEvent) error { + t.registerEventCall("OnRealtimeMessageSend") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRealtimeSubscribeRequest().Bind(&hook.Handler[*core.RealtimeSubscribeRequestEvent]{ + Func: func(e *core.RealtimeSubscribeRequestEvent) error { + t.registerEventCall("OnRealtimeSubscribeRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnSettingsListRequest().Bind(&hook.Handler[*core.SettingsListRequestEvent]{ + Func: func(e *core.SettingsListRequestEvent) error { + t.registerEventCall("OnSettingsListRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnSettingsUpdateRequest().Bind(&hook.Handler[*core.SettingsUpdateRequestEvent]{ + Func: func(e *core.SettingsUpdateRequestEvent) error { + t.registerEventCall("OnSettingsUpdateRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnSettingsReload().Bind(&hook.Handler[*core.SettingsReloadEvent]{ + Func: func(e *core.SettingsReloadEvent) error { + t.registerEventCall("OnSettingsReload") + return e.Next() + }, + Priority: -99999, + }) + + t.OnFileDownloadRequest().Bind(&hook.Handler[*core.FileDownloadRequestEvent]{ + Func: func(e *core.FileDownloadRequestEvent) error { + t.registerEventCall("OnFileDownloadRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnFileTokenRequest().Bind(&hook.Handler[*core.FileTokenRequestEvent]{ + Func: func(e *core.FileTokenRequestEvent) error { + t.registerEventCall("OnFileTokenRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRecordAuthRequest().Bind(&hook.Handler[*core.RecordAuthRequestEvent]{ + Func: func(e *core.RecordAuthRequestEvent) error { + t.registerEventCall("OnRecordAuthRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRecordAuthWithPasswordRequest().Bind(&hook.Handler[*core.RecordAuthWithPasswordRequestEvent]{ + Func: func(e *core.RecordAuthWithPasswordRequestEvent) error { + t.registerEventCall("OnRecordAuthWithPasswordRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRecordAuthWithOAuth2Request().Bind(&hook.Handler[*core.RecordAuthWithOAuth2RequestEvent]{ + Func: func(e *core.RecordAuthWithOAuth2RequestEvent) error { + t.registerEventCall("OnRecordAuthWithOAuth2Request") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRecordAuthRefreshRequest().Bind(&hook.Handler[*core.RecordAuthRefreshRequestEvent]{ + Func: func(e *core.RecordAuthRefreshRequestEvent) error { + t.registerEventCall("OnRecordAuthRefreshRequest") + return e.Next() + }, + Priority: -99999, + }) + + t.OnRecordRequestPasswordResetRequest().Bind(&hook.Handler[*core.RecordRequestPasswordResetRequestEvent]{ + Func: func(e *core.RecordRequestPasswordResetRequestEvent) error { + t.registerEventCall("OnRecordRequestPasswordResetRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnMailerBeforeRecordVerificationSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerBeforeRecordVerificationSend") + t.OnRecordConfirmPasswordResetRequest().Bind(&hook.Handler[*core.RecordConfirmPasswordResetRequestEvent]{ + Func: func(e *core.RecordConfirmPasswordResetRequestEvent) error { + t.registerEventCall("OnRecordConfirmPasswordResetRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnMailerAfterRecordVerificationSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerAfterRecordVerificationSend") + t.OnRecordRequestVerificationRequest().Bind(&hook.Handler[*core.RecordRequestVerificationRequestEvent]{ + Func: func(e *core.RecordRequestVerificationRequestEvent) error { + t.registerEventCall("OnRecordRequestVerificationRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnMailerBeforeRecordChangeEmailSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerBeforeRecordChangeEmailSend") + t.OnRecordConfirmVerificationRequest().Bind(&hook.Handler[*core.RecordConfirmVerificationRequestEvent]{ + Func: func(e *core.RecordConfirmVerificationRequestEvent) error { + t.registerEventCall("OnRecordConfirmVerificationRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnMailerAfterRecordChangeEmailSend().Add(func(e *core.MailerRecordEvent) error { - return t.registerEventCall("OnMailerAfterRecordChangeEmailSend") + t.OnRecordRequestEmailChangeRequest().Bind(&hook.Handler[*core.RecordRequestEmailChangeRequestEvent]{ + Func: func(e *core.RecordRequestEmailChangeRequestEvent) error { + t.registerEventCall("OnRecordRequestEmailChangeRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnRealtimeConnectRequest().Add(func(e *core.RealtimeConnectEvent) error { - return t.registerEventCall("OnRealtimeConnectRequest") + t.OnRecordConfirmEmailChangeRequest().Bind(&hook.Handler[*core.RecordConfirmEmailChangeRequestEvent]{ + Func: func(e *core.RecordConfirmEmailChangeRequestEvent) error { + t.registerEventCall("OnRecordConfirmEmailChangeRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnRealtimeDisconnectRequest().Add(func(e *core.RealtimeDisconnectEvent) error { - return t.registerEventCall("OnRealtimeDisconnectRequest") + t.OnRecordRequestOTPRequest().Bind(&hook.Handler[*core.RecordCreateOTPRequestEvent]{ + Func: func(e *core.RecordCreateOTPRequestEvent) error { + t.registerEventCall("OnRecordRequestOTPRequest") + return e.Next() + }, + Priority: -99999, }) - t.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error { - return t.registerEventCall("OnRealtimeBeforeMessageSend") - }) + t.OnRecordAuthWithOTPRequest().Bind(&hook.Handler[*core.RecordAuthWithOTPRequestEvent]{ + Func: func(e *core.RecordAuthWithOTPRequestEvent) error { + t.registerEventCall("OnRecordAuthWithOTPRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnRealtimeAfterMessageSend().Add(func(e *core.RealtimeMessageEvent) error { - return t.registerEventCall("OnRealtimeAfterMessageSend") - }) + t.OnRecordsListRequest().Bind(&hook.Handler[*core.RecordsListRequestEvent]{ + Func: func(e *core.RecordsListRequestEvent) error { + t.registerEventCall("OnRecordsListRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnRealtimeBeforeSubscribeRequest().Add(func(e *core.RealtimeSubscribeEvent) error { - return t.registerEventCall("OnRealtimeBeforeSubscribeRequest") - }) + t.OnRecordViewRequest().Bind(&hook.Handler[*core.RecordRequestEvent]{ + Func: func(e *core.RecordRequestEvent) error { + t.registerEventCall("OnRecordViewRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnRealtimeAfterSubscribeRequest().Add(func(e *core.RealtimeSubscribeEvent) error { - return t.registerEventCall("OnRealtimeAfterSubscribeRequest") - }) + t.OnRecordCreateRequest().Bind(&hook.Handler[*core.RecordRequestEvent]{ + Func: func(e *core.RecordRequestEvent) error { + t.registerEventCall("OnRecordCreateRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnSettingsListRequest().Add(func(e *core.SettingsListEvent) error { - return t.registerEventCall("OnSettingsListRequest") - }) + t.OnRecordUpdateRequest().Bind(&hook.Handler[*core.RecordRequestEvent]{ + Func: func(e *core.RecordRequestEvent) error { + t.registerEventCall("OnRecordUpdateRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnSettingsBeforeUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error { - return t.registerEventCall("OnSettingsBeforeUpdateRequest") - }) + t.OnRecordDeleteRequest().Bind(&hook.Handler[*core.RecordRequestEvent]{ + Func: func(e *core.RecordRequestEvent) error { + t.registerEventCall("OnRecordDeleteRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error { - return t.registerEventCall("OnSettingsAfterUpdateRequest") - }) + t.OnCollectionsListRequest().Bind(&hook.Handler[*core.CollectionsListRequestEvent]{ + Func: func(e *core.CollectionsListRequestEvent) error { + t.registerEventCall("OnCollectionsListRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnCollectionsListRequest().Add(func(e *core.CollectionsListEvent) error { - return t.registerEventCall("OnCollectionsListRequest") - }) + t.OnCollectionViewRequest().Bind(&hook.Handler[*core.CollectionRequestEvent]{ + Func: func(e *core.CollectionRequestEvent) error { + t.registerEventCall("OnCollectionViewRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnCollectionViewRequest().Add(func(e *core.CollectionViewEvent) error { - return t.registerEventCall("OnCollectionViewRequest") - }) + t.OnCollectionCreateRequest().Bind(&hook.Handler[*core.CollectionRequestEvent]{ + Func: func(e *core.CollectionRequestEvent) error { + t.registerEventCall("OnCollectionCreateRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnCollectionBeforeCreateRequest().Add(func(e *core.CollectionCreateEvent) error { - return t.registerEventCall("OnCollectionBeforeCreateRequest") - }) + t.OnCollectionUpdateRequest().Bind(&hook.Handler[*core.CollectionRequestEvent]{ + Func: func(e *core.CollectionRequestEvent) error { + t.registerEventCall("OnCollectionUpdateRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error { - return t.registerEventCall("OnCollectionAfterCreateRequest") - }) + t.OnCollectionDeleteRequest().Bind(&hook.Handler[*core.CollectionRequestEvent]{ + Func: func(e *core.CollectionRequestEvent) error { + t.registerEventCall("OnCollectionDeleteRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnCollectionBeforeUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error { - return t.registerEventCall("OnCollectionBeforeUpdateRequest") - }) - - t.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error { - return t.registerEventCall("OnCollectionAfterUpdateRequest") - }) - - t.OnCollectionBeforeDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error { - return t.registerEventCall("OnCollectionBeforeDeleteRequest") - }) - - t.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error { - return t.registerEventCall("OnCollectionAfterDeleteRequest") - }) - - t.OnCollectionsBeforeImportRequest().Add(func(e *core.CollectionsImportEvent) error { - return t.registerEventCall("OnCollectionsBeforeImportRequest") - }) - - t.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error { - return t.registerEventCall("OnCollectionsAfterImportRequest") - }) - - t.OnAdminsListRequest().Add(func(e *core.AdminsListEvent) error { - return t.registerEventCall("OnAdminsListRequest") - }) - - t.OnAdminViewRequest().Add(func(e *core.AdminViewEvent) error { - return t.registerEventCall("OnAdminViewRequest") - }) - - t.OnAdminBeforeCreateRequest().Add(func(e *core.AdminCreateEvent) error { - return t.registerEventCall("OnAdminBeforeCreateRequest") - }) - - t.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error { - return t.registerEventCall("OnAdminAfterCreateRequest") - }) - - t.OnAdminBeforeUpdateRequest().Add(func(e *core.AdminUpdateEvent) error { - return t.registerEventCall("OnAdminBeforeUpdateRequest") - }) - - t.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error { - return t.registerEventCall("OnAdminAfterUpdateRequest") - }) - - t.OnAdminBeforeDeleteRequest().Add(func(e *core.AdminDeleteEvent) error { - return t.registerEventCall("OnAdminBeforeDeleteRequest") - }) - - t.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error { - return t.registerEventCall("OnAdminAfterDeleteRequest") - }) - - t.OnAdminAuthRequest().Add(func(e *core.AdminAuthEvent) error { - return t.registerEventCall("OnAdminAuthRequest") - }) - - t.OnAdminBeforeAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error { - return t.registerEventCall("OnAdminBeforeAuthWithPasswordRequest") - }) - - t.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error { - return t.registerEventCall("OnAdminAfterAuthWithPasswordRequest") - }) - - t.OnAdminBeforeAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error { - return t.registerEventCall("OnAdminBeforeAuthRefreshRequest") - }) - - t.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error { - return t.registerEventCall("OnAdminAfterAuthRefreshRequest") - }) - - t.OnAdminBeforeRequestPasswordResetRequest().Add(func(e *core.AdminRequestPasswordResetEvent) error { - return t.registerEventCall("OnAdminBeforeRequestPasswordResetRequest") - }) - - t.OnAdminAfterRequestPasswordResetRequest().Add(func(e *core.AdminRequestPasswordResetEvent) error { - return t.registerEventCall("OnAdminAfterRequestPasswordResetRequest") - }) - - t.OnAdminBeforeConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error { - return t.registerEventCall("OnAdminBeforeConfirmPasswordResetRequest") - }) - - t.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error { - return t.registerEventCall("OnAdminAfterConfirmPasswordResetRequest") - }) - - t.OnFileDownloadRequest().Add(func(e *core.FileDownloadEvent) error { - return t.registerEventCall("OnFileDownloadRequest") - }) - - t.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error { - return t.registerEventCall("OnFileBeforeTokenRequest") - }) + t.OnCollectionsImportRequest().Bind(&hook.Handler[*core.CollectionsImportRequestEvent]{ + Func: func(e *core.CollectionsImportRequestEvent) error { + t.registerEventCall("OnCollectionsImportRequest") + return e.Next() + }, + Priority: -99999, + }) - t.OnFileAfterTokenRequest().Add(func(e *core.FileTokenEvent) error { - return t.registerEventCall("OnFileAfterTokenRequest") + t.OnBatchRequest().Bind(&hook.Handler[*core.BatchRequestEvent]{ + Func: func(e *core.BatchRequestEvent) error { + t.registerEventCall("OnBatchRequest") + return e.Next() + }, + Priority: -99999, }) return t, nil diff --git a/tests/data/auxiliary.db b/tests/data/auxiliary.db new file mode 100644 index 0000000000..242d2ce6fc Binary files /dev/null and b/tests/data/auxiliary.db differ diff --git a/tests/data/data.db b/tests/data/data.db index 72a25fbfc6..3e0f184e73 100644 Binary files a/tests/data/data.db and b/tests/data/data.db differ diff --git a/tests/data/logs.db b/tests/data/logs.db deleted file mode 100644 index 8c38954505..0000000000 Binary files a/tests/data/logs.db and /dev/null differ diff --git a/tests/data/lost+found/test.txt b/tests/data/lost+found/test.txt new file mode 100644 index 0000000000..9daeafb986 --- /dev/null +++ b/tests/data/lost+found/test.txt @@ -0,0 +1 @@ +test diff --git a/tests/dynamic_stubs.go b/tests/dynamic_stubs.go new file mode 100644 index 0000000000..6fda676b04 --- /dev/null +++ b/tests/dynamic_stubs.go @@ -0,0 +1,147 @@ +package tests + +import ( + "strconv" + "time" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/types" +) + +func StubOTPRecords(app core.App) error { + superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com") + if err != nil { + return err + } + superuser2.SetRaw("stubId", "superuser2") + + superuser3, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test3@example.com") + if err != nil { + return err + } + superuser3.SetRaw("stubId", "superuser3") + + user1, err := app.FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + return err + } + user1.SetRaw("stubId", "user1") + + now := types.NowDateTime() + old := types.NowDateTime().Add(-1 * time.Hour) + + stubs := map[*core.Record][]types.DateTime{ + superuser2: {now, now.Add(-1 * time.Millisecond), old, now.Add(-2 * time.Millisecond), old.Add(-1 * time.Millisecond)}, + superuser3: {now.Add(-3 * time.Millisecond), now.Add(-2 * time.Minute)}, + user1: {old}, + } + for record, idDates := range stubs { + for i, date := range idDates { + otp := core.NewOTP(app) + otp.Id = record.GetString("stubId") + "_" + strconv.Itoa(i) + otp.SetRecordRef(record.Id) + otp.SetCollectionRef(record.Collection().Id) + otp.SetPassword("test123") + otp.SetRaw("created", date) + if err := app.SaveNoValidate(otp); err != nil { + return err + } + } + } + + return nil +} + +func StubMFARecords(app core.App) error { + superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com") + if err != nil { + return err + } + superuser2.SetRaw("stubId", "superuser2") + + superuser3, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test3@example.com") + if err != nil { + return err + } + superuser3.SetRaw("stubId", "superuser3") + + user1, err := app.FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + return err + } + user1.SetRaw("stubId", "user1") + + now := types.NowDateTime() + old := types.NowDateTime().Add(-1 * time.Hour) + + type mfaData struct { + method string + date types.DateTime + } + + stubs := map[*core.Record][]mfaData{ + superuser2: { + {core.MFAMethodOTP, now}, + {core.MFAMethodOTP, old}, + {core.MFAMethodPassword, now.Add(-2 * time.Minute)}, + {core.MFAMethodPassword, now.Add(-1 * time.Millisecond)}, + {core.MFAMethodOAuth2, old.Add(-1 * time.Millisecond)}, + }, + superuser3: { + {core.MFAMethodOAuth2, now.Add(-3 * time.Millisecond)}, + {core.MFAMethodPassword, now.Add(-3 * time.Minute)}, + }, + user1: { + {core.MFAMethodOAuth2, old}, + }, + } + for record, idDates := range stubs { + for i, data := range idDates { + otp := core.NewMFA(app) + otp.Id = record.GetString("stubId") + "_" + strconv.Itoa(i) + otp.SetRecordRef(record.Id) + otp.SetCollectionRef(record.Collection().Id) + otp.SetMethod(data.method) + otp.SetRaw("created", data.date) + if err := app.SaveNoValidate(otp); err != nil { + return err + } + } + } + + return nil +} + +func StubLogsData(app *TestApp) error { + _, err := app.AuxDB().NewQuery(` + delete from {{_logs}}; + + insert into {{_logs}} ( + [[id]], + [[level]], + [[message]], + [[data]], + [[created]], + [[updated]] + ) + values + ( + "873f2133-9f38-44fb-bf82-c8f53b310d91", + 0, + "test_message1", + '{"status":200}', + "2022-05-01 10:00:00.123Z", + "2022-05-01 10:00:00.123Z" + ), + ( + "f2133873-44fb-9f38-bf82-c918f53b310d", + 8, + "test_message2", + '{"status":400}', + "2022-05-02 10:00:00.123Z", + "2022-05-02 10:00:00.123Z" + ); + `).Execute() + + return err +} diff --git a/tests/logs.go b/tests/logs.go deleted file mode 100644 index 39ed21724c..0000000000 --- a/tests/logs.go +++ /dev/null @@ -1,53 +0,0 @@ -package tests - -func MockRequestLogsData(app *TestApp) error { - _, err := app.LogsDB().NewQuery(` - delete from {{_requests}}; - - insert into {{_requests}} ( - [[id]], - [[url]], - [[method]], - [[status]], - [[auth]], - [[userIp]], - [[remoteIp]], - [[referer]], - [[userAgent]], - [[meta]], - [[created]], - [[updated]] - ) - values - ( - "873f2133-9f38-44fb-bf82-c8f53b310d91", - "/test1", - "get", - 200, - "guest", - "127.0.0.1", - "127.0.0.1", - "", - "", - "{}", - "2022-05-01 10:00:00.123Z", - "2022-05-01 10:00:00.123Z" - ), - ( - "f2133873-44fb-9f38-bf82-c918f53b310d", - "/test2", - "post", - 400, - "admin", - "127.0.0.1", - "127.0.0.1", - "", - "", - '{"errorDetails":"error_details..."}', - "2022-05-02 10:00:00.123Z", - "2022-05-02 10:00:00.123Z" - ); - `).Execute() - - return err -} diff --git a/tests/mailer.go b/tests/mailer.go index fa6c2116fb..15d6190c5d 100644 --- a/tests/mailer.go +++ b/tests/mailer.go @@ -1,27 +1,81 @@ package tests import ( + "slices" + "sync" + "github.com/pocketbase/pocketbase/tools/mailer" ) var _ mailer.Mailer = (*TestMailer)(nil) -// TestMailer is a mock `mailer.Mailer` implementation. +// TestMailer is a mock [mailer.Mailer] implementation. type TestMailer struct { - TotalSend int - LastMessage mailer.Message + mux sync.Mutex + messages []*mailer.Message +} + +// Send implements [mailer.Mailer] interface. +func (tm *TestMailer) Send(m *mailer.Message) error { + tm.mux.Lock() + defer tm.mux.Unlock() + + tm.messages = append(tm.messages, m) + return nil } // Reset clears any previously test collected data. -func (m *TestMailer) Reset() { - m.TotalSend = 0 - m.LastMessage = mailer.Message{} +func (tm *TestMailer) Reset() { + tm.mux.Lock() + defer tm.mux.Unlock() + + tm.messages = nil } -// Send implements `mailer.Mailer` interface. -func (c *TestMailer) Send(m *mailer.Message) error { - c.TotalSend++ - c.LastMessage = *m +// TotalSend returns the total number of sent messages. +func (tm *TestMailer) TotalSend() int { + tm.mux.Lock() + defer tm.mux.Unlock() - return nil + return len(tm.messages) +} + +// Messages returns a shallow copy of all of the collected test messages. +func (tm *TestMailer) Messages() []*mailer.Message { + tm.mux.Lock() + defer tm.mux.Unlock() + + return slices.Clone(tm.messages) +} + +// FirstMessage returns a shallow copy of the first sent message. +// +// Returns an empty mailer.Message struct if there are no sent messages. +func (tm *TestMailer) FirstMessage() mailer.Message { + tm.mux.Lock() + defer tm.mux.Unlock() + + var m mailer.Message + + if len(tm.messages) > 0 { + return *tm.messages[0] + } + + return m +} + +// LastMessage returns a shallow copy of the last sent message. +// +// Returns an empty mailer.Message struct if there are no sent messages. +func (tm *TestMailer) LastMessage() mailer.Message { + tm.mux.Lock() + defer tm.mux.Unlock() + + var m mailer.Message + + if len(tm.messages) > 0 { + return *tm.messages[len(tm.messages)-1] + } + + return m } diff --git a/tests/validation_errors.go b/tests/validation_errors.go new file mode 100644 index 0000000000..a7344646f9 --- /dev/null +++ b/tests/validation_errors.go @@ -0,0 +1,32 @@ +package tests + +import ( + "errors" + "testing" + + validation "github.com/pocketbase/ozzo-validation/v4" +) + +// TestValidationErrors checks whether the provided rawErrors are +// instance of [validation.Errors] and contains the expectedErrors keys. +func TestValidationErrors(t *testing.T, rawErrors error, expectedErrors []string) { + var errs validation.Errors + + if rawErrors != nil && !errors.As(rawErrors, &errs) { + t.Fatalf("Failed to parse errors, expected to find validation.Errors, got %T\n%v", rawErrors, rawErrors) + } + + if len(errs) != len(expectedErrors) { + keys := make([]string, 0, len(errs)) + for k := range errs { + keys = append(keys, k) + } + t.Fatalf("Expected error keys \n%v\ngot\n%v\n%v", expectedErrors, keys, errs) + } + + for _, k := range expectedErrors { + if _, ok := errs[k]; !ok { + t.Fatalf("Missing expected error key %q in %v", k, errs) + } + } +} diff --git a/tokens/admin.go b/tokens/admin.go deleted file mode 100644 index c5e7e01d6f..0000000000 --- a/tokens/admin.go +++ /dev/null @@ -1,35 +0,0 @@ -package tokens - -import ( - "github.com/golang-jwt/jwt/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/security" -) - -// NewAdminAuthToken generates and returns a new admin authentication token. -func NewAdminAuthToken(app core.App, admin *models.Admin) (string, error) { - return security.NewJWT( - jwt.MapClaims{"id": admin.Id, "type": TypeAdmin}, - (admin.TokenKey + app.Settings().AdminAuthToken.Secret), - app.Settings().AdminAuthToken.Duration, - ) -} - -// NewAdminResetPasswordToken generates and returns a new admin password reset request token. -func NewAdminResetPasswordToken(app core.App, admin *models.Admin) (string, error) { - return security.NewJWT( - jwt.MapClaims{"id": admin.Id, "type": TypeAdmin, "email": admin.Email}, - (admin.TokenKey + app.Settings().AdminPasswordResetToken.Secret), - app.Settings().AdminPasswordResetToken.Duration, - ) -} - -// NewAdminFileToken generates and returns a new admin private file access token. -func NewAdminFileToken(app core.App, admin *models.Admin) (string, error) { - return security.NewJWT( - jwt.MapClaims{"id": admin.Id, "type": TypeAdmin}, - (admin.TokenKey + app.Settings().AdminFileToken.Secret), - app.Settings().AdminFileToken.Duration, - ) -} diff --git a/tokens/admin_test.go b/tokens/admin_test.go deleted file mode 100644 index a6f571e562..0000000000 --- a/tokens/admin_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package tokens_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tokens" -) - -func TestNewAdminAuthToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - admin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewAdminAuthToken(app, admin) - if err != nil { - t.Fatal(err) - } - - tokenAdmin, _ := app.Dao().FindAdminByToken( - token, - app.Settings().AdminAuthToken.Secret, - ) - if tokenAdmin == nil || tokenAdmin.Id != admin.Id { - t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin) - } -} - -func TestNewAdminResetPasswordToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - admin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewAdminResetPasswordToken(app, admin) - if err != nil { - t.Fatal(err) - } - - tokenAdmin, _ := app.Dao().FindAdminByToken( - token, - app.Settings().AdminPasswordResetToken.Secret, - ) - if tokenAdmin == nil || tokenAdmin.Id != admin.Id { - t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin) - } -} - -func TestNewAdminFileToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - admin, err := app.Dao().FindAdminByEmail("test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewAdminFileToken(app, admin) - if err != nil { - t.Fatal(err) - } - - tokenAdmin, _ := app.Dao().FindAdminByToken( - token, - app.Settings().AdminFileToken.Secret, - ) - if tokenAdmin == nil || tokenAdmin.Id != admin.Id { - t.Fatalf("Expected admin %v, got %v", admin, tokenAdmin) - } -} diff --git a/tokens/record.go b/tokens/record.go deleted file mode 100644 index 028bcc8ec1..0000000000 --- a/tokens/record.go +++ /dev/null @@ -1,95 +0,0 @@ -package tokens - -import ( - "errors" - - "github.com/golang-jwt/jwt/v4" - "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/models" - "github.com/pocketbase/pocketbase/tools/security" -) - -// NewRecordAuthToken generates and returns a new auth record authentication token. -func NewRecordAuthToken(app core.App, record *models.Record) (string, error) { - if !record.Collection().IsAuth() { - return "", errors.New("The record is not from an auth collection.") - } - - return security.NewJWT( - jwt.MapClaims{ - "id": record.Id, - "type": TypeAuthRecord, - "collectionId": record.Collection().Id, - }, - (record.TokenKey() + app.Settings().RecordAuthToken.Secret), - app.Settings().RecordAuthToken.Duration, - ) -} - -// NewRecordVerifyToken generates and returns a new record verification token. -func NewRecordVerifyToken(app core.App, record *models.Record) (string, error) { - if !record.Collection().IsAuth() { - return "", errors.New("The record is not from an auth collection.") - } - - return security.NewJWT( - jwt.MapClaims{ - "id": record.Id, - "type": TypeAuthRecord, - "collectionId": record.Collection().Id, - "email": record.Email(), - }, - (record.TokenKey() + app.Settings().RecordVerificationToken.Secret), - app.Settings().RecordVerificationToken.Duration, - ) -} - -// NewRecordResetPasswordToken generates and returns a new auth record password reset request token. -func NewRecordResetPasswordToken(app core.App, record *models.Record) (string, error) { - if !record.Collection().IsAuth() { - return "", errors.New("The record is not from an auth collection.") - } - - return security.NewJWT( - jwt.MapClaims{ - "id": record.Id, - "type": TypeAuthRecord, - "collectionId": record.Collection().Id, - "email": record.Email(), - }, - (record.TokenKey() + app.Settings().RecordPasswordResetToken.Secret), - app.Settings().RecordPasswordResetToken.Duration, - ) -} - -// NewRecordChangeEmailToken generates and returns a new auth record change email request token. -func NewRecordChangeEmailToken(app core.App, record *models.Record, newEmail string) (string, error) { - return security.NewJWT( - jwt.MapClaims{ - "id": record.Id, - "type": TypeAuthRecord, - "collectionId": record.Collection().Id, - "email": record.Email(), - "newEmail": newEmail, - }, - (record.TokenKey() + app.Settings().RecordEmailChangeToken.Secret), - app.Settings().RecordEmailChangeToken.Duration, - ) -} - -// NewRecordFileToken generates and returns a new record private file access token. -func NewRecordFileToken(app core.App, record *models.Record) (string, error) { - if !record.Collection().IsAuth() { - return "", errors.New("The record is not from an auth collection.") - } - - return security.NewJWT( - jwt.MapClaims{ - "id": record.Id, - "type": TypeAuthRecord, - "collectionId": record.Collection().Id, - }, - (record.TokenKey() + app.Settings().RecordFileToken.Secret), - app.Settings().RecordFileToken.Duration, - ) -} diff --git a/tokens/record_test.go b/tokens/record_test.go deleted file mode 100644 index 04358a6e58..0000000000 --- a/tokens/record_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package tokens_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/tests" - "github.com/pocketbase/pocketbase/tokens" -) - -func TestNewRecordAuthToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewRecordAuthToken(app, user) - if err != nil { - t.Fatal(err) - } - - tokenRecord, _ := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordAuthToken.Secret, - ) - if tokenRecord == nil || tokenRecord.Id != user.Id { - t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) - } -} - -func TestNewRecordVerifyToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewRecordVerifyToken(app, user) - if err != nil { - t.Fatal(err) - } - - tokenRecord, _ := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordVerificationToken.Secret, - ) - if tokenRecord == nil || tokenRecord.Id != user.Id { - t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) - } -} - -func TestNewRecordResetPasswordToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewRecordResetPasswordToken(app, user) - if err != nil { - t.Fatal(err) - } - - tokenRecord, _ := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordPasswordResetToken.Secret, - ) - if tokenRecord == nil || tokenRecord.Id != user.Id { - t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) - } -} - -func TestNewRecordChangeEmailToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewRecordChangeEmailToken(app, user, "test_new@example.com") - if err != nil { - t.Fatal(err) - } - - tokenRecord, _ := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordEmailChangeToken.Secret, - ) - if tokenRecord == nil || tokenRecord.Id != user.Id { - t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) - } -} - -func TestNewRecordFileToken(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() - - user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") - if err != nil { - t.Fatal(err) - } - - token, err := tokens.NewRecordFileToken(app, user) - if err != nil { - t.Fatal(err) - } - - tokenRecord, _ := app.Dao().FindAuthRecordByToken( - token, - app.Settings().RecordFileToken.Secret, - ) - if tokenRecord == nil || tokenRecord.Id != user.Id { - t.Fatalf("Expected auth record %v, got %v", user, tokenRecord) - } -} diff --git a/tokens/tokens.go b/tokens/tokens.go deleted file mode 100644 index 7a0a928aee..0000000000 --- a/tokens/tokens.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package tokens implements various user and admin tokens generation methods. -package tokens - -const ( - TypeAdmin = "admin" - TypeAuthRecord = "authRecord" -) diff --git a/tools/archive/create.go b/tools/archive/create.go index 4907fbbe1c..fcd20ca5c1 100644 --- a/tools/archive/create.go +++ b/tools/archive/create.go @@ -3,6 +3,7 @@ package archive import ( "archive/zip" "compress/flate" + "errors" "io" "io/fs" "os" @@ -23,24 +24,21 @@ func Create(src string, dest string, skipPaths ...string) error { if err != nil { return err } - defer zf.Close() zw := zip.NewWriter(zf) - defer zw.Close() // register a custom Deflate compressor zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { return flate.NewWriter(out, flate.BestSpeed) }) - if err := zipAddFS(zw, os.DirFS(src), skipPaths...); err != nil { + err = zipAddFS(zw, os.DirFS(src), skipPaths...) + if err != nil { // try to cleanup at least the created zip file - os.Remove(dest) - - return err + return errors.Join(err, zw.Close(), zf.Close(), os.Remove(dest)) } - return nil + return errors.Join(zw.Close(), zf.Close()) } // note remove after similar method is added in the std lib (https://github.com/golang/go/issues/54898) diff --git a/tools/archive/create_test.go b/tools/archive/create_test.go index 8f8aa32f8f..178a01c76a 100644 --- a/tools/archive/create_test.go +++ b/tools/archive/create_test.go @@ -48,7 +48,7 @@ func TestCreateSuccess(t *testing.T) { t.Fatalf("Expected zip with name %q, got %q", zipName, name) } - expectedSize := int64(405) + expectedSize := int64(532) if size := info.Size(); size != expectedSize { t.Fatalf("Expected zip with size %d, got %d", expectedSize, size) } @@ -116,5 +116,10 @@ func createTestDir(t *testing.T) string { f.Close() } + // symbolic link + if err := os.Symlink(filepath.Join(dir, "test"), filepath.Join(dir, "test_symlink")); err != nil { + t.Fatal(err) + } + return dir } diff --git a/tools/archive/extract.go b/tools/archive/extract.go index 02d8202a93..a9d581e933 100644 --- a/tools/archive/extract.go +++ b/tools/archive/extract.go @@ -9,7 +9,11 @@ import ( "strings" ) -// Extract extracts the zip archive at src to dest. +// Extract extracts the zip archive at "src" to "dest". +// +// Note that only dirs and regular files will be extracted. +// Symbolic links, named pipes, sockets, or any other irregular files +// are skipped because they come with too many edge cases and ambiguities. func Extract(src, dest string) error { zr, err := zip.OpenReader(src) if err != nil { @@ -46,11 +50,12 @@ func extractFile(zipFile *zip.File, basePath string) error { } defer r.Close() + // allow only dirs or regular files if zipFile.FileInfo().IsDir() { if err := os.MkdirAll(path, os.ModePerm); err != nil { return err } - } else { + } else if zipFile.FileInfo().Mode().IsRegular() { // ensure that the file path directories are created if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { return err diff --git a/tools/archive/extract_test.go b/tools/archive/extract_test.go index ce553de432..16e2617204 100644 --- a/tools/archive/extract_test.go +++ b/tools/archive/extract_test.go @@ -37,7 +37,7 @@ func TestExtractSuccess(t *testing.T) { defer os.RemoveAll(extractedPath) // zip testDir content (with exclude) - if err := archive.Create(testDir, zipPath, "a/b/c", "test", "sub2"); err != nil { + if err := archive.Create(testDir, zipPath, "a/b/c", "test2", "sub2"); err != nil { t.Fatalf("Failed to create archive: %v", err) } @@ -64,8 +64,9 @@ func TestExtractSuccess(t *testing.T) { t.Fatalf("Failed to read the extracted dir: %v", walkErr) } + // (note: symbolic links and other regular files should be missing) expectedFiles := []string{ - filepath.Join(extractedPath, "test2"), + filepath.Join(extractedPath, "test"), filepath.Join(extractedPath, "a/test"), filepath.Join(extractedPath, "a/b/sub1"), } diff --git a/tools/auth/apple.go b/tools/auth/apple.go index 01b32b8cea..3a9cbb1830 100644 --- a/tools/auth/apple.go +++ b/tools/auth/apple.go @@ -2,21 +2,21 @@ package auth import ( "context" - "crypto/rsa" - "encoding/base64" - "encoding/json" + "encoding/json/v2" "errors" "fmt" - "io" - "math/big" - "net/http" - "strings" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" + "github.com/pocketbase/pocketbase/tools/auth/internal/jwk" + "github.com/pocketbase/pocketbase/tools/types" "github.com/spf13/cast" "golang.org/x/oauth2" ) +func init() { + Providers[NameApple] = wrapFactory(NewAppleProvider) +} + var _ Provider = (*Apple)(nil) // NameApple is the unique name of the Apple provider. @@ -24,31 +24,35 @@ const NameApple string = "apple" // Apple allows authentication via Apple OAuth2. // -// [OIDC differences]: https://bitbucket.org/openid/connect/src/master/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md +// OIDC differences: https://bitbucket.org/openid/connect/src/master/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md. type Apple struct { - *baseProvider + BaseProvider - jwksUrl string + jwksURL string } // NewAppleProvider creates a new Apple provider instance with some defaults. func NewAppleProvider() *Apple { return &Apple{ - baseProvider: &baseProvider{ - scopes: nil, // custom scopes are currently not supported since they require a POST redirect - ctx: context.Background(), - authUrl: "https://appleid.apple.com/auth/authorize", - tokenUrl: "https://appleid.apple.com/auth/token", + BaseProvider: BaseProvider{ + ctx: context.Background(), + order: 1, + logo: ``, + displayName: "Apple", + pkce: true, + scopes: []string{"name", "email"}, + authURL: "https://appleid.apple.com/auth/authorize", + tokenURL: "https://appleid.apple.com/auth/token", }, - jwksUrl: "https://appleid.apple.com/auth/keys", + jwksURL: "https://appleid.apple.com/auth/keys", } } // FetchAuthUser returns an AuthUser instance based on the provided token. // -// API reference: https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse. +// API reference: https://developer.apple.com/documentation/signinwithapple/authenticating-users-with-sign-in-with-apple#Retrieve-the-users-information-from-Apple-ID-servers. func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -59,9 +63,13 @@ func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id string `json:"sub"` - Email string `json:"email"` EmailVerified any `json:"email_verified"` // could be string or bool + Email string `json:"email"` + Id string `json:"sub"` + + // not returned at the time of writing and it is usually + // manually populated in apis.recordAuthWithOAuth2 + Name string `json:"name"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -69,11 +77,14 @@ func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Id: extracted.Id, + Name: extracted.Name, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if cast.ToBool(extracted.EmailVerified) { user.Email = extracted.Email } @@ -81,11 +92,11 @@ func (p *Apple) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { return user, nil } -// FetchRawUserData implements Provider.FetchRawUserData interface. +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface. // -// Apple doesn't have a UserInfo endpoint and claims about users -// are instead included in the "id_token" (https://openid.net/specs/openid-connect-core-1_0.html#id_tokenExample) -func (p *Apple) FetchRawUserData(token *oauth2.Token) ([]byte, error) { +// Note that Apple doesn't have a UserInfo endpoint and claims about +// the users are included in the id_token (without the name - see #7090). +func (p *Apple) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { idToken, _ := token.Extra("id_token").(string) claims, err := p.parseAndVerifyIdToken(idToken) @@ -96,127 +107,43 @@ func (p *Apple) FetchRawUserData(token *oauth2.Token) ([]byte, error) { return json.Marshal(claims) } -// ------------------------------------------------------------------- - func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) { if idToken == "" { return nil, errors.New("empty id_token") } - // extract the token header params and claims + // extract the token claims // --- claims := jwt.MapClaims{} - t, _, err := jwt.NewParser().ParseUnverified(idToken, claims) + _, _, err := jwt.NewParser().ParseUnverified(idToken, claims) if err != nil { return nil, err } // validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769 // --- - if !claims.VerifyIssuer("https://appleid.apple.com", true) { - return nil, errors.New("iss must be https://appleid.apple.com") - } - - if !claims.VerifyAudience(p.clientId, true) { - return nil, errors.New("aud must be the developer's client_id") - } - - // fetch the public key set - // --- - kid, _ := t.Header["kid"].(string) - if kid == "" { - return nil, errors.New("missing kid header value") - } - - key, err := p.fetchJWK(kid) - if err != nil { - return nil, err - } - - // decode the key params per RFC 7518 (https://tools.ietf.org/html/rfc7518#section-6.3) - // and construct a valid publicKey from them - // --- - exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "=")) + jwtValidator := jwt.NewValidator( + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithLeeway(idTokenLeeway), + jwt.WithIssuer("https://appleid.apple.com"), + jwt.WithAudience(p.clientId), + ) + err = jwtValidator.Validate(claims) if err != nil { return nil, err } - modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "=")) - if err != nil { - return nil, err - } - - publicKey := &rsa.PublicKey{ - // https://tools.ietf.org/html/rfc7517#appendix-A.1 - E: int(big.NewInt(0).SetBytes(exponent).Uint64()), - N: big.NewInt(0).SetBytes(modulus), - } - - // verify the id_token + // validate id_token signature + // + // note: this step could be technically considered optional because we trust + // the token which is a result of direct TLS communication with the provider + // (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) // --- - parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg})) - - parsedToken, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) { - return publicKey, nil - }) + err = jwk.ValidateTokenSignature(p.ctx, idToken, p.jwksURL) if err != nil { - return nil, err - } - - if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok && parsedToken.Valid { - return claims, nil - } - - return nil, errors.New("the parsed id_token is invalid") -} - -type jwk struct { - Kty string - Kid string - Use string - Alg string - N string - E string -} - -func (p *Apple) fetchJWK(kid string) (*jwk, error) { - req, err := http.NewRequestWithContext(p.ctx, "GET", p.jwksUrl, nil) - if err != nil { - return nil, err - } - - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - rawBody, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - // http.Client.Get doesn't treat non 2xx responses as error - if res.StatusCode >= 400 { - return nil, fmt.Errorf( - "failed to verify the provided id_token (%d):\n%s", - res.StatusCode, - string(rawBody), - ) - } - - jwks := struct { - Keys []*jwk - }{} - if err := json.Unmarshal(rawBody, &jwks); err != nil { - return nil, err - } - - for _, key := range jwks.Keys { - if key.Kid == kid { - return key, nil - } + return nil, fmt.Errorf("id_token validation failed: %w", err) } - return nil, fmt.Errorf("jwk with kid %q was not found", kid) + return claims, nil } diff --git a/tools/auth/auth.go b/tools/auth/auth.go index a764c6761d..fcac40e517 100644 --- a/tools/auth/auth.go +++ b/tools/auth/auth.go @@ -2,32 +2,65 @@ package auth import ( "context" + "encoding/json/v2" "errors" "net/http" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) -// AuthUser defines a standardized oauth2 user data structure. -type AuthUser struct { - Id string `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - AvatarUrl string `json:"avatarUrl"` - RawUser map[string]any `json:"rawUser"` - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` +// ProviderFactoryFunc defines a function for initializing a new OAuth2 provider. +type ProviderFactoryFunc func() Provider + +// Providers defines a map with all of the available OAuth2 providers. +// +// To register a new provider append a new entry in the map. +var Providers = map[string]ProviderFactoryFunc{} + +// NewProviderByName returns a new preconfigured provider instance by its name identifier. +func NewProviderByName(name string) (Provider, error) { + factory, ok := Providers[name] + if !ok { + return nil, errors.New("missing provider " + name) + } + + return factory(), nil } +// @todo refactor and consider replace with a plain struct +// // Provider defines a common interface for an OAuth2 client. type Provider interface { - // Scopes returns the context associated with the provider (if any). + // @todo temp backport + // + // Order returns the sorting order of the provider usually used in the auth methods list response. + Logo() string + + // @todo temp backport + // + // Order returns the sorting order of the provider usually used in the auth methods list response. + Order() int + + // Context returns the context associated with the provider (if any). Context() context.Context // SetContext assigns the specified context to the current provider. SetContext(ctx context.Context) + // PKCE indicates whether the provider can use the PKCE flow. + PKCE() bool + + // SetPKCE toggles the state whether the provider can use the PKCE flow or not. + SetPKCE(enable bool) + + // DisplayName usually returns provider name as it is officially written + // and it could be used directly in the UI. + DisplayName() string + + // SetDisplayName sets the provider's display name. + SetDisplayName(displayName string) + // Scopes returns the provider access permissions that will be requested. Scopes() []string @@ -46,96 +79,98 @@ type Provider interface { // SetClientSecret sets the provider client's app secret. SetClientSecret(secret string) - // RedirectUrl returns the end address to redirect the user + // RedirectURL returns the end address to redirect the user // going through the OAuth flow. - RedirectUrl() string + RedirectURL() string + + // SetRedirectURL sets the provider's RedirectURL. + SetRedirectURL(url string) - // SetRedirectUrl sets the provider's RedirectUrl. - SetRedirectUrl(url string) + // AuthURL returns the provider's authorization service url. + AuthURL() string - // AuthUrl returns the provider's authorization service url. - AuthUrl() string + // SetAuthURL sets the provider's AuthURL. + SetAuthURL(url string) - // SetAuthUrl sets the provider's AuthUrl. - SetAuthUrl(url string) + // TokenURL returns the provider's token exchange service url. + TokenURL() string - // TokenUrl returns the provider's token exchange service url. - TokenUrl() string + // SetTokenURL sets the provider's TokenURL. + SetTokenURL(url string) - // SetTokenUrl sets the provider's TokenUrl. - SetTokenUrl(url string) + // UserInfoURL returns the provider's user info api url. + UserInfoURL() string - // UserApiUrl returns the provider's user info api url. - UserApiUrl() string + // SetUserInfoURL sets the provider's UserInfoURL. + SetUserInfoURL(url string) - // SetUserApiUrl sets the provider's UserApiUrl. - SetUserApiUrl(url string) + // Extra returns a shallow copy of any custom config data + // that the provider may need. + Extra() map[string]any + + // SetExtra updates the provider's custom config data. + SetExtra(data map[string]any) // Client returns an http client using the provided token. Client(token *oauth2.Token) *http.Client - // BuildAuthUrl returns a URL to the provider's consent page + // BuildAuthURL returns a URL to the provider's consent page // that asks for permissions for the required scopes explicitly. - BuildAuthUrl(state string, opts ...oauth2.AuthCodeOption) string + BuildAuthURL(state string, opts ...oauth2.AuthCodeOption) string // FetchToken converts an authorization code to token. FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) - // FetchRawUserData requests and marshalizes into `result` the + // FetchRawUserInfo requests and marshalizes into `result` the // the OAuth user api response. - FetchRawUserData(token *oauth2.Token) ([]byte, error) + FetchRawUserInfo(token *oauth2.Token) ([]byte, error) - // FetchAuthUser is similar to FetchRawUserData, but normalizes and + // FetchAuthUser is similar to FetchRawUserInfo, but normalizes and // marshalizes the user api response into a standardized AuthUser struct. FetchAuthUser(token *oauth2.Token) (user *AuthUser, err error) } -// NewProviderByName returns a new preconfigured provider instance by its name identifier. -func NewProviderByName(name string) (Provider, error) { - switch name { - case NameGoogle: - return NewGoogleProvider(), nil - case NameFacebook: - return NewFacebookProvider(), nil - case NameGithub: - return NewGithubProvider(), nil - case NameGitlab: - return NewGitlabProvider(), nil - case NameDiscord: - return NewDiscordProvider(), nil - case NameTwitter: - return NewTwitterProvider(), nil - case NameMicrosoft: - return NewMicrosoftProvider(), nil - case NameSpotify: - return NewSpotifyProvider(), nil - case NameKakao: - return NewKakaoProvider(), nil - case NameTwitch: - return NewTwitchProvider(), nil - case NameStrava: - return NewStravaProvider(), nil - case NameGitee: - return NewGiteeProvider(), nil - case NameLivechat: - return NewLivechatProvider(), nil - case NameGitea: - return NewGiteaProvider(), nil - case NameOIDC: - return NewOIDCProvider(), nil - case NameOIDC + "2": - return NewOIDCProvider(), nil - case NameOIDC + "3": - return NewOIDCProvider(), nil - case NameApple: - return NewAppleProvider(), nil - case NameInstagram: - return NewInstagramProvider(), nil - case NameVK: - return NewVKProvider(), nil - case NameYandex: - return NewYandexProvider(), nil - default: - return nil, errors.New("Missing provider " + name) +// wrapFactory is a helper that wraps a Provider specific factory +// function and returns its result as Provider interface. +func wrapFactory[T Provider](factory func() T) ProviderFactoryFunc { + return func() Provider { + return factory() } } + +// AuthUser defines a standardized OAuth2 user data structure. +type AuthUser struct { + Expiry types.DateTime `json:"expiry"` + RawUser map[string]any `json:"rawUser"` + Id string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + AvatarURL string `json:"avatarURL"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + + // @todo consider assigning the non-verified email and combining + // with an extra Verified bool flag. + + // The VERIFIED OAuth2 account email. + // + // It must be empty if the provider is not able to verify the email ownership. + Email string `json:"email"` + + // @todo + // deprecated: use AvatarURL instead + // AvatarUrl will be removed after dropping v0.22 support + AvatarUrl string `json:"avatarUrl"` +} + +// MarshalJSON implements the [json.Marshaler] interface. +// +// @todo remove after dropping v0.22 support +func (au AuthUser) MarshalJSON() ([]byte, error) { + type alias AuthUser // prevent recursion + + au2 := alias(au) + au2.AvatarUrl = au.AvatarURL // ensure that the legacy field is populated + + return json.Marshal(au2) +} diff --git a/tools/auth/auth_test.go b/tools/auth/auth_test.go index 402cb56464..e025f8c616 100644 --- a/tools/auth/auth_test.go +++ b/tools/auth/auth_test.go @@ -6,6 +6,14 @@ import ( "github.com/pocketbase/pocketbase/tools/auth" ) +func TestProvidersCount(t *testing.T) { + expected := 32 + + if total := len(auth.Providers); total != expected { + t.Fatalf("Expected %d providers, got %d", expected, total) + } +} + func TestNewProviderByName(t *testing.T) { var err error var p auth.Provider @@ -205,6 +213,105 @@ func TestNewProviderByName(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } if _, ok := p.(*auth.Yandex); !ok { - t.Error("Expected to be instance of *auth.yandex") + t.Error("Expected to be instance of *auth.Yandex") + } + + // patreon + p, err = auth.NewProviderByName(auth.NamePatreon) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Patreon); !ok { + t.Error("Expected to be instance of *auth.Patreon") + } + + // mailcow + p, err = auth.NewProviderByName(auth.NameMailcow) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Mailcow); !ok { + t.Error("Expected to be instance of *auth.Mailcow") + } + + // bitbucket + p, err = auth.NewProviderByName(auth.NameBitbucket) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Bitbucket); !ok { + t.Error("Expected to be instance of *auth.Bitbucket") + } + + // planningcenter + p, err = auth.NewProviderByName(auth.NamePlanningcenter) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Planningcenter); !ok { + t.Error("Expected to be instance of *auth.Planningcenter") + } + + // notion + p, err = auth.NewProviderByName(auth.NameNotion) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Notion); !ok { + t.Error("Expected to be instance of *auth.Notion") + } + + // monday + p, err = auth.NewProviderByName(auth.NameMonday) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Monday); !ok { + t.Error("Expected to be instance of *auth.Monday") + } + + // wakatime + p, err = auth.NewProviderByName(auth.NameWakatime) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Wakatime); !ok { + t.Error("Expected to be instance of *auth.Wakatime") + } + + // box + p, err = auth.NewProviderByName(auth.NameBox) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Box); !ok { + t.Error("Expected to be instance of *auth.Box") + } + + // linear + p, err = auth.NewProviderByName(auth.NameLinear) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Linear); !ok { + t.Error("Expected to be instance of *auth.Linear") + } + + // trakt + p, err = auth.NewProviderByName(auth.NameTrakt) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Trakt); !ok { + t.Error("Expected to be instance of *auth.Trakt") + } + + // lark + p, err = auth.NewProviderByName(auth.NameLark) + if err != nil { + t.Errorf("Expected nil, got error %v", err) + } + if _, ok := p.(*auth.Lark); !ok { + t.Error("Expected to be instance of *auth.Lark") } } diff --git a/tools/auth/base_provider.go b/tools/auth/base_provider.go index 7b250280e9..715c227076 100644 --- a/tools/auth/base_provider.go +++ b/tools/auth/base_provider.go @@ -4,130 +4,176 @@ import ( "context" "fmt" "io" + "maps" "net/http" "golang.org/x/oauth2" ) -// baseProvider defines common fields and methods used by OAuth2 client providers. -type baseProvider struct { +// BaseProvider defines common fields and methods used by OAuth2 client providers. +type BaseProvider struct { ctx context.Context - scopes []string + extra map[string]any + redirectURL string clientId string clientSecret string - redirectUrl string - authUrl string - tokenUrl string - userApiUrl string + displayName string + logo string + authURL string + tokenURL string + userInfoURL string + scopes []string + order int + pkce bool +} + +// Order implements Provider.Order() interface method. +func (p *BaseProvider) Order() int { + return p.order +} + +// Logo implements Provider.Logo() interface method. +func (p *BaseProvider) Logo() string { + return p.logo } // Context implements Provider.Context() interface method. -func (p *baseProvider) Context() context.Context { +func (p *BaseProvider) Context() context.Context { return p.ctx } // SetContext implements Provider.SetContext() interface method. -func (p *baseProvider) SetContext(ctx context.Context) { +func (p *BaseProvider) SetContext(ctx context.Context) { p.ctx = ctx } +// PKCE implements Provider.PKCE() interface method. +func (p *BaseProvider) PKCE() bool { + return p.pkce +} + +// SetPKCE implements Provider.SetPKCE() interface method. +func (p *BaseProvider) SetPKCE(enable bool) { + p.pkce = enable +} + +// DisplayName implements Provider.DisplayName() interface method. +func (p *BaseProvider) DisplayName() string { + return p.displayName +} + +// SetDisplayName implements Provider.SetDisplayName() interface method. +func (p *BaseProvider) SetDisplayName(displayName string) { + p.displayName = displayName +} + // Scopes implements Provider.Scopes() interface method. -func (p *baseProvider) Scopes() []string { +func (p *BaseProvider) Scopes() []string { return p.scopes } // SetScopes implements Provider.SetScopes() interface method. -func (p *baseProvider) SetScopes(scopes []string) { +func (p *BaseProvider) SetScopes(scopes []string) { p.scopes = scopes } // ClientId implements Provider.ClientId() interface method. -func (p *baseProvider) ClientId() string { +func (p *BaseProvider) ClientId() string { return p.clientId } // SetClientId implements Provider.SetClientId() interface method. -func (p *baseProvider) SetClientId(clientId string) { +func (p *BaseProvider) SetClientId(clientId string) { p.clientId = clientId } // ClientSecret implements Provider.ClientSecret() interface method. -func (p *baseProvider) ClientSecret() string { +func (p *BaseProvider) ClientSecret() string { return p.clientSecret } // SetClientSecret implements Provider.SetClientSecret() interface method. -func (p *baseProvider) SetClientSecret(secret string) { +func (p *BaseProvider) SetClientSecret(secret string) { p.clientSecret = secret } -// RedirectUrl implements Provider.RedirectUrl() interface method. -func (p *baseProvider) RedirectUrl() string { - return p.redirectUrl +// RedirectURL implements Provider.RedirectURL() interface method. +func (p *BaseProvider) RedirectURL() string { + return p.redirectURL +} + +// SetRedirectURL implements Provider.SetRedirectURL() interface method. +func (p *BaseProvider) SetRedirectURL(url string) { + p.redirectURL = url +} + +// AuthURL implements Provider.AuthURL() interface method. +func (p *BaseProvider) AuthURL() string { + return p.authURL } -// SetRedirectUrl implements Provider.SetRedirectUrl() interface method. -func (p *baseProvider) SetRedirectUrl(url string) { - p.redirectUrl = url +// SetAuthURL implements Provider.SetAuthURL() interface method. +func (p *BaseProvider) SetAuthURL(url string) { + p.authURL = url } -// AuthUrl implements Provider.AuthUrl() interface method. -func (p *baseProvider) AuthUrl() string { - return p.authUrl +// TokenURL implements Provider.TokenURL() interface method. +func (p *BaseProvider) TokenURL() string { + return p.tokenURL } -// SetAuthUrl implements Provider.SetAuthUrl() interface method. -func (p *baseProvider) SetAuthUrl(url string) { - p.authUrl = url +// SetTokenURL implements Provider.SetTokenURL() interface method. +func (p *BaseProvider) SetTokenURL(url string) { + p.tokenURL = url } -// TokenUrl implements Provider.TokenUrl() interface method. -func (p *baseProvider) TokenUrl() string { - return p.tokenUrl +// UserInfoURL implements Provider.UserInfoURL() interface method. +func (p *BaseProvider) UserInfoURL() string { + return p.userInfoURL } -// SetTokenUrl implements Provider.SetTokenUrl() interface method. -func (p *baseProvider) SetTokenUrl(url string) { - p.tokenUrl = url +// SetUserInfoURL implements Provider.SetUserInfoURL() interface method. +func (p *BaseProvider) SetUserInfoURL(url string) { + p.userInfoURL = url } -// UserApiUrl implements Provider.UserApiUrl() interface method. -func (p *baseProvider) UserApiUrl() string { - return p.userApiUrl +// Extra implements Provider.Extra() interface method. +func (p *BaseProvider) Extra() map[string]any { + return maps.Clone(p.extra) } -// SetUserApiUrl implements Provider.SetUserApiUrl() interface method. -func (p *baseProvider) SetUserApiUrl(url string) { - p.userApiUrl = url +// SetExtra implements Provider.SetExtra() interface method. +func (p *BaseProvider) SetExtra(data map[string]any) { + p.extra = data } -// BuildAuthUrl implements Provider.BuildAuthUrl() interface method. -func (p *baseProvider) BuildAuthUrl(state string, opts ...oauth2.AuthCodeOption) string { +// BuildAuthURL implements Provider.BuildAuthURL() interface method. +func (p *BaseProvider) BuildAuthURL(state string, opts ...oauth2.AuthCodeOption) string { return p.oauth2Config().AuthCodeURL(state, opts...) } // FetchToken implements Provider.FetchToken() interface method. -func (p *baseProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { +func (p *BaseProvider) FetchToken(code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { return p.oauth2Config().Exchange(p.ctx, code, opts...) } // Client implements Provider.Client() interface method. -func (p *baseProvider) Client(token *oauth2.Token) *http.Client { +func (p *BaseProvider) Client(token *oauth2.Token) *http.Client { return p.oauth2Config().Client(p.ctx, token) } -// FetchRawUserData implements Provider.FetchRawUserData() interface method. -func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) { - req, err := http.NewRequestWithContext(p.ctx, "GET", p.userApiUrl, nil) +// FetchRawUserInfo implements Provider.FetchRawUserInfo() interface method. +func (p *BaseProvider) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil) if err != nil { return nil, err } - return p.sendRawUserDataRequest(req, token) + return p.sendRawUserInfoRequest(req, token) } -// sendRawUserDataRequest sends the specified user data request and return its raw response body. -func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token) ([]byte, error) { +// sendRawUserInfoRequest sends the specified user info request and return its raw response body. +func (p *BaseProvider) sendRawUserInfoRequest(req *http.Request, token *oauth2.Token) ([]byte, error) { client := p.Client(token) res, err := client.Do(req) @@ -145,7 +191,7 @@ func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.T if res.StatusCode >= 400 { return nil, fmt.Errorf( "failed to fetch OAuth2 user profile via %s (%d):\n%s", - p.userApiUrl, + p.userInfoURL, res.StatusCode, string(result), ) @@ -155,15 +201,15 @@ func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.T } // oauth2Config constructs a oauth2.Config instance based on the provider settings. -func (p *baseProvider) oauth2Config() *oauth2.Config { +func (p *BaseProvider) oauth2Config() *oauth2.Config { return &oauth2.Config{ - RedirectURL: p.redirectUrl, + RedirectURL: p.redirectURL, ClientID: p.clientId, ClientSecret: p.clientSecret, Scopes: p.scopes, Endpoint: oauth2.Endpoint{ - AuthURL: p.authUrl, - TokenURL: p.tokenUrl, + AuthURL: p.authURL, + TokenURL: p.tokenURL, }, } } diff --git a/tools/auth/base_provider_test.go b/tools/auth/base_provider_test.go index 052eb8eb29..b5bd7af42b 100644 --- a/tools/auth/base_provider_test.go +++ b/tools/auth/base_provider_test.go @@ -1,14 +1,16 @@ package auth import ( + "bytes" "context" + "encoding/json/v2" "testing" "golang.org/x/oauth2" ) func TestContext(t *testing.T) { - b := baseProvider{} + b := BaseProvider{} before := b.Scopes() if before != nil { @@ -23,130 +25,229 @@ func TestContext(t *testing.T) { } } +func TestDisplayName(t *testing.T) { + b := BaseProvider{} + + before := b.DisplayName() + if before != "" { + t.Fatalf("Expected displayName to be empty, got %q", before) + } + + b.SetDisplayName("test") + + after := b.DisplayName() + if after != "test" { + t.Fatalf("Expected displayName to be %q, got %q", "test", after) + } +} + +func TestOrder(t *testing.T) { + b := BaseProvider{} + + before := b.Order() + if before != 0 { + t.Fatalf("Expected order to be empty, got %d", before) + } + + b.order = 123 + + after := b.Order() + if after != 123 { + t.Fatalf("Expected order to be %d, got %d", 123, after) + } +} + +func TestLogo(t *testing.T) { + b := BaseProvider{} + + before := b.Logo() + if before != "" { + t.Fatalf("Expected logo to be empty, got %q", before) + } + + b.logo = "test" + + after := b.Logo() + if after != "test" { + t.Fatalf("Expected logo to be %q, got %q", "test", after) + } +} + +func TestPKCE(t *testing.T) { + b := BaseProvider{} + + before := b.PKCE() + if before != false { + t.Fatalf("Expected pkce to be %v, got %v", false, before) + } + + b.SetPKCE(true) + + after := b.PKCE() + if after != true { + t.Fatalf("Expected pkce to be %v, got %v", true, after) + } +} + func TestScopes(t *testing.T) { - b := baseProvider{} + b := BaseProvider{} before := b.Scopes() if len(before) != 0 { - t.Errorf("Expected 0 scopes, got %v", before) + t.Fatalf("Expected 0 scopes, got %v", before) } b.SetScopes([]string{"test1", "test2"}) after := b.Scopes() if len(after) != 2 { - t.Errorf("Expected 2 scopes, got %v", after) + t.Fatalf("Expected 2 scopes, got %v", after) } } func TestClientId(t *testing.T) { - b := baseProvider{} + b := BaseProvider{} before := b.ClientId() if before != "" { - t.Errorf("Expected clientId to be empty, got %v", before) + t.Fatalf("Expected clientId to be empty, got %q", before) } b.SetClientId("test") after := b.ClientId() if after != "test" { - t.Errorf("Expected clientId to be 'test', got %v", after) + t.Fatalf("Expected clientId to be %q, got %q", "test", after) } } func TestClientSecret(t *testing.T) { - b := baseProvider{} + b := BaseProvider{} before := b.ClientSecret() if before != "" { - t.Errorf("Expected clientSecret to be empty, got %v", before) + t.Fatalf("Expected clientSecret to be empty, got %q", before) } b.SetClientSecret("test") after := b.ClientSecret() if after != "test" { - t.Errorf("Expected clientSecret to be 'test', got %v", after) + t.Fatalf("Expected clientSecret to be %q, got %q", "test", after) } } -func TestRedirectUrl(t *testing.T) { - b := baseProvider{} +func TestRedirectURL(t *testing.T) { + b := BaseProvider{} - before := b.RedirectUrl() + before := b.RedirectURL() if before != "" { - t.Errorf("Expected RedirectUrl to be empty, got %v", before) + t.Fatalf("Expected RedirectURL to be empty, got %q", before) } - b.SetRedirectUrl("test") + b.SetRedirectURL("test") - after := b.RedirectUrl() + after := b.RedirectURL() if after != "test" { - t.Errorf("Expected RedirectUrl to be 'test', got %v", after) + t.Fatalf("Expected RedirectURL to be %q, got %q", "test", after) } } -func TestAuthUrl(t *testing.T) { - b := baseProvider{} +func TestAuthURL(t *testing.T) { + b := BaseProvider{} - before := b.AuthUrl() + before := b.AuthURL() if before != "" { - t.Errorf("Expected authUrl to be empty, got %v", before) + t.Fatalf("Expected authURL to be empty, got %q", before) } - b.SetAuthUrl("test") + b.SetAuthURL("test") - after := b.AuthUrl() + after := b.AuthURL() if after != "test" { - t.Errorf("Expected authUrl to be 'test', got %v", after) + t.Fatalf("Expected authURL to be %q, got %q", "test", after) } } -func TestTokenUrl(t *testing.T) { - b := baseProvider{} +func TestTokenURL(t *testing.T) { + b := BaseProvider{} - before := b.TokenUrl() + before := b.TokenURL() if before != "" { - t.Errorf("Expected tokenUrl to be empty, got %v", before) + t.Fatalf("Expected tokenURL to be empty, got %q", before) } - b.SetTokenUrl("test") + b.SetTokenURL("test") - after := b.TokenUrl() + after := b.TokenURL() if after != "test" { - t.Errorf("Expected tokenUrl to be 'test', got %v", after) + t.Fatalf("Expected tokenURL to be %q, got %q", "test", after) } } -func TestUserApiUrl(t *testing.T) { - b := baseProvider{} +func TestUserInfoURL(t *testing.T) { + b := BaseProvider{} - before := b.UserApiUrl() + before := b.UserInfoURL() if before != "" { - t.Errorf("Expected userApiUrl to be empty, got %v", before) + t.Fatalf("Expected userInfoURL to be empty, got %q", before) } - b.SetUserApiUrl("test") + b.SetUserInfoURL("test") - after := b.UserApiUrl() + after := b.UserInfoURL() if after != "test" { - t.Errorf("Expected userApiUrl to be 'test', got %v", after) + t.Fatalf("Expected userInfoURL to be %q, got %q", "test", after) + } +} + +func TestExtra(t *testing.T) { + b := BaseProvider{} + + before := b.Extra() + if before != nil { + t.Fatalf("Expected extra to be empty, got %v", before) + } + + extra := map[string]any{"a": 1, "b": 2} + + b.SetExtra(extra) + + after := b.Extra() + + rawExtra, err := json.Marshal(extra, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + rawAfter, err := json.Marshal(after, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(rawExtra, rawAfter) { + t.Fatalf("Expected extra to be\n%s\ngot\n%s", rawExtra, rawAfter) + } + + // ensure that it was shallow copied + after["b"] = 3 + if d := b.Extra(); d["b"] != 2 { + t.Fatalf("Expected extra to remain unchanged, got\n%v", d) } } -func TestBuildAuthUrl(t *testing.T) { - b := baseProvider{ - authUrl: "authUrl_test", - tokenUrl: "tokenUrl_test", - redirectUrl: "redirectUrl_test", +func TestBuildAuthURL(t *testing.T) { + b := BaseProvider{ + authURL: "authURL_test", + tokenURL: "tokenURL_test", + redirectURL: "redirectURL_test", clientId: "clientId_test", clientSecret: "clientSecret_test", scopes: []string{"test_scope"}, } - expected := "authUrl_test?access_type=offline&client_id=clientId_test&prompt=consent&redirect_uri=redirectUrl_test&response_type=code&scope=test_scope&state=state_test" - result := b.BuildAuthUrl("state_test", oauth2.AccessTypeOffline, oauth2.ApprovalForce) + expected := "authURL_test?access_type=offline&client_id=clientId_test&prompt=consent&redirect_uri=redirectURL_test&response_type=code&scope=test_scope&state=state_test" + result := b.BuildAuthURL("state_test", oauth2.AccessTypeOffline, oauth2.ApprovalForce) if result != expected { t.Errorf("Expected auth url %q, got %q", expected, result) @@ -154,7 +255,7 @@ func TestBuildAuthUrl(t *testing.T) { } func TestClient(t *testing.T) { - b := baseProvider{} + b := BaseProvider{} result := b.Client(&oauth2.Token{}) if result == nil { @@ -163,10 +264,10 @@ func TestClient(t *testing.T) { } func TestOauth2Config(t *testing.T) { - b := baseProvider{ - authUrl: "authUrl_test", - tokenUrl: "tokenUrl_test", - redirectUrl: "redirectUrl_test", + b := BaseProvider{ + authURL: "authURL_test", + tokenURL: "tokenURL_test", + redirectURL: "redirectURL_test", clientId: "clientId_test", clientSecret: "clientSecret_test", scopes: []string{"test"}, @@ -174,8 +275,8 @@ func TestOauth2Config(t *testing.T) { result := b.oauth2Config() - if result.RedirectURL != b.RedirectUrl() { - t.Errorf("Expected redirectUrl %s, got %s", b.RedirectUrl(), result.RedirectURL) + if result.RedirectURL != b.RedirectURL() { + t.Errorf("Expected redirectURL %s, got %s", b.RedirectURL(), result.RedirectURL) } if result.ClientID != b.ClientId() { @@ -186,12 +287,12 @@ func TestOauth2Config(t *testing.T) { t.Errorf("Expected clientSecret %s, got %s", b.ClientSecret(), result.ClientSecret) } - if result.Endpoint.AuthURL != b.AuthUrl() { - t.Errorf("Expected authUrl %s, got %s", b.AuthUrl(), result.Endpoint.AuthURL) + if result.Endpoint.AuthURL != b.AuthURL() { + t.Errorf("Expected authURL %s, got %s", b.AuthURL(), result.Endpoint.AuthURL) } - if result.Endpoint.TokenURL != b.TokenUrl() { - t.Errorf("Expected authUrl %s, got %s", b.TokenUrl(), result.Endpoint.TokenURL) + if result.Endpoint.TokenURL != b.TokenURL() { + t.Errorf("Expected authURL %s, got %s", b.TokenURL(), result.Endpoint.TokenURL) } if len(result.Scopes) != len(b.Scopes()) || result.Scopes[0] != b.Scopes()[0] { diff --git a/tools/auth/bitbucket.go b/tools/auth/bitbucket.go new file mode 100644 index 0000000000..fb1f39aa87 --- /dev/null +++ b/tools/auth/bitbucket.go @@ -0,0 +1,139 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "errors" + "io" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameBitbucket] = wrapFactory(NewBitbucketProvider) +} + +var _ Provider = (*Bitbucket)(nil) + +// NameBitbucket is the unique name of the Bitbucket provider. +const NameBitbucket = "bitbucket" + +// Bitbucket is an auth provider for Bitbucket. +type Bitbucket struct { + BaseProvider +} + +// NewBitbucketProvider creates a new Bitbucket provider instance with some defaults. +func NewBitbucketProvider() *Bitbucket { + return &Bitbucket{BaseProvider{ + ctx: context.Background(), + order: 9, + logo: ``, + displayName: "Bitbucket", + pkce: false, + scopes: []string{"account"}, + authURL: "https://bitbucket.org/site/oauth2/authorize", + tokenURL: "https://bitbucket.org/site/oauth2/access_token", + userInfoURL: "https://api.bitbucket.org/2.0/user", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Bitbucket's user API. +// +// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get +func (p *Bitbucket) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + UUID string `json:"uuid"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AccountStatus string `json:"account_status"` + Links struct { + Avatar struct { + Href string `json:"href"` + } `json:"avatar"` + } `json:"links"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if extracted.AccountStatus != "active" { + return nil, errors.New("the Bitbucket user is not active") + } + + email, err := p.fetchPrimaryEmail(token) + if err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.UUID, + Name: extracted.DisplayName, + Username: extracted.Username, + Email: email, + AvatarURL: extracted.Links.Avatar.Href, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} + +// fetchPrimaryEmail sends an API request to retrieve the first +// verified primary email. +// +// NB! This method can succeed and still return an empty email. +// Error responses that are result of insufficient scopes permissions are ignored. +// +// API reference: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-emails-get +func (p *Bitbucket) fetchPrimaryEmail(token *oauth2.Token) (string, error) { + response, err := p.Client(token).Get(p.userInfoURL + "/emails") + if err != nil { + return "", err + } + defer response.Body.Close() + + // ignore common http errors caused by insufficient scope permissions + // (the email field is optional, aka. return the auth user without it) + if response.StatusCode >= 400 { + return "", nil + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + expected := struct { + Values []struct { + Email string `json:"email"` + IsPrimary bool `json:"is_primary"` + IsConfirmed bool `json:"is_confirmed"` + } `json:"values"` + }{} + if err := json.Unmarshal(data, &expected); err != nil { + return "", err + } + + for _, v := range expected.Values { + if v.IsPrimary && v.IsConfirmed { + return v.Email, nil + } + } + + return "", nil +} diff --git a/tools/auth/box.go b/tools/auth/box.go new file mode 100644 index 0000000000..d139ceb36b --- /dev/null +++ b/tools/auth/box.go @@ -0,0 +1,83 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "fmt" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameBox] = wrapFactory(NewBoxProvider) +} + +var _ Provider = (*Box)(nil) + +// NameBox is the unique name of the Box provider. +const NameBox = "box" + +// Box is an auth provider for Box. +type Box struct { + BaseProvider +} + +// NewBoxProvider creates a new Box provider instance with some defaults. +func NewBoxProvider() *Box { + return &Box{BaseProvider{ + ctx: context.Background(), + order: 20, + logo: ``, + displayName: "Box", + pkce: true, + scopes: []string{"root_readonly"}, + authURL: "https://account.box.com/api/oauth2/authorize", + tokenURL: "https://api.box.com/oauth2/token", + userInfoURL: "https://api.box.com/2.0/users/me", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Box's user API. +// +// API reference: https://developer.box.com/reference/get-users-me/ +func (p *Box) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Id string `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Status string `json:"status"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if extracted.Status != "active" { + return nil, fmt.Errorf("Box user account is not active (status: %q)", extracted.Status) + } + + user := &AuthUser{ + Id: extracted.Id, + Name: extracted.Name, + AvatarURL: extracted.AvatarURL, + Email: extracted.Login, // Box requires verified email for OAuth authorization + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} diff --git a/tools/auth/discord.go b/tools/auth/discord.go index a74dc76b3e..acd65aaa03 100644 --- a/tools/auth/discord.go +++ b/tools/auth/discord.go @@ -2,12 +2,17 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "fmt" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameDiscord] = wrapFactory(NewDiscordProvider) +} + var _ Provider = (*Discord)(nil) // NameDiscord is the unique name of the Discord provider. @@ -15,19 +20,23 @@ const NameDiscord string = "discord" // Discord allows authentication via Discord OAuth2. type Discord struct { - *baseProvider + BaseProvider } // NewDiscordProvider creates a new Discord provider instance with some defaults. func NewDiscordProvider() *Discord { // https://discord.com/developers/docs/topics/oauth2 // https://discord.com/developers/docs/resources/user#get-current-user - return &Discord{&baseProvider{ - ctx: context.Background(), - scopes: []string{"identify", "email"}, - authUrl: "https://discord.com/api/oauth2/authorize", - tokenUrl: "https://discord.com/api/oauth2/token", - userApiUrl: "https://discord.com/api/users/@me", + return &Discord{BaseProvider{ + ctx: context.Background(), + order: 12, + logo: ``, + displayName: "Discord", + pkce: true, + scopes: []string{"identify", "email"}, + authURL: "https://discord.com/api/oauth2/authorize", + tokenURL: "https://discord.com/api/oauth2/token", + userInfoURL: "https://discord.com/api/users/@me", }} } @@ -35,7 +44,7 @@ func NewDiscordProvider() *Discord { // // API reference: https://discord.com/developers/docs/resources/user#user-object func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -47,32 +56,49 @@ func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { extracted := struct { Id string `json:"id"` + GlobalName string `json:"global_name"` Username string `json:"username"` Discriminator string `json:"discriminator"` + Avatar string `json:"avatar"` Email string `json:"email"` Verified bool `json:"verified"` - Avatar string `json:"avatar"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } - // Build a full avatar URL using the avatar hash provided in the API response + // build a full avatar URL using the avatar hash provided in the API response // https://discord.com/developers/docs/reference#image-formatting - avatarUrl := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", extracted.Id, extracted.Avatar) + var avatarURL string + if extracted.Avatar != "" { + avatarURL = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", extracted.Id, extracted.Avatar) + } - // Concatenate the user's username and discriminator into a single username string - username := fmt.Sprintf("%s#%s", extracted.Username, extracted.Discriminator) + name := extracted.GlobalName + if name == "" { + // fallback to username+discriminator + // + // Note: Discord migrated to unique usernames without discriminators. + // Legacy accounts still have a non-zero discriminator (e.g. "1234"). + // See https://support.discord.com/hc/en-us/articles/12620128861463-New-Usernames-Display-Names. + name = extracted.Username + if extracted.Discriminator != "" && extracted.Discriminator != "0" { + name += "#" + extracted.Discriminator + } + } user := &AuthUser{ Id: extracted.Id, - Name: username, + Name: name, Username: extracted.Username, - AvatarUrl: avatarUrl, + AvatarURL: avatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if extracted.Verified { user.Email = extracted.Email } diff --git a/tools/auth/facebook.go b/tools/auth/facebook.go index c83acd3933..dd92f980ba 100644 --- a/tools/auth/facebook.go +++ b/tools/auth/facebook.go @@ -2,12 +2,17 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/facebook" ) +func init() { + Providers[NameFacebook] = wrapFactory(NewFacebookProvider) +} + var _ Provider = (*Facebook)(nil) // NameFacebook is the unique name of the Facebook provider. @@ -15,17 +20,21 @@ const NameFacebook string = "facebook" // Facebook allows authentication via Facebook OAuth2. type Facebook struct { - *baseProvider + BaseProvider } // NewFacebookProvider creates new Facebook provider instance with some defaults. func NewFacebookProvider() *Facebook { - return &Facebook{&baseProvider{ - ctx: context.Background(), - scopes: []string{"email"}, - authUrl: facebook.Endpoint.AuthURL, - tokenUrl: facebook.Endpoint.TokenURL, - userApiUrl: "https://graph.facebook.com/me?fields=name,email,picture.type(large)", + return &Facebook{BaseProvider{ + ctx: context.Background(), + order: 5, + logo: ``, + displayName: "Facebook", + pkce: true, + scopes: []string{"email"}, + authURL: facebook.Endpoint.AuthURL, + tokenURL: facebook.Endpoint.TokenURL, + userInfoURL: "https://graph.facebook.com/me?fields=name,email,picture.type(large)", }} } @@ -33,7 +42,7 @@ func NewFacebookProvider() *Facebook { // // API reference: https://developers.facebook.com/docs/graph-api/reference/user/ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -44,12 +53,14 @@ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id string - Name string - Email string + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` Picture struct { - Data struct{ Url string } - } + Data struct { + Url string `json:"url"` + } `json:"data"` + } `json:"picture"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -59,11 +70,13 @@ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Id: extracted.Id, Name: extracted.Name, Email: extracted.Email, - AvatarUrl: extracted.Picture.Data.Url, + AvatarURL: extracted.Picture.Data.Url, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + return user, nil } diff --git a/tools/auth/gitea.go b/tools/auth/gitea.go index 8174f17126..9d25f232a6 100644 --- a/tools/auth/gitea.go +++ b/tools/auth/gitea.go @@ -2,38 +2,50 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "errors" + "fmt" + "io" "strconv" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameGitea] = wrapFactory(NewGiteaProvider) +} + var _ Provider = (*Gitea)(nil) -// NameGitea is the unique name of the Gitea provider. +// NameGitea is the unique name of the Gitea/Forgejo provider. const NameGitea string = "gitea" -// Gitea allows authentication via Gitea OAuth2. +// Gitea allows authentication via Gitea/Forgejo OAuth2. type Gitea struct { - *baseProvider + BaseProvider } // NewGiteaProvider creates new Gitea provider instance with some defaults. func NewGiteaProvider() *Gitea { - return &Gitea{&baseProvider{ - ctx: context.Background(), - scopes: []string{"read:user", "user:email"}, - authUrl: "https://gitea.com/login/oauth/authorize", - tokenUrl: "https://gitea.com/login/oauth/access_token", - userApiUrl: "https://gitea.com/api/v1/user", + return &Gitea{BaseProvider{ + ctx: context.Background(), + order: 11, + logo: ``, + displayName: "Gitea/Forgejo", + pkce: true, + scopes: []string{"read:user", "user:email"}, + authURL: "https://gitea.com/login/oauth/authorize", + tokenURL: "https://gitea.com/login/oauth/access_token", + userInfoURL: "https://gitea.com/api/v1/user", }} } -// FetchAuthUser returns an AuthUser instance based on Gitea's user api. +// FetchAuthUser returns an AuthUser instance based on Gitea/Forgejo's user api. // -// API reference: https://try.gitea.io/api/swagger#/user/userGetCurrent +// API reference: https://codeberg.org/api/swagger#/user/userGetCurrent func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -44,26 +56,83 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id int `json:"id"` Name string `json:"full_name"` Username string `json:"login"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` + AvatarURL string `json:"avatar_url"` + Id int64 `json:"id"` + Active bool `json:"active"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } + if !extracted.Active { + return nil, errors.New("user account is not active") + } + user := &AuthUser{ - Id: strconv.Itoa(extracted.Id), + Id: strconv.FormatInt(extracted.Id, 10), Name: extracted.Name, Username: extracted.Username, - Email: extracted.Email, - AvatarUrl: extracted.AvatarUrl, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + email, err := p.fetchVerifiedPrimaryEmail(token) + if err != nil { + return nil, fmt.Errorf("failed to fetch primary email: %w", err) + } + user.Email = email + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + return user, nil } + +// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified +// primary email, in case "Keep my email address private" was set. +// +// NB! This method can succeed and still return an empty email. +// Error responses that are result of insufficient scopes permissions are ignored. +// +// API reference: https://codeberg.org/api/swagger#/user/userListEmails +func (p *Gitea) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) { + client := p.Client(token) + + response, err := client.Get(p.userInfoURL + "/emails") + if err != nil { + return "", err + } + defer response.Body.Close() + + // ignore common http errors caused by insufficient scope permissions + // (the email field is optional, aka. return the auth user without it) + if response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode == 404 { + return "", nil + } + + content, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + emails := []struct { + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` + }{} + if err := json.Unmarshal(content, &emails); err != nil { + return "", err + } + + // extract the verified primary email + for _, email := range emails { + if email.Verified && email.Primary { + return email.Email, nil + } + } + + return "", nil +} diff --git a/tools/auth/gitee.go b/tools/auth/gitee.go index 5f314aa349..45234fedf2 100644 --- a/tools/auth/gitee.go +++ b/tools/auth/gitee.go @@ -2,14 +2,19 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "io" "strconv" - "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameGitee] = wrapFactory(NewGiteeProvider) +} + var _ Provider = (*Gitee)(nil) // NameGitee is the unique name of the Gitee provider. @@ -17,17 +22,21 @@ const NameGitee string = "gitee" // Gitee allows authentication via Gitee OAuth2. type Gitee struct { - *baseProvider + BaseProvider } // NewGiteeProvider creates new Gitee provider instance with some defaults. func NewGiteeProvider() *Gitee { - return &Gitee{&baseProvider{ - ctx: context.Background(), - scopes: []string{"user_info", "emails"}, - authUrl: "https://gitee.com/oauth/authorize", - tokenUrl: "https://gitee.com/oauth/token", - userApiUrl: "https://gitee.com/api/v5/user", + return &Gitee{BaseProvider{ + ctx: context.Background(), + order: 10, + logo: ``, + displayName: "Gitee", + pkce: true, + scopes: []string{"user_info", "emails"}, + authURL: "https://gitee.com/oauth/authorize", + tokenURL: "https://gitee.com/oauth/token", + userInfoURL: "https://gitee.com/api/v5/user", }} } @@ -35,7 +44,7 @@ func NewGiteeProvider() *Gitee { // // API reference: https://gitee.com/api/v5/swagger#/getV5User func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -47,25 +56,27 @@ func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { extracted := struct { Login string `json:"login"` - Id int `json:"id"` Name string `json:"name"` Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` + AvatarURL string `json:"avatar_url"` + Id int64 `json:"id"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(extracted.Id), + Id: strconv.FormatInt(extracted.Id, 10), Name: extracted.Name, Username: extracted.Login, - AvatarUrl: extracted.AvatarUrl, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if extracted.Email != "" && is.EmailFormat.Validate(extracted.Email) == nil { // valid public primary email user.Email = extracted.Email @@ -109,9 +120,9 @@ func (p *Gitee) fetchPrimaryEmail(token *oauth2.Token) (string, error) { } emails := []struct { - Email string - State string - Scope []string + Email string `json:"email"` + State string `json:"state"` + Scope []string `json:"scope"` }{} if err := json.Unmarshal(content, &emails); err != nil { // ignore unmarshal error in case "Keep my email address private" diff --git a/tools/auth/github.go b/tools/auth/github.go index af49aeffa8..8606cc893e 100644 --- a/tools/auth/github.go +++ b/tools/auth/github.go @@ -2,14 +2,19 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "io" "strconv" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) +func init() { + Providers[NameGithub] = wrapFactory(NewGithubProvider) +} + var _ Provider = (*Github)(nil) // NameGithub is the unique name of the Github provider. @@ -17,25 +22,29 @@ const NameGithub string = "github" // Github allows authentication via Github OAuth2. type Github struct { - *baseProvider + BaseProvider } // NewGithubProvider creates new Github provider instance with some defaults. func NewGithubProvider() *Github { - return &Github{&baseProvider{ - ctx: context.Background(), - scopes: []string{"read:user", "user:email"}, - authUrl: github.Endpoint.AuthURL, - tokenUrl: github.Endpoint.TokenURL, - userApiUrl: "https://api.github.com/user", + return &Github{BaseProvider{ + ctx: context.Background(), + order: 7, + logo: ``, + displayName: "GitHub", + pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored + scopes: []string{"read:user", "user:email"}, + authURL: github.Endpoint.AuthURL, + tokenURL: github.Endpoint.TokenURL, + userInfoURL: "https://api.github.com/user", }} } // FetchAuthUser returns an AuthUser instance based the Github's user api. // -// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user +// API reference: https://docs.github.com/en/rest/users/users?apiVersion=2026-03-10#get-the-authenticated-user func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -46,51 +55,50 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Login string `json:"login"` - Id int `json:"id"` Name string `json:"name"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Id int64 `json:"id"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(extracted.Id), + Id: strconv.FormatInt(extracted.Id, 10), Name: extracted.Name, Username: extracted.Login, - Email: extracted.Email, - AvatarUrl: extracted.AvatarUrl, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } - // in case user has set "Keep my email address private", send an - // **optional** API request to retrieve the verified primary email - if user.Email == "" { - email, err := p.fetchPrimaryEmail(token) - if err != nil { - return nil, err - } - user.Email = email + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + // always send a primary email request even though the email is + // returned in the userinfo endpoint since the API may change and + // enterprise setups may have configuration that could allow unverified emails + email, err := p.fetchVerifiedPrimaryEmail(token) + if err != nil { + return nil, err } + user.Email = email return user, nil } -// fetchPrimaryEmail sends an API request to retrieve the verified +// fetchVerifiedPrimaryEmail sends an API request to retrieve the verified // primary email, in case "Keep my email address private" was set. // // NB! This method can succeed and still return an empty email. // Error responses that are result of insufficient scopes permissions are ignored. // -// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28 -func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) { +// API reference: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user +func (p *Github) fetchVerifiedPrimaryEmail(token *oauth2.Token) (string, error) { client := p.Client(token) - response, err := client.Get(p.userApiUrl + "/emails") + response, err := client.Get(p.userInfoURL + "/emails") if err != nil { return "", err } @@ -108,9 +116,9 @@ func (p *Github) fetchPrimaryEmail(token *oauth2.Token) (string, error) { } emails := []struct { - Email string - Verified bool - Primary bool + Email string `json:"email"` + Verified bool `json:"verified"` + Primary bool `json:"primary"` }{} if err := json.Unmarshal(content, &emails); err != nil { return "", err diff --git a/tools/auth/gitlab.go b/tools/auth/gitlab.go index cbf4f14dd6..79eac0fdff 100644 --- a/tools/auth/gitlab.go +++ b/tools/auth/gitlab.go @@ -2,12 +2,18 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "strconv" + "time" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameGitlab] = wrapFactory(NewGitlabProvider) +} + var _ Provider = (*Gitlab)(nil) // NameGitlab is the unique name of the Gitlab provider. @@ -15,25 +21,29 @@ const NameGitlab string = "gitlab" // Gitlab allows authentication via Gitlab OAuth2. type Gitlab struct { - *baseProvider + BaseProvider } // NewGitlabProvider creates new Gitlab provider instance with some defaults. func NewGitlabProvider() *Gitlab { - return &Gitlab{&baseProvider{ - ctx: context.Background(), - scopes: []string{"read_user"}, - authUrl: "https://gitlab.com/oauth/authorize", - tokenUrl: "https://gitlab.com/oauth/token", - userApiUrl: "https://gitlab.com/api/v4/user", + return &Gitlab{BaseProvider{ + ctx: context.Background(), + order: 8, + logo: ``, + displayName: "GitLab", + pkce: true, + scopes: []string{"read_user"}, + authURL: "https://gitlab.com/oauth/authorize", + tokenURL: "https://gitlab.com/oauth/token", + userInfoURL: "https://gitlab.com/api/v4/user", }} } // FetchAuthUser returns an AuthUser instance based the Gitlab's user api. // -// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin +// API reference: https://docs.gitlab.com/api/users/#retrieve-the-current-user func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -44,26 +54,33 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id int `json:"id"` - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + ConfirmedAt string `json:"confirmed_at"` + Id int64 `json:"id"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(extracted.Id), + Id: strconv.FormatInt(extracted.Id, 10), Name: extracted.Name, Username: extracted.Username, - Email: extracted.Email, - AvatarUrl: extracted.AvatarUrl, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + confirmedAt, err := time.Parse(time.RFC3339, extracted.ConfirmedAt) + if err == nil && !confirmedAt.IsZero() { + user.Email = extracted.Email + } + return user, nil } diff --git a/tools/auth/google.go b/tools/auth/google.go index e42f7df188..f989080bef 100644 --- a/tools/auth/google.go +++ b/tools/auth/google.go @@ -2,11 +2,16 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameGoogle] = wrapFactory(NewGoogleProvider) +} + var _ Provider = (*Google)(nil) // NameGoogle is the unique name of the Google provider. @@ -14,26 +19,30 @@ const NameGoogle string = "google" // Google allows authentication via Google OAuth2. type Google struct { - *baseProvider + BaseProvider } // NewGoogleProvider creates new Google provider instance with some defaults. func NewGoogleProvider() *Google { - return &Google{&baseProvider{ - ctx: context.Background(), + return &Google{BaseProvider{ + ctx: context.Background(), + order: 2, + logo: ``, + displayName: "Google", + pkce: true, scopes: []string{ "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email", }, - authUrl: "https://accounts.google.com/o/oauth2/auth", - tokenUrl: "https://accounts.google.com/o/oauth2/token", - userApiUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + authURL: "https://accounts.google.com/o/oauth2/v2/auth", + tokenURL: "https://oauth2.googleapis.com/token", + userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo", }} } // FetchAuthUser returns an AuthUser instance based the Google's user api. func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -44,11 +53,11 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id string `json:"id"` + Id string `json:"sub"` Name string `json:"name"` - Email string `json:"email"` Picture string `json:"picture"` - VerifiedEmail bool `json:"verified_email"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -57,13 +66,15 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Id: extracted.Id, Name: extracted.Name, - AvatarUrl: extracted.Picture, + AvatarURL: extracted.Picture, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } - if extracted.VerifiedEmail { + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + if extracted.EmailVerified { user.Email = extracted.Email } diff --git a/tools/auth/instagram.go b/tools/auth/instagram.go index db2f60aa29..43884e99f5 100644 --- a/tools/auth/instagram.go +++ b/tools/auth/instagram.go @@ -2,38 +2,46 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" - "golang.org/x/oauth2/instagram" ) +func init() { + Providers[NameInstagram] = wrapFactory(NewInstagramProvider) +} + var _ Provider = (*Instagram)(nil) // NameInstagram is the unique name of the Instagram provider. -const NameInstagram string = "instagram" +const NameInstagram string = "instagram2" // "2" suffix to avoid conflicts with the old deprecated version -// Instagram allows authentication via Instagram OAuth2. +// Instagram allows authentication via Instagram Login OAuth2. type Instagram struct { - *baseProvider + BaseProvider } // NewInstagramProvider creates new Instagram provider instance with some defaults. func NewInstagramProvider() *Instagram { - return &Instagram{&baseProvider{ - ctx: context.Background(), - scopes: []string{"user_profile"}, - authUrl: instagram.Endpoint.AuthURL, - tokenUrl: instagram.Endpoint.TokenURL, - userApiUrl: "https://graph.instagram.com/me?fields=id,username,account_type", + return &Instagram{BaseProvider{ + ctx: context.Background(), + order: 6, + logo: ``, + displayName: "Instagram", + pkce: true, + scopes: []string{"instagram_business_basic"}, + authURL: "https://www.instagram.com/oauth/authorize", + tokenURL: "https://api.instagram.com/oauth/access_token", + userInfoURL: "https://graph.instagram.com/me?fields=id,username,account_type,user_id,name,profile_picture_url,followers_count,follows_count,media_count", }} } -// FetchAuthUser returns an AuthUser instance based on the Instagram's user api. +// FetchAuthUser returns an AuthUser instance based on the Instagram Login user api response. // -// API reference: https://developers.facebook.com/docs/instagram-basic-display-api/reference/user#fields +// API reference: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/get-started#fields func (p *Instagram) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -43,9 +51,18 @@ func (p *Instagram) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { return nil, err } + // include list of granted permissions to RawUser's payload + if _, ok := rawUser["permissions"]; !ok { + if permissions := token.Extra("permissions"); permissions != nil { + rawUser["permissions"] = permissions + } + } + extracted := struct { - Id string `json:"id"` - Username string `json:"username"` + Id string `json:"user_id"` + Name string `json:"name"` + Username string `json:"username"` + AvatarURL string `json:"profile_picture_url"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -54,10 +71,14 @@ func (p *Instagram) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Id: extracted.Id, Username: extracted.Username, + Name: extracted.Name, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + return user, nil } diff --git a/tools/auth/internal/jwk/jwk.go b/tools/auth/internal/jwk/jwk.go new file mode 100644 index 0000000000..0535b039f3 --- /dev/null +++ b/tools/auth/internal/jwk/jwk.go @@ -0,0 +1,163 @@ +// Package jwk implements some common utilities for interacting with JWKs +// (mostly used with OIDC providers). +package jwk + +import ( + "context" + "crypto/ed25519" + "crypto/rsa" + "encoding/base64" + "encoding/json/v2" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type JWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + // RS256 (RSA) + E string `json:"e"` + N string `json:"n"` + // Ed25519 (OKP) + Crv string `json:"crv"` + X string `json:"x"` +} + +// PublicKey reconstructs and returns the public key from the current JWK. +func (key *JWK) PublicKey() (any, error) { + switch key.Kty { + case "RSA": + // RFC 7518 + // https://datatracker.ietf.org/doc/html/rfc7518#section-6.3 + // https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1 + exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.E, "=")) + if err != nil { + return nil, err + } + + modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.N, "=")) + if err != nil { + return nil, err + } + + return &rsa.PublicKey{ + E: int(big.NewInt(0).SetBytes(exponent).Uint64()), + N: big.NewInt(0).SetBytes(modulus), + }, nil + case "OKP": + // RFC 8037 + // https://datatracker.ietf.org/doc/html/rfc8037#section-2 + // https://datatracker.ietf.org/doc/html/rfc8037#appendix-A + if key.Crv != "Ed25519" { + return nil, fmt.Errorf("unsupported OKP curve (must be Ed25519): %q", key.Crv) + } + + x, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(key.X, "=")) + if err != nil { + return nil, err + } + + if l := len(x); l != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid Ed25519 key length: %d", l) + } + + return ed25519.PublicKey(x), nil + default: + return nil, fmt.Errorf("unsupported kty (must be RSA or OKP): %q", key.Kty) + } +} + +// Fetch retrieves the JSON Web Key Set located at jwksURL and returns +// the first key that matches the specified kid. +func Fetch(ctx context.Context, jwksURL string, kid string) (*JWK, error) { + req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + rawBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + // http.Client.Get doesn't treat non 2xx responses as error + if res.StatusCode >= 400 { + return nil, fmt.Errorf( + "failed to fetch JSON Web Key Set from %s (%d):\n%s", + jwksURL, + res.StatusCode, + string(rawBody), + ) + } + + jwks := struct { + Keys []*JWK `json:"keys"` + }{} + + err = json.Unmarshal(rawBody, &jwks) + if err != nil { + return nil, err + } + + for _, key := range jwks.Keys { + if key.Kid == kid && key.Alg != "" { + return key, nil + } + } + + return nil, fmt.Errorf("missing JWK with kid %q and non-empty alg", kid) +} + +// ValidateTokenSignature validates the signature of a token with the +// public key retrieved from a remote JWKS. +func ValidateTokenSignature(ctx context.Context, token string, jwksURL string) error { + // extract the kid token header + // --- + t, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{}) + if err != nil { + return err + } + + kid, _ := t.Header["kid"].(string) + if kid == "" { + return errors.New("missing kid header value") + } + + // fetch the public key set + // --- + key, err := Fetch(ctx, jwksURL, kid) + if err != nil { + return err + } + + // verify the signature + // --- + parser := jwt.NewParser(jwt.WithValidMethods([]string{key.Alg})) + + parsedToken, err := parser.Parse(token, func(t *jwt.Token) (any, error) { + return key.PublicKey() + }) + if err != nil { + return err + } + + if !parsedToken.Valid { + return errors.New("the parsed token is invalid") + } + + return nil +} diff --git a/tools/auth/internal/jwk/jwk_test.go b/tools/auth/internal/jwk/jwk_test.go new file mode 100644 index 0000000000..4dc43f3b45 --- /dev/null +++ b/tools/auth/internal/jwk/jwk_test.go @@ -0,0 +1,320 @@ +package jwk_test + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json/v2" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/pocketbase/pocketbase/tools/auth/internal/jwk" +) + +type publicKey interface { + Equal(x crypto.PublicKey) bool +} + +func TestJWK_PublicKey(t *testing.T) { + t.Parallel() + + rsaPrivate, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + name string + key *jwk.JWK + expectError bool + expectKey crypto.PublicKey + }{ + { + "empty", + &jwk.JWK{}, + true, + nil, + }, + { + "invalid kty", + &jwk.JWK{ + Kty: "invalid", + Alg: "RS256", + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()), + N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()), + }, + true, + nil, + }, + { + "RSA", + &jwk.JWK{ + Kty: "RSA", + Alg: "RS256", + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()), + N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()), + }, + false, + &rsaPrivate.PublicKey, + }, + { + "OKP with unsupported curve", + &jwk.JWK{ + Kty: "OKP", + Crv: "invalid", + X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize))), + }, + true, + nil, + }, + { + "OKP with invalid public key length", + &jwk.JWK{ + Kty: "OKP", + Crv: "Ed25519", + X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize-1))), + }, + true, + nil, + }, + { + "valid OKP", + &jwk.JWK{ + Kty: "OKP", + Crv: "Ed25519", + X: base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", ed25519.PublicKeySize))), + }, + false, + ed25519.PublicKey([]byte(strings.Repeat("a", ed25519.PublicKeySize))), + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := s.key.PublicKey() + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr && result == nil { + return + } + + k, ok := result.(publicKey) + if !ok { + t.Fatalf("The returned public key %T doesn't satisfy the expected common interface", k) + } + + if !k.Equal(s.expectKey) { + t.Fatalf("The returned public key doesn't match with the expected one:\n%v\n%v", k, s.expectKey) + } + }) + } +} + +func TestFetch(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if req.URL.Query().Has("error") { + res.WriteHeader(http.StatusBadRequest) + } + + fmt.Fprintf(res, `{ + "keys": [ + { + "kid": "abc", + "kty": "OKP", + "crv": "Ed25519", + "x": "test_x" + }, + { + "kid": "def", + "kty": "RSA", + "alg": "RS256", + "n": "test_n", + "e": "test_e" + } + ] + }`) + })) + defer server.Close() + + scenarios := []struct { + name string + kid string + expectError bool + contains []string + }{ + { + "error response", + "def", + true, + nil, + }, + { + "non-matching kid", + "missing", + true, + nil, + }, + { + "matching kid (no alg)", + "abc", + true, + nil, + }, + { + "matching kid", + "def", + false, + []string{ + `"kid":"def"`, + `"kty":"RSA"`, + `"alg":"RS256"`, + `"n":"test_n"`, + `"e":"test_e"`, + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + url := server.URL + if s.expectError { + url += "?error" + } + + key, err := jwk.Fetch(context.Background(), url, s.kid) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + raw, err := json.Marshal(key) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + for _, substr := range s.contains { + if !strings.Contains(rawStr, substr) { + t.Fatalf("Missing expected substring\n%s\nin\n%s", substr, rawStr) + } + } + }) + } +} + +func TestValidateTokenSignature(t *testing.T) { + t.Parallel() + + rsaPrivate, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatal(err) + } + + ed25519Public, ed25519Private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + nonmatchingKidToken := jwt.New(&jwt.SigningMethodEd25519{}) + nonmatchingKidToken.Header["kid"] = "missing" + nonmatchingKidTokenStr, err := nonmatchingKidToken.SignedString(ed25519Private) + if err != nil { + t.Fatal(err) + } + + key1Token := jwt.New(&jwt.SigningMethodEd25519{}) + key1Token.Header["kid"] = "key1" + key1TokenStr, err := key1Token.SignedString(ed25519Private) + if err != nil { + t.Fatal(err) + } + + key2Token := jwt.New(jwt.SigningMethodRS256) + key2Token.Header["kid"] = "key2" + key2TokenStr, err := key2Token.SignedString(rsaPrivate) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + _ = json.MarshalWrite(res, map[string]any{"keys": []*jwk.JWK{ + { + Kid: "key1", + Kty: "OKP", + Alg: "EdDSA", + Crv: "Ed25519", + X: base64.RawURLEncoding.EncodeToString(ed25519Public), + }, + { + Kid: "key2", + Kty: "RSA", + Alg: "RS256", + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPrivate.E)).Bytes()), + N: base64.RawURLEncoding.EncodeToString(rsaPrivate.N.Bytes()), + }, + }}) + })) + defer server.Close() + + scenarios := []struct { + name string + token string + expectError bool + }{ + { + "empty token", + "", + true, + }, + { + "invalid token", + "abc", + true, + }, + { + "no matching kid", + nonmatchingKidTokenStr, + true, + }, + { + "valid Ed25519 token", + key1TokenStr, + false, + }, + { + "valid RSA token", + key2TokenStr, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + err := jwk.ValidateTokenSignature( + context.Background(), + s.token, + server.URL, + ) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + }) + } +} diff --git a/tools/auth/kakao.go b/tools/auth/kakao.go index 8c71097062..d8c143c9bc 100644 --- a/tools/auth/kakao.go +++ b/tools/auth/kakao.go @@ -2,13 +2,18 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "strconv" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/kakao" ) +func init() { + Providers[NameKakao] = wrapFactory(NewKakaoProvider) +} + var _ Provider = (*Kakao)(nil) // NameKakao is the unique name of the Kakao provider. @@ -16,17 +21,21 @@ const NameKakao string = "kakao" // Kakao allows authentication via Kakao OAuth2. type Kakao struct { - *baseProvider + BaseProvider } // NewKakaoProvider creates a new Kakao provider instance with some defaults. func NewKakaoProvider() *Kakao { - return &Kakao{&baseProvider{ - ctx: context.Background(), - scopes: []string{"account_email", "profile_nickname", "profile_image"}, - authUrl: kakao.Endpoint.AuthURL, - tokenUrl: kakao.Endpoint.TokenURL, - userApiUrl: "https://kapi.kakao.com/v2/user/me", + return &Kakao{BaseProvider{ + ctx: context.Background(), + order: 14, + logo: ``, + displayName: "Kakao", + pkce: true, + scopes: []string{"account_email", "profile_nickname", "profile_image"}, + authURL: kakao.Endpoint.AuthURL, + tokenURL: kakao.Endpoint.TokenURL, + userInfoURL: "https://kapi.kakao.com/v2/user/me", }} } @@ -34,7 +43,7 @@ func NewKakaoProvider() *Kakao { // // API reference: https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -45,29 +54,32 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id int `json:"id"` Profile struct { Nickname string `json:"nickname"` - ImageUrl string `json:"profile_image"` + ImageURL string `json:"profile_image"` } `json:"properties"` KakaoAccount struct { Email string `json:"email"` IsEmailVerified bool `json:"is_email_verified"` IsEmailValid bool `json:"is_email_valid"` } `json:"kakao_account"` + Id int64 `json:"id"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } user := &AuthUser{ - Id: strconv.Itoa(extracted.Id), + Id: strconv.FormatInt(extracted.Id, 10), Username: extracted.Profile.Nickname, - AvatarUrl: extracted.Profile.ImageUrl, + AvatarURL: extracted.Profile.ImageURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if extracted.KakaoAccount.IsEmailValid && extracted.KakaoAccount.IsEmailVerified { user.Email = extracted.KakaoAccount.Email } diff --git a/tools/auth/lark.go b/tools/auth/lark.go new file mode 100644 index 0000000000..b8bf437aeb --- /dev/null +++ b/tools/auth/lark.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "encoding/json/v2" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameLark] = wrapFactory(NewLarkProvider) +} + +var _ Provider = (*Lark)(nil) + +// NameLark is the unique name of the Lark provider. +const NameLark string = "lark" + +// Lark allows authentication via Lark OAuth2. +type Lark struct { + BaseProvider +} + +// NewLarkProvider creates new Lark provider instance with some defaults. +func NewLarkProvider() *Lark { + return &Lark{BaseProvider{ + ctx: context.Background(), + order: 19, + logo: ``, + displayName: "Lark", + pkce: true, + // Lark has two domains with the same API: feishu.cn and larksuite.com. + // The former is used in China and the latter is used in the other regions. + // We choose feishu.cn as a default, matching the behavior of Lark's official SDK. + // Endpoint URLs can be overridden from the frontend if needed. + // SDK Reference: https://github.com/larksuite/oapi-sdk-go + authURL: "https://accounts.feishu.cn/open-apis/authen/v1/authorize", + tokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token", + userInfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info", + }} +} + +// FetchAuthUser returns an AuthUser instance based the Lark's user api. +// +// API reference: https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get +func (p *Lark) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + // https://open.feishu.cn/document/platform-overveiw/basic-concepts/user-identity-introduction/introduction#3f2d4b63 + UnionId string `json:"union_id"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.Data.UnionId, + Name: extracted.Data.Name, + AvatarURL: extracted.Data.AvatarURL, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} diff --git a/tools/auth/linear.go b/tools/auth/linear.go new file mode 100644 index 0000000000..708c2adeef --- /dev/null +++ b/tools/auth/linear.go @@ -0,0 +1,110 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json/v2" + "errors" + "net/http" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameLinear] = wrapFactory(NewLinearProvider) +} + +var _ Provider = (*Linear)(nil) + +// NameLinear is the unique name of the Linear provider. +const NameLinear string = "linear" + +// Linear allows authentication via Linear OAuth2. +type Linear struct { + BaseProvider +} + +// NewLinearProvider creates new Linear provider instance with some defaults. +// +// API reference: https://developers.linear.app/docs/oauth/authentication +func NewLinearProvider() *Linear { + return &Linear{BaseProvider{ + ctx: context.Background(), + order: 16, + logo: ``, + displayName: "Linear", + pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled + scopes: []string{"read"}, + authURL: "https://linear.app/oauth/authorize", + tokenURL: "https://api.linear.app/oauth/token", + userInfoURL: "https://api.linear.app/graphql", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Linear's user api. +// +// API reference: https://developers.linear.app/docs/graphql/working-with-the-graphql-api#authentication +func (p *Linear) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + Viewer struct { + Id string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + Active bool `json:"active"` + } `json:"viewer"` + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if !extracted.Data.Viewer.Active { + return nil, errors.New("the Linear user account is not active") + } + + user := &AuthUser{ + Id: extracted.Data.Viewer.Id, + Name: extracted.Data.Viewer.Name, + Username: extracted.Data.Viewer.DisplayName, + Email: extracted.Data.Viewer.Email, + AvatarURL: extracted.Data.Viewer.AvatarURL, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} + +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method. +// +// Linear doesn't have a UserInfo endpoint and information on the user +// is retrieved using their GraphQL API (https://developers.linear.app/docs/graphql/working-with-the-graphql-api#queries-and-mutations) +func (p *Linear) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + query := []byte(`{"query": "query Me { viewer { id displayName name email avatarUrl active } }"}`) + bodyReader := bytes.NewReader(query) + + req, err := http.NewRequestWithContext(p.ctx, "POST", p.userInfoURL, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + return p.sendRawUserInfoRequest(req, token) +} diff --git a/tools/auth/livechat.go b/tools/auth/livechat.go index f1972bd265..ffa0be71a0 100644 --- a/tools/auth/livechat.go +++ b/tools/auth/livechat.go @@ -2,11 +2,16 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameLivechat] = wrapFactory(NewLivechatProvider) +} + var _ Provider = (*Livechat)(nil) // NameLivechat is the unique name of the Livechat provider. @@ -14,17 +19,21 @@ const NameLivechat = "livechat" // Livechat allows authentication via Livechat OAuth2. type Livechat struct { - *baseProvider + BaseProvider } // NewLivechatProvider creates new Livechat provider instance with some defaults. func NewLivechatProvider() *Livechat { - return &Livechat{&baseProvider{ - ctx: context.Background(), - scopes: []string{}, // default scopes are specified from the provider dashboard - authUrl: "https://accounts.livechat.com/", - tokenUrl: "https://accounts.livechat.com/token", - userApiUrl: "https://accounts.livechat.com/v2/accounts/me", + return &Livechat{BaseProvider{ + ctx: context.Background(), + order: 27, + logo: ``, + displayName: "LiveChat", + pkce: true, + scopes: []string{}, // default scopes are specified from the provider dashboard + authURL: "https://accounts.livechat.com/", + tokenURL: "https://accounts.livechat.com/token", + userInfoURL: "https://accounts.livechat.com/v2/accounts/me", }} } @@ -32,7 +41,7 @@ func NewLivechatProvider() *Livechat { // // API reference: https://developers.livechat.com/docs/authorization func (p *Livechat) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -47,7 +56,7 @@ func (p *Livechat) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Name string `json:"name"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` - AvatarUrl string `json:"avatar_url"` + AvatarURL string `json:"avatar_url"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -56,12 +65,14 @@ func (p *Livechat) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Id: extracted.Id, Name: extracted.Name, - AvatarUrl: extracted.AvatarUrl, + AvatarURL: extracted.AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if extracted.EmailVerified { user.Email = extracted.Email } diff --git a/tools/auth/mailcow.go b/tools/auth/mailcow.go new file mode 100644 index 0000000000..9f39dcb0d8 --- /dev/null +++ b/tools/auth/mailcow.go @@ -0,0 +1,86 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "errors" + "strings" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameMailcow] = wrapFactory(NewMailcowProvider) +} + +var _ Provider = (*Mailcow)(nil) + +// NameMailcow is the unique name of the mailcow provider. +const NameMailcow string = "mailcow" + +// Mailcow allows authentication via mailcow OAuth2. +type Mailcow struct { + BaseProvider +} + +// NewMailcowProvider creates a new mailcow provider instance with some defaults. +func NewMailcowProvider() *Mailcow { + return &Mailcow{BaseProvider{ + ctx: context.Background(), + order: 28, + logo: ``, + displayName: "mailcow", + pkce: true, + scopes: []string{"profile"}, + }} +} + +// FetchAuthUser returns an AuthUser instance based on mailcow's user api. +// +// API reference: https://github.com/mailcow/mailcow-dockerized/blob/master/data/web/oauth/profile.php +func (p *Mailcow) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + Active int `json:"active"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if extracted.Active != 1 { + return nil, errors.New("the mailcow user is not active") + } + + user := &AuthUser{ + Id: extracted.Id, + Name: extracted.FullName, + Username: extracted.Username, + Email: extracted.Email, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + // mailcow usernames are usually just the email adresses, so we just take the part in front of the @ + if strings.Contains(user.Username, "@") { + user.Username = strings.Split(user.Username, "@")[0] + } + + return user, nil +} diff --git a/tools/auth/microsoft.go b/tools/auth/microsoft.go index 79083aa8f6..47d42186d8 100644 --- a/tools/auth/microsoft.go +++ b/tools/auth/microsoft.go @@ -2,40 +2,76 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "errors" + "slices" + "github.com/golang-jwt/jwt/v5" + "github.com/pocketbase/pocketbase/tools/types" + "github.com/spf13/cast" "golang.org/x/oauth2" "golang.org/x/oauth2/microsoft" ) +func init() { + Providers[NameMicrosoft] = wrapFactory(NewMicrosoftProvider) +} + var _ Provider = (*Microsoft)(nil) // NameMicrosoft is the unique name of the Microsoft provider. const NameMicrosoft string = "microsoft" -// Microsoft allows authentication via AzureADEndpoint OAuth2. +// extraIdTokenEmailClaim is the name of the extra map entry that +// specifies which email extraction method to use +const extraIdTokenEmailClaim string = "idTokenEmailClaim" + +// Microsoft allows authentication via Azure AD/Entra ID OAuth2. type Microsoft struct { - *baseProvider + BaseProvider } // NewMicrosoftProvider creates new Microsoft AD provider instance with some defaults. func NewMicrosoftProvider() *Microsoft { endpoints := microsoft.AzureADEndpoint("") - return &Microsoft{&baseProvider{ - ctx: context.Background(), - scopes: []string{"User.Read"}, - authUrl: endpoints.AuthURL, - tokenUrl: endpoints.TokenURL, - userApiUrl: "https://graph.microsoft.com/v1.0/me", + return &Microsoft{BaseProvider{ + ctx: context.Background(), + order: 3, + logo: ``, + displayName: "Microsoft", + pkce: true, + scopes: []string{"User.Read"}, + authURL: endpoints.AuthURL, + tokenURL: endpoints.TokenURL, + userInfoURL: "https://graph.microsoft.com/v1.0/me", }} } +// SetExtra implements Provider.SetExtra() interface method. +// +// If the [extraIdTokenEmailClaim] data is set it will also add "openid" +// to the list of default scopes in order to be able to get an id_token. +func (p *Microsoft) SetExtra(data map[string]any) { + p.extra = data + + if cast.ToString(p.extra[extraIdTokenEmailClaim]) != "" { + scopes := p.Scopes() + if !slices.Contains(scopes, "openid") { + scopes = append(scopes, "openid") + p.SetScopes(scopes) + } + } +} + // FetchAuthUser returns an AuthUser instance based on the Microsoft's user api. // -// API reference: https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo -// Graph explorer: https://developer.microsoft.com/en-us/graph/graph-explorer +// Graph explorer: https://developer.microsoft.com/en-us/graph/graph-explorer +// API reference: https://learn.microsoft.com/en-us/graph/api/user-get +// Optional claims: https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + // @todo with the future update to v2 endpoint consider skipping the request + // if id_token is available (we need to make sure that the graph's id is the same as id_token's sub!) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -46,9 +82,9 @@ func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id string `json:"id"` - Name string `json:"displayName"` - Email string `json:"mail"` + Id string `json:"id"` + Name string `json:"displayName"` + Mail string `json:"mail"` }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -57,11 +93,89 @@ func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Id: extracted.Id, Name: extracted.Name, - Email: extracted.Email, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + // decide which email to trust and assign + switch p.extra[extraIdTokenEmailClaim] { + case "any_verified": + user.Email = p.extractIdTokenVerifiedPrimaryEmail(token) + if user.Email == "" { + user.Email = p.extractIdTokenVerifiedXmsEdovEmail(token) + } + case "verified_primary_email": + user.Email = p.extractIdTokenVerifiedPrimaryEmail(token) + case "email_and_xms_edov": + user.Email = p.extractIdTokenVerifiedXmsEdovEmail(token) + case "email": + user.Email = p.extractIdTokenEmail(token) + default: + // This is kept to avoid introducing breaking changes and generally + // it is considered safe because the provider was originally created + // for single-tenants apps. Furthermore the value is expected to be + // synced with the id_token's `email` claim which since 2023 + // by *default* would be empty if it is unverified. + user.Email = extracted.Mail + } + return user, nil } + +func (p *Microsoft) extractIdTokenClaims(trustedIdToken *oauth2.Token) (jwt.MapClaims, error) { + idToken, _ := trustedIdToken.Extra("id_token").(string) + if idToken == "" { + return nil, errors.New("empty id_token") + } + + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(idToken, claims) + if err != nil { + return nil, err + } + + // loosely validate common claims + // (we don't check the signature because the token is expected to be + // from a trusted source, usually from a direct TLS communication with the provider) + jwtValidator := jwt.NewValidator( + jwt.WithIssuedAt(), + jwt.WithLeeway(idTokenLeeway), + ) + err = jwtValidator.Validate(claims) + if err != nil { + return nil, err + } + + return claims, nil +} + +func (p *Microsoft) extractIdTokenVerifiedPrimaryEmail(trustedIdToken *oauth2.Token) string { + claims, _ := p.extractIdTokenClaims(trustedIdToken) + + email, _ := claims["verified_primary_email"].(string) + + return email +} + +func (p *Microsoft) extractIdTokenVerifiedXmsEdovEmail(trustedIdToken *oauth2.Token) string { + claims, _ := p.extractIdTokenClaims(trustedIdToken) + + if !cast.ToBool(claims["xms_edov"]) { + return "" + } + + email, _ := claims["email"].(string) + + return email +} + +func (p *Microsoft) extractIdTokenEmail(trustedIdToken *oauth2.Token) string { + claims, _ := p.extractIdTokenClaims(trustedIdToken) + + email, _ := claims["email"].(string) + + return email +} diff --git a/tools/auth/monday.go b/tools/auth/monday.go new file mode 100644 index 0000000000..3dd933702c --- /dev/null +++ b/tools/auth/monday.go @@ -0,0 +1,110 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json/v2" + "errors" + "net/http" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameMonday] = wrapFactory(NewMondayProvider) +} + +var _ Provider = (*Monday)(nil) + +// NameMonday is the unique name of the Monday provider. +const NameMonday = "monday" + +// Monday is an auth provider for monday.com. +type Monday struct { + BaseProvider +} + +// NewMondayProvider creates a new Monday provider instance with some defaults. +func NewMondayProvider() *Monday { + return &Monday{BaseProvider{ + ctx: context.Background(), + order: 18, + logo: ``, + displayName: "monday.com", + pkce: true, + scopes: []string{"me:read"}, + authURL: "https://auth.monday.com/oauth2/authorize", + tokenURL: "https://auth.monday.com/oauth2/token", + userInfoURL: "https://api.monday.com/v2", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Monday's user api. +// +// API reference: https://developer.monday.com/api-reference/reference/me +func (p *Monday) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + Me struct { + Id string `json:"id"` + Enabled bool `json:"enabled"` + Name string `json:"name"` + Email string `json:"email"` + IsVerified bool `json:"is_verified"` + Avatar string `json:"photo_small"` + } `json:"me"` + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if !extracted.Data.Me.Enabled { + return nil, errors.New("the monday.com user account is not enabled") + } + + user := &AuthUser{ + Id: extracted.Data.Me.Id, + Name: extracted.Data.Me.Name, + AvatarURL: extracted.Data.Me.Avatar, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + if extracted.Data.Me.IsVerified { + user.Email = extracted.Data.Me.Email + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} + +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface. +// +// monday.com doesn't have a UserInfo endpoint and information on the user +// is retrieved using their GraphQL API (https://developer.monday.com/api-reference/reference/me#queries) +func (p *Monday) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + query := []byte(`{"query": "query { me { id enabled name email is_verified photo_small }}"}`) + bodyReader := bytes.NewReader(query) + + req, err := http.NewRequestWithContext(p.ctx, "POST", p.userInfoURL, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + return p.sendRawUserInfoRequest(req, token) +} diff --git a/tools/auth/notion.go b/tools/auth/notion.go new file mode 100644 index 0000000000..685d08007d --- /dev/null +++ b/tools/auth/notion.go @@ -0,0 +1,108 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "net/http" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameNotion] = wrapFactory(NewNotionProvider) +} + +var _ Provider = (*Notion)(nil) + +// NameNotion is the unique name of the Notion provider. +const NameNotion string = "notion" + +// Notion allows authentication via Notion OAuth2. +type Notion struct { + BaseProvider +} + +// NewNotionProvider creates new Notion provider instance with some defaults. +func NewNotionProvider() *Notion { + return &Notion{BaseProvider{ + ctx: context.Background(), + order: 17, + logo: ``, + displayName: "Notion", + pkce: true, + authURL: "https://api.notion.com/v1/oauth/authorize", + tokenURL: "https://api.notion.com/v1/oauth/token", + userInfoURL: "https://api.notion.com/v1/users/me", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Notion's User api. +// API reference: https://developers.notion.com/reference/get-self +func (p *Notion) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + AvatarURL string `json:"avatar_url"` + Bot struct { + Owner struct { + Type string `json:"type"` + User struct { + AvatarURL string `json:"avatar_url"` + Id string `json:"id"` + Name string `json:"name"` + Person struct { + Email string `json:"email"` + } `json:"person"` + } `json:"user"` + } `json:"owner"` + WorkspaceName string `json:"workspace_name"` + } `json:"bot"` + Id string `json:"id"` + Name string `json:"name"` + Object string `json:"object"` + RequestId string `json:"request_id"` + Type string `json:"type"` + }{} + + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.Bot.Owner.User.Id, + Name: extracted.Bot.Owner.User.Name, + Email: extracted.Bot.Owner.User.Person.Email, + AvatarURL: extracted.Bot.Owner.User.AvatarURL, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} + +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method. +// +// This differ from BaseProvider because Notion requires a version header for all requests +// (https://developers.notion.com/reference/versioning). +func (p *Notion) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Notion-Version", "2022-06-28") + + return p.sendRawUserInfoRequest(req, token) +} diff --git a/tools/auth/oidc.go b/tools/auth/oidc.go index 59d23dde6c..dfef2ad421 100644 --- a/tools/auth/oidc.go +++ b/tools/auth/oidc.go @@ -2,25 +2,65 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "errors" + "fmt" + "os" + "strconv" + "time" + "github.com/golang-jwt/jwt/v5" + "github.com/pocketbase/pocketbase/tools/auth/internal/jwk" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/types" + "github.com/spf13/cast" "golang.org/x/oauth2" ) +// idTokenLeeway is the optional leeway for the id_token timestamp fields validation. +// +// It can be changed externally using the PB_ID_TOKEN_LEEWAY env variable +// (the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute). +var idTokenLeeway time.Duration = 5 * time.Minute + +func init() { + Providers[NameOIDC] = wrapFactory(NewOIDCProvider) + Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider) + Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider) + + if leewayStr := os.Getenv("PB_ID_TOKEN_LEEWAY"); leewayStr != "" { + leeway, err := strconv.Atoi(leewayStr) + if err == nil { + idTokenLeeway = time.Duration(leeway) * time.Second + } + } +} + var _ Provider = (*OIDC)(nil) // NameOIDC is the unique name of the OpenID Connect (OIDC) provider. const NameOIDC string = "oidc" // OIDC allows authentication via OpenID Connect (OIDC) OAuth2 provider. +// +// If specified the user data is fetched from the userInfoURL. +// Otherwise - from the id_token payload. +// +// The provider support the following Extra config options: +// - "jwksURL" - url to the keys to validate the id_token signature (optional and used only when reading the user data from the id_token) +// - "issuers" - list of valid issuers for the iss id_token claim (optional and used only when reading the user data from the id_token) type OIDC struct { - *baseProvider + BaseProvider } // NewOIDCProvider creates new OpenID Connect (OIDC) provider instance with some defaults. func NewOIDCProvider() *OIDC { - return &OIDC{&baseProvider{ - ctx: context.Background(), + return &OIDC{BaseProvider{ + ctx: context.Background(), + order: 99, + logo: ``, + displayName: "OIDC", + pkce: true, scopes: []string{ "openid", // minimal requirement to return the id "email", @@ -33,7 +73,7 @@ func NewOIDCProvider() *OIDC { // // API reference: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims func (p *OIDC) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -49,7 +89,7 @@ func (p *OIDC) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Username string `json:"preferred_username"` Picture string `json:"picture"` Email string `json:"email"` - EmailVerified bool `json:"email_verified"` + EmailVerified any `json:"email_verified"` // see #6657 }{} if err := json.Unmarshal(data, &extracted); err != nil { return nil, err @@ -59,15 +99,90 @@ func (p *OIDC) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Id: extracted.Id, Name: extracted.Name, Username: extracted.Username, - AvatarUrl: extracted.Picture, + AvatarURL: extracted.Picture, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } - if extracted.EmailVerified { + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + if cast.ToBool(extracted.EmailVerified) { user.Email = extracted.Email } return user, nil } + +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method. +// +// It either fetch the data from p.userInfoURL, or if not set - returns the id_token claims. +func (p *OIDC) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + if p.userInfoURL != "" { + return p.BaseProvider.FetchRawUserInfo(token) + } + + claims, err := p.parseIdToken(token) + if err != nil { + return nil, err + } + + return json.Marshal(claims) +} + +func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) { + idToken, _ := token.Extra("id_token").(string) + if idToken == "" { + return nil, errors.New("empty or invalid id_token") + } + + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(idToken, claims) + if err != nil { + return nil, err + } + + // validate common claims + jwtValidator := jwt.NewValidator( + jwt.WithIssuedAt(), + jwt.WithLeeway(idTokenLeeway), + jwt.WithAudience(p.clientId), + ) + err = jwtValidator.Validate(claims) + if err != nil { + return nil, err + } + + // validate iss (if "issuers" extra config is set) + issuers := cast.ToStringSlice(p.Extra()["issuers"]) + if len(issuers) > 0 { + var isIssValid bool + claimIssuer, _ := claims.GetIssuer() + + for _, issuer := range issuers { + if security.Equal(claimIssuer, issuer) { + isIssValid = true + break + } + } + + if !isIssValid { + return nil, fmt.Errorf("iss must be one of %v, got %#v", issuers, claims["iss"]) + } + } + + // validate signature (if "jwksURL" extra config is set) + // + // note: this step could be technically considered optional because we trust + // the token which is a result of direct TLS communication with the provider + // (see also https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) + jwksURL := cast.ToString(p.Extra()["jwksURL"]) + if jwksURL != "" { + err = jwk.ValidateTokenSignature(p.ctx, idToken, jwksURL) + if err != nil { + return nil, fmt.Errorf("id_token validation failed: %w", err) + } + } + + return claims, nil +} diff --git a/tools/auth/patreon.go b/tools/auth/patreon.go new file mode 100644 index 0000000000..e3b4ca31cf --- /dev/null +++ b/tools/auth/patreon.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "encoding/json/v2" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" +) + +func init() { + Providers[NamePatreon] = wrapFactory(NewPatreonProvider) +} + +var _ Provider = (*Patreon)(nil) + +// NamePatreon is the unique name of the Patreon provider. +const NamePatreon string = "patreon" + +// Patreon allows authentication via Patreon OAuth2. +type Patreon struct { + BaseProvider +} + +// NewPatreonProvider creates new Patreon provider instance with some defaults. +func NewPatreonProvider() *Patreon { + return &Patreon{BaseProvider{ + ctx: context.Background(), + order: 24, + logo: ``, + displayName: "Patreon", + pkce: true, + scopes: []string{"identity", "identity[email]"}, + authURL: endpoints.Patreon.AuthURL, + tokenURL: endpoints.Patreon.TokenURL, + userInfoURL: "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=full_name,email,vanity,image_url,is_email_verified", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Patreons's identity api. +// +// API reference: +// https://docs.patreon.com/#get-api-oauth2-v2-identity +// https://docs.patreon.com/#user-v2 +func (p *Patreon) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + Id string `json:"id"` + Attributes struct { + Email string `json:"email"` + Name string `json:"full_name"` + Username string `json:"vanity"` + AvatarURL string `json:"image_url"` + IsEmailVerified bool `json:"is_email_verified"` + } `json:"attributes"` + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.Data.Id, + Username: extracted.Data.Attributes.Username, + Name: extracted.Data.Attributes.Name, + AvatarURL: extracted.Data.Attributes.AvatarURL, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + if extracted.Data.Attributes.IsEmailVerified { + user.Email = extracted.Data.Attributes.Email + } + + return user, nil +} diff --git a/tools/auth/planningcenter.go b/tools/auth/planningcenter.go new file mode 100644 index 0000000000..3ba5136946 --- /dev/null +++ b/tools/auth/planningcenter.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "errors" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NamePlanningcenter] = wrapFactory(NewPlanningcenterProvider) +} + +var _ Provider = (*Planningcenter)(nil) + +// NamePlanningcenter is the unique name of the Planningcenter provider. +const NamePlanningcenter string = "planningcenter" + +// Planningcenter allows authentication via Planningcenter OAuth2. +type Planningcenter struct { + BaseProvider +} + +// NewPlanningcenterProvider creates a new Planningcenter provider instance with some defaults. +func NewPlanningcenterProvider() *Planningcenter { + return &Planningcenter{BaseProvider{ + ctx: context.Background(), + order: 29, + logo: ``, + displayName: "Planning Center", + pkce: true, + scopes: []string{"people"}, + authURL: "https://api.planningcenteronline.com/oauth/authorize", + tokenURL: "https://api.planningcenteronline.com/oauth/token", + userInfoURL: "https://api.planningcenteronline.com/people/v2/me", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Planningcenter's user api. +// +// API reference: https://developer.planning.center/docs/#/overview/authentication +func (p *Planningcenter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + Id string `json:"id"` + Attributes struct { + Status string `json:"status"` + Name string `json:"name"` + AvatarURL string `json:"avatar"` + // don't map the email because users can have multiple assigned + // and it's not clear if they are verified + } + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + if extracted.Data.Attributes.Status != "active" { + return nil, errors.New("the user is not active") + } + + user := &AuthUser{ + Id: extracted.Data.Id, + Name: extracted.Data.Attributes.Name, + AvatarURL: extracted.Data.Attributes.AvatarURL, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} diff --git a/tools/auth/spotify.go b/tools/auth/spotify.go index 3ca6107670..1401cf4bdd 100644 --- a/tools/auth/spotify.go +++ b/tools/auth/spotify.go @@ -2,12 +2,17 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/spotify" ) +func init() { + Providers[NameSpotify] = wrapFactory(NewSpotifyProvider) +} + var _ Provider = (*Spotify)(nil) // NameSpotify is the unique name of the Spotify provider. @@ -15,21 +20,25 @@ const NameSpotify string = "spotify" // Spotify allows authentication via Spotify OAuth2. type Spotify struct { - *baseProvider + BaseProvider } // NewSpotifyProvider creates a new Spotify provider instance with some defaults. func NewSpotifyProvider() *Spotify { - return &Spotify{&baseProvider{ - ctx: context.Background(), + return &Spotify{BaseProvider{ + ctx: context.Background(), + order: 21, + logo: ``, + displayName: "Spotify", + pkce: true, scopes: []string{ "user-read-private", // currently Spotify doesn't return information whether the email is verified or not // "user-read-email", }, - authUrl: spotify.Endpoint.AuthURL, - tokenUrl: spotify.Endpoint.TokenURL, - userApiUrl: "https://api.spotify.com/v1/me", + authURL: spotify.Endpoint.AuthURL, + tokenURL: spotify.Endpoint.TokenURL, + userInfoURL: "https://api.spotify.com/v1/me", }} } @@ -37,7 +46,7 @@ func NewSpotifyProvider() *Spotify { // // API reference: https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -51,7 +60,7 @@ func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Id string `json:"id"` Name string `json:"display_name"` Images []struct { - Url string `json:"url"` + URL string `json:"url"` } `json:"images"` // don't map the email because per the official docs // the email field is "unverified" and there is no proof @@ -69,8 +78,11 @@ func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if len(extracted.Images) > 0 { - user.AvatarUrl = extracted.Images[0].Url + user.AvatarURL = extracted.Images[0].URL } return user, nil diff --git a/tools/auth/strava.go b/tools/auth/strava.go index 2406edd0f4..3efd907468 100644 --- a/tools/auth/strava.go +++ b/tools/auth/strava.go @@ -2,12 +2,17 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "strconv" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameStrava] = wrapFactory(NewStravaProvider) +} + var _ Provider = (*Strava)(nil) // NameStrava is the unique name of the Strava provider. @@ -15,19 +20,23 @@ const NameStrava string = "strava" // Strava allows authentication via Strava OAuth2. type Strava struct { - *baseProvider + BaseProvider } // NewStravaProvider creates new Strava provider instance with some defaults. func NewStravaProvider() *Strava { - return &Strava{&baseProvider{ - ctx: context.Background(), + return &Strava{BaseProvider{ + ctx: context.Background(), + order: 25, + logo: ``, + displayName: "Strava", + pkce: true, scopes: []string{ "profile:read_all", }, - authUrl: "https://www.strava.com/oauth/authorize", - tokenUrl: "https://www.strava.com/api/v3/oauth/token", - userApiUrl: "https://www.strava.com/api/v3/athlete", + authURL: "https://www.strava.com/oauth/authorize", + tokenURL: "https://www.strava.com/api/v3/oauth/token", + userInfoURL: "https://www.strava.com/api/v3/athlete", }} } @@ -35,7 +44,7 @@ func NewStravaProvider() *Strava { // // API reference: https://developers.strava.com/docs/authentication/ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -46,11 +55,11 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } extracted := struct { - Id int `json:"id"` + Id int64 `json:"id"` FirstName string `json:"firstname"` LastName string `json:"lastname"` Username string `json:"username"` - ProfileImageUrl string `json:"profile"` + ProfileImageURL string `json:"profile"` // At the time of writing, Strava OAuth2 doesn't support returning the user email address // Email string `json:"email"` @@ -62,14 +71,16 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { user := &AuthUser{ Name: extracted.FirstName + " " + extracted.LastName, Username: extracted.Username, - AvatarUrl: extracted.ProfileImageUrl, + AvatarURL: extracted.ProfileImageURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if extracted.Id != 0 { - user.Id = strconv.Itoa(extracted.Id) + user.Id = strconv.FormatInt(extracted.Id, 10) } return user, nil diff --git a/tools/auth/trakt.go b/tools/auth/trakt.go new file mode 100644 index 0000000000..affa4e430b --- /dev/null +++ b/tools/auth/trakt.go @@ -0,0 +1,104 @@ +package auth + +import ( + "context" + "encoding/json/v2" + "net/http" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameTrakt] = wrapFactory(NewTraktProvider) +} + +var _ Provider = (*Trakt)(nil) + +// NameTrakt is the unique name of the Trakt provider. +const NameTrakt string = "trakt" + +// Trakt allows authentication via Trakt OAuth2. +type Trakt struct { + BaseProvider +} + +// NewTraktProvider creates new Trakt provider instance with some defaults. +func NewTraktProvider() *Trakt { + return &Trakt{BaseProvider{ + ctx: context.Background(), + order: 22, + logo: ``, + displayName: "Trakt", + pkce: true, + authURL: "https://trakt.tv/oauth/authorize", + tokenURL: "https://api.trakt.tv/oauth/token", + userInfoURL: "https://api.trakt.tv/users/settings", + }} +} + +// FetchAuthUser returns an AuthUser instance based on Trakt's user settings API. +// API reference: https://trakt.docs.apiary.io/#reference/users/settings/retrieve-settings +func (p *Trakt) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + User struct { + Username string `json:"username"` + Name string `json:"name"` + Ids struct { + Slug string `json:"slug"` + UUID string `json:"uuid"` + } `json:"ids"` + Images struct { + Avatar struct { + Full string `json:"full"` + } `json:"avatar"` + } `json:"images"` + } `json:"user"` + }{} + + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.User.Ids.UUID, + Username: extracted.User.Username, + Name: extracted.User.Name, + AvatarURL: extracted.User.Images.Avatar.Full, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} + +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method. +// +// This differ from BaseProvider because Trakt requires a number of +// mandatory headers for all requests +// (https://trakt.docs.apiary.io/#introduction/required-headers). +func (p *Trakt) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Content-type", "application/json") + req.Header.Set("trakt-api-key", p.clientId) + req.Header.Set("trakt-api-version", "2") + + return p.sendRawUserInfoRequest(req, token) +} diff --git a/tools/auth/twitch.go b/tools/auth/twitch.go index 1b9d590b4d..028ce0ae26 100644 --- a/tools/auth/twitch.go +++ b/tools/auth/twitch.go @@ -2,14 +2,19 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "errors" "net/http" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/twitch" ) +func init() { + Providers[NameTwitch] = wrapFactory(NewTwitchProvider) +} + var _ Provider = (*Twitch)(nil) // NameTwitch is the unique name of the Twitch provider. @@ -17,17 +22,21 @@ const NameTwitch string = "twitch" // Twitch allows authentication via Twitch OAuth2. type Twitch struct { - *baseProvider + BaseProvider } // NewTwitchProvider creates new Twitch provider instance with some defaults. func NewTwitchProvider() *Twitch { - return &Twitch{&baseProvider{ - ctx: context.Background(), - scopes: []string{"user:read:email"}, - authUrl: twitch.Endpoint.AuthURL, - tokenUrl: twitch.Endpoint.TokenURL, - userApiUrl: "https://api.twitch.tv/helix/users", + return &Twitch{BaseProvider{ + ctx: context.Background(), + order: 23, + logo: ``, + displayName: "Twitch", + pkce: true, + scopes: []string{"user:read:email"}, + authURL: twitch.Endpoint.AuthURL, + tokenURL: twitch.Endpoint.TokenURL, + userInfoURL: "https://api.twitch.tv/helix/users", }} } @@ -35,7 +44,7 @@ func NewTwitchProvider() *Twitch { // // API reference: https://dev.twitch.tv/docs/api/reference#get-users func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -51,7 +60,7 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Login string `json:"login"` DisplayName string `json:"display_name"` Email string `json:"email"` - ProfileImageUrl string `json:"profile_image_url"` + ProfileImageURL string `json:"profile_image_url"` } `json:"data"` }{} if err := json.Unmarshal(data, &extracted); err != nil { @@ -59,7 +68,7 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } if len(extracted.Data) == 0 { - return nil, errors.New("Failed to fetch AuthUser data") + return nil, errors.New("failed to fetch AuthUser data") } user := &AuthUser{ @@ -67,25 +76,27 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Name: extracted.Data[0].DisplayName, Username: extracted.Data[0].Login, Email: extracted.Data[0].Email, - AvatarUrl: extracted.Data[0].ProfileImageUrl, + AvatarURL: extracted.Data[0].ProfileImageURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + return user, nil } -// FetchRawUserData implements Provider.FetchRawUserData interface. +// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method. // -// This differ from baseProvider because Twitch requires the `Client-Id` header. -func (p *Twitch) FetchRawUserData(token *oauth2.Token) ([]byte, error) { - req, err := http.NewRequest("GET", p.userApiUrl, nil) +// This differ from BaseProvider because Twitch requires the Client-Id header. +func (p *Twitch) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) { + req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil) if err != nil { return nil, err } req.Header.Set("Client-Id", p.clientId) - return p.sendRawUserDataRequest(req, token) + return p.sendRawUserInfoRequest(req, token) } diff --git a/tools/auth/twitter.go b/tools/auth/twitter.go index a887b6c3a5..26de646dfb 100644 --- a/tools/auth/twitter.go +++ b/tools/auth/twitter.go @@ -2,11 +2,16 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" ) +func init() { + Providers[NameTwitter] = wrapFactory(NewTwitterProvider) +} + var _ Provider = (*Twitter)(nil) // NameTwitter is the unique name of the Twitter provider. @@ -14,31 +19,36 @@ const NameTwitter string = "twitter" // Twitter allows authentication via Twitter OAuth2. type Twitter struct { - *baseProvider + BaseProvider } // NewTwitterProvider creates new Twitter provider instance with some defaults. func NewTwitterProvider() *Twitter { - return &Twitter{&baseProvider{ - ctx: context.Background(), + return &Twitter{BaseProvider{ + ctx: context.Background(), + order: 13, + logo: ``, + displayName: "X/Twitter", + pkce: true, scopes: []string{ "users.read", + "users.email", - // we don't actually use this scope, but for some reason it is required by the `/2/users/me` endpoint - // (see https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me) + // we don't actually use this scope, but for some reason the `/2/users/me` endpoint fails with 403 without it + // (see https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping#x-api-v2-authentication-mapping) "tweet.read", }, - authUrl: "https://twitter.com/i/oauth2/authorize", - tokenUrl: "https://api.twitter.com/2/oauth2/token", - userApiUrl: "https://api.twitter.com/2/users/me?user.fields=id,name,username,profile_image_url", + authURL: "https://x.com/i/oauth2/authorize", + tokenURL: "https://api.x.com/2/oauth2/token", + userInfoURL: "https://api.x.com/2/users/me?user.fields=id,name,username,profile_image_url,confirmed_email", }} } // FetchAuthUser returns an AuthUser instance based on the Twitter's user api. // -// API reference: https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me +// API reference: https://docs.x.com/x-api/users/user-lookup-me func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -53,11 +63,8 @@ func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Id string `json:"id"` Name string `json:"name"` Username string `json:"username"` - ProfileImageUrl string `json:"profile_image_url"` - - // NB! At the time of writing, Twitter OAuth2 doesn't support returning the user email address - // (see https://twittercommunity.com/t/which-api-to-get-user-after-oauth2-authorization/162417/33) - // Email string `json:"email"` + Email string `json:"confirmed_email"` + ProfileImageURL string `json:"profile_image_url"` } `json:"data"` }{} if err := json.Unmarshal(data, &extracted); err != nil { @@ -68,11 +75,14 @@ func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { Id: extracted.Data.Id, Name: extracted.Data.Name, Username: extracted.Data.Username, - AvatarUrl: extracted.Data.ProfileImageUrl, + Email: extracted.Data.Email, + AvatarURL: extracted.Data.ProfileImageURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + return user, nil } diff --git a/tools/auth/vk.go b/tools/auth/vk.go index 936abe987c..1f91483ea4 100644 --- a/tools/auth/vk.go +++ b/tools/auth/vk.go @@ -2,36 +2,47 @@ package auth import ( "context" - "encoding/json" + "encoding/json/v2" "errors" "fmt" "strconv" "strings" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/vk" ) +func init() { + Providers[NameVK] = wrapFactory(NewVKProvider) +} + var _ Provider = (*VK)(nil) // NameVK is the unique name of the VK provider. const NameVK string = "vk" +// @todo mark as deprecated +// // VK allows authentication via VK OAuth2. type VK struct { - *baseProvider + BaseProvider } // NewVKProvider creates new VK provider instance with some defaults. // // Docs: https://dev.vk.com/api/oauth-parameters func NewVKProvider() *VK { - return &VK{&baseProvider{ - ctx: context.Background(), - scopes: []string{"email"}, - authUrl: vk.Endpoint.AuthURL, - tokenUrl: vk.Endpoint.TokenURL, - userApiUrl: "https://api.vk.com/method/users.get?fields=photo_max,screen_name&v=5.131", + return &VK{BaseProvider{ + ctx: context.Background(), + order: 15, + logo: ``, + displayName: "ВКонтакте", + pkce: false, // VK currently doesn't support PKCE and throws an error if PKCE params are send + scopes: []string{"email"}, + authURL: vk.Endpoint.AuthURL, + tokenURL: vk.Endpoint.TokenURL, + userInfoURL: "https://api.vk.com/method/users.get?fields=photo_max,screen_name&v=5.131", }} } @@ -39,7 +50,7 @@ func NewVKProvider() *VK { // // API reference: https://dev.vk.com/method/users.get func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -51,14 +62,13 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { extracted := struct { Response []struct { - Id int `json:"id"` + Id int64 `json:"id"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Username string `json:"screen_name"` - AvatarUrl string `json:"photo_max"` + AvatarURL string `json:"photo_max"` } `json:"response"` }{} - if err := json.Unmarshal(data, &extracted); err != nil { return nil, err } @@ -68,15 +78,17 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { } user := &AuthUser{ - Id: strconv.Itoa(extracted.Response[0].Id), + Id: strconv.FormatInt(extracted.Response[0].Id, 10), Name: strings.TrimSpace(extracted.Response[0].FirstName + " " + extracted.Response[0].LastName), Username: extracted.Response[0].Username, - AvatarUrl: extracted.Response[0].AvatarUrl, + AvatarURL: extracted.Response[0].AvatarURL, RawUser: rawUser, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if email := token.Extra("email"); email != nil { user.Email = fmt.Sprint(email) } diff --git a/tools/auth/wakatime.go b/tools/auth/wakatime.go new file mode 100644 index 0000000000..b67f66ff4e --- /dev/null +++ b/tools/auth/wakatime.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "encoding/json/v2" + + "github.com/pocketbase/pocketbase/tools/types" + "golang.org/x/oauth2" +) + +func init() { + Providers[NameWakatime] = wrapFactory(NewWakatimeProvider) +} + +var _ Provider = (*Wakatime)(nil) + +// NameWakatime is the unique name of the Wakatime provider. +const NameWakatime = "wakatime" + +// Wakatime is an auth provider for Wakatime. +type Wakatime struct { + BaseProvider +} + +// NewWakatimeProvider creates a new Wakatime provider instance with some defaults. +func NewWakatimeProvider() *Wakatime { + return &Wakatime{BaseProvider{ + ctx: context.Background(), + order: 26, + logo: ``, + displayName: "WakaTime", + pkce: true, + scopes: []string{"email"}, + authURL: "https://wakatime.com/oauth/authorize", + tokenURL: "https://wakatime.com/oauth/token", + userInfoURL: "https://wakatime.com/api/v1/users/current", + }} +} + +// FetchAuthUser returns an AuthUser instance based on the Wakatime's user API. +// +// API reference: https://wakatime.com/developers#users +func (p *Wakatime) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { + data, err := p.FetchRawUserInfo(token) + if err != nil { + return nil, err + } + + rawUser := map[string]any{} + if err := json.Unmarshal(data, &rawUser); err != nil { + return nil, err + } + + extracted := struct { + Data struct { + Id string `json:"id"` + DisplayName string `json:"display_name"` + Username string `json:"username"` + Email string `json:"email"` + Photo string `json:"photo"` + IsPhotoPublic bool `json:"photo_public"` + IsEmailConfirmed bool `json:"is_email_confirmed"` + } `json:"data"` + }{} + if err := json.Unmarshal(data, &extracted); err != nil { + return nil, err + } + + user := &AuthUser{ + Id: extracted.Data.Id, + Name: extracted.Data.DisplayName, + Username: extracted.Data.Username, + RawUser: rawUser, + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + // note: we don't check for is_email_public field because PocketBase + // has its own emailVisibility flag which is false by default + if extracted.Data.IsEmailConfirmed { + user.Email = extracted.Data.Email + } + + if extracted.Data.IsPhotoPublic { + user.AvatarURL = extracted.Data.Photo + } + + user.Expiry, _ = types.ParseDateTime(token.Expiry) + + return user, nil +} diff --git a/tools/auth/yandex.go b/tools/auth/yandex.go index 8b52061959..fb02e0afa5 100644 --- a/tools/auth/yandex.go +++ b/tools/auth/yandex.go @@ -1,12 +1,18 @@ package auth import ( - "encoding/json" + "context" + "encoding/json/v2" + "github.com/pocketbase/pocketbase/tools/types" "golang.org/x/oauth2" "golang.org/x/oauth2/yandex" ) +func init() { + Providers[NameYandex] = wrapFactory(NewYandexProvider) +} + var _ Provider = (*Yandex)(nil) // NameYandex is the unique name of the Yandex provider. @@ -14,18 +20,23 @@ const NameYandex string = "yandex" // Yandex allows authentication via Yandex OAuth2. type Yandex struct { - *baseProvider + BaseProvider } // NewYandexProvider creates new Yandex provider instance with some defaults. // // Docs: https://yandex.ru/dev/id/doc/en/ func NewYandexProvider() *Yandex { - return &Yandex{&baseProvider{ - scopes: []string{"login:email", "login:avatar", "login:info"}, - authUrl: yandex.Endpoint.AuthURL, - tokenUrl: yandex.Endpoint.TokenURL, - userApiUrl: "https://login.yandex.ru/info", + return &Yandex{BaseProvider{ + ctx: context.Background(), + order: 4, + logo: ``, + displayName: "Yandex", + pkce: true, + scopes: []string{"login:email", "login:avatar", "login:info"}, + authURL: yandex.Endpoint.AuthURL, + tokenURL: yandex.Endpoint.TokenURL, + userInfoURL: "https://login.yandex.ru/info", }} } @@ -33,7 +44,7 @@ func NewYandexProvider() *Yandex { // // API reference: https://yandex.ru/dev/id/doc/en/user-information#response-format func (p *Yandex) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { - data, err := p.FetchRawUserData(token) + data, err := p.FetchRawUserInfo(token) if err != nil { return nil, err } @@ -65,8 +76,10 @@ func (p *Yandex) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) { RefreshToken: token.RefreshToken, } + user.Expiry, _ = types.ParseDateTime(token.Expiry) + if !extracted.IsAvatarEmpty { - user.AvatarUrl = "https://avatars.yandex.net/get-yapic/" + extracted.AvatarId + "/islands-200" + user.AvatarURL = "https://avatars.yandex.net/get-yapic/" + extracted.AvatarId + "/islands-200" } return user, nil diff --git a/tools/cron/cron.go b/tools/cron/cron.go index d3f1d1d0f8..387da7c819 100644 --- a/tools/cron/cron.go +++ b/tools/cron/cron.go @@ -11,23 +11,22 @@ package cron import ( "errors" "fmt" + "slices" "sync" "time" -) -type job struct { - schedule *Schedule - run func() -} + "github.com/pocketbase/pocketbase/tools/routine" +) // Cron is a crontab-like struct for tasks/jobs scheduling. type Cron struct { - sync.RWMutex - - interval time.Duration - timezone *time.Location - ticker *time.Ticker - jobs map[string]*job + timezone *time.Location + ticker *time.Ticker + startTimer *time.Timer + tickerDone chan bool + jobs []*Job + interval time.Duration + mux sync.RWMutex } // New create a new Cron struct with default tick interval of 1 minute @@ -37,9 +36,10 @@ type Cron struct { // You can change the default timezone with Cron.SetTimezone(). func New() *Cron { return &Cron{ - interval: 1 * time.Minute, - timezone: time.UTC, - jobs: map[string]*job{}, + interval: 1 * time.Minute, + timezone: time.UTC, + jobs: []*Job{}, + tickerDone: make(chan bool), } } @@ -47,10 +47,10 @@ func New() *Cron { // (it usually should be >= 1 minute). func (c *Cron) SetInterval(d time.Duration) { // update interval - c.Lock() + c.mux.Lock() wasStarted := c.ticker != nil c.interval = d - c.Unlock() + c.mux.Unlock() // restart the ticker if wasStarted { @@ -60,8 +60,8 @@ func (c *Cron) SetInterval(d time.Duration) { // SetTimezone changes the current cron tick timezone. func (c *Cron) SetTimezone(l *time.Location) { - c.Lock() - defer c.Unlock() + c.mux.Lock() + defer c.mux.Unlock() c.timezone = l } @@ -80,62 +80,94 @@ func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) { // // cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour). // Check cron.NewSchedule() for the supported tokens. -func (c *Cron) Add(jobId string, cronExpr string, run func()) error { - if run == nil { - return errors.New("failed to add new cron job: run must be non-nil function") +func (c *Cron) Add(jobId string, cronExpr string, fn func()) error { + if fn == nil { + return errors.New("failed to add new cron job: fn must be non-nil function") } - c.Lock() - defer c.Unlock() - schedule, err := NewSchedule(cronExpr) if err != nil { return fmt.Errorf("failed to add new cron job: %w", err) } - c.jobs[jobId] = &job{ + c.mux.Lock() + defer c.mux.Unlock() + + // remove previous (if any) + c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool { + return j.Id() == jobId + }) + + // add new + c.jobs = append(c.jobs, &Job{ + id: jobId, + fn: fn, schedule: schedule, - run: run, - } + }) return nil } // Remove removes a single cron job by its id. func (c *Cron) Remove(jobId string) { - c.Lock() - defer c.Unlock() + c.mux.Lock() + defer c.mux.Unlock() + + if c.jobs == nil { + return // nothing to remove + } - delete(c.jobs, jobId) + c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool { + return j.Id() == jobId + }) } // RemoveAll removes all registered cron jobs. func (c *Cron) RemoveAll() { - c.Lock() - defer c.Unlock() + c.mux.Lock() + defer c.mux.Unlock() - c.jobs = map[string]*job{} + c.jobs = []*Job{} } // Total returns the current total number of registered cron jobs. func (c *Cron) Total() int { - c.RLock() - defer c.RUnlock() + c.mux.RLock() + defer c.mux.RUnlock() return len(c.jobs) } +// Jobs returns a shallow copy of the currently registered cron jobs. +func (c *Cron) Jobs() []*Job { + c.mux.RLock() + defer c.mux.RUnlock() + + copy := make([]*Job, len(c.jobs)) + for i, j := range c.jobs { + copy[i] = j + } + + return copy +} + // Stop stops the current cron ticker (if not already). // // You can resume the ticker by calling Start(). func (c *Cron) Stop() { - c.Lock() - defer c.Unlock() + c.mux.Lock() + defer c.mux.Unlock() + + if c.startTimer != nil { + c.startTimer.Stop() + c.startTimer = nil + } if c.ticker == nil { return // already stopped } + c.tickerDone <- true c.ticker.Stop() c.ticker = nil } @@ -146,35 +178,53 @@ func (c *Cron) Stop() { func (c *Cron) Start() { c.Stop() - c.Lock() - c.ticker = time.NewTicker(c.interval) - c.Unlock() - - go func() { - for t := range c.ticker.C { - c.runDue(t) - } - }() + // delay the ticker to start at 00 of 1 c.interval duration + now := time.Now() + next := now.Add(c.interval).Truncate(c.interval) + delay := next.Sub(now) + + c.mux.Lock() + c.startTimer = time.AfterFunc(delay, func() { + c.mux.Lock() + c.ticker = time.NewTicker(c.interval) + c.mux.Unlock() + + // run immediately at 00 + c.runDue(time.Now()) + + // run after each tick + routine.FireAndForget(func() { + for { + select { + case <-c.tickerDone: + return + case t := <-c.ticker.C: + c.runDue(t) + } + } + }) + }) + c.mux.Unlock() } // HasStarted checks whether the current Cron ticker has been started. func (c *Cron) HasStarted() bool { - c.RLock() - defer c.RUnlock() + c.mux.RLock() + defer c.mux.RUnlock() return c.ticker != nil } // runDue runs all registered jobs that are scheduled for the provided time. func (c *Cron) runDue(t time.Time) { - c.RLock() - defer c.RUnlock() + c.mux.RLock() + defer c.mux.RUnlock() moment := NewMoment(t.In(c.timezone)) for _, j := range c.jobs { if j.schedule.IsDue(moment) { - go j.run() + routine.FireAndForget(j.Run) } } } diff --git a/tools/cron/cron_test.go b/tools/cron/cron_test.go index b802067fd1..018e468dc6 100644 --- a/tools/cron/cron_test.go +++ b/tools/cron/cron_test.go @@ -1,12 +1,17 @@ package cron import ( - "encoding/json" + "encoding/json/v2" + "slices" + "sync" "testing" + "testing/synctest" "time" ) func TestCronNew(t *testing.T) { + t.Parallel() + c := New() expectedInterval := 1 * time.Minute @@ -29,6 +34,8 @@ func TestCronNew(t *testing.T) { } func TestCronSetInterval(t *testing.T) { + t.Parallel() + c := New() interval := 2 * time.Minute @@ -41,6 +48,8 @@ func TestCronSetInterval(t *testing.T) { } func TestCronSetTimezone(t *testing.T) { + t.Parallel() + c := New() timezone, _ := time.LoadLocation("Asia/Tokyo") @@ -53,6 +62,8 @@ func TestCronSetTimezone(t *testing.T) { } func TestCronAddAndRemove(t *testing.T) { + t.Parallel() + c := New() if err := c.Add("test0", "* * * * *", nil); err == nil { @@ -90,6 +101,11 @@ func TestCronAddAndRemove(t *testing.T) { // try to remove non-existing (should be no-op) c.Remove("missing") + indexedJobs := make(map[string]*Job, len(c.jobs)) + for _, j := range c.jobs { + indexedJobs[j.Id()] = j + } + // check job keys { expectedKeys := []string{"test3", "test2", "test5"} @@ -99,7 +115,7 @@ func TestCronAddAndRemove(t *testing.T) { } for _, k := range expectedKeys { - if c.jobs[k] == nil { + if indexedJobs[k] == nil { t.Fatalf("Expected job with key %s, got nil", k) } } @@ -113,7 +129,7 @@ func TestCronAddAndRemove(t *testing.T) { "test5": `{"minutes":{"1":{}},"hours":{"2":{}},"days":{"3":{}},"months":{"4":{}},"daysOfWeek":{"5":{}}}`, } for k, v := range expectedSchedules { - raw, err := json.Marshal(c.jobs[k].schedule) + raw, err := json.Marshal(indexedJobs[k].schedule, json.Deterministic(true)) if err != nil { t.Fatal(err) } @@ -126,6 +142,8 @@ func TestCronAddAndRemove(t *testing.T) { } func TestCronMustAdd(t *testing.T) { + t.Parallel() + c := New() defer func() { @@ -138,12 +156,14 @@ func TestCronMustAdd(t *testing.T) { c.MustAdd("test2", "* * * * *", func() {}) - if _, ok := c.jobs["test2"]; !ok { + if !slices.ContainsFunc(c.jobs, func(j *Job) bool { return j.Id() == "test2" }) { t.Fatal("Couldn't find job test2") } } func TestCronRemoveAll(t *testing.T) { + t.Parallel() + c := New() if err := c.Add("test1", "* * * * *", func() {}); err != nil { @@ -170,6 +190,8 @@ func TestCronRemoveAll(t *testing.T) { } func TestCronTotal(t *testing.T) { + t.Parallel() + c := New() if v := c.Total(); v != 0 { @@ -194,52 +216,104 @@ func TestCronTotal(t *testing.T) { } } -func TestCronStartStop(t *testing.T) { - c := New() - - c.SetInterval(1 * time.Second) +func TestCronJobs(t *testing.T) { + t.Parallel() - test1 := 0 - test2 := 0 + c := New() - c.Add("test1", "* * * * *", func() { - test1++ - }) + calls := "" - c.Add("test2", "* * * * *", func() { - test2++ - }) + if err := c.Add("a", "1 * * * *", func() { calls += "a" }); err != nil { + t.Fatal(err) + } - expectedCalls := 3 + if err := c.Add("b", "2 * * * *", func() { calls += "b" }); err != nil { + t.Fatal(err) + } - // call twice Start to check if the previous ticker will be reseted - c.Start() - c.Start() + // overwrite + if err := c.Add("b", "3 * * * *", func() { calls += "b" }); err != nil { + t.Fatal(err) + } - time.Sleep(3250 * time.Millisecond) + jobs := c.Jobs() - // call twice Stop to ensure that the second stop is no-op - c.Stop() - c.Stop() + if len(jobs) != 2 { + t.Fatalf("Expected 2 jobs, got %v", len(jobs)) + } - if test1 != expectedCalls { - t.Fatalf("Expected %d test1, got %d", expectedCalls, test1) + for _, j := range jobs { + j.Run() } - if test2 != expectedCalls { - t.Fatalf("Expected %d test2, got %d", expectedCalls, test2) + + expectedCalls := "ab" + if calls != expectedCalls { + t.Fatalf("Expected %q calls, got %q", expectedCalls, calls) } +} - // resume for ~5 seconds - c.Start() - time.Sleep(5250 * time.Millisecond) - c.Stop() +func TestCronStartStop(t *testing.T) { + t.Parallel() - expectedCalls += 5 + synctest.Test(t, func(t *testing.T) { + var mu sync.Mutex - if test1 != expectedCalls { - t.Fatalf("Expected %d test1, got %d", expectedCalls, test1) - } - if test2 != expectedCalls { - t.Fatalf("Expected %d test2, got %d", expectedCalls, test2) - } + test1 := 0 + test2 := 0 + + c := New() + + c.SetInterval(250 * time.Millisecond) + + c.Add("test1", "* * * * *", func() { + mu.Lock() + defer mu.Unlock() + test1++ + }) + + c.Add("test2", "* * * * *", func() { + mu.Lock() + defer mu.Unlock() + test2++ + }) + + // call twice Start to check if the previous ticker will be reseted + c.Start() + c.Start() + + synctest.Sleep(500 * time.Millisecond) + + // call twice Stop to ensure that the second stop is no-op + c.Stop() + c.Stop() + + expectedCalls := 2 + + mu.Lock() + if test1 != expectedCalls { + t.Fatalf("Expected %d test1, got %d", expectedCalls, test1) + } + if test2 != expectedCalls { + t.Fatalf("Expected %d test2, got %d", expectedCalls, test2) + } + mu.Unlock() + + // resume for 1 seconds + c.Start() + + synctest.Sleep(1000 * time.Millisecond) + + c.Stop() + + expectedCalls += 4 + + mu.Lock() + if test1 != expectedCalls { + t.Fatalf("Expected %d test1, got %d", expectedCalls, test1) + } + if test2 != expectedCalls { + t.Fatalf("Expected %d test2, got %d", expectedCalls, test2) + } + mu.Unlock() + }) } diff --git a/tools/cron/job.go b/tools/cron/job.go new file mode 100644 index 0000000000..7827da52bc --- /dev/null +++ b/tools/cron/job.go @@ -0,0 +1,41 @@ +package cron + +import "encoding/json/v2" + +// Job defines a single registered cron job. +type Job struct { + fn func() + schedule *Schedule + id string +} + +// Id returns the cron job id. +func (j *Job) Id() string { + return j.id +} + +// Expression returns the plain cron job schedule expression. +func (j *Job) Expression() string { + return j.schedule.rawExpr +} + +// Run runs the cron job function. +func (j *Job) Run() { + if j.fn != nil { + j.fn() + } +} + +// MarshalJSON implements [json.Marshaler] and export the current +// jobs data into valid JSON. +func (j Job) MarshalJSON() ([]byte, error) { + plain := struct { + Id string `json:"id"` + Expression string `json:"expression"` + }{ + Id: j.Id(), + Expression: j.Expression(), + } + + return json.Marshal(plain) +} diff --git a/tools/cron/job_test.go b/tools/cron/job_test.go new file mode 100644 index 0000000000..04fcd98eea --- /dev/null +++ b/tools/cron/job_test.go @@ -0,0 +1,71 @@ +package cron + +import ( + "encoding/json/v2" + "testing" +) + +func TestJobId(t *testing.T) { + expected := "test" + + j := Job{id: expected} + + if j.Id() != expected { + t.Fatalf("Expected job with id %q, got %q", expected, j.Id()) + } +} + +func TestJobExpr(t *testing.T) { + expected := "1 2 3 4 5" + + s, err := NewSchedule(expected) + if err != nil { + t.Fatal(err) + } + + j := Job{schedule: s} + + if j.Expression() != expected { + t.Fatalf("Expected job with cron expression %q, got %q", expected, j.Expression()) + } +} + +func TestJobRun(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Shouldn't panic: %v", r) + } + }() + + calls := "" + + j1 := Job{} + j2 := Job{fn: func() { calls += "2" }} + + j1.Run() + j2.Run() + + expected := "2" + if calls != expected { + t.Fatalf("Expected calls %q, got %q", expected, calls) + } +} + +func TestJobMarshalJSON(t *testing.T) { + s, err := NewSchedule("1 2 3 4 5") + if err != nil { + t.Fatal(err) + } + + j := Job{id: "test_id", schedule: s} + + raw, err := json.Marshal(j, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + expected := `{"id":"test_id","expression":"1 2 3 4 5"}` + if str := string(raw); str != expected { + t.Fatalf("Expected\n%s\ngot\n%s", expected, str) + } +} diff --git a/tools/cron/schedule.go b/tools/cron/schedule.go index 26f281ffee..aab96f3d5b 100644 --- a/tools/cron/schedule.go +++ b/tools/cron/schedule.go @@ -35,6 +35,8 @@ type Schedule struct { Days map[int]struct{} `json:"days"` Months map[int]struct{} `json:"months"` DaysOfWeek map[int]struct{} `json:"daysOfWeek"` + + rawExpr string } // IsDue checks whether the provided Moment satisfies the current Schedule. @@ -130,6 +132,7 @@ func NewSchedule(cronExpr string) (*Schedule, error) { Days: days, Months: months, DaysOfWeek: daysOfWeek, + rawExpr: cronExpr, }, nil } @@ -171,7 +174,7 @@ func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error switch len(rangeParts) { case 1: if step != 1 { - return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format") + return nil, errors.New("invalid segment step - step > 1 could be used only with the wildcard or range format") } parsed, err := strconv.Atoi(rangeParts[0]) if err != nil { diff --git a/tools/cron/schedule_test.go b/tools/cron/schedule_test.go index a3a470867c..1e7f4751d5 100644 --- a/tools/cron/schedule_test.go +++ b/tools/cron/schedule_test.go @@ -1,7 +1,8 @@ package cron_test import ( - "encoding/json" + "encoding/json/v2" + "fmt" "testing" "time" @@ -9,6 +10,8 @@ import ( ) func TestNewMoment(t *testing.T) { + t.Parallel() + date, err := time.Parse("2006-01-02 15:04", "2023-05-09 15:20") if err != nil { t.Fatal(err) @@ -38,6 +41,8 @@ func TestNewMoment(t *testing.T) { } func TestNewSchedule(t *testing.T) { + t.Parallel() + scenarios := []struct { cronExpr string expectError bool @@ -248,30 +253,34 @@ func TestNewSchedule(t *testing.T) { } for _, s := range scenarios { - schedule, err := cron.NewSchedule(s.cronExpr) + t.Run(s.cronExpr, func(t *testing.T) { + schedule, err := cron.NewSchedule(s.cronExpr) - hasErr := err != nil - if hasErr != s.expectError { - t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.cronExpr, s.expectError, hasErr, err) - } + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err) + } - if hasErr { - continue - } + if hasErr { + return + } - encoded, err := json.Marshal(schedule) - if err != nil { - t.Fatalf("[%s] Failed to marshalize the result schedule: %v", s.cronExpr, err) - } - encodedStr := string(encoded) + encoded, err := json.Marshal(schedule, json.Deterministic(true)) + if err != nil { + t.Fatalf("Failed to marshalize the result schedule: %v", err) + } + encodedStr := string(encoded) - if encodedStr != s.expectSchedule { - t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.cronExpr, s.expectSchedule, encodedStr) - } + if encodedStr != s.expectSchedule { + t.Fatalf("Expected \n%s, \ngot \n%s", s.expectSchedule, encodedStr) + } + }) } } func TestScheduleIsDue(t *testing.T) { + t.Parallel() + scenarios := []struct { cronExpr string moment *cron.Moment @@ -384,15 +393,17 @@ func TestScheduleIsDue(t *testing.T) { } for i, s := range scenarios { - schedule, err := cron.NewSchedule(s.cronExpr) - if err != nil { - t.Fatalf("[%d-%s] Unexpected cron error: %v", i, s.cronExpr, err) - } + t.Run(fmt.Sprintf("%d-%s", i, s.cronExpr), func(t *testing.T) { + schedule, err := cron.NewSchedule(s.cronExpr) + if err != nil { + t.Fatalf("Unexpected cron error: %v", err) + } - result := schedule.IsDue(s.moment) + result := schedule.IsDue(s.moment) - if result != s.expected { - t.Fatalf("[%d-%s] Expected %v, got %v", i, s.cronExpr, s.expected, result) - } + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) } } diff --git a/tools/dbutils/index.go b/tools/dbutils/index.go index cdb6b1b3e4..40dc4a77a3 100644 --- a/tools/dbutils/index.go +++ b/tools/dbutils/index.go @@ -8,8 +8,8 @@ import ( ) var ( - indexRegex = regexp.MustCompile(`(?im)create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*)\)(?:\s*where\s+([\s\S]*))?`) - indexColumnRegex = regexp.MustCompile(`(?im)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?$`) + indexRegex = regexp.MustCompile(`(?i)\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s+where\s+([\s\S]*?))?\s*$`) + indexColumnRegex = regexp.MustCompile(`(?i)^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$`) ) // IndexColumn represents a single parsed SQL index column. @@ -21,13 +21,13 @@ type IndexColumn struct { // Index represents a single parsed SQL CREATE INDEX expression. type Index struct { - Unique bool `json:"unique"` - Optional bool `json:"optional"` SchemaName string `json:"schemaName"` IndexName string `json:"indexName"` TableName string `json:"tableName"` - Columns []IndexColumn `json:"columns"` Where string `json:"where"` + Columns []IndexColumn `json:"columns"` + Unique bool `json:"unique"` + Optional bool `json:"optional"` } // IsValid checks if the current Index contains the minimum required fields to be considered valid. @@ -148,11 +148,12 @@ func ParseIndex(createIndexExpr string) Index { nameTk.Separators('.') nameParts, _ := nameTk.ScanAll() - if len(nameParts) == 2 { + switch len(nameParts) { + case 1: + result.IndexName = strings.Trim(nameParts[0], trimChars) + case 2: result.SchemaName = strings.Trim(nameParts[0], trimChars) result.IndexName = strings.Trim(nameParts[1], trimChars) - } else { - result.IndexName = strings.Trim(nameParts[0], trimChars) } // TableName @@ -186,9 +187,37 @@ func ParseIndex(createIndexExpr string) Index { }) } + if len(rawColumns) != len(result.Columns) { + // unset to trigger validation error + result.Columns = []IndexColumn{} + } + // WHERE expression // --- result.Where = strings.TrimSpace(matches[6]) return result } + +// FindSingleColumnUniqueIndex returns the first matching single column unique index. +func FindSingleColumnUniqueIndex(indexes []string, column string) (Index, bool) { + var index Index + + for _, idx := range indexes { + index := ParseIndex(idx) + if index.Unique && len(index.Columns) == 1 && strings.EqualFold(index.Columns[0].Name, column) { + return index, true + } + } + + return index, false +} + +// Deprecated: Use `_, ok := FindSingleColumnUniqueIndex(indexes, column)` instead. +// +// HasColumnUniqueIndex loosely checks whether the specified column has +// a single column unique index (WHERE statements are ignored). +func HasSingleColumnUniqueIndex(column string, indexes []string) bool { + _, ok := FindSingleColumnUniqueIndex(indexes, column) + return ok +} diff --git a/tools/dbutils/index_test.go b/tools/dbutils/index_test.go index aa0a201d42..74692353fe 100644 --- a/tools/dbutils/index_test.go +++ b/tools/dbutils/index_test.go @@ -2,7 +2,8 @@ package dbutils_test import ( "bytes" - "encoding/json" + "encoding/json/v2" + "strings" "testing" "github.com/pocketbase/pocketbase/tools/dbutils" @@ -10,17 +11,28 @@ import ( func TestParseIndex(t *testing.T) { scenarios := []struct { + name string index string expected dbutils.Index }{ - // invalid { + "invalid", `invalid`, dbutils.Index{}, }, - // simple (multiple spaces between the table and columns list) { - `create index indexname on tablename (col1)`, + "no names", + `create index on ()`, + dbutils.Index{}, + }, + { + "invalid index name", + `create index a.b.c on ()`, + dbutils.Index{}, + }, + { + "simple (multiple spaces as prefix, suffix, between the table and columns list)", + ` create index indexname on tablename (col1) `, dbutils.Index{ IndexName: "indexname", TableName: "tablename", @@ -29,8 +41,8 @@ func TestParseIndex(t *testing.T) { }, }, }, - // simple (no space between the table and the columns list) { + "simple (no space between the table and the columns list)", `create index indexname on tablename(col1)`, dbutils.Index{ IndexName: "indexname", @@ -40,15 +52,15 @@ func TestParseIndex(t *testing.T) { }, }, }, - // all fields { + "all fields", `CREATE UNIQUE INDEX IF NOT EXISTS "schemaname".[indexname] on 'tablename' ( col0, ` + "`" + `col1` + "`" + `, - json_extract("col2", "$.a") asc, + json_extract("col2_multiline",` + "\n" + ` "$.a") asc, "col3" collate NOCASE, "col4" collate RTRIM desc - ) where test = 1`, + ) where cast(test1 as ` + "\n" + `int) = 1 and test2 != ''`, dbutils.Index{ Unique: true, Optional: true, @@ -58,31 +70,33 @@ func TestParseIndex(t *testing.T) { Columns: []dbutils.IndexColumn{ {Name: "col0"}, {Name: "col1"}, - {Name: `json_extract("col2", "$.a")`, Sort: "ASC"}, + {Name: `json_extract("col2_multiline",` + "\n" + ` "$.a")`, Sort: "ASC"}, {Name: `col3`, Collate: "NOCASE"}, {Name: `col4`, Collate: "RTRIM", Sort: "DESC"}, }, - Where: "test = 1", + Where: "cast(test1 as \nint) = 1 and test2 != ''", }, }, } - for i, s := range scenarios { - result := dbutils.ParseIndex(s.index) + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := dbutils.ParseIndex(s.index) - resultRaw, err := json.Marshal(result) - if err != nil { - t.Fatalf("[%d] %v", i, err) - } + resultRaw, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to marshalize parse result: %v", err) + } - expectedRaw, err := json.Marshal(s.expected) - if err != nil { - t.Fatalf("[%d] %v", i, err) - } + expectedRaw, err := json.Marshal(s.expected) + if err != nil { + t.Fatalf("Failed to marshalize expected index: %v", err) + } - if !bytes.Equal(resultRaw, expectedRaw) { - t.Errorf("[%d] Expected \n%s \ngot \n%s", i, expectedRaw, resultRaw) - } + if !bytes.Equal(resultRaw, expectedRaw) { + t.Errorf("Expected \n%s \ngot \n%s", expectedRaw, resultRaw) + } + }) } } @@ -146,11 +160,12 @@ func TestIndexIsValid(t *testing.T) { } for _, s := range scenarios { - result := s.index.IsValid() - - if result != s.expected { - t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result) - } + t.Run(s.name, func(t *testing.T) { + result := s.index.IsValid() + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) } } @@ -218,10 +233,183 @@ func TestIndexBuild(t *testing.T) { } for _, s := range scenarios { - result := s.index.Build() + t.Run(s.name, func(t *testing.T) { + result := s.index.Build() + if result != s.expected { + t.Fatalf("Expected \n%v \ngot \n%v", s.expected, result) + } + }) + } +} + +func TestHasSingleColumnUniqueIndex(t *testing.T) { + scenarios := []struct { + name string + column string + indexes []string + expected bool + }{ + { + "empty indexes", + "test", + nil, + false, + }, + { + "empty column", + "", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`)", + }, + false, + }, + { + "mismatched column", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test2`)", + }, + false, + }, + { + "non unique index", + "test", + []string{ + "CREATE INDEX `index1` ON `example` (`test`)", + }, + false, + }, + { + "matching columnd and unique index", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`)", + }, + true, + }, + { + "multiple columns", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)", + }, + false, + }, + { + "multiple indexes", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)", + "CREATE UNIQUE INDEX `index2` ON `example` (`test`)", + }, + true, + }, + { + "partial unique index", + "test", + []string{ + "CREATE UNIQUE INDEX `index` ON `example` (`test`) where test != ''", + }, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := dbutils.HasSingleColumnUniqueIndex(s.column, s.indexes) + if result != s.expected { + t.Fatalf("Expected %v got %v", s.expected, result) + } + }) + } +} + +func TestFindSingleColumnUniqueIndex(t *testing.T) { + scenarios := []struct { + name string + column string + indexes []string + expected bool + }{ + { + "empty indexes", + "test", + nil, + false, + }, + { + "empty column", + "", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`)", + }, + false, + }, + { + "mismatched column", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test2`)", + }, + false, + }, + { + "non unique index", + "test", + []string{ + "CREATE INDEX `index1` ON `example` (`test`)", + }, + false, + }, + { + "matching columnd and unique index", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`)", + }, + true, + }, + { + "multiple columns", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)", + }, + false, + }, + { + "multiple indexes", + "test", + []string{ + "CREATE UNIQUE INDEX `index1` ON `example` (`test`, `test2`)", + "CREATE UNIQUE INDEX `index2` ON `example` (`test`)", + }, + true, + }, + { + "partial unique index", + "test", + []string{ + "CREATE UNIQUE INDEX `index` ON `example` (`test`) where test != ''", + }, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + index, exists := dbutils.FindSingleColumnUniqueIndex(s.indexes, s.column) + if exists != s.expected { + t.Fatalf("Expected exists %v got %v", s.expected, exists) + } + + if !exists && len(index.Columns) > 0 { + t.Fatal("Expected index.Columns to be empty") + } - if result != s.expected { - t.Errorf("[%s] Expected \n%v \ngot \n%v", s.name, s.expected, result) - } + if exists && !strings.EqualFold(index.Columns[0].Name, s.column) { + t.Fatalf("Expected to find column %q in %v", s.column, index) + } + }) } } diff --git a/tools/dbutils/json.go b/tools/dbutils/json.go new file mode 100644 index 0000000000..723456493b --- /dev/null +++ b/tools/dbutils/json.go @@ -0,0 +1,51 @@ +package dbutils + +import ( + "fmt" + "strings" +) + +// JSONEach returns JSON_EACH SQLite string expression with +// some normalizations for non-json columns. +func JSONEach(column string) string { + // note: we are not using the new and shorter "if(x,y)" syntax for + // compatibility with custom drivers that use older SQLite version + return fmt.Sprintf( + `json_each(CASE WHEN iif(json_valid([[%s]]), json_type([[%s]])='array', FALSE) THEN [[%s]] ELSE json_array([[%s]]) END)`, + column, column, column, column, + ) +} + +// JSONArrayLength returns JSON_ARRAY_LENGTH SQLite string expression +// with some normalizations for non-json columns. +// +// It works with both json and non-json column values. +// +// Returns 0 for empty string or NULL column values. +func JSONArrayLength(column string) string { + // note: we are not using the new and shorter "if(x,y)" syntax for + // compatibility with custom drivers that use older SQLite version + return fmt.Sprintf( + `json_array_length(CASE WHEN iif(json_valid([[%s]]), json_type([[%s]])='array', FALSE) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`, + column, column, column, column, column, column, + ) +} + +// JSONExtract returns a JSON_EXTRACT SQLite string expression with +// some normalizations for non-json columns. +func JSONExtract(column string, path string) string { + // prefix the path with dot if it is not starting with array notation + if path != "" && !strings.HasPrefix(path, "[") { + path = "." + path + } + + return fmt.Sprintf( + // note: the extra object wrapping is needed to workaround the cases where a json_extract is used with non-json columns. + "(CASE WHEN json_valid([[%s]]) THEN JSON_EXTRACT([[%s]], '$%s') ELSE JSON_EXTRACT(json_object('pb', [[%s]]), '$.pb%s') END)", + column, + column, + path, + column, + path, + ) +} diff --git a/tools/dbutils/json_test.go b/tools/dbutils/json_test.go new file mode 100644 index 0000000000..194b9a6cd6 --- /dev/null +++ b/tools/dbutils/json_test.go @@ -0,0 +1,65 @@ +package dbutils_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/dbutils" +) + +func TestJSONEach(t *testing.T) { + result := dbutils.JSONEach("a.b") + + expected := "json_each(CASE WHEN iif(json_valid([[a.b]]), json_type([[a.b]])='array', FALSE) THEN [[a.b]] ELSE json_array([[a.b]]) END)" + + if result != expected { + t.Fatalf("Expected\n%v\ngot\n%v", expected, result) + } +} + +func TestJSONArrayLength(t *testing.T) { + result := dbutils.JSONArrayLength("a.b") + + expected := "json_array_length(CASE WHEN iif(json_valid([[a.b]]), json_type([[a.b]])='array', FALSE) THEN [[a.b]] ELSE (CASE WHEN [[a.b]] = '' OR [[a.b]] IS NULL THEN json_array() ELSE json_array([[a.b]]) END) END)" + + if result != expected { + t.Fatalf("Expected\n%v\ngot\n%v", expected, result) + } +} + +func TestJSONExtract(t *testing.T) { + scenarios := []struct { + name string + column string + path string + expected string + }{ + { + "empty path", + "a.b", + "", + "(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb') END)", + }, + { + "starting with array index", + "a.b", + "[1].a[2]", + "(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$[1].a[2]') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb[1].a[2]') END)", + }, + { + "starting with key", + "a.b", + "a.b[2].c", + "(CASE WHEN json_valid([[a.b]]) THEN JSON_EXTRACT([[a.b]], '$.a.b[2].c') ELSE JSON_EXTRACT(json_object('pb', [[a.b]]), '$.pb.a.b[2].c') END)", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := dbutils.JSONExtract(s.column, s.path) + + if result != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result) + } + }) + } +} diff --git a/tools/dbutils/select.go b/tools/dbutils/select.go new file mode 100644 index 0000000000..f806646d2d --- /dev/null +++ b/tools/dbutils/select.go @@ -0,0 +1,18 @@ +package dbutils + +import "regexp" + +// Regexp for columns and tables (the same as the one in dbx). +var selectRegex = regexp.MustCompile(`(?i:\s+as\s+|\s+)([\w\-_\.]+)$`) + +// AliasOrIdentifier returns the alias from a column or table identifier. +// Returns the identifier unmodified if no alias was found. +func AliasOrIdentifier(columnOrTableIdentifier string) string { + matches := selectRegex.FindStringSubmatch(columnOrTableIdentifier) + + if len(matches) > 0 && matches[1] != "" { + return matches[1] + } + + return columnOrTableIdentifier +} diff --git a/tools/dbutils/select_test.go b/tools/dbutils/select_test.go new file mode 100644 index 0000000000..74553d2fee --- /dev/null +++ b/tools/dbutils/select_test.go @@ -0,0 +1,38 @@ +package dbutils_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/dbutils" +) + +func TestAliasOrIdentifier(t *testing.T) { + scenarios := []struct { + value string + expected string + }{ + {"", ""}, + {"abc", "abc"}, + {"abc ", "abc "}, // return unmodified + {"abc.def", "abc.def"}, + {"abc.123 def", "def"}, + {"abc.123 as def.456", "def.456"}, + {"(abc) def", "def"}, + {"(abc) as def", "def"}, + {"abc def", "def"}, + {"abc as def", "def"}, + // technically invalid identifier but consistent with the dbx regex matching + {"a b c d", "d"}, + {"a b c as d", "d"}, + } + + for _, s := range scenarios { + t.Run(s.value, func(t *testing.T) { + result := dbutils.AliasOrIdentifier(s.value) + + if result != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result) + } + }) + } +} diff --git a/tools/filesystem/blob/bucket.go b/tools/filesystem/blob/bucket.go new file mode 100644 index 0000000000..23464be34e --- /dev/null +++ b/tools/filesystem/blob/bucket.go @@ -0,0 +1,731 @@ +// Package blob defines a lightweight abstration for interacting with +// various storage services (local filesystem, S3, etc.). +// +// NB! +// For compatibility with earlier PocketBase versions and to prevent +// unnecessary breaking changes, this package is based and implemented +// as a minimal, stripped down version of the previously used gocloud.dev/blob. +// While there is no promise that it won't diverge in the future to accommodate +// better some PocketBase specific use cases, currently it copies and +// tries to follow as close as possible the same implementations, +// conventions and rules for the key escaping/unescaping, blob read/write +// interfaces and struct options as gocloud.dev/blob, therefore the +// credits goes to the original Go Cloud Development Kit Authors. +package blob + +import ( + "bytes" + "context" + "crypto/md5" + "errors" + "fmt" + "io" + "log" + "mime" + "runtime" + "strings" + "sync" + "time" + "unicode/utf8" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrClosed = errors.New("bucket or blob is closed") +) + +// Bucket provides an easy and portable way to interact with blobs +// within a "bucket", including read, write, and list operations. +// To create a Bucket, use constructors found in driver subpackages. +type Bucket struct { + drv Driver + + // mu protects the closed variable. + // Read locks are kept to allow holding a read lock for long-running calls, + // and thereby prevent closing until a call finishes. + mu sync.RWMutex + closed bool +} + +// NewBucket creates a new *Bucket based on a specific driver implementation. +func NewBucket(drv Driver) *Bucket { + return &Bucket{drv: drv} +} + +// ListOptions sets options for listing blobs via Bucket.List. +type ListOptions struct { + // Prefix indicates that only blobs with a key starting with this prefix + // should be returned. + Prefix string + + // Delimiter sets the delimiter used to define a hierarchical namespace, + // like a filesystem with "directories". It is highly recommended that you + // use "" or "/" as the Delimiter. Other values should work through this API, + // but service UIs generally assume "/". + // + // An empty delimiter means that the bucket is treated as a single flat + // namespace. + // + // A non-empty delimiter means that any result with the delimiter in its key + // after Prefix is stripped will be returned with ListObject.IsDir = true, + // ListObject.Key truncated after the delimiter, and zero values for other + // ListObject fields. These results represent "directories". Multiple results + // in a "directory" are returned as a single result. + Delimiter string + + // PageSize sets the maximum number of objects to be returned. + // 0 means no maximum; driver implementations should choose a reasonable + // max. It is guaranteed to be >= 0. + PageSize int + + // PageToken may be filled in with the NextPageToken from a previous + // ListPaged call. + PageToken []byte +} + +// ListPage represents a page of results return from ListPaged. +type ListPage struct { + // Objects is the slice of objects found. If ListOptions.PageSize > 0, + // it should have at most ListOptions.PageSize entries. + // + // Objects should be returned in lexicographical order of UTF-8 encoded keys, + // including across pages. I.e., all objects returned from a ListPage request + // made using a PageToken from a previous ListPage request's NextPageToken + // should have Key >= the Key for all objects from the previous request. + Objects []*ListObject `json:"objects"` + + // NextPageToken should be left empty unless there are more objects + // to return. The value may be returned as ListOptions.PageToken on a + // subsequent ListPaged call, to fetch the next page of results. + // It can be an arbitrary []byte; it need not be a valid key. + NextPageToken []byte `json:"nextPageToken"` +} + +// ListIterator iterates over List results. +type ListIterator struct { + b *Bucket + opts *ListOptions + page *ListPage + nextIdx int +} + +// Next returns a *ListObject for the next blob. +// It returns (nil, io.EOF) if there are no more. +func (i *ListIterator) Next(ctx context.Context) (*ListObject, error) { + if i.page != nil { + // We've already got a page of results. + if i.nextIdx < len(i.page.Objects) { + // Next object is in the page; return it. + dobj := i.page.Objects[i.nextIdx] + i.nextIdx++ + return &ListObject{ + Key: dobj.Key, + ModTime: dobj.ModTime, + Size: dobj.Size, + MD5: dobj.MD5, + IsDir: dobj.IsDir, + }, nil + } + + if len(i.page.NextPageToken) == 0 { + // Done with current page, and there are no more; return io.EOF. + return nil, io.EOF + } + + // We need to load the next page. + i.opts.PageToken = i.page.NextPageToken + } + + i.b.mu.RLock() + defer i.b.mu.RUnlock() + + if i.b.closed { + return nil, ErrClosed + } + + // Loading a new page. + p, err := i.b.drv.ListPaged(ctx, i.opts) + if err != nil { + return nil, wrapError(i.b.drv, err, "") + } + + i.page = p + i.nextIdx = 0 + + return i.Next(ctx) +} + +// ListObject represents a single blob returned from List. +type ListObject struct { + // Key is the key for this blob. + Key string `json:"key"` + + // ModTime is the time the blob was last modified. + ModTime time.Time `json:"modTime"` + + // Size is the size of the blob's content in bytes. + Size int64 `json:"size"` + + // MD5 is an MD5 hash of the blob contents or nil if not available. + MD5 []byte `json:"md5"` + + // IsDir indicates that this result represents a "directory" in the + // hierarchical namespace, ending in ListOptions.Delimiter. Key can be + // passed as ListOptions.Prefix to list items in the "directory". + // Fields other than Key and IsDir will not be set if IsDir is true. + IsDir bool `json:"isDir"` +} + +// List returns a ListIterator that can be used to iterate over blobs in a +// bucket, in lexicographical order of UTF-8 encoded keys. The underlying +// implementation fetches results in pages. +// +// A nil ListOptions is treated the same as the zero value. +// +// List is not guaranteed to include all recently-written blobs; +// some services are only eventually consistent. +func (b *Bucket) List(opts *ListOptions) *ListIterator { + if opts == nil { + opts = &ListOptions{} + } + + dopts := &ListOptions{ + Prefix: opts.Prefix, + Delimiter: opts.Delimiter, + } + + return &ListIterator{b: b, opts: dopts} +} + +// FirstPageToken is the pageToken to pass to ListPage to retrieve the first page of results. +var FirstPageToken = []byte("first page") + +// ListPage returns a page of ListObject results for blobs in a bucket, in lexicographical +// order of UTF-8 encoded keys. +// +// To fetch the first page, pass FirstPageToken as the pageToken. For subsequent pages, pass +// the pageToken returned from a previous call to ListPage. +// It is not possible to "skip ahead" pages. +// +// Each call will return pageSize results, unless there are not enough blobs to fill the +// page, in which case it will return fewer results (possibly 0). +// +// If there are no more blobs available, ListPage will return an empty pageToken. Note that +// this may happen regardless of the number of returned results -- the last page might have +// 0 results (i.e., if the last item was deleted), pageSize results, or anything in between. +// +// Calling ListPage with an empty pageToken will immediately return io.EOF. When looping +// over pages, callers can either check for an empty pageToken, or they can make one more +// call and check for io.EOF. +// +// The underlying implementation fetches results in pages, but one call to ListPage may +// require multiple page fetches (and therefore, multiple calls to the BeforeList callback). +// +// A nil ListOptions is treated the same as the zero value. +// +// ListPage is not guaranteed to include all recently-written blobs; +// some services are only eventually consistent. +func (b *Bucket) ListPage(ctx context.Context, pageToken []byte, pageSize int, opts *ListOptions) (retval []*ListObject, nextPageToken []byte, err error) { + if opts == nil { + opts = &ListOptions{} + } + if pageSize <= 0 { + return nil, nil, fmt.Errorf("pageSize must be > 0 (%d)", pageSize) + } + + // Nil pageToken means no more results. + if len(pageToken) == 0 { + return nil, nil, io.EOF + } + + // FirstPageToken fetches the first page. Drivers use nil. + // The public API doesn't use nil for the first page because it would be too easy to + // keep fetching forever (since the last page return nil for the next pageToken). + if bytes.Equal(pageToken, FirstPageToken) { + pageToken = nil + } + + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return nil, nil, ErrClosed + } + + dopts := &ListOptions{ + Prefix: opts.Prefix, + Delimiter: opts.Delimiter, + PageToken: pageToken, + PageSize: pageSize, + } + + retval = make([]*ListObject, 0, pageSize) + for len(retval) < pageSize { + p, err := b.drv.ListPaged(ctx, dopts) + if err != nil { + return nil, nil, wrapError(b.drv, err, "") + } + + for _, dobj := range p.Objects { + retval = append(retval, &ListObject{ + Key: dobj.Key, + ModTime: dobj.ModTime, + Size: dobj.Size, + MD5: dobj.MD5, + IsDir: dobj.IsDir, + }) + } + + // ListPaged may return fewer results than pageSize. If there are more results + // available, signalled by non-empty p.NextPageToken, try to fetch the remainder + // of the page. + // It does not work to ask for more results than we need, because then we'd have + // a NextPageToken on a non-page boundary. + dopts.PageSize = pageSize - len(retval) + dopts.PageToken = p.NextPageToken + if len(dopts.PageToken) == 0 { + dopts.PageToken = nil + break + } + } + + return retval, dopts.PageToken, nil +} + +// Attributes contains attributes about a blob. +type Attributes struct { + // CacheControl specifies caching attributes that services may use + // when serving the blob. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + CacheControl string `json:"cacheControl"` + + // ContentDisposition specifies whether the blob content is expected to be + // displayed inline or as an attachment. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + ContentDisposition string `json:"contentDisposition"` + + // ContentEncoding specifies the encoding used for the blob's content, if any. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding + ContentEncoding string `json:"contentEncoding"` + + // ContentLanguage specifies the language used in the blob's content, if any. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language + ContentLanguage string `json:"contentLanguage"` + + // ContentType is the MIME type of the blob. It will not be empty. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + ContentType string `json:"contentType"` + + // Metadata holds key/value pairs associated with the blob. + // Keys are guaranteed to be in lowercase, even if the backend service + // has case-sensitive keys (although note that Metadata written via + // this package will always be lowercased). If there are duplicate + // case-insensitive keys (e.g., "foo" and "FOO"), only one value + // will be kept, and it is undefined which one. + Metadata map[string]string `json:"metadata"` + + // CreateTime is the time the blob was created, if available. If not available, + // CreateTime will be the zero time. + CreateTime time.Time `json:"createTime"` + + // ModTime is the time the blob was last modified. + ModTime time.Time `json:"modTime"` + + // Size is the size of the blob's content in bytes. + Size int64 `json:"size"` + + // MD5 is an MD5 hash of the blob contents or nil if not available. + MD5 []byte `json:"md5"` + + // ETag for the blob; see https://en.wikipedia.org/wiki/HTTP_ETag. + ETag string `json:"etag"` +} + +// Attributes returns attributes for the blob stored at key. +// +// If the blob does not exist, Attributes returns an error for which +// gcerrors.Code will return gcerrors.NotFound. +func (b *Bucket) Attributes(ctx context.Context, key string) (_ *Attributes, err error) { + if !utf8.ValidString(key) { + return nil, fmt.Errorf("Attributes key must be a valid UTF-8 string: %q", key) + } + + b.mu.RLock() + defer b.mu.RUnlock() + if b.closed { + return nil, ErrClosed + } + + a, err := b.drv.Attributes(ctx, key) + if err != nil { + return nil, wrapError(b.drv, err, key) + } + + var md map[string]string + if len(a.Metadata) > 0 { + // Services are inconsistent, but at least some treat keys + // as case-insensitive. To make the behavior consistent, we + // force-lowercase them when writing and reading. + md = make(map[string]string, len(a.Metadata)) + for k, v := range a.Metadata { + md[strings.ToLower(k)] = v + } + } + + return &Attributes{ + CacheControl: a.CacheControl, + ContentDisposition: a.ContentDisposition, + ContentEncoding: a.ContentEncoding, + ContentLanguage: a.ContentLanguage, + ContentType: a.ContentType, + Metadata: md, + CreateTime: a.CreateTime, + ModTime: a.ModTime, + Size: a.Size, + MD5: a.MD5, + ETag: a.ETag, + }, nil +} + +// Exists returns true if a blob exists at key, false if it does not exist, or an error. +// +// It is a shortcut for calling Attributes and checking if it returns +// an error with code ErrNotFound. +func (b *Bucket) Exists(ctx context.Context, key string) (bool, error) { + _, err := b.Attributes(ctx, key) + if err == nil { + return true, nil + } + + if errors.Is(err, ErrNotFound) { + return false, nil + } + + return false, err +} + +// NewReader is a shortcut for NewRangeReader with offset=0 and length=-1. +func (b *Bucket) NewReader(ctx context.Context, key string) (*Reader, error) { + return b.newRangeReader(ctx, key, 0, -1) +} + +// NewRangeReader returns a Reader to read content from the blob stored at key. +// It reads at most length bytes starting at offset (>= 0). +// If length is negative, it will read till the end of the blob. +// +// For the purposes of Seek, the returned Reader will start at offset and +// end at the minimum of the actual end of the blob or (if length > 0) offset + length. +// +// Note that ctx is used for all reads performed during the lifetime of the reader. +// +// If the blob does not exist, NewRangeReader returns an ErrNotFound. +// Exists is a lighter-weight wayto check for existence. +// +// The caller must call Close on the returned Reader when done reading. +func (b *Bucket) NewRangeReader(ctx context.Context, key string, offset, length int64) (_ *Reader, err error) { + return b.newRangeReader(ctx, key, offset, length) +} + +func (b *Bucket) newRangeReader(ctx context.Context, key string, offset, length int64) (_ *Reader, err error) { + b.mu.RLock() + defer b.mu.RUnlock() + if b.closed { + return nil, ErrClosed + } + + if offset < 0 { + return nil, fmt.Errorf("NewRangeReader offset must be non-negative (%d)", offset) + } + + if !utf8.ValidString(key) { + return nil, fmt.Errorf("NewRangeReader key must be a valid UTF-8 string: %q", key) + } + + var dr DriverReader + dr, err = b.drv.NewRangeReader(ctx, key, offset, length) + if err != nil { + return nil, wrapError(b.drv, err, key) + } + + r := &Reader{ + drv: b.drv, + r: dr, + key: key, + ctx: ctx, + baseOffset: offset, + baseLength: length, + savedOffset: -1, + } + + _, file, lineno, ok := runtime.Caller(2) + runtime.SetFinalizer(r, func(r *Reader) { + if !r.closed { + var caller string + if ok { + caller = fmt.Sprintf(" (%s:%d)", file, lineno) + } + log.Printf("A blob.Reader reading from %q was never closed%s", key, caller) + } + }) + + return r, nil +} + +// WriterOptions sets options for NewWriter. +type WriterOptions struct { + // BufferSize changes the default size in bytes of the chunks that + // Writer will upload in a single request; larger blobs will be split into + // multiple requests. + // + // This option may be ignored by some drivers. + // + // If 0, the driver will choose a reasonable default. + // + // If the Writer is used to do many small writes concurrently, using a + // smaller BufferSize may reduce memory usage. + BufferSize int + + // MaxConcurrency changes the default concurrency for parts of an upload. + // + // This option may be ignored by some drivers. + // + // If 0, the driver will choose a reasonable default. + MaxConcurrency int + + // CacheControl specifies caching attributes that services may use + // when serving the blob. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + CacheControl string + + // ContentDisposition specifies whether the blob content is expected to be + // displayed inline or as an attachment. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + ContentDisposition string + + // ContentEncoding specifies the encoding used for the blob's content, if any. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding + ContentEncoding string + + // ContentLanguage specifies the language used in the blob's content, if any. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language + ContentLanguage string + + // ContentType specifies the MIME type of the blob being written. If not set, + // it will be inferred from the content using the algorithm described at + // http://mimesniff.spec.whatwg.org/. + // Set DisableContentTypeDetection to true to disable the above and force + // the ContentType to stay empty. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + ContentType string + + // When true, if ContentType is the empty string, it will stay the empty + // string rather than being inferred from the content. + // Note that while the blob will be written with an empty string ContentType, + // most providers will fill one in during reads, so don't expect an empty + // ContentType if you read the blob back. + DisableContentTypeDetection bool + + // ContentMD5 is used as a message integrity check. + // If len(ContentMD5) > 0, the MD5 hash of the bytes written must match + // ContentMD5, or Close will return an error without completing the write. + // https://tools.ietf.org/html/rfc1864 + ContentMD5 []byte + + // Metadata holds key/value strings to be associated with the blob, or nil. + // Keys may not be empty, and are lowercased before being written. + // Duplicate case-insensitive keys (e.g., "foo" and "FOO") will result in + // an error. + Metadata map[string]string +} + +// NewWriter returns a Writer that writes to the blob stored at key. +// A nil WriterOptions is treated the same as the zero value. +// +// If a blob with this key already exists, it will be replaced. +// The blob being written is not guaranteed to be readable until Close +// has been called; until then, any previous blob will still be readable. +// Even after Close is called, newly written blobs are not guaranteed to be +// returned from List; some services are only eventually consistent. +// +// The returned Writer will store ctx for later use in Write and/or Close. +// To abort a write, cancel ctx; otherwise, it must remain open until +// Close is called. +// +// The caller must call Close on the returned Writer, even if the write is +// aborted. +func (b *Bucket) NewWriter(ctx context.Context, key string, opts *WriterOptions) (_ *Writer, err error) { + if !utf8.ValidString(key) { + return nil, fmt.Errorf("NewWriter key must be a valid UTF-8 string: %q", key) + } + if opts == nil { + opts = &WriterOptions{} + } + dopts := &WriterOptions{ + CacheControl: opts.CacheControl, + ContentDisposition: opts.ContentDisposition, + ContentEncoding: opts.ContentEncoding, + ContentLanguage: opts.ContentLanguage, + ContentMD5: opts.ContentMD5, + BufferSize: opts.BufferSize, + MaxConcurrency: opts.MaxConcurrency, + DisableContentTypeDetection: opts.DisableContentTypeDetection, + } + + if len(opts.Metadata) > 0 { + // Services are inconsistent, but at least some treat keys + // as case-insensitive. To make the behavior consistent, we + // force-lowercase them when writing and reading. + md := make(map[string]string, len(opts.Metadata)) + for k, v := range opts.Metadata { + if k == "" { + return nil, errors.New("WriterOptions.Metadata keys may not be empty strings") + } + if !utf8.ValidString(k) { + return nil, fmt.Errorf("WriterOptions.Metadata keys must be valid UTF-8 strings: %q", k) + } + if !utf8.ValidString(v) { + return nil, fmt.Errorf("WriterOptions.Metadata values must be valid UTF-8 strings: %q", v) + } + lowerK := strings.ToLower(k) + if _, found := md[lowerK]; found { + return nil, fmt.Errorf("WriterOptions.Metadata has a duplicate case-insensitive metadata key: %q", lowerK) + } + md[lowerK] = v + } + dopts.Metadata = md + } + + b.mu.RLock() + defer b.mu.RUnlock() + if b.closed { + return nil, ErrClosed + } + + ctx, cancel := context.WithCancel(ctx) + + w := &Writer{ + drv: b.drv, + cancel: cancel, + key: key, + contentMD5: opts.ContentMD5, + md5hash: md5.New(), + } + + if opts.ContentType != "" || opts.DisableContentTypeDetection { + var ct string + if opts.ContentType != "" { + t, p, err := mime.ParseMediaType(opts.ContentType) + if err != nil { + cancel() + return nil, err + } + ct = mime.FormatMediaType(t, p) + } + dw, err := b.drv.NewTypedWriter(ctx, key, ct, dopts) + if err != nil { + cancel() + return nil, wrapError(b.drv, err, key) + } + w.w = dw + } else { + // Save the fields needed to called NewTypedWriter later, once we've gotten + // sniffLen bytes; see the comment on Writer. + w.ctx = ctx + w.opts = dopts + w.buf = bytes.NewBuffer([]byte{}) + } + + _, file, lineno, ok := runtime.Caller(1) + runtime.SetFinalizer(w, func(w *Writer) { + if !w.closed { + var caller string + if ok { + caller = fmt.Sprintf(" (%s:%d)", file, lineno) + } + log.Printf("A blob.Writer writing to %q was never closed%s", key, caller) + } + }) + + return w, nil +} + +// Copy the blob stored at srcKey to dstKey. +// A nil CopyOptions is treated the same as the zero value. +// +// If the source blob does not exist, Copy returns an ErrNotFound. +// +// If the destination blob already exists, it is overwritten. +func (b *Bucket) Copy(ctx context.Context, dstKey, srcKey string) (err error) { + if !utf8.ValidString(srcKey) { + return fmt.Errorf("Copy srcKey must be a valid UTF-8 string: %q", srcKey) + } + + if !utf8.ValidString(dstKey) { + return fmt.Errorf("Copy dstKey must be a valid UTF-8 string: %q", dstKey) + } + + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return ErrClosed + } + + return wrapError(b.drv, b.drv.Copy(ctx, dstKey, srcKey), fmt.Sprintf("%s -> %s", srcKey, dstKey)) +} + +// Delete deletes the blob stored at key. +// +// If the blob does not exist, Delete returns an error for which +// gcerrors.Code will return gcerrors.NotFound. +func (b *Bucket) Delete(ctx context.Context, key string) (err error) { + if !utf8.ValidString(key) { + return fmt.Errorf("Delete key must be a valid UTF-8 string: %q", key) + } + + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return ErrClosed + } + + return wrapError(b.drv, b.drv.Delete(ctx, key), key) +} + +// Close releases any resources used for the bucket. +// +// @todo Consider removing it. +func (b *Bucket) Close() error { + b.mu.Lock() + prev := b.closed + b.closed = true + b.mu.Unlock() + + if prev { + return ErrClosed + } + + return wrapError(b.drv, b.drv.Close(), "") +} + +func wrapError(b Driver, err error, key string) error { + if err == nil { + return nil + } + + // don't wrap or normalize EOF errors since there are many places + // in the standard library (e.g. io.ReadAll) that rely on checks + // such as "err == io.EOF" and they will fail + if errors.Is(err, io.EOF) { + return err + } + + err = b.NormalizeError(err) + + if key != "" { + err = fmt.Errorf("[key: %s] %w", key, err) + } + + return err +} diff --git a/tools/filesystem/blob/driver.go b/tools/filesystem/blob/driver.go new file mode 100644 index 0000000000..b8dd727c54 --- /dev/null +++ b/tools/filesystem/blob/driver.go @@ -0,0 +1,97 @@ +package blob + +import ( + "context" + "io" + "time" +) + +// ReaderAttributes contains a subset of attributes about a blob that are +// accessible from Reader. +type ReaderAttributes struct { + // ContentType is the MIME type of the blob object. It must not be empty. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type + ContentType string `json:"contentType"` + + // ModTime is the time the blob object was last modified. + ModTime time.Time `json:"modTime"` + + // Size is the size of the object in bytes. + Size int64 `json:"size"` +} + +// DriverReader reads an object from the blob. +type DriverReader interface { + io.ReadCloser + + // Attributes returns a subset of attributes about the blob. + // The portable type will not modify the returned ReaderAttributes. + Attributes() *ReaderAttributes +} + +// DriverWriter writes an object to the blob. +type DriverWriter interface { + io.WriteCloser +} + +// Driver provides read, write and delete operations on objects within it on the +// blob service. +type Driver interface { + NormalizeError(err error) error + + // Attributes returns attributes for the blob. If the specified object does + // not exist, Attributes must return an ErrNotFound. + // The portable type will not modify the returned Attributes. + Attributes(ctx context.Context, key string) (*Attributes, error) + + // ListPaged lists objects in the bucket, in lexicographical order by + // UTF-8-encoded key, returning pages of objects at a time. + // Services are only required to be eventually consistent with respect + // to recently written or deleted objects. That is to say, there is no + // guarantee that an object that's been written will immediately be returned + // from ListPaged. + ListPaged(ctx context.Context, opts *ListOptions) (*ListPage, error) + + // NewRangeReader returns a Reader that reads part of an object, reading at + // most length bytes starting at the given offset. If length is negative, it + // will read until the end of the object. If the specified object does not + // exist, NewRangeReader must return an ErrNotFound. + NewRangeReader(ctx context.Context, key string, offset, length int64) (DriverReader, error) + + // NewTypedWriter returns Writer that writes to an object associated with key. + // + // A new object will be created unless an object with this key already exists. + // Otherwise any previous object with the same key will be replaced. + // The object may not be available (and any previous object will remain) + // until Close has been called. + // + // contentType sets the MIME type of the object to be written. + // opts is guaranteed to be non-nil. + // + // The caller must call Close on the returned Writer when done writing. + // + // Implementations should abort an ongoing write if ctx is later canceled, + // and do any necessary cleanup in Close. Close should then return ctx.Err(). + // + // The returned Writer *may* also implement Uploader if the underlying + // implementation can take advantage of that. The Upload call is guaranteed + // to be the only non-Close call to the Writer.. + NewTypedWriter(ctx context.Context, key, contentType string, opts *WriterOptions) (DriverWriter, error) + + // Copy copies the object associated with srcKey to dstKey. + // + // If the source object does not exist, Copy must return an ErrNotFound. + // + // If the destination object already exists, it should be overwritten. + Copy(ctx context.Context, dstKey, srcKey string) error + + // Delete deletes the object associated with key. If the specified object does + // not exist, Delete must return an ErrNotFound. + Delete(ctx context.Context, key string) error + + // Close cleans up any resources used by the Bucket. Once Close is called, + // there will be no method calls to the Bucket other than As, ErrorAs, and + // ErrorCode. There may be open readers or writers that will receive calls. + // It is up to the driver as to how these will be handled. + Close() error +} diff --git a/tools/filesystem/blob/hex.go b/tools/filesystem/blob/hex.go new file mode 100644 index 0000000000..d41909ea5d --- /dev/null +++ b/tools/filesystem/blob/hex.go @@ -0,0 +1,153 @@ +package blob + +import ( + "fmt" + "strconv" +) + +// Copied from gocloud.dev/blob to avoid nuances around the specific +// HEX escaping/unescaping rules. +// +// ------------------------------------------------------------------- +// Copyright 2019 The Go Cloud Development Kit Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------- + +// HexEscape returns s, with all runes for which shouldEscape returns true +// escaped to "__0xXXX__", where XXX is the hex representation of the rune +// value. For example, " " would escape to "__0x20__". +// +// Non-UTF-8 strings will have their non-UTF-8 characters escaped to +// unicode.ReplacementChar; the original value is lost. Please file an +// issue if you need non-UTF8 support. +// +// Note: shouldEscape takes the whole string as a slice of runes and an +// index. Passing it a single byte or a single rune doesn't provide +// enough context for some escape decisions; for example, the caller might +// want to escape the second "/" in "//" but not the first one. +// We pass a slice of runes instead of the string or a slice of bytes +// because some decisions will be made on a rune basis (e.g., encode +// all non-ASCII runes). +func HexEscape(s string, shouldEscape func(s []rune, i int) bool) string { + // Do a first pass to see which runes (if any) need escaping. + runes := []rune(s) + var toEscape []int + for i := range runes { + if shouldEscape(runes, i) { + toEscape = append(toEscape, i) + } + } + if len(toEscape) == 0 { + return s + } + + // Each escaped rune turns into at most 14 runes ("__0x7fffffff__"), + // so allocate an extra 13 for each. We'll reslice at the end + // if we didn't end up using them. + escaped := make([]rune, len(runes)+13*len(toEscape)) + n := 0 // current index into toEscape + j := 0 // current index into escaped + for i, r := range runes { + if n < len(toEscape) && i == toEscape[n] { + // We were asked to escape this rune. + for _, x := range fmt.Sprintf("__%#x__", r) { + escaped[j] = x + j++ + } + n++ + } else { + escaped[j] = r + j++ + } + } + + return string(escaped[0:j]) +} + +// unescape tries to unescape starting at r[i]. +// It returns a boolean indicating whether the unescaping was successful, +// and (if true) the unescaped rune and the last index of r that was used +// during unescaping. +func unescape(r []rune, i int) (bool, rune, int) { + // Look for "__0x". + if r[i] != '_' { + return false, 0, 0 + } + i++ + if i >= len(r) || r[i] != '_' { + return false, 0, 0 + } + i++ + if i >= len(r) || r[i] != '0' { + return false, 0, 0 + } + i++ + if i >= len(r) || r[i] != 'x' { + return false, 0, 0 + } + i++ + + // Capture the digits until the next "_" (if any). + var hexdigits []rune + for ; i < len(r) && r[i] != '_'; i++ { + hexdigits = append(hexdigits, r[i]) + } + + // Look for the trailing "__". + if i >= len(r) || r[i] != '_' { + return false, 0, 0 + } + i++ + if i >= len(r) || r[i] != '_' { + return false, 0, 0 + } + + // Parse the hex digits into an int32. + retval, err := strconv.ParseInt(string(hexdigits), 16, 32) + if err != nil { + return false, 0, 0 + } + + return true, rune(retval), i +} + +// HexUnescape reverses HexEscape. +func HexUnescape(s string) string { + var unescaped []rune + + runes := []rune(s) + for i := 0; i < len(runes); i++ { + if ok, newR, newI := unescape(runes, i); ok { + // We unescaped some runes starting at i, resulting in the + // unescaped rune newR. The last rune used was newI. + if unescaped == nil { + // This is the first rune we've encountered that + // needed unescaping. Allocate a buffer and copy any + // previous runes. + unescaped = make([]rune, i) + copy(unescaped, runes) + } + unescaped = append(unescaped, newR) + i = newI + } else if unescaped != nil { + unescaped = append(unescaped, runes[i]) + } + } + + if unescaped == nil { + return s + } + + return string(unescaped) +} diff --git a/tools/filesystem/blob/reader.go b/tools/filesystem/blob/reader.go new file mode 100644 index 0000000000..69a0d1e974 --- /dev/null +++ b/tools/filesystem/blob/reader.go @@ -0,0 +1,196 @@ +package blob + +import ( + "context" + "fmt" + "io" + "log" + "time" +) + +// Largely copied from gocloud.dev/blob.Reader to minimize breaking changes. +// +// ------------------------------------------------------------------- +// Copyright 2019 The Go Cloud Development Kit Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------- + +var _ io.ReadSeekCloser = (*Reader)(nil) + +// Reader reads bytes from a blob. +// It implements io.ReadSeekCloser, and must be closed after reads are finished. +type Reader struct { + ctx context.Context // Used to recreate r after Seeks + r DriverReader + drv Driver + key string + baseOffset int64 // The base offset provided to NewRangeReader. + baseLength int64 // The length provided to NewRangeReader (may be negative). + relativeOffset int64 // Current offset (relative to baseOffset). + savedOffset int64 // Last relativeOffset for r, saved after relativeOffset is changed in Seek, or -1 if no Seek. + closed bool +} + +// Read implements io.Reader (https://golang.org/pkg/io/#Reader). +func (r *Reader) Read(p []byte) (int, error) { + if r.savedOffset != -1 { + // We've done one or more Seeks since the last read. We may have + // to recreate the Reader. + // + // Note that remembering the savedOffset and lazily resetting the + // reader like this allows the caller to Seek, then Seek again back, + // to the original offset, without having to recreate the reader. + // We only have to recreate the reader if we actually read after a Seek. + // This is an important optimization because it's common to Seek + // to (SeekEnd, 0) and use the return value to determine the size + // of the data, then Seek back to (SeekStart, 0). + saved := r.savedOffset + if r.relativeOffset == saved { + // Nope! We're at the same place we left off. + r.savedOffset = -1 + } else { + // Yep! We've changed the offset. Recreate the reader. + length := r.baseLength + if length >= 0 { + length -= r.relativeOffset + if length < 0 { + // Shouldn't happen based on checks in Seek. + return 0, fmt.Errorf("invalid Seek (base length %d, relative offset %d)", r.baseLength, r.relativeOffset) + } + } + newR, err := r.drv.NewRangeReader(r.ctx, r.key, r.baseOffset+r.relativeOffset, length) + if err != nil { + return 0, wrapError(r.drv, err, r.key) + } + _ = r.r.Close() + r.savedOffset = -1 + r.r = newR + } + } + n, err := r.r.Read(p) + r.relativeOffset += int64(n) + return n, wrapError(r.drv, err, r.key) +} + +// Seek implements io.Seeker (https://golang.org/pkg/io/#Seeker). +func (r *Reader) Seek(offset int64, whence int) (int64, error) { + if r.savedOffset == -1 { + // Save the current offset for our reader. If the Seek changes the + // offset, and then we try to read, we'll need to recreate the reader. + // See comment above in Read for why we do it lazily. + r.savedOffset = r.relativeOffset + } + // The maximum relative offset is the minimum of: + // 1. The actual size of the blob, minus our initial baseOffset. + // 2. The length provided to NewRangeReader (if it was non-negative). + maxRelativeOffset := r.Size() - r.baseOffset + if r.baseLength >= 0 && r.baseLength < maxRelativeOffset { + maxRelativeOffset = r.baseLength + } + switch whence { + case io.SeekStart: + r.relativeOffset = offset + case io.SeekCurrent: + r.relativeOffset += offset + case io.SeekEnd: + r.relativeOffset = maxRelativeOffset + offset + } + if r.relativeOffset < 0 { + // "Seeking to an offset before the start of the file is an error." + invalidOffset := r.relativeOffset + r.relativeOffset = 0 + return 0, fmt.Errorf("Seek resulted in invalid offset %d, using 0", invalidOffset) + } + if r.relativeOffset > maxRelativeOffset { + // "Seeking to any positive offset is legal, but the behavior of subsequent + // I/O operations on the underlying object is implementation-dependent." + // We'll choose to set the offset to the EOF. + log.Printf("blob.Reader.Seek set an offset after EOF (base offset/length from NewRangeReader %d, %d; actual blob size %d; relative offset %d -> absolute offset %d).", r.baseOffset, r.baseLength, r.Size(), r.relativeOffset, r.baseOffset+r.relativeOffset) + r.relativeOffset = maxRelativeOffset + } + return r.relativeOffset, nil +} + +// Close implements io.Closer (https://golang.org/pkg/io/#Closer). +func (r *Reader) Close() error { + r.closed = true + err := wrapError(r.drv, r.r.Close(), r.key) + return err +} + +// ContentType returns the MIME type of the blob. +func (r *Reader) ContentType() string { + return r.r.Attributes().ContentType +} + +// ModTime returns the time the blob was last modified. +func (r *Reader) ModTime() time.Time { + return r.r.Attributes().ModTime +} + +// Size returns the size of the blob content in bytes. +func (r *Reader) Size() int64 { + return r.r.Attributes().Size +} + +// WriteTo reads from r and writes to w until there's no more data or +// an error occurs. +// The return value is the number of bytes written to w. +// +// It implements the io.WriterTo interface. +func (r *Reader) WriteTo(w io.Writer) (int64, error) { + // If the writer has a ReaderFrom method, use it to do the copy. + // Don't do this for our own *Writer to avoid infinite recursion. + // Avoids an allocation and a copy. + switch w.(type) { + case *Writer: + default: + if rf, ok := w.(io.ReaderFrom); ok { + n, err := rf.ReadFrom(r) + return n, err + } + } + + _, nw, err := readFromWriteTo(r, w) + return nw, err +} + +// readFromWriteTo is a helper for ReadFrom and WriteTo. +// It reads data from r and writes to w, until EOF or a read/write error. +// It returns the number of bytes read from r and the number of bytes +// written to w. +func readFromWriteTo(r io.Reader, w io.Writer) (int64, int64, error) { + // Note: can't use io.Copy because it will try to use r.WriteTo + // or w.WriteTo, which is recursive in this context. + buf := make([]byte, 4096) + var totalRead, totalWritten int64 + for { + numRead, rerr := r.Read(buf) + if numRead > 0 { + totalRead += int64(numRead) + numWritten, werr := w.Write(buf[0:numRead]) + totalWritten += int64(numWritten) + if werr != nil { + return totalRead, totalWritten, werr + } + } + if rerr == io.EOF { + // Done! + return totalRead, totalWritten, nil + } + if rerr != nil { + return totalRead, totalWritten, rerr + } + } +} diff --git a/tools/filesystem/blob/writer.go b/tools/filesystem/blob/writer.go new file mode 100644 index 0000000000..718a342f67 --- /dev/null +++ b/tools/filesystem/blob/writer.go @@ -0,0 +1,184 @@ +package blob + +import ( + "bytes" + "context" + "fmt" + "hash" + "io" + "net/http" +) + +// Largely copied from gocloud.dev/blob.Writer to minimize breaking changes. +// +// ------------------------------------------------------------------- +// Copyright 2019 The Go Cloud Development Kit Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------- + +var _ io.WriteCloser = (*Writer)(nil) + +// Writer writes bytes to a blob. +// +// It implements io.WriteCloser (https://golang.org/pkg/io/#Closer), and must be +// closed after all writes are done. +type Writer struct { + drv Driver + w DriverWriter + key string + cancel func() // cancels the ctx provided to NewTypedWriter if contentMD5 verification fails + contentMD5 []byte + md5hash hash.Hash + bytesWritten int + closed bool + + // These fields are non-zero values only when w is nil (not yet created). + // + // A ctx is stored in the Writer since we need to pass it into NewTypedWriter + // when we finish detecting the content type of the blob and create the + // underlying driver.Writer. This step happens inside Write or Close and + // neither of them take a context.Context as an argument. + // + // All 3 fields are only initialized when we create the Writer without + // setting the w field, and are reset to zero values after w is created. + ctx context.Context + opts *WriterOptions + buf *bytes.Buffer +} + +// sniffLen is the byte size of Writer.buf used to detect content-type. +const sniffLen = 512 + +// Write implements the io.Writer interface (https://golang.org/pkg/io/#Writer). +// +// Writes may happen asynchronously, so the returned error can be nil +// even if the actual write eventually fails. The write is only guaranteed to +// have succeeded if Close returns no error. +func (w *Writer) Write(p []byte) (int, error) { + if len(w.contentMD5) > 0 { + if _, err := w.md5hash.Write(p); err != nil { + return 0, err + } + } + + if w.w != nil { + return w.write(p) + } + + // If w is not yet created due to no content-type being passed in, try to sniff + // the MIME type based on at most 512 bytes of the blob content of p. + + // Detect the content-type directly if the first chunk is at least 512 bytes. + if w.buf.Len() == 0 && len(p) >= sniffLen { + return w.open(p) + } + + // Store p in w.buf and detect the content-type when the size of content in + // w.buf is at least 512 bytes. + n, err := w.buf.Write(p) + if err != nil { + return 0, err + } + + if w.buf.Len() >= sniffLen { + // Note that w.open will return the full length of the buffer; we don't want + // to return that as the length of this write since some of them were written in + // previous writes. Instead, we return the n from this write, above. + _, err := w.open(w.buf.Bytes()) + return n, err + } + + return n, nil +} + +// Close closes the blob writer. The write operation is not guaranteed +// to have succeeded until Close returns with no error. +// +// Close may return an error if the context provided to create the +// Writer is canceled or reaches its deadline. +func (w *Writer) Close() (err error) { + w.closed = true + + // Verify the MD5 hash of what was written matches the ContentMD5 provided by the user. + if len(w.contentMD5) > 0 { + md5sum := w.md5hash.Sum(nil) + if !bytes.Equal(md5sum, w.contentMD5) { + // No match! Return an error, but first cancel the context and call the + // driver's Close function to ensure the write is aborted. + w.cancel() + if w.w != nil { + _ = w.w.Close() + } + return fmt.Errorf("the WriterOptions.ContentMD5 you specified (%X) did not match what was written (%X)", w.contentMD5, md5sum) + } + } + + defer w.cancel() + + if w.w != nil { + return wrapError(w.drv, w.w.Close(), w.key) + } + + if _, err := w.open(w.buf.Bytes()); err != nil { + return err + } + + return wrapError(w.drv, w.w.Close(), w.key) +} + +// open tries to detect the MIME type of p and write it to the blob. +// The error it returns is wrapped. +func (w *Writer) open(p []byte) (int, error) { + ct := http.DetectContentType(p) + + var err error + w.w, err = w.drv.NewTypedWriter(w.ctx, w.key, ct, w.opts) + if err != nil { + return 0, wrapError(w.drv, err, w.key) + } + + // Set the 3 fields needed for lazy NewTypedWriter back to zero values + // (see the comment on Writer). + w.buf = nil + w.ctx = nil + w.opts = nil + + return w.write(p) +} + +func (w *Writer) write(p []byte) (int, error) { + n, err := w.w.Write(p) + w.bytesWritten += n + return n, wrapError(w.drv, err, w.key) +} + +// ReadFrom reads from r and writes to w until EOF or error. +// The return value is the number of bytes read from r. +// +// It implements the io.ReaderFrom interface. +func (w *Writer) ReadFrom(r io.Reader) (int64, error) { + // If the reader has a WriteTo method, use it to do the copy. + // Don't do this for our own *Reader to avoid infinite recursion. + // Avoids an allocation and a copy. + switch r.(type) { + case *Reader: + default: + if wt, ok := r.(io.WriterTo); ok { + return wt.WriteTo(w) + } + } + + nr, _, err := readFromWriteTo(r, w) + return nr, err +} diff --git a/tools/filesystem/file.go b/tools/filesystem/file.go index 912ef8821a..6cb54264b8 100644 --- a/tools/filesystem/file.go +++ b/tools/filesystem/file.go @@ -2,12 +2,14 @@ package filesystem import ( "bytes" + "context" "errors" "fmt" "io" "mime/multipart" + "net/http" "os" - "path/filepath" + "path" "regexp" "strings" @@ -23,12 +25,22 @@ type FileReader interface { // File defines a single file [io.ReadSeekCloser] resource. // -// The file could be from a local path, multipipart/formdata header, etc. +// The file could be from a local path, multipart/form-data header, etc. type File struct { - Name string - OriginalName string - Size int64 - Reader FileReader + Reader FileReader `form:"-" json:"-" xml:"-"` + Name string `form:"name" json:"name" xml:"name"` + OriginalName string `form:"originalName" json:"originalName" xml:"originalName"` + Size int64 `form:"size" json:"size" xml:"size"` +} + +// AsMap implements [core.mapExtractor] and returns a value suitable +// to be used in an API rule expression. +func (f *File) AsMap() map[string]any { + return map[string]any{ + "name": f.Name, + "originalName": f.OriginalName, + "size": f.Size, + } } // NewFileFromPath creates a new File instance from the provided local file path. @@ -65,7 +77,7 @@ func NewFileFromBytes(b []byte, name string) (*File, error) { return f, nil } -// NewFileFromMultipart creates a new File instace from the provided multipart header. +// NewFileFromMultipart creates a new File from the provided multipart header. func NewFileFromMultipart(mh *multipart.FileHeader) (*File, error) { f := &File{} @@ -77,6 +89,40 @@ func NewFileFromMultipart(mh *multipart.FileHeader) (*File, error) { return f, nil } +// NewFileFromURL creates a new File from the provided url by +// downloading the resource and load it as BytesReader. +// +// Example +// +// ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +// defer cancel() +// +// file, err := filesystem.NewFileFromURL(ctx, "https://example.com/image.png") +func NewFileFromURL(ctx context.Context, url string) (*File, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.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) + } + + var buf bytes.Buffer + + if _, err = io.Copy(&buf, res.Body); err != nil { + return nil, err + } + + return NewFileFromBytes(buf.Bytes(), path.Base(url)) +} + // ------------------------------------------------------------------- var _ FileReader = (*MultipartReader)(nil) @@ -130,24 +176,52 @@ func (r *bytesReadSeekCloser) Close() error { // ------------------------------------------------------------------- +var _ FileReader = (openFuncAsReader)(nil) + +// openFuncAsReader defines a FileReader from a bare Open function. +type openFuncAsReader func() (io.ReadSeekCloser, error) + +// Open implements the [filesystem.FileReader] interface. +func (r openFuncAsReader) Open() (io.ReadSeekCloser, error) { + return r() +} + +// ------------------------------------------------------------------- + var extInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`) +const randomAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + func normalizeName(fr FileReader, name string) string { + // cut the name even if it is not multibyte safe to avoid operating on too large strings + // --- + originalLength := len(name) + if originalLength > 300 { + name = name[originalLength-300:] + } + // extension // --- - originalExt := filepath.Ext(name) - cleanExt := extInvalidCharsRegex.ReplaceAllString(originalExt, "") - if cleanExt == "" { + originalExt := extractExtension(name) + cleanExt := "." + strings.Trim(extInvalidCharsRegex.ReplaceAllString(originalExt, ""), ".") + if cleanExt == "." { // try to detect the extension from the file content cleanExt, _ = detectExtension(fr) } + if extLength := len(cleanExt); extLength > 20 { + // keep only the last 20 characters (it is multibyte safe after the regex replace) + cleanExt = "." + strings.Trim(cleanExt[extLength-20:], ".") + } // name + // + // note: leading dot is trimmed to prevent various subtle issues with files + // sync programs as they sometimes have special handling for "invisible" files // --- - cleanName := inflector.Snakecase(strings.TrimSuffix(name, originalExt)) + cleanName := inflector.Snakecase(strings.Trim(strings.TrimSuffix(name, originalExt), ".")) if length := len(cleanName); length < 3 { // the name is too short so we concatenate an additional random part - cleanName += security.RandomString(10) + cleanName += security.RandomStringWithAlphabet(10, randomAlphabet) } else if length > 100 { // keep only the first 100 characters (it is multibyte safe after Snakecase) cleanName = cleanName[:100] @@ -156,20 +230,46 @@ func normalizeName(fr FileReader, name string) string { return fmt.Sprintf( "%s_%s%s", cleanName, - security.RandomString(10), // ensure that there is always a random part + security.RandomStringWithAlphabet(10, randomAlphabet), // ensure that there is always a random part cleanExt, ) } +// extractExtension extracts the extension (with leading dot) from name. +// +// This differ from filepath.Ext() by supporting double extensions (eg. ".tar.gz"). +// +// Returns an empty string if no match is found. +// +// Example: +// extractExtension("test.txt") // .txt +// extractExtension("test.tar.gz") // .tar.gz +// extractExtension("test.a.tar.gz") // .tar.gz +func extractExtension(name string) string { + primaryDot := strings.LastIndex(name, ".") + + if primaryDot == -1 { + return "" + } + + // look for secondary extension + secondaryDot := strings.LastIndex(name[:primaryDot], ".") + if secondaryDot >= 0 { + return name[secondaryDot:] + } + + return name[primaryDot:] +} + +// detectExtension tries to detect the extension from file mime type. func detectExtension(fr FileReader) (string, error) { - // try to detect the extension from the mime type r, err := fr.Open() if err != nil { return "", err } defer r.Close() - mt, _ := mimetype.DetectReader(r) + mt, err := mimetype.DetectReader(r) if err != nil { return "", err } diff --git a/tools/filesystem/file_test.go b/tools/filesystem/file_test.go index 15c7dfd2ae..3b46150f32 100644 --- a/tools/filesystem/file_test.go +++ b/tools/filesystem/file_test.go @@ -1,17 +1,46 @@ package filesystem_test import ( + "context" + "fmt" + "net/http" "net/http/httptest" "os" "path/filepath" "regexp" + "strconv" + "strings" "testing" - "github.com/labstack/echo/v5" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/filesystem" ) +func TestFileAsMap(t *testing.T) { + file, err := filesystem.NewFileFromBytes([]byte("test"), "test123.txt") + if err != nil { + t.Fatal(err) + } + + result := file.AsMap() + + if len(result) != 3 { + t.Fatalf("Expected map with %d keys, got\n%v", 3, result) + } + + if result["size"] != int64(4) { + t.Fatalf("Expected size %d, got %#v", 4, result["size"]) + } + + if str, ok := result["name"].(string); !ok || !strings.HasPrefix(str, "test123") { + t.Fatalf("Expected name to have prefix %q, got %#v", "test123", result["name"]) + } + + if result["originalName"] != "test123.txt" { + t.Fatalf("Expected originalName %q, got %#v", "test123.txt", result["originalName"]) + } +} + func TestNewFileFromPath(t *testing.T) { testDir := createTestDir(t) defer os.RemoveAll(testDir) @@ -23,8 +52,8 @@ func TestNewFileFromPath(t *testing.T) { } // existing file - originalName := "image_! noext" - normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".png") + originalName := "image_!@ special" + normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".png") f, err := filesystem.NewFileFromPath(filepath.Join(testDir, originalName)) if err != nil { t.Fatalf("Expected nil error, got %v", err) @@ -32,11 +61,11 @@ func TestNewFileFromPath(t *testing.T) { if f.OriginalName != originalName { t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName) } - if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { + if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err) } - if f.Size != 73 { - t.Fatalf("Expected Size %v, got %v", 73, f.Size) + if f.Size != 77 { + t.Fatalf("Expected Size %v, got %v", 77, f.Size) } if _, ok := f.Reader.(*filesystem.PathReader); !ok { t.Fatalf("Expected Reader to be PathReader, got %v", f.Reader) @@ -54,8 +83,8 @@ func TestNewFileFromBytes(t *testing.T) { t.Fatal("Expected error, got nil") } - originalName := "image_! noext" - normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".txt") + originalName := "image_!@ special" + normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".txt") f, err := filesystem.NewFileFromBytes([]byte("text\n"), originalName) if err != nil { t.Fatal(err) @@ -66,7 +95,7 @@ func TestNewFileFromBytes(t *testing.T) { if f.OriginalName != originalName { t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName) } - if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { + if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err) } } @@ -78,7 +107,7 @@ func TestNewFileFromMultipart(t *testing.T) { } req := httptest.NewRequest("", "/", formData) - req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + req.Header.Set("Content-Type", mp.FormDataContentType()) req.ParseMultipartForm(32 << 20) _, mh, err := req.FormFile("test") @@ -92,12 +121,12 @@ func TestNewFileFromMultipart(t *testing.T) { } originalNamePattern := regexp.QuoteMeta("tmpfile-") + `\w+` + regexp.QuoteMeta(".txt") - if match, _ := regexp.Match(originalNamePattern, []byte(f.OriginalName)); !match { + if match, err := regexp.Match(originalNamePattern, []byte(f.OriginalName)); !match { t.Fatalf("Expected OriginalName to match %v, got %q (%v)", originalNamePattern, f.OriginalName, err) } normalizedNamePattern := regexp.QuoteMeta("tmpfile_") + `\w+\_\w{10}` + regexp.QuoteMeta(".txt") - if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { + if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err) } @@ -109,3 +138,99 @@ func TestNewFileFromMultipart(t *testing.T) { t.Fatalf("Expected Reader to be MultipartReader, got %v", f.Reader) } } + +func TestNewFileFromURLTimeout(t *testing.T) { + 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() + + // cancelled context + { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f, err := filesystem.NewFileFromURL(ctx, srv.URL+"/cancel") + if err == nil { + t.Fatal("[ctx_cancel] Expected error, got nil") + } + if f != nil { + t.Fatalf("[ctx_cancel] Expected file to be nil, got %v", f) + } + } + + // error response + { + f, err := filesystem.NewFileFromURL(context.Background(), srv.URL+"/error") + if err == nil { + t.Fatal("[error_status] Expected error, got nil") + } + if f != nil { + t.Fatalf("[error_status] Expected file to be nil, got %v", f) + } + } + + // valid response + { + originalName := "image_!@ special" + normalizedNamePattern := regexp.QuoteMeta("image_special_") + `\w{10}` + regexp.QuoteMeta(".txt") + + f, err := filesystem.NewFileFromURL(context.Background(), srv.URL+"/"+originalName) + if err != nil { + t.Fatalf("[valid] Unexpected error %v", err) + } + if f == nil { + t.Fatal("[valid] Expected non-nil file") + } + + // check the created file fields + if f.OriginalName != originalName { + t.Fatalf("Expected OriginalName %q, got %q", originalName, f.OriginalName) + } + if match, err := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match { + t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err) + } + if f.Size != 4 { + t.Fatalf("Expected Size %v, got %v", 4, f.Size) + } + if _, ok := f.Reader.(*filesystem.BytesReader); !ok { + t.Fatalf("Expected Reader to be BytesReader, got %v", f.Reader) + } + } +} + +func TestFileNameNormalizations(t *testing.T) { + scenarios := []struct { + name string + pattern string + }{ + {"", `^\w{10}_\w{10}\.txt$`}, + {".png", `^\w{10}_\w{10}\.png$`}, + {".tar.gz", `^\w{10}_\w{10}\.tar\.gz$`}, + {"a.tar.gz", `^a\w{10}_\w{10}\.tar\.gz$`}, + {"....abc", `^\w{10}_\w{10}\.abc$`}, + {"a.b.c.?.?.?.2", `^a_b_c_\w{10}\.2$`}, + {"a.b.c.d.tar.gz", `^a_b_c_d_\w{10}\.tar\.gz$`}, + {"abcd", `^abcd_\w{10}\.txt$`}, + {".abcd.123.", `^abcd_\w{10}\.123$`}, + {"a b! c d . 456", `^a_b_c_d_\w{10}\.456$`}, // normalize spaces + {strings.Repeat("a", 101) + "." + strings.Repeat("b", 21), `^a{100}_\w{10}\.b{20}$`}, // name and extension length cut + {"abc" + strings.Repeat("d", 290) + "." + strings.Repeat("b", 9), `^d{100}_\w{10}\.b{9}$`}, // initial total length cut + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i)+"_"+s.name, func(t *testing.T) { + f, err := filesystem.NewFileFromBytes([]byte("abc"), s.name) + if err != nil { + t.Fatal(err) + } + match, err := regexp.Match(s.pattern, []byte(f.Name)) + if !match { + t.Fatalf("Expected Name to match %v, got %q (%v)", s.pattern, f.Name, err) + } + }) + } +} diff --git a/tools/filesystem/filesystem.go b/tools/filesystem/filesystem.go index 92f195ccba..32185e9581 100644 --- a/tools/filesystem/filesystem.go +++ b/tools/filesystem/filesystem.go @@ -8,29 +8,59 @@ import ( "mime/multipart" "net/http" "os" + "path" "path/filepath" "regexp" "sort" "strconv" "strings" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" "github.com/disintegration/imaging" + "github.com/fatih/color" "github.com/gabriel-vasile/mimetype" + "github.com/pocketbase/pocketbase/tools/filesystem/blob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/fileblob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/list" - "gocloud.dev/blob" - "gocloud.dev/blob/fileblob" - "gocloud.dev/blob/s3blob" + "github.com/pocketbase/pocketbase/tools/routine" + + // manually register the webp decoder because disintegration/imaging does not support webp + _ "golang.org/x/image/webp" ) +// note: the same as blob.ErrNotFound for backward compatibility with earlier versions +var ErrNotFound = blob.ErrNotFound + +const MetadataOriginalName = "original-filename" + +type DeleteEvent struct { + hook.Event + Filesystem *System + FileKey string +} + +type NewWriterEvent struct { + hook.Event + Filesystem *System + FileKey string + Options *blob.WriterOptions + Writer *blob.Writer // filled only after e.Next() +} + +// @todo consider renaming + type System struct { ctx context.Context bucket *blob.Bucket + + // @todo consider with the refactoring to bind on driver level + onNewWriter *hook.Hook[*NewWriterEvent] + onDelete *hook.Hook[*DeleteEvent] } -// NewS3 initializes an S3 filesystem instance. +// NewS3 initializes a new S3 filesystem instance. // // NB! Make sure to call `Close()` after you are done working with it. func NewS3( @@ -43,24 +73,21 @@ func NewS3( ) (*System, error) { ctx := context.Background() // default context - cred := credentials.NewStaticCredentials(accessKey, secretKey, "") - - sess, err := session.NewSession(&aws.Config{ - Region: aws.String(region), - Endpoint: aws.String(endpoint), - Credentials: cred, - S3ForcePathStyle: aws.Bool(s3ForcePathStyle), - }) - if err != nil { - return nil, err + client := &s3.S3{ + Bucket: bucketName, + Region: region, + Endpoint: endpoint, + AccessKey: accessKey, + SecretKey: secretKey, + UsePathStyle: s3ForcePathStyle, } - bucket, err := s3blob.OpenBucket(ctx, sess, bucketName, nil) + drv, err := s3blob.New(client) if err != nil { return nil, err } - return &System{ctx: ctx, bucket: bucket}, nil + return &System{ctx: ctx, bucket: blob.NewBucket(drv)}, nil } // NewLocal initializes a new local filesystem instance. @@ -74,14 +101,14 @@ func NewLocal(dirPath string) (*System, error) { return nil, err } - bucket, err := fileblob.OpenBucket(dirPath, &fileblob.Options{ + drv, err := fileblob.New(dirPath, &fileblob.Options{ NoTempDir: true, }) if err != nil { return nil, err } - return &System{ctx: ctx, bucket: bucket}, nil + return &System{ctx: ctx, bucket: blob.NewBucket(drv)}, nil } // SetContext assigns the specified context to the current filesystem. @@ -91,29 +118,116 @@ func (s *System) SetContext(ctx context.Context) { // Close releases any resources used for the related filesystem. func (s *System) Close() error { + if s.onNewWriter != nil { + s.onNewWriter.UnbindAll() + s.onNewWriter = nil + } + + if s.onDelete != nil { + s.onDelete.UnbindAll() + s.onDelete = nil + } + return s.bucket.Close() } +// 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. +func (s *System) OnNewWriter() *hook.Hook[*NewWriterEvent] { + if s.onNewWriter == nil { + s.onNewWriter = &hook.Hook[*NewWriterEvent]{} + } + + return s.onNewWriter +} + +// 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. +func (s *System) OnDelete() *hook.Hook[*DeleteEvent] { + if s.onDelete == nil { + s.onDelete = &hook.Hook[*DeleteEvent]{} + } + + return s.onDelete +} + // Exists checks if file with fileKey path exists or not. func (s *System) Exists(fileKey string) (bool, error) { return s.bucket.Exists(s.ctx, fileKey) } // Attributes returns the attributes for the file with fileKey path. +// +// If the file doesn't exist it returns ErrNotFound. func (s *System) Attributes(fileKey string) (*blob.Attributes, error) { return s.bucket.Attributes(s.ctx, fileKey) } -// GetFile returns a file content reader for the given fileKey. +// GetReader returns a file content reader for the given fileKey. // -// NB! Make sure to call `Close()` after you are done working with it. +// NB! Make sure to call Close() on the file after you are done working with it. +// +// If the file doesn't exist returns ErrNotFound. +func (s *System) GetReader(fileKey string) (*blob.Reader, error) { + return s.bucket.NewReader(s.ctx, fileKey) +} + +// Deprecated: Please use GetReader(fileKey) instead. func (s *System) GetFile(fileKey string) (*blob.Reader, error) { - br, err := s.bucket.NewReader(s.ctx, fileKey, nil) + color.Yellow("Deprecated: Please replace GetFile with GetReader.") + return s.GetReader(fileKey) +} + +// 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. +func (s *System) GetReuploadableFile(fileKey string, preserveName bool) (*File, error) { + attrs, err := s.Attributes(fileKey) if err != nil { return nil, err } - return br, nil + name := path.Base(fileKey) + originalName := attrs.Metadata[MetadataOriginalName] + if originalName == "" { + originalName = name + } + + file := &File{} + file.Size = attrs.Size + file.OriginalName = originalName + file.Reader = openFuncAsReader(func() (io.ReadSeekCloser, error) { + return s.GetReader(fileKey) + }) + + if preserveName { + file.Name = name + } else { + file.Name = normalizeName(file.Reader, originalName) + } + + return file, nil +} + +// 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. +func (s *System) Copy(srcKey, dstKey string) error { + return s.bucket.Copy(s.ctx, dstKey, srcKey) } // List returns a flat list with info for all files under the specified prefix. @@ -127,7 +241,7 @@ func (s *System) List(prefix string) ([]*blob.ListObject, error) { for { obj, err := iter.Next(s.ctx) if err != nil { - if err != io.EOF { + if !errors.Is(err, io.EOF) { return nil, err } break @@ -144,20 +258,19 @@ func (s *System) Upload(content []byte, fileKey string) error { ContentType: mimetype.Detect(content).String(), } - w, writerErr := s.bucket.NewWriter(s.ctx, fileKey, opts) + w, writerErr := s.NewWriter(fileKey, opts) if writerErr != nil { return writerErr } if _, err := w.Write(content); err != nil { - w.Close() - return err + return errors.Join(err, w.Close()) } return w.Close() } -// UploadFile uploads the provided multipart file to the fileKey location. +// UploadFile uploads the provided File to the fileKey location. func (s *System) UploadFile(file *File, fileKey string) error { f, err := file.Reader.Open() if err != nil { @@ -182,11 +295,11 @@ func (s *System) UploadFile(file *File, fileKey string) error { opts := &blob.WriterOptions{ ContentType: mt.String(), Metadata: map[string]string{ - "original-filename": originalName, + MetadataOriginalName: originalName, }, } - w, err := s.bucket.NewWriter(s.ctx, fileKey, opts) + w, err := s.NewWriter(fileKey, opts) if err != nil { return err } @@ -224,16 +337,17 @@ func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error opts := &blob.WriterOptions{ ContentType: mt.String(), Metadata: map[string]string{ - "original-filename": originalName, + MetadataOriginalName: originalName, }, } - w, err := s.bucket.NewWriter(s.ctx, fileKey, opts) + w, err := s.NewWriter(fileKey, opts) if err != nil { return err } - if _, err := w.ReadFrom(f); err != nil { + _, err = w.ReadFrom(f) + if err != nil { w.Close() return err } @@ -241,22 +355,90 @@ func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error return w.Close() } +// NewWriter returns a new blob.Writer instance allowing direct file +// create from an io.Reader value. +// +// If a file with the specified fileKey already exists, it will be replaced. +// +// 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() +func (s *System) NewWriter(fileKey string, opts *blob.WriterOptions) (*blob.Writer, error) { + if s.onNewWriter == nil { + return s.bucket.NewWriter(s.ctx, fileKey, opts) + } + + event := new(NewWriterEvent) + event.Filesystem = s + event.FileKey = fileKey + event.Options = opts + + err := s.onNewWriter.Trigger(event, func(e *NewWriterEvent) error { + writer, err := e.Filesystem.bucket.NewWriter(e.Filesystem.ctx, e.FileKey, e.Options) + if err != nil { + return err + } + + e.Writer = writer + + return nil + }) + if err != nil { + return nil, err + } + + return event.Writer, nil +} + // Delete deletes stored file at fileKey location. +// +// If the file doesn't exist returns ErrNotFound. func (s *System) Delete(fileKey string) error { - return s.bucket.Delete(s.ctx, fileKey) + if s.onDelete == nil { + return s.bucket.Delete(s.ctx, fileKey) + } + + event := new(DeleteEvent) + event.Filesystem = s + event.FileKey = fileKey + + return s.onDelete.Trigger(event, func(e *DeleteEvent) error { + return e.Filesystem.bucket.Delete(e.Filesystem.ctx, e.FileKey) + }) } // DeletePrefix deletes everything starting with the specified prefix. +// +// The prefix could be subpath (ex. "/a/b/") or filename prefix (ex. "/a/b/file_"). func (s *System) DeletePrefix(prefix string) []error { failed := []error{} if prefix == "" { - failed = append(failed, errors.New("Prefix mustn't be empty.")) + failed = append(failed, errors.New("prefix mustn't be empty")) return failed } dirsMap := map[string]struct{}{} - dirsMap[prefix] = struct{}{} + + var isPrefixDir bool + + // treat the prefix as directory only if it ends with trailing slash + if strings.HasSuffix(prefix, "/") { + isPrefixDir = true + dirsMap[strings.TrimRight(prefix, "/")] = struct{}{} + } // delete all files with the prefix // --- @@ -266,7 +448,7 @@ func (s *System) DeletePrefix(prefix string) []error { for { obj, err := iter.Next(s.ctx) if err != nil { - if err != io.EOF { + if !errors.Is(err, io.EOF) { failed = append(failed, err) } break @@ -274,8 +456,11 @@ func (s *System) DeletePrefix(prefix string) []error { if err := s.Delete(obj.Key); err != nil { failed = append(failed, err) - } else { - dirsMap[filepath.Dir(obj.Key)] = struct{}{} + } else if isPrefixDir { + slashIdx := strings.LastIndex(obj.Key, "/") + if slashIdx > -1 { + dirsMap[obj.Key[:slashIdx]] = struct{}{} + } } } // --- @@ -305,6 +490,26 @@ func (s *System) DeletePrefix(prefix string) []error { return failed } +// 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". +func (s *System) IsEmptyDir(dir string) bool { + if dir != "" && !strings.HasSuffix(dir, "/") { + dir += "/" + } + + iter := s.bucket.List(&blob.ListOptions{ + Prefix: dir, + }) + + _, err := iter.Next(s.ctx) + + return err != nil && errors.Is(err, io.EOF) +} + var inlineServeContentTypes = []string{ // image "image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp", @@ -319,8 +524,20 @@ var inlineServeContentTypes = []string{ // manualExtensionContentTypes is a map of file extensions to content types. var manualExtensionContentTypes = map[string]string{ - ".svg": "image/svg+xml", // (see https://github.com/whatwg/mimesniff/issues/7) - ".css": "text/css", // (see https://github.com/gabriel-vasile/mimetype/pull/113) + // https://github.com/whatwg/mimesniff/issues/7 + ".svg": "image/svg+xml", + + // https://github.com/gabriel-vasile/mimetype/pull/113 + ".css": "text/css", + + // https://github.com/pocketbase/pocketbase/issues/6597 + ".js": "text/javascript", + ".mjs": "text/javascript", + + // https://github.com/pocketbase/pocketbase/discussions/7467 + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", } // forceAttachmentParam is the name of the request query parameter to @@ -331,8 +548,11 @@ const forceAttachmentParam = "download" // // 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"). +// +// Internally this method uses [http.ServeContent] so Range requests, +// If-Match, If-Unmodified-Since, etc. headers are handled transparently. func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey string, name string) error { - br, readErr := s.bucket.NewReader(s.ctx, fileKey, nil) + br, readErr := s.GetReader(fileKey) if readErr != nil { return readErr } @@ -352,11 +572,11 @@ func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey strin // make an exception for specific content types and force a custom // content type to send in the response so that it can be loaded properly extContentType := realContentType - if ct, found := manualExtensionContentTypes[filepath.Ext(name)]; found && extContentType != ct { + if ct, found := manualExtensionContentTypes[filepath.Ext(fileKey)]; found { extContentType = ct } - setHeaderIfMissing(res, "Content-Disposition", disposition+"; filename="+name) + setHeaderIfMissing(res, "Content-Disposition", disposition+"; filename="+strconv.Quote(name)) setHeaderIfMissing(res, "Content-Type", extContentType) setHeaderIfMissing(res, "Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox") @@ -389,10 +609,18 @@ var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`) // - 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) -func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) error { +func (s *System) CreateThumb(originalKey, thumbKey, thumbSize string) error { + // note: the wrapping is an extra precaution since there were several + // golang.org/x/image panic related issues over the years + return routine.SafeWrap(func() error { + return s.createThumb(originalKey, thumbKey, thumbSize) + })() +} + +func (s *System) createThumb(originalKey, thumbKey, thumbSize string) error { sizeParts := ThumbSizeRegex.FindStringSubmatch(thumbSize) if len(sizeParts) != 4 { - return errors.New("Thumb size must be in WxH, WxHt, WxHb or WxHf format.") + return errors.New("thumb size must be in WxH, WxHt, WxHb or WxHf format") } width, _ := strconv.Atoi(sizeParts[1]) @@ -400,11 +628,11 @@ func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) err resizeType := sizeParts[3] if width == 0 && height == 0 { - return errors.New("Thumb width and height cannot be zero at the same time.") + return errors.New("thumb width and height cannot be zero at the same time") } // fetch the original - r, readErr := s.bucket.NewReader(s.ctx, originalKey, nil) + r, readErr := s.GetReader(originalKey) if readErr != nil { return readErr } @@ -421,43 +649,56 @@ func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) err if width == 0 || height == 0 { // force resize preserving aspect ratio - thumbImg = imaging.Resize(img, width, height, imaging.CatmullRom) + thumbImg = imaging.Resize(img, width, height, imaging.Linear) } else { switch resizeType { case "f": // fit - thumbImg = imaging.Fit(img, width, height, imaging.CatmullRom) + thumbImg = imaging.Fit(img, width, height, imaging.Linear) case "t": // fill and crop from top - thumbImg = imaging.Fill(img, width, height, imaging.Top, imaging.CatmullRom) + thumbImg = imaging.Fill(img, width, height, imaging.Top, imaging.Linear) case "b": // fill and crop from bottom - thumbImg = imaging.Fill(img, width, height, imaging.Bottom, imaging.CatmullRom) + thumbImg = imaging.Fill(img, width, height, imaging.Bottom, imaging.Linear) default: // fill and crop from center - thumbImg = imaging.Fill(img, width, height, imaging.Center, imaging.CatmullRom) + thumbImg = imaging.Fill(img, width, height, imaging.Center, imaging.Linear) } } + originalContentType := r.ContentType() + opts := &blob.WriterOptions{ - ContentType: r.ContentType(), + ContentType: originalContentType, + } + + var format imaging.Format + + switch originalContentType { + case "image/jpeg": + format = imaging.JPEG + case "image/gif": + format = imaging.GIF + case "image/tiff": + format = imaging.TIFF + case "image/bmp": + format = imaging.BMP + default: + // fallback to PNG (this includes webp!) + opts.ContentType = "image/png" + format = imaging.PNG } // open a thumb storage writer (aka. prepare for upload) - w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, opts) - if writerErr != nil { - return writerErr - } - - // try to detect the thumb format based on the original file name - // (fallbacks to png on error) - format, err := imaging.FormatFromFilename(thumbKey) + w, err := s.NewWriter(thumbKey, opts) if err != nil { - format = imaging.PNG + return err } // thumb encode (aka. upload) - if err := imaging.Encode(w, thumbImg, format); err != nil { + err = imaging.Encode(w, thumbImg, format) + if err != nil { w.Close() return err } diff --git a/tools/filesystem/filesystem_test.go b/tools/filesystem/filesystem_test.go index 3667aee02b..c470713e88 100644 --- a/tools/filesystem/filesystem_test.go +++ b/tools/filesystem/filesystem_test.go @@ -2,8 +2,11 @@ package filesystem_test import ( "bytes" + "errors" "image" + "image/jpeg" "image/png" + "io" "mime/multipart" "net/http" "net/http/httptest" @@ -12,18 +15,22 @@ import ( "strings" "testing" + "github.com/gabriel-vasile/mimetype" "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/filesystem/blob" ) -func TestFileSystemExists(t *testing.T) { +func TestFilesystemExists(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) scenarios := []struct { file string @@ -35,24 +42,34 @@ func TestFileSystemExists(t *testing.T) { {"image.png", true}, } - for i, scenario := range scenarios { - exists, _ := fs.Exists(scenario.file) + for _, s := range scenarios { + t.Run(s.file, func(t *testing.T) { + exists, err := fsys.Exists(s.file) - if exists != scenario.exists { - t.Errorf("(%d) Expected %v, got %v", i, scenario.exists, exists) - } + if err != nil { + t.Fatal(err) + } + + if exists != s.exists { + t.Fatalf("Expected exists %v, got %v", s.exists, exists) + } + }) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemAttributes(t *testing.T) { +func TestFilesystemAttributes(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) scenarios := []struct { file string @@ -65,74 +82,233 @@ func TestFileSystemAttributes(t *testing.T) { {"image.png", false, "image/png"}, } - for i, scenario := range scenarios { - attr, err := fs.Attributes(scenario.file) + for _, s := range scenarios { + t.Run(s.file, func(t *testing.T) { + attr, err := fsys.Attributes(s.file) - if err == nil && scenario.expectError { - t.Errorf("(%d) Expected error, got nil", i) - } + hasErr := err != nil - if err != nil && !scenario.expectError { - t.Errorf("(%d) Expected nil, got error, %v", i, err) - } + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } - if err == nil && attr.ContentType != scenario.expectContentType { - t.Errorf("(%d) Expected attr.ContentType to be %q, got %q", i, scenario.expectContentType, attr.ContentType) - } + if hasErr && !errors.Is(err, filesystem.ErrNotFound) { + t.Fatalf("Expected ErrNotFound err, got %q", err) + } + + if !hasErr && attr.ContentType != s.expectContentType { + t.Fatalf("Expected attr.ContentType to be %q, got %q", s.expectContentType, attr.ContentType) + } + }) + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) +} + +func TestFilesystemNewWriter(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() + + hookCalls := bindHooks(fsys) + + testKey := "test_writer" + + opts := &blob.WriterOptions{ + Metadata: map[string]string{"test": "abc"}, + } + + w, err := fsys.NewWriter("test_writer", opts) + if err != nil { + t.Fatal(err) + } + + expectedContent := "test_content" + + _, err = w.ReadFrom(strings.NewReader(expectedContent)) + if err != nil { + t.Fatal(err) + } + + err = w.Close() + if err != nil { + t.Fatal(err) + } + + // verify that the file attributes and content were properly saved + // --------------------------------------------------------------- + attrs, err := fsys.Attributes(testKey) + if err != nil { + t.Fatal(err) } + if attrs.Metadata["test"] != "abc" { + t.Fatalf("Expected metadata test:abc, got %v", attrs.Metadata) + } + + r, err := fsys.GetReader(testKey) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + + _, err = io.Copy(&buf, r) + if err != nil { + t.Fatal(err) + } + + result := buf.String() + if result != expectedContent { + t.Fatalf("Expected file content\n%s\ngot\n%s", expectedContent, result) + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 1}) } -func TestFileSystemDelete(t *testing.T) { +func TestFilesystemDelete(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) - if err := fs.Delete("missing.txt"); err == nil { - t.Fatal("Expected error, got nil") + if err := fsys.Delete("missing.txt"); err == nil || !errors.Is(err, filesystem.ErrNotFound) { + t.Fatalf("Expected ErrNotFound error, got %v", err) } - if err := fs.Delete("image.png"); err != nil { + if err := fsys.Delete("image.png"); err != nil { t.Fatalf("Expected nil, got error %v", err) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 2, "OnNewWriter": 0}) } -func TestFileSystemDeletePrefix(t *testing.T) { +func TestFilesystemDeletePrefixWithoutTrailingSlash(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() - if errs := fs.DeletePrefix(""); len(errs) == 0 { + hookCalls := bindHooks(fsys) + + if errs := fsys.DeletePrefix(""); len(errs) == 0 { t.Fatal("Expected error, got nil", errs) } - if errs := fs.DeletePrefix("missing/"); len(errs) != 0 { + if errs := fsys.DeletePrefix("missing"); len(errs) != 0 { + t.Fatalf("Not existing prefix shouldn't error, got %v", errs) + } + + if errs := fsys.DeletePrefix("test"); len(errs) != 0 { + t.Fatalf("Expected nil, got errors %v", errs) + } + + // ensure that the test/* files are deleted + if exists, _ := fsys.Exists("test/sub1.txt"); exists { + t.Fatalf("Expected test/sub1.txt to be deleted") + } + if exists, _ := fsys.Exists("test/sub2.txt"); exists { + t.Fatalf("Expected test/sub2.txt to be deleted") + } + + // the test directory should remain since the prefix didn't have trailing slash + if _, err := os.Stat(filepath.Join(dir, "test")); os.IsNotExist(err) { + t.Fatal("Expected the prefix dir to remain") + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 2, "OnNewWriter": 0}) +} + +func TestFilesystemDeletePrefixWithTrailingSlash(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() + + hookCalls := bindHooks(fsys) + + if errs := fsys.DeletePrefix("missing/"); len(errs) != 0 { t.Fatalf("Not existing prefix shouldn't error, got %v", errs) } - if errs := fs.DeletePrefix("test"); len(errs) != 0 { + if errs := fsys.DeletePrefix("test/"); len(errs) != 0 { t.Fatalf("Expected nil, got errors %v", errs) } - // ensure that the test/ files are deleted - if exists, _ := fs.Exists("test/sub1.txt"); exists { + // ensure that the test/* files are deleted + if exists, _ := fsys.Exists("test/sub1.txt"); exists { t.Fatalf("Expected test/sub1.txt to be deleted") } - if exists, _ := fs.Exists("test/sub2.txt"); exists { + if exists, _ := fsys.Exists("test/sub2.txt"); exists { t.Fatalf("Expected test/sub2.txt to be deleted") } + + // the test directory should be also deleted since the prefix has trailing slash + if _, err := os.Stat(filepath.Join(dir, "test")); !os.IsNotExist(err) { + t.Fatal("Expected the prefix dir to be deleted") + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 4, "OnNewWriter": 0}) } -func TestFileSystemUploadMultipart(t *testing.T) { +func TestFilesystemIsEmptyDir(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() + + hookCalls := bindHooks(fsys) + + scenarios := []struct { + dir string + expected bool + }{ + {"", false}, // special case that shouldn't be suffixed with delimiter to search for any files within the bucket + {"/", true}, + {"missing", true}, + {"missing/", true}, + {"test", false}, + {"test/", false}, + {"empty", true}, + {"empty/", true}, + } + + for _, s := range scenarios { + t.Run(s.dir, func(t *testing.T) { + result := fsys.IsEmptyDir(s.dir) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) +} + +func TestFilesystemUploadMultipart(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) @@ -156,41 +332,47 @@ func TestFileSystemUploadMultipart(t *testing.T) { defer file.Close() // --- - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) fileKey := "newdir/newkey.txt" - uploadErr := fs.UploadMultipart(fh, fileKey) + uploadErr := fsys.UploadMultipart(fh, fileKey) if uploadErr != nil { t.Fatal(uploadErr) } - if exists, _ := fs.Exists(fileKey); !exists { + if exists, _ := fsys.Exists(fileKey); !exists { t.Fatalf("Expected %q to exist", fileKey) } - attrs, err := fs.Attributes(fileKey) + attrs, err := fsys.Attributes(fileKey) if err != nil { t.Fatalf("Failed to fetch file attributes: %v", err) } if name, ok := attrs.Metadata["original-filename"]; !ok || name != "test" { t.Fatalf("Expected original-filename to be %q, got %q", "test", name) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 1}) } -func TestFileSystemUploadFile(t *testing.T) { +func TestFilesystemUploadFile(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) fileKey := "newdir/newkey.txt" @@ -201,55 +383,63 @@ func TestFileSystemUploadFile(t *testing.T) { file.OriginalName = "test.txt" - uploadErr := fs.UploadFile(file, fileKey) + uploadErr := fsys.UploadFile(file, fileKey) if uploadErr != nil { t.Fatal(uploadErr) } - if exists, _ := fs.Exists(fileKey); !exists { + if exists, _ := fsys.Exists(fileKey); !exists { t.Fatalf("Expected %q to exist", fileKey) } - attrs, err := fs.Attributes(fileKey) + attrs, err := fsys.Attributes(fileKey) if err != nil { t.Fatalf("Failed to fetch file attributes: %v", err) } if name, ok := attrs.Metadata["original-filename"]; !ok || name != file.OriginalName { t.Fatalf("Expected original-filename to be %q, got %q", file.OriginalName, name) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 1}) } -func TestFileSystemUpload(t *testing.T) { +func TestFilesystemUpload(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) fileKey := "newdir/newkey.txt" - uploadErr := fs.Upload([]byte("demo"), fileKey) + uploadErr := fsys.Upload([]byte("demo"), fileKey) if uploadErr != nil { t.Fatal(uploadErr) } - if exists, _ := fs.Exists(fileKey); !exists { + if exists, _ := fsys.Exists(fileKey); !exists { t.Fatalf("Expected %s to exist", fileKey) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 1}) } -func TestFileSystemServe(t *testing.T) { +func TestFilesystemServe(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) csp := "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox" cacheControl := "max-age=2592000, stale-while-revalidate=86400" @@ -279,9 +469,9 @@ func TestFileSystemServe(t *testing.T) { nil, false, map[string]string{ - "Content-Disposition": "attachment; filename=test_name.txt", + "Content-Disposition": `attachment; filename="test_name.txt"`, "Content-Type": "application/octet-stream", - "Content-Length": "0", + "Content-Length": "4", "Content-Security-Policy": csp, "Cache-Control": cacheControl, }, @@ -294,9 +484,9 @@ func TestFileSystemServe(t *testing.T) { nil, false, map[string]string{ - "Content-Disposition": "inline; filename=test_name.png", + "Content-Disposition": `inline; filename="test_name.png"`, "Content-Type": "image/png", - "Content-Length": "73", + "Content-Length": "77", "Content-Security-Policy": csp, "Cache-Control": cacheControl, }, @@ -309,9 +499,9 @@ func TestFileSystemServe(t *testing.T) { nil, false, map[string]string{ - "Content-Disposition": "attachment; filename=test_name_download.png", + "Content-Disposition": `attachment; filename="test_name_download.png"`, "Content-Type": "image/png", - "Content-Length": "73", + "Content-Length": "77", "Content-Security-Policy": csp, "Cache-Control": cacheControl, }, @@ -319,12 +509,12 @@ func TestFileSystemServe(t *testing.T) { { // svg exception "image.svg", - "test_name.svg", + "test_name.abc", nil, nil, false, map[string]string{ - "Content-Disposition": "attachment; filename=test_name.svg", + "Content-Disposition": `attachment; filename="test_name.abc"`, "Content-Type": "image/svg+xml", "Content-Length": "0", "Content-Security-Policy": csp, @@ -334,18 +524,93 @@ func TestFileSystemServe(t *testing.T) { { // css exception "style.css", - "test_name.css", + "test_name", nil, nil, false, map[string]string{ - "Content-Disposition": "attachment; filename=test_name.css", + "Content-Disposition": `attachment; filename="test_name"`, "Content-Type": "text/css", "Content-Length": "0", "Content-Security-Policy": csp, "Cache-Control": cacheControl, }, }, + { + // js exception + "main.js", + "test_name.abc", + nil, + nil, + false, + map[string]string{ + "Content-Disposition": `attachment; filename="test_name.abc"`, + "Content-Type": "text/javascript", + "Content-Length": "0", + "Content-Security-Policy": csp, + "Cache-Control": cacheControl, + }, + }, + { + // mjs exception + "main.mjs", + "test_name.abc", + nil, + nil, + false, + map[string]string{ + "Content-Disposition": `attachment; filename="test_name.abc"`, + "Content-Type": "text/javascript", + "Content-Length": "0", + "Content-Security-Policy": csp, + "Cache-Control": cacheControl, + }, + }, + { + // xlsx exception + "dummy.xlsx", + "test_name.abc", + nil, + nil, + false, + map[string]string{ + "Content-Disposition": `attachment; filename="test_name.abc"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Length": "0", + "Content-Security-Policy": csp, + "Cache-Control": cacheControl, + }, + }, + { + // docx exception + "dummy.docx", + "test_name.abc", + nil, + nil, + false, + map[string]string{ + "Content-Disposition": `attachment; filename="test_name.abc"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Content-Length": "0", + "Content-Security-Policy": csp, + "Cache-Control": cacheControl, + }, + }, + { + // pptx exception + "dummy.pptx", + "test_name.abc", + nil, + nil, + false, + map[string]string{ + "Content-Disposition": `attachment; filename="test_name.abc"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "Content-Length": "0", + "Content-Security-Policy": csp, + "Cache-Control": cacheControl, + }, + }, { // custom header "test/sub2.txt", @@ -354,7 +619,7 @@ func TestFileSystemServe(t *testing.T) { map[string]string{ "Content-Disposition": "1", "Content-Type": "2", - "Content-Length": "3", + "Content-Length": "1", "Content-Security-Policy": "4", "Cache-Control": "5", "X-Custom": "6", @@ -363,7 +628,7 @@ func TestFileSystemServe(t *testing.T) { map[string]string{ "Content-Disposition": "1", "Content-Type": "2", - "Content-Length": "0", // overwriten by http.ServeContent + "Content-Length": "4", // overwriten by http.ServeContent "Content-Security-Policy": "4", "Cache-Control": "5", "X-Custom": "6", @@ -372,72 +637,233 @@ func TestFileSystemServe(t *testing.T) { } for _, s := range scenarios { - res := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/", nil) + t.Run(s.path, func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + query := req.URL.Query() + for k, v := range s.query { + query.Set(k, v) + } + req.URL.RawQuery = query.Encode() + + for k, v := range s.headers { + res.Header().Set(k, v) + } + + err := fsys.Serve(res, req, s.path, s.name) + hasErr := err != nil + + if hasErr != s.expectError { + t.Fatalf("Expected hasError %v, got %v (%v)", s.expectError, hasErr, err) + } + + if s.expectError { + return + } + + result := res.Result() + defer result.Body.Close() + + for hName, hValue := range s.expectHeaders { + v := result.Header.Get(hName) + if v != hValue { + t.Errorf("Expected value %q for header %q, got %q", hValue, hName, v) + } + } + }) + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) +} + +func TestFilesystemGetReader(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() + + hookCalls := bindHooks(fsys) + + scenarios := []struct { + file string + expectError bool + expectedContent string + }{ + {"test/missing.txt", true, ""}, + {"test/sub1.txt", false, "sub1"}, + } + + for _, s := range scenarios { + t.Run(s.file, func(t *testing.T) { + f, err := fsys.GetReader(s.file) + defer func() { + if f != nil { + f.Close() + } + }() + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + + if hasErr { + if !errors.Is(err, filesystem.ErrNotFound) { + t.Fatalf("Expected ErrNotFound error, got %v", err) + } + return + } + + raw, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + + if str := string(raw); str != s.expectedContent { + t.Fatalf("Expected content\n%s\ngot\n%s", s.expectedContent, str) + } + }) + } - query := req.URL.Query() - for k, v := range s.query { - query.Set(k, v) + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) +} + +func TestFilesystemGetReuploadableFile(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) + + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() + + hookCalls := bindHooks(fsys) + + t.Run("missing.txt", func(t *testing.T) { + _, err := fsys.GetReuploadableFile("missing.txt", false) + if err == nil { + t.Fatal("Expected error, got nil") } - req.URL.RawQuery = query.Encode() + }) - for k, v := range s.headers { - res.Header().Set(k, v) + testReader := func(t *testing.T, f *filesystem.File, expectedContent string) { + r, err := f.Reader.Open() + if err != nil { + t.Fatal(err) } + defer r.Close() - err := fs.Serve(res, req, s.path, s.name) - hasErr := err != nil + raw, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) - if hasErr != s.expectError { - t.Errorf("(%s) Expected hasError %v, got %v (%v)", s.path, s.expectError, hasErr, err) - continue + if rawStr != expectedContent { + t.Fatalf("Expected content %q, got %q", expectedContent, rawStr) } + } - if s.expectError { - continue + t.Run("existing (preserve name)", func(t *testing.T) { + file, err := fsys.GetReuploadableFile("test/sub1.txt", true) + if err != nil { + t.Fatal(err) } - result := res.Result() + if v := file.OriginalName; v != "sub1.txt" { + t.Fatalf("Expected originalName %q, got %q", "sub1.txt", v) + } - for hName, hValue := range s.expectHeaders { - v := result.Header.Get(hName) - if v != hValue { - t.Errorf("(%s) Expected value %q for header %q, got %q", s.path, hValue, hName, v) - } + if v := file.Size; v != 4 { + t.Fatalf("Expected size %d, got %d", 4, v) } - } + + if v := file.Name; v != "sub1.txt" { + t.Fatalf("Expected name to be preserved, got %q", v) + } + + testReader(t, file, "sub1") + }) + + t.Run("existing (new random suffix name)", func(t *testing.T) { + file, err := fsys.GetReuploadableFile("test/sub1.txt", false) + if err != nil { + t.Fatal(err) + } + + if v := file.OriginalName; v != "sub1.txt" { + t.Fatalf("Expected originalName %q, got %q", "sub1.txt", v) + } + + if v := file.Size; v != 4 { + t.Fatalf("Expected size %d, got %d", 4, v) + } + + if v := file.Name; v == "sub1.txt" || len(v) <= len("sub1.txt.png") { + t.Fatalf("Expected name to have new random suffix, got %q", v) + } + + testReader(t, file, "sub1") + }) + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemGetFile(t *testing.T) { +func TestFilesystemCopy(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() - f, err := fs.GetFile("image.png") + hookCalls := bindHooks(fsys) + + src := "image.png" + dst := "image.png_copy" + + // copy missing file + if err := fsys.Copy(dst, src); err == nil { + t.Fatalf("Expected to fail copying %q to %q, got nil", dst, src) + } + + // copy existing file + if err := fsys.Copy(src, dst); err != nil { + t.Fatalf("Failed to copy %q to %q: %v", src, dst, err) + } + + f, err := fsys.GetReader(dst) if err != nil { - t.Fatal(err) + t.Fatalf("Missing copied file %q: %v", dst, err) } defer f.Close() - if f == nil { - t.Fatal("File is supposed to be found") + if f.Size() != 77 { + t.Fatalf("Expected file size %d, got %d", 77, f.Size()) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemList(t *testing.T) { +func TestFilesystemList(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) scenarios := []struct { prefix string @@ -447,9 +873,17 @@ func TestFileSystemList(t *testing.T) { "", []string{ "image.png", + "image.jpg", "image.svg", - "image_! noext", + "image.webp", + "image_!@ special", + "image_noext", "style.css", + "main.js", + "main.mjs", + "dummy.xlsx", + "dummy.docx", + "dummy.pptx", "test/sub1.txt", "test/sub2.txt", }, @@ -468,43 +902,52 @@ func TestFileSystemList(t *testing.T) { } for _, s := range scenarios { - objs, err := fs.List(s.prefix) - if err != nil { - t.Fatalf("[%s] %v", s.prefix, err) - } + t.Run("prefix_"+s.prefix, func(t *testing.T) { + objs, err := fsys.List(s.prefix) + if err != nil { + t.Fatal(err) + } - if len(s.expected) != len(objs) { - t.Fatalf("[%s] Expected %d files, got \n%v", s.prefix, len(s.expected), objs) - } + if len(s.expected) != len(objs) { + t.Fatalf("Expected %d files, got \n%v", len(s.expected), objs) + } - ObjsLoop: - for _, obj := range objs { - for _, name := range s.expected { - if name == obj.Key { - continue ObjsLoop + for _, obj := range objs { + var exists bool + for _, name := range s.expected { + if name == obj.Key { + exists = true + break + } } - } - t.Fatalf("[%s] Unexpected file %q", s.prefix, obj.Key) - } + if !exists { + t.Fatalf("Unexpected file %q", obj.Key) + } + } + }) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemServeSingleRange(t *testing.T) { +func TestFilesystemServeSingleRange(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) res := httptest.NewRecorder() req := httptest.NewRequest("GET", "/", nil) req.Header.Add("Range", "bytes=0-20") - if err := fs.Serve(res, req, "image.png", "image.png"); err != nil { + if err := fsys.Serve(res, req, "image.png", "image.png"); err != nil { t.Fatal(err) } @@ -514,7 +957,7 @@ func TestFileSystemServeSingleRange(t *testing.T) { t.Fatalf("Expected StatusCode %d, got %d", http.StatusPartialContent, result.StatusCode) } - expectedRange := "bytes 0-20/73" + expectedRange := "bytes 0-20/77" if cr := result.Header.Get("Content-Range"); cr != expectedRange { t.Fatalf("Expected Content-Range %q, got %q", expectedRange, cr) } @@ -522,23 +965,27 @@ func TestFileSystemServeSingleRange(t *testing.T) { if l := result.Header.Get("Content-Length"); l != "21" { t.Fatalf("Expected Content-Length %v, got %v", 21, l) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemServeMultiRange(t *testing.T) { +func TestFilesystemServeMultiRange(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - defer fs.Close() + defer fsys.Close() + + hookCalls := bindHooks(fsys) res := httptest.NewRecorder() req := httptest.NewRequest("GET", "/", nil) req.Header.Add("Range", "bytes=0-20, 25-30") - if err := fs.Serve(res, req, "image.png", "image.png"); err != nil { + if err := fsys.Serve(res, req, "image.png", "image.png"); err != nil { t.Fatal(err) } @@ -551,109 +998,315 @@ func TestFileSystemServeMultiRange(t *testing.T) { if ct := result.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/byteranges; boundary=") { t.Fatalf("Expected Content-Type to be multipart/byteranges, got %v", ct) } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 0}) } -func TestFileSystemCreateThumb(t *testing.T) { +func TestFilesystemCreateThumb(t *testing.T) { dir := createTestDir(t) defer os.RemoveAll(dir) - fs, err := filesystem.NewLocal(dir) - if err != nil { - t.Fatal(err) - } - defer fs.Close() - scenarios := []struct { - file string - thumb string - cropCenter bool - expectError bool + file string + thumb string + size string + expectedMimeType string }{ // missing - {"missing.txt", "thumb_test_missing", true, true}, + {"missing.txt", "thumb_test_missing", "100x100", ""}, // non-image existing file - {"test/sub1.txt", "thumb_test_sub1", true, true}, - // existing image file - crop center - {"image.png", "thumb_file_center", true, false}, - // existing image file - crop top - {"image.png", "thumb_file_top", false, false}, + {"test/sub1.txt", "thumb_test_sub1", "100x100", ""}, // existing image file with existing thumb path = should fail - {"image.png", "test", true, true}, + {"image.png", "test", "100x100", ""}, + // existing image file with invalid thumb size + {"image.png", "thumb0", "invalid", ""}, + // existing image file with 0xH thumb size + {"image.png", "thumb_0xH", "0x100", "image/png"}, + // existing image file with Wx0 thumb size + {"image.png", "thumb_Wx0", "100x0", "image/png"}, + // existing image file with WxH thumb size + {"image.png", "thumb_WxH", "100x100", "image/png"}, + // existing image file with WxHt thumb size + {"image.png", "thumb_WxHt", "100x100t", "image/png"}, + // existing image file with WxHb thumb size + {"image.png", "thumb_WxHb", "100x100b", "image/png"}, + // existing image file with WxHf thumb size + {"image.png", "thumb_WxHf", "100x100f", "image/png"}, + // jpg + {"image.jpg", "thumb.jpg", "100x100", "image/jpeg"}, + // webp (should produce png) + {"image.webp", "thumb.webp", "100x100", "image/png"}, + // without extension (should extract the mimetype from its stored ContentType) + {"image_noext", "image_noext.jpeg", "100x100", "image/jpeg"}, } - for i, scenario := range scenarios { - err := fs.CreateThumb(scenario.file, scenario.thumb, "100x100") + for _, s := range scenarios { + t.Run(s.file+"_"+s.thumb+"_"+s.size, func(t *testing.T) { + fsys, err := filesystem.NewLocal(dir) + if err != nil { + t.Fatal(err) + } + defer fsys.Close() - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) - continue - } + hookCalls := bindHooks(fsys) - if scenario.expectError { - continue - } + expectErr := s.expectedMimeType == "" - if exists, _ := fs.Exists(scenario.thumb); !exists { - t.Errorf("(%d) Couldn't find %q thumb", i, scenario.thumb) - } + err = fsys.CreateThumb(s.file, s.thumb, s.size) + + hasErr := err != nil + if hasErr != expectErr { + t.Fatalf("Expected hasErr to be %v, got %v (%v)", expectErr, hasErr, err) + } + + if hasErr { + return + } + + f, err := fsys.GetReader(s.thumb) + if err != nil { + t.Fatalf("Missing expected thumb %s (%v)", s.thumb, err) + } + defer f.Close() + + attrsMimeType := f.ContentType() + + mt, err := mimetype.DetectReader(f) + if err != nil { + t.Fatalf("Failed to detect thumb %s mimetype (%v)", s.thumb, err) + } + fileMimeType := mt.String() + + if fileMimeType != s.expectedMimeType { + t.Fatalf("Expected thumb file %s MimeType %q, got %q", s.thumb, s.expectedMimeType, fileMimeType) + } + + if attrsMimeType != s.expectedMimeType { + t.Fatalf("Expected thumb attrs %s MimeType %q, got %q", s.thumb, s.expectedMimeType, attrsMimeType) + } + + checkHooks(t, hookCalls, map[string]int{"OnDelete": 0, "OnNewWriter": 1}) + }) } } -// --- +func TestFilesystemClose(t *testing.T) { + dir := createTestDir(t) + defer os.RemoveAll(dir) -func createTestDir(t *testing.T) string { - dir, err := os.MkdirTemp(os.TempDir(), "pb_test") + fsys, err := filesystem.NewLocal(dir) if err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(dir, "test"), os.ModePerm); err != nil { - t.Fatal(err) + bindHooks(fsys) + + if v := fsys.OnDelete().Length(); v != 1 { + t.Fatalf("Expected 1 OnDelete listener, got %d", v) + } + + if v := fsys.OnNewWriter().Length(); v != 1 { + t.Fatalf("Expected 1 OnNewWriter listener, got %d", v) } - file1, err := os.OpenFile(filepath.Join(dir, "test/sub1.txt"), os.O_WRONLY|os.O_CREATE, 0644) + err = fsys.Close() if err != nil { t.Fatal(err) } - file1.Close() - file2, err := os.OpenFile(filepath.Join(dir, "test/sub2.txt"), os.O_WRONLY|os.O_CREATE, 0644) + if v := fsys.OnDelete().Length(); v != 0 { + t.Fatalf("Expected 0 OnDelete listeners after close, got %d", v) + } + + if v := fsys.OnNewWriter().Length(); v != 0 { + t.Fatalf("Expected 0 OnNewWriter listeners after close, got %d", v) + } +} + +// --- + +func createTestDir(t *testing.T) string { + dir, err := os.MkdirTemp(os.TempDir(), "pb_test") if err != nil { t.Fatal(err) } - file2.Close() - file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0644) + err = os.MkdirAll(filepath.Join(dir, "empty"), os.ModePerm) if err != nil { t.Fatal(err) } - imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 png - png.Encode(file3, imgRect) - file3.Close() - err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0644) - if err2 != nil { - t.Fatal(err2) - } - file4, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0644) + err = os.MkdirAll(filepath.Join(dir, "test"), os.ModePerm) if err != nil { t.Fatal(err) } - file4.Close() - file5, err := os.OpenFile(filepath.Join(dir, "style.css"), os.O_WRONLY|os.O_CREATE, 0644) + err = os.WriteFile(filepath.Join(dir, "test/sub1.txt"), []byte("sub1"), 0644) if err != nil { t.Fatal(err) } - file5.Close() - file6, err := os.OpenFile(filepath.Join(dir, "image_! noext"), os.O_WRONLY|os.O_CREATE, 0644) + err = os.WriteFile(filepath.Join(dir, "test/sub2.txt"), []byte("sub2"), 0644) if err != nil { t.Fatal(err) } - png.Encode(file6, image.Rect(0, 0, 1, 1)) // tiny 1x1 png - file6.Close() + + // png + { + file, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 png + _ = png.Encode(file, imgRect) + file.Close() + err = os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0644) + if err != nil { + t.Fatal(err) + } + } + + // jpg + { + file, err := os.OpenFile(filepath.Join(dir, "image.jpg"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 jpg + _ = jpeg.Encode(file, imgRect, nil) + file.Close() + err = os.WriteFile(filepath.Join(dir, "image.jpg.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/jpeg","user.metadata":null}`), 0644) + if err != nil { + t.Fatal(err) + } + } + + // svg + { + file, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // webp + { + err := os.WriteFile(filepath.Join(dir, "image.webp"), []byte{ + 82, 73, 70, 70, 36, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 32, + 24, 0, 0, 0, 48, 1, 0, 157, 1, 42, 1, 0, 1, 0, 2, 0, 52, 37, + 164, 0, 3, 112, 0, 254, 251, 253, 80, 0, + }, 0644) + if err != nil { + t.Fatal(err) + } + } + + // invalid/special characters + { + file, err := os.OpenFile(filepath.Join(dir, "image_!@ special"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 png + _ = png.Encode(file, imgRect) + file.Close() + } + + // no extension + { + fullPath := filepath.Join(dir, "image_noext") + file, err := os.OpenFile(fullPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 jpg + _ = jpeg.Encode(file, imgRect, nil) + file.Close() + err = os.WriteFile(fullPath+".attrs", []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/jpeg","user.metadata":null}`), 0644) + if err != nil { + t.Fatal(err) + } + } + + // css + { + file, err := os.OpenFile(filepath.Join(dir, "style.css"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // js + { + file, err := os.OpenFile(filepath.Join(dir, "main.js"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // mjs + { + file, err := os.OpenFile(filepath.Join(dir, "main.mjs"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // "docx" (we are interested only in the extension) + { + file, err := os.OpenFile(filepath.Join(dir, "dummy.docx"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // "xlsx" (we are interested only in the extension) + { + file, err := os.OpenFile(filepath.Join(dir, "dummy.xlsx"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } + + // "pptx" (we are interested only in the extension) + { + file, err := os.OpenFile(filepath.Join(dir, "dummy.pptx"), os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + t.Fatal(err) + } + file.Close() + } return dir } + +func bindHooks(fsys *filesystem.System) map[string]int { + hookCalls := map[string]int{} + + fsys.OnDelete().BindFunc(func(e *filesystem.DeleteEvent) error { + hookCalls["OnDelete"]++ + return e.Next() + }) + + fsys.OnNewWriter().BindFunc(func(e *filesystem.NewWriterEvent) error { + hookCalls["OnNewWriter"]++ + return e.Next() + }) + + return hookCalls +} + +func checkHooks(t *testing.T, hookCalls, expectations map[string]int) { + for event, expected := range expectations { + got, _ := hookCalls[event] + if got != expected { + t.Fatalf("Expected event %q to be called %d, got %d", event, expected, got) + } + } +} diff --git a/tools/filesystem/internal/fileblob/attrs.go b/tools/filesystem/internal/fileblob/attrs.go new file mode 100644 index 0000000000..b5bbf8c9c8 --- /dev/null +++ b/tools/filesystem/internal/fileblob/attrs.go @@ -0,0 +1,87 @@ +package fileblob + +import ( + "encoding/json/v2" + "fmt" + "os" +) + +// Largely copied from gocloud.dev/blob/fileblob to apply the same +// retrieve and write side-car .attrs rules. +// +// ------------------------------------------------------------------- +// Copyright 2018 The Go Cloud Development Kit Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------- + +const attrsExt = ".attrs" + +var errAttrsExt = fmt.Errorf("file extension %q is reserved", attrsExt) + +// xattrs stores extended attributes for an object. The format is like +// filesystem extended attributes, see +// https://www.freedesktop.org/wiki/CommonExtendedAttributes. +type xattrs struct { + CacheControl string `json:"user.cache_control"` + ContentDisposition string `json:"user.content_disposition"` + ContentEncoding string `json:"user.content_encoding"` + ContentLanguage string `json:"user.content_language"` + ContentType string `json:"user.content_type"` + Metadata map[string]string `json:"user.metadata"` + MD5 []byte `json:"md5"` +} + +// setAttrs creates a "path.attrs" file along with blob to store the attributes, +// it uses JSON format. +func setAttrs(path string, xa xattrs) error { + f, err := os.Create(path + attrsExt) + if err != nil { + return err + } + + err = json.MarshalWrite(f, xa) + if err != nil { + f.Close() + os.Remove(f.Name()) + return err + } + + return f.Close() +} + +// getAttrs looks at the "path.attrs" file to retrieve the attributes and +// decodes them into a xattrs struct. It doesn't return error when there is no +// such .attrs file. +func getAttrs(path string) (xattrs, error) { + f, err := os.Open(path + attrsExt) + if err != nil { + if os.IsNotExist(err) { + // Handle gracefully for non-existent .attr files. + return xattrs{ + ContentType: "application/octet-stream", + }, nil + } + return xattrs{}, err + } + + xa := new(xattrs) + + err = json.UnmarshalRead(f, xa) + if err != nil { + f.Close() + return xattrs{}, err + } + + return *xa, f.Close() +} diff --git a/tools/filesystem/internal/fileblob/fileblob.go b/tools/filesystem/internal/fileblob/fileblob.go new file mode 100644 index 0000000000..9a2d95034f --- /dev/null +++ b/tools/filesystem/internal/fileblob/fileblob.go @@ -0,0 +1,715 @@ +// Package fileblob provides a blob.Bucket driver implementation. +// +// NB! To minimize breaking changes with older PocketBase releases, +// the driver is a stripped down and adapted version of the previously +// used gocloud.dev/blob/fileblob, hence many of the below doc comments, +// struct options and interface implementations are the same. +// +// To avoid partial writes, fileblob writes to a temporary file and then renames +// the temporary file to the final path on Close. By default, it creates these +// temporary files in `os.TempDir`. If `os.TempDir` is on a different mount than +// your base bucket path, the `os.Rename` will fail with `invalid cross-device link`. +// To avoid this, either configure the temp dir to use by setting the environment +// variable `TMPDIR`, or set `Options.NoTempDir` to `true` (fileblob will create +// the temporary files next to the actual files instead of in a temporary directory). +// +// By default fileblob stores blob metadata in "sidecar" files under the original +// filename with an additional ".attrs" suffix. +// This behaviour can be changed via `Options.Metadata`; +// writing of those metadata files can be suppressed by setting it to +// `MetadataDontWrite` or its equivalent "metadata=skip" in the URL for the opener. +// In either case, absent any stored metadata many `blob.Attributes` fields +// will be set to default values. +// +// The blob abstraction supports all UTF-8 strings; to make this work with services lacking +// full UTF-8 support, strings must be escaped (during writes) and unescaped +// (during reads). The following escapes are performed for fileblob: +// - Blob keys: ASCII characters 0-31 are escaped to "__0x__". +// If os.PathSeparator != "/", it is also escaped. +// Additionally, the "/" in "../", the trailing "/" in "//", and a trailing +// "/" is key names are escaped in the same way. +// On Windows, the characters "<>:"|?*" are also escaped. +// +// Example: +// +// drv, _ := fileblob.New("/path/to/dir", nil) +// bucket := blob.NewBucket(drv) +package fileblob + +import ( + "context" + "crypto/md5" + "errors" + "fmt" + "hash" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pocketbase/pocketbase/tools/filesystem/blob" +) + +const defaultPageSize = 1000 + +type metadataOption string // Not exported as subject to change. + +// Settings for Options.Metadata. +const ( + // Metadata gets written to a separate file. + MetadataInSidecar metadataOption = "" + + // Writes won't carry metadata, as per the package docstring. + MetadataDontWrite metadataOption = "skip" +) + +// Options sets options for constructing a *blob.Bucket backed by fileblob. +type Options struct { + // Refers to the strategy for how to deal with metadata (such as blob.Attributes). + // For supported values please see the Metadata* constants. + // If left unchanged, 'MetadataInSidecar' will be used. + Metadata metadataOption + + // The FileMode to use when creating directories for the top-level directory + // backing the bucket (when CreateDir is true), and for subdirectories for keys. + // Defaults to 0777. + DirFileMode os.FileMode + + // If true, create the directory backing the Bucket if it does not exist + // (using os.MkdirAll). + CreateDir bool + + // If true, don't use os.TempDir for temporary files, but instead place them + // next to the actual files. This may result in "stranded" temporary files + // (e.g., if the application is killed before the file cleanup runs). + // + // If your bucket directory is on a different mount than os.TempDir, you will + // need to set this to true, as os.Rename will fail across mount points. + NoTempDir bool +} + +// New creates a new instance of the fileblob driver backed by the +// filesystem and rooted at dir, which must exist. +func New(dir string, opts *Options) (blob.Driver, error) { + if opts == nil { + opts = &Options{} + } + if opts.DirFileMode == 0 { + opts.DirFileMode = os.FileMode(0o777) + } + + absdir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to convert %s into an absolute path: %v", dir, err) + } + + // Optionally, create the directory if it does not already exist. + info, err := os.Stat(absdir) + if err != nil && opts.CreateDir && os.IsNotExist(err) { + err = os.MkdirAll(absdir, opts.DirFileMode) + if err != nil { + return nil, fmt.Errorf("tried to create directory but failed: %v", err) + } + info, err = os.Stat(absdir) + } + if err != nil { + return nil, err + } + + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", absdir) + } + + return &driver{dir: absdir, opts: opts}, nil +} + +type driver struct { + opts *Options + dir string +} + +// Close implements [blob/Driver.Close]. +func (drv *driver) Close() error { + return nil +} + +// NormalizeError implements [blob/Driver.NormalizeError]. +func (drv *driver) NormalizeError(err error) error { + if os.IsNotExist(err) { + return errors.Join(err, blob.ErrNotFound) + } + + return err +} + +// path returns the full path for a key. +func (drv *driver) path(key string) (string, error) { + path := filepath.Join(drv.dir, escapeKey(key)) + + if strings.HasSuffix(path, attrsExt) { + return "", errAttrsExt + } + + return path, nil +} + +// forKey returns the full path, os.FileInfo, and attributes for key. +func (drv *driver) forKey(key string) (string, os.FileInfo, *xattrs, error) { + path, err := drv.path(key) + if err != nil { + return "", nil, nil, err + } + + info, err := os.Stat(path) + if err != nil { + return "", nil, nil, err + } + + if info.IsDir() { + return "", nil, nil, os.ErrNotExist + } + + xa, err := getAttrs(path) + if err != nil { + return "", nil, nil, err + } + + return path, info, &xa, nil +} + +// ListPaged implements [blob/Driver.ListPaged]. +func (drv *driver) ListPaged(ctx context.Context, opts *blob.ListOptions) (*blob.ListPage, error) { + var pageToken string + if len(opts.PageToken) > 0 { + pageToken = string(opts.PageToken) + } + + pageSize := opts.PageSize + if pageSize == 0 { + pageSize = defaultPageSize + } + + // If opts.Delimiter != "", lastPrefix contains the last "directory" key we + // added. It is used to avoid adding it again; all files in this "directory" + // are collapsed to the single directory entry. + var lastPrefix string + var lastKeyAdded string + + // If the Prefix contains a "/", we can set the root of the Walk + // to the path specified by the Prefix as any files below the path will not + // match the Prefix. + // Note that we use "/" explicitly and not os.PathSeparator, as the opts.Prefix + // is in the unescaped form. + root := drv.dir + if i := strings.LastIndex(opts.Prefix, "/"); i > -1 { + root = filepath.Join(root, opts.Prefix[:i]) + } + + var result blob.ListPage + + // Do a full recursive scan of the root directory. + err := filepath.WalkDir(root, func(path string, info fs.DirEntry, err error) error { + if err != nil { + // Couldn't read this file/directory for some reason; just skip it. + return nil + } + + // Skip the self-generated attribute files. + if strings.HasSuffix(path, attrsExt) { + return nil + } + + // os.Walk returns the root directory; skip it. + if path == drv.dir { + return nil + } + + // Strip the prefix from path. + prefixLen := len(drv.dir) + // Include the separator for non-root. + if drv.dir != "/" { + prefixLen++ + } + path = path[prefixLen:] + + // Unescape the path to get the key. + key := unescapeKey(path) + + // Skip all directories. If opts.Delimiter is set, we'll create + // pseudo-directories later. + // Note that returning nil means that we'll still recurse into it; + // we're just not adding a result for the directory itself. + if info.IsDir() { + key += "/" + // Avoid recursing into subdirectories if the directory name already + // doesn't match the prefix; any files in it are guaranteed not to match. + if len(key) > len(opts.Prefix) && !strings.HasPrefix(key, opts.Prefix) { + return filepath.SkipDir + } + // Similarly, avoid recursing into subdirectories if we're making + // "directories" and all of the files in this subdirectory are guaranteed + // to collapse to a "directory" that we've already added. + if lastPrefix != "" && strings.HasPrefix(key, lastPrefix) { + return filepath.SkipDir + } + return nil + } + + // Skip files/directories that don't match the Prefix. + if !strings.HasPrefix(key, opts.Prefix) { + return nil + } + + var md5 []byte + if xa, err := getAttrs(path); err == nil { + // Note: we only have the MD5 hash for blobs that we wrote. + // For other blobs, md5 will remain nil. + md5 = xa.MD5 + } + + fi, err := info.Info() + if err != nil { + return err + } + + obj := &blob.ListObject{ + Key: key, + ModTime: fi.ModTime(), + Size: fi.Size(), + MD5: md5, + } + + // If using Delimiter, collapse "directories". + if opts.Delimiter != "" { + // Strip the prefix, which may contain Delimiter. + keyWithoutPrefix := key[len(opts.Prefix):] + // See if the key still contains Delimiter. + // If no, it's a file and we just include it. + // If yes, it's a file in a "sub-directory" and we want to collapse + // all files in that "sub-directory" into a single "directory" result. + if idx := strings.Index(keyWithoutPrefix, opts.Delimiter); idx != -1 { + prefix := opts.Prefix + keyWithoutPrefix[0:idx+len(opts.Delimiter)] + // We've already included this "directory"; don't add it. + if prefix == lastPrefix { + return nil + } + // Update the object to be a "directory". + obj = &blob.ListObject{ + Key: prefix, + IsDir: true, + } + lastPrefix = prefix + } + } + + // If there's a pageToken, skip anything before it. + if pageToken != "" && obj.Key <= pageToken { + return nil + } + + // If we've already got a full page of results, set NextPageToken and stop. + // Unless the current object is a directory, in which case there may + // still be objects coming that are alphabetically before it (since + // we appended the delimiter). In that case, keep going; we'll trim the + // extra entries (if any) before returning. + if len(result.Objects) == pageSize && !obj.IsDir { + result.NextPageToken = []byte(result.Objects[pageSize-1].Key) + return io.EOF + } + + result.Objects = append(result.Objects, obj) + + // Normally, objects are added in the correct order (by Key). + // However, sometimes adding the file delimiter messes that up + // (e.g., if the file delimiter is later in the alphabet than the last character of a key). + // Detect if this happens and swap if needed. + if len(result.Objects) > 1 && obj.Key < lastKeyAdded { + i := len(result.Objects) - 1 + result.Objects[i-1], result.Objects[i] = result.Objects[i], result.Objects[i-1] + lastKeyAdded = result.Objects[i].Key + } else { + lastKeyAdded = obj.Key + } + + return nil + }) + if err != nil && err != io.EOF { + return nil, err + } + + if len(result.Objects) > pageSize { + result.Objects = result.Objects[0:pageSize] + result.NextPageToken = []byte(result.Objects[pageSize-1].Key) + } + + return &result, nil +} + +// Attributes implements [blob/Driver.Attributes]. +func (drv *driver) Attributes(ctx context.Context, key string) (*blob.Attributes, error) { + _, info, xa, err := drv.forKey(key) + if err != nil { + return nil, err + } + + return &blob.Attributes{ + CacheControl: xa.CacheControl, + ContentDisposition: xa.ContentDisposition, + ContentEncoding: xa.ContentEncoding, + ContentLanguage: xa.ContentLanguage, + ContentType: xa.ContentType, + Metadata: xa.Metadata, + // CreateTime left as the zero time. + ModTime: info.ModTime(), + Size: info.Size(), + MD5: xa.MD5, + ETag: fmt.Sprintf("\"%x-%x\"", info.ModTime().UnixNano(), info.Size()), + }, nil +} + +// NewRangeReader implements [blob/Driver.NewRangeReader]. +func (drv *driver) NewRangeReader(ctx context.Context, key string, offset, length int64) (blob.DriverReader, error) { + path, info, xa, err := drv.forKey(key) + if err != nil { + return nil, err + } + + // @todo consider replacing with os.Root + f, err := os.Open(path) + if err != nil { + return nil, err + } + + if offset > 0 { + if _, err := f.Seek(offset, io.SeekStart); err != nil { + f.Close() + return nil, err + } + } + + r := io.Reader(f) + if length >= 0 { + r = io.LimitReader(r, length) + } + + return &reader{ + r: r, + c: f, + attrs: &blob.ReaderAttributes{ + ContentType: xa.ContentType, + ModTime: info.ModTime(), + Size: info.Size(), + }, + }, nil +} + +func createTemp(path string, noTempDir bool) (*os.File, error) { + // Use a custom createTemp function rather than os.CreateTemp() as + // os.CreateTemp() sets the permissions of the tempfile to 0600, rather than + // 0666, making it inconsistent with the directories and attribute files. + try := 0 + for { + // Append the current time with nanosecond precision and .tmp to the + // base path. If the file already exists try again. Nanosecond changes enough + // between each iteration to make a conflict unlikely. Using the full + // time lowers the chance of a collision with a file using a similar + // pattern, but has undefined behavior after the year 2262. + var name string + if noTempDir { + name = path + } else { + name = filepath.Join(os.TempDir(), filepath.Base(path)) + } + name += "." + strconv.FormatInt(time.Now().UnixNano(), 16) + ".tmp" + + f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) + if os.IsExist(err) { + if try++; try < 10000 { + continue + } + return nil, &os.PathError{Op: "createtemp", Path: path + ".*.tmp", Err: os.ErrExist} + } + + return f, err + } +} + +// NewTypedWriter implements [blob/Driver.NewTypedWriter]. +func (drv *driver) NewTypedWriter(ctx context.Context, key, contentType string, opts *blob.WriterOptions) (blob.DriverWriter, error) { + path, err := drv.path(key) + if err != nil { + return nil, err + } + + err = os.MkdirAll(filepath.Dir(path), drv.opts.DirFileMode) + if err != nil { + return nil, err + } + + f, err := createTemp(path, drv.opts.NoTempDir) + if err != nil { + return nil, err + } + + if drv.opts.Metadata == MetadataDontWrite { + w := &writer{ + ctx: ctx, + File: f, + path: path, + } + return w, nil + } + + var metadata map[string]string + if len(opts.Metadata) > 0 { + metadata = opts.Metadata + } + + return &writerWithSidecar{ + ctx: ctx, + f: f, + path: path, + contentMD5: opts.ContentMD5, + md5hash: md5.New(), + attrs: xattrs{ + CacheControl: opts.CacheControl, + ContentDisposition: opts.ContentDisposition, + ContentEncoding: opts.ContentEncoding, + ContentLanguage: opts.ContentLanguage, + ContentType: contentType, + Metadata: metadata, + }, + }, nil +} + +// Copy implements [blob/Driver.Copy]. +func (drv *driver) Copy(ctx context.Context, dstKey, srcKey string) error { + // Note: we could use NewRangeReader here, but since we need to copy all of + // the metadata (from xa), it's more efficient to do it directly. + srcPath, _, xa, err := drv.forKey(srcKey) + if err != nil { + return err + } + + f, err := os.Open(srcPath) + if err != nil { + return err + } + defer f.Close() + + // We'll write the copy using Writer, to avoid re-implementing making of a + // temp file, cleaning up after partial failures, etc. + wopts := blob.WriterOptions{ + CacheControl: xa.CacheControl, + ContentDisposition: xa.ContentDisposition, + ContentEncoding: xa.ContentEncoding, + ContentLanguage: xa.ContentLanguage, + Metadata: xa.Metadata, + } + + // Create a cancelable context so we can cancel the write if there are problems. + writeCtx, cancel := context.WithCancel(ctx) + defer cancel() + + w, err := drv.NewTypedWriter(writeCtx, dstKey, xa.ContentType, &wopts) + if err != nil { + return err + } + + _, err = io.Copy(w, f) + if err != nil { + cancel() // cancel before Close cancels the write + w.Close() + return err + } + + return w.Close() +} + +// Delete implements [blob/Driver.Delete]. +func (b *driver) Delete(ctx context.Context, key string) error { + path, err := b.path(key) + if err != nil { + return err + } + + err = os.Remove(path) + if err != nil { + return err + } + + err = os.Remove(path + attrsExt) + if err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +// ------------------------------------------------------------------- + +type reader struct { + r io.Reader + c io.Closer + attrs *blob.ReaderAttributes +} + +func (r *reader) Read(p []byte) (int, error) { + if r.r == nil { + return 0, io.EOF + } + return r.r.Read(p) +} + +func (r *reader) Close() error { + if r.c == nil { + return nil + } + return r.c.Close() +} + +// Attributes implements [blob/DriverReader.Attributes]. +func (r *reader) Attributes() *blob.ReaderAttributes { + return r.attrs +} + +// ------------------------------------------------------------------- + +// writerWithSidecar implements the strategy of storing metadata in a distinct file. +type writerWithSidecar struct { + ctx context.Context + md5hash hash.Hash + f *os.File + path string + attrs xattrs + contentMD5 []byte +} + +func (w *writerWithSidecar) Write(p []byte) (n int, err error) { + n, err = w.f.Write(p) + if err != nil { + // Don't hash the unwritten tail twice when writing is resumed. + w.md5hash.Write(p[:n]) + return n, err + } + + if _, err := w.md5hash.Write(p); err != nil { + return n, err + } + + return n, nil +} + +func (w *writerWithSidecar) Close() error { + err := w.f.Close() + if err != nil { + return err + } + + // Always delete the temp file. On success, it will have been + // renamed so the Remove will fail. + defer func() { + _ = os.Remove(w.f.Name()) + }() + + // Check if the write was cancelled. + if err := w.ctx.Err(); err != nil { + return err + } + + md5sum := w.md5hash.Sum(nil) + w.attrs.MD5 = md5sum + + // Write the attributes file. + if err := setAttrs(w.path, w.attrs); err != nil { + return err + } + + // Rename the temp file to path. + if err := os.Rename(w.f.Name(), w.path); err != nil { + _ = os.Remove(w.path + attrsExt) + return err + } + + return nil +} + +// writer is a file with a temporary name until closed. +// +// Embedding os.File allows the likes of io.Copy to use optimizations, +// which is why it is not folded into writerWithSidecar. +type writer struct { + *os.File + ctx context.Context + path string +} + +func (w *writer) Close() error { + err := w.File.Close() + if err != nil { + return err + } + + // Always delete the temp file. On success, it will have been renamed so + // the Remove will fail. + tempname := w.Name() + defer os.Remove(tempname) + + // Check if the write was cancelled. + if err := w.ctx.Err(); err != nil { + return err + } + + // Rename the temp file to path. + return os.Rename(tempname, w.path) +} + +// ------------------------------------------------------------------- + +// escapeKey does all required escaping for UTF-8 strings to work the filesystem. +func escapeKey(s string) string { + s = blob.HexEscape(s, func(r []rune, i int) bool { + c := r[i] + switch { + case c < 32: + return true + // We're going to replace '/' with os.PathSeparator below. In order for this + // to be reversible, we need to escape raw os.PathSeparators. + case os.PathSeparator != '/' && c == os.PathSeparator: + return true + // For "../", escape the trailing slash. + case i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.': + return true + // For "//", escape the trailing slash. + case i > 0 && c == '/' && r[i-1] == '/': + return true + // Escape the trailing slash in a key. + case c == '/' && i == len(r)-1: + return true + // https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file + case os.PathSeparator == '\\' && (c == '>' || c == '<' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*'): + return true + } + return false + }) + + // Replace "/" with os.PathSeparator if needed, so that the local filesystem + // can use subdirectories. + if os.PathSeparator != '/' { + s = strings.ReplaceAll(s, "/", string(os.PathSeparator)) + } + + return s +} + +// unescapeKey reverses escapeKey. +func unescapeKey(s string) string { + if os.PathSeparator != '/' { + s = strings.ReplaceAll(s, string(os.PathSeparator), "/") + } + + return blob.HexUnescape(s) +} diff --git a/tools/filesystem/internal/s3blob/s3/copy_object.go b/tools/filesystem/internal/s3blob/s3/copy_object.go new file mode 100644 index 0000000000..de26badec9 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/copy_object.go @@ -0,0 +1,59 @@ +package s3 + +import ( + "context" + "encoding/xml" + "net/http" + "net/url" + "strings" + "time" +) + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_ResponseSyntax +type CopyObjectResponse struct { + CopyObjectResult xml.Name `json:"copyObjectResult" xml:"CopyObjectResult"` + ETag string `json:"etag" xml:"ETag"` + LastModified time.Time `json:"lastModified" xml:"LastModified"` + ChecksumType string `json:"checksumType" xml:"ChecksumType"` + ChecksumCRC32 string `json:"checksumCRC32" xml:"ChecksumCRC32"` + ChecksumCRC32C string `json:"checksumCRC32C" xml:"ChecksumCRC32C"` + ChecksumCRC64NVME string `json:"checksumCRC64NVME" xml:"ChecksumCRC64NVME"` + ChecksumSHA1 string `json:"checksumSHA1" xml:"ChecksumSHA1"` + ChecksumSHA256 string `json:"checksumSHA256" xml:"ChecksumSHA256"` +} + +// CopyObject copies a single object from srcKey to dstKey destination. +// (both keys are expected to be operating within the same bucket). +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html +func (s3 *S3) CopyObject(ctx context.Context, srcKey string, dstKey string, optReqFuncs ...func(*http.Request)) (*CopyObjectResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, s3.URL(dstKey), nil) + if err != nil { + return nil, err + } + + // per the doc the header value must be URL-encoded + req.Header.Set("x-amz-copy-source", url.PathEscape(s3.Bucket+"/"+strings.TrimLeft(srcKey, "/"))) + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := s3.SignAndSend(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + result := &CopyObjectResponse{} + + err = xml.NewDecoder(resp.Body).Decode(result) + if err != nil { + return nil, err + } + + return result, nil +} diff --git a/tools/filesystem/internal/s3blob/s3/copy_object_test.go b/tools/filesystem/internal/s3blob/s3/copy_object_test.go new file mode 100644 index 0000000000..04fdb4adf6 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/copy_object_test.go @@ -0,0 +1,67 @@ +package s3_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3CopyObject(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/%40dst_test", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "x-amz-copy-source": "test_bucket%2F@src_test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + 2025-01-01T01:02:03.456Z + test_etag + + `)), + }, + }, + ) + + s3Client := &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + copyResp, err := s3Client.CopyObject(context.Background(), "@src_test", "@dst_test", func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + + if copyResp.ETag != "test_etag" { + t.Fatalf("Expected ETag %q, got %q", "test_etag", copyResp.ETag) + } + + if date := copyResp.LastModified.Format("2006-01-02T15:04:05.000Z"); date != "2025-01-01T01:02:03.456Z" { + t.Fatalf("Expected LastModified %q, got %q", "2025-01-01T01:02:03.456Z", date) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/delete_object.go b/tools/filesystem/internal/s3blob/s3/delete_object.go new file mode 100644 index 0000000000..a46c8ab2bb --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/delete_object.go @@ -0,0 +1,31 @@ +package s3 + +import ( + "context" + "net/http" +) + +// DeleteObject deletes a single object by its key. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html +func (s3 *S3) DeleteObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s3.URL(key), nil) + if err != nil { + return err + } + + // apply optional request funcs + for _, fn := range optFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := s3.SignAndSend(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/tools/filesystem/internal/s3blob/s3/delete_object_test.go b/tools/filesystem/internal/s3blob/s3/delete_object_test.go new file mode 100644 index 0000000000..48d245ccb7 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/delete_object_test.go @@ -0,0 +1,48 @@ +package s3_test + +import ( + "context" + "net/http" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3DeleteObject(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodDelete, + URL: "http://test_bucket.example.com/test_key", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + }, + ) + + s3Client := &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + err := s3Client.DeleteObject(context.Background(), "test_key", func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/error.go b/tools/filesystem/internal/s3blob/s3/error.go new file mode 100644 index 0000000000..e8d76af8fe --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/error.go @@ -0,0 +1,49 @@ +package s3 + +import ( + "encoding/xml" + "strconv" + "strings" +) + +var _ error = (*ResponseError)(nil) + +// ResponseError defines a general S3 response error. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html +type ResponseError struct { + XMLName xml.Name `json:"-" xml:"Error"` + Code string `json:"code" xml:"Code"` + Message string `json:"message" xml:"Message"` + RequestId string `json:"requestId" xml:"RequestId"` + Resource string `json:"resource" xml:"Resource"` + Raw []byte `json:"-" xml:"-"` + Status int `json:"status" xml:"Status"` +} + +// Error implements the std error interface. +func (err *ResponseError) Error() string { + var strBuilder strings.Builder + + strBuilder.WriteString(strconv.Itoa(err.Status)) + strBuilder.WriteString(" ") + + if err.Code != "" { + strBuilder.WriteString(err.Code) + } else { + strBuilder.WriteString("S3ResponseError") + } + + if err.Message != "" { + strBuilder.WriteString(": ") + strBuilder.WriteString(err.Message) + } + + if len(err.Raw) > 0 { + strBuilder.WriteString("\n(RAW: ") + strBuilder.Write(err.Raw) + strBuilder.WriteString(")") + } + + return strBuilder.String() +} diff --git a/tools/filesystem/internal/s3blob/s3/error_test.go b/tools/filesystem/internal/s3blob/s3/error_test.go new file mode 100644 index 0000000000..3eb77ed089 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/error_test.go @@ -0,0 +1,86 @@ +package s3_test + +import ( + "encoding/json/v2" + "encoding/xml" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" +) + +func TestResponseErrorSerialization(t *testing.T) { + raw := ` + + + test_code + test_message + test_request_id + test_resource + + ` + + respErr := &s3.ResponseError{ + Status: 123, + Raw: []byte("test"), + } + + err := xml.Unmarshal([]byte(raw), &respErr) + if err != nil { + t.Fatal(err) + } + + jsonRaw, err := json.Marshal(respErr, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + jsonStr := string(jsonRaw) + + expected := `{"code":"test_code","message":"test_message","requestId":"test_request_id","resource":"test_resource","status":123}` + + if expected != jsonStr { + t.Fatalf("Expected JSON\n%s\ngot\n%s", expected, jsonStr) + } +} + +func TestResponseErrorErrorInterface(t *testing.T) { + scenarios := []struct { + name string + err *s3.ResponseError + expected string + }{ + { + "empty", + &s3.ResponseError{}, + "0 S3ResponseError", + }, + { + "with code and message (nil raw)", + &s3.ResponseError{ + Status: 123, + Code: "test_code", + Message: "test_message", + }, + "123 test_code: test_message", + }, + { + "with code and message (non-nil raw)", + &s3.ResponseError{ + Status: 123, + Code: "test_code", + Message: "test_message", + Raw: []byte("test_raw"), + }, + "123 test_code: test_message\n(RAW: test_raw)", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := s.err.Error() + + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/get_object.go b/tools/filesystem/internal/s3blob/s3/get_object.go new file mode 100644 index 0000000000..e83f38faf9 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/get_object.go @@ -0,0 +1,43 @@ +package s3 + +import ( + "context" + "io" + "net/http" +) + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html#API_GetObject_ResponseElements +type GetObjectResponse struct { + Body io.ReadCloser `json:"-" xml:"-"` + + HeadObjectResponse +} + +// GetObject retrieves a single object by its key. +// +// NB! Make sure to call GetObjectResponse.Body.Close() after done working with the result. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html +func (s3 *S3) GetObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) (*GetObjectResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s3.URL(key), nil) + if err != nil { + return nil, err + } + + // apply optional request funcs + for _, fn := range optFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := s3.SignAndSend(req) + if err != nil { + return nil, err + } + + result := &GetObjectResponse{Body: resp.Body} + result.load(resp.Header) + + return result, nil +} diff --git a/tools/filesystem/internal/s3blob/s3/get_object_test.go b/tools/filesystem/internal/s3blob/s3/get_object_test.go new file mode 100644 index 0000000000..495b7f4166 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/get_object_test.go @@ -0,0 +1,92 @@ +package s3_test + +import ( + "context" + "encoding/json/v2" + "io" + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3GetObject(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodGet, + URL: "http://test_bucket.example.com/test_key", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Cache-Control": []string{"test_cache"}, + "Content-Disposition": []string{"test_disposition"}, + "Content-Encoding": []string{"test_encoding"}, + "Content-Language": []string{"test_language"}, + "Content-Type": []string{"test_type"}, + "Content-Range": []string{"test_range"}, + "Etag": []string{"test_etag"}, + "Content-Length": []string{"100"}, + "x-amz-meta-AbC": []string{"test_meta_a"}, + "x-amz-meta-Def": []string{"test_meta_b"}, + }, + Body: io.NopCloser(strings.NewReader("test")), + }, + }, + ) + + s3Client := &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + resp, err := s3Client.GetObject(context.Background(), "test_key", func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + + // check body + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + bodyStr := string(body) + + if bodyStr != "test" { + t.Fatalf("Expected body\n%q\ngot\n%q", "test", bodyStr) + } + + // check serialized attributes + raw, err := json.Marshal(resp, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + expected := `{"metadata":{"abc":"test_meta_a","def":"test_meta_b"},"lastModified":"2025-02-01T03:04:05Z","cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","contentRange":"test_range","etag":"test_etag","contentLength":100}` + + if rawStr != expected { + t.Fatalf("Expected attributes\n%s\ngot\n%s", expected, rawStr) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/head_object.go b/tools/filesystem/internal/s3blob/s3/head_object.go new file mode 100644 index 0000000000..ff5d7ab62a --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/head_object.go @@ -0,0 +1,89 @@ +package s3 + +import ( + "context" + "net/http" + "strconv" + "time" +) + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html#API_HeadObject_ResponseElements +type HeadObjectResponse struct { + // Metadata is the extra data that is stored with the S3 object (aka. the "x-amz-meta-*" header values). + // + // The map keys are normalized to lower-case. + Metadata map[string]string `json:"metadata"` + + // LastModified date and time when the object was last modified. + LastModified time.Time `json:"lastModified"` + + // CacheControl specifies caching behavior along the request/reply chain. + CacheControl string `json:"cacheControl"` + + // ContentDisposition specifies presentational information for the object. + ContentDisposition string `json:"contentDisposition"` + + // ContentEncoding indicates what content encodings have been applied to the object + // and thus what decoding mechanisms must be applied to obtain the + // media-type referenced by the Content-Type header field. + ContentEncoding string `json:"contentEncoding"` + + // ContentLanguage indicates the language the content is in. + ContentLanguage string `json:"contentLanguage"` + + // ContentType is a standard MIME type describing the format of the object data. + ContentType string `json:"contentType"` + + // ContentRange is the portion of the object usually returned in the response for a GET request. + ContentRange string `json:"contentRange"` + + // ETag is an opaque identifier assigned by a web + // server to a specific version of a resource found at a URL. + ETag string `json:"etag"` + + // ContentLength is size of the body in bytes. + ContentLength int64 `json:"contentLength"` +} + +// load parses and load the header values into the current HeadObjectResponse fields. +func (o *HeadObjectResponse) load(headers http.Header) { + o.LastModified, _ = time.Parse(time.RFC1123, headers.Get("Last-Modified")) + o.CacheControl = headers.Get("Cache-Control") + o.ContentDisposition = headers.Get("Content-Disposition") + o.ContentEncoding = headers.Get("Content-Encoding") + o.ContentLanguage = headers.Get("Content-Language") + o.ContentType = headers.Get("Content-Type") + o.ContentRange = headers.Get("Content-Range") + o.ETag = headers.Get("ETag") + o.ContentLength, _ = strconv.ParseInt(headers.Get("Content-Length"), 10, 0) + o.Metadata = extractMetadata(headers) +} + +// HeadObject sends a HEAD request for a single object to check its +// existence and to retrieve its metadata. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html +func (s3 *S3) HeadObject(ctx context.Context, key string, optFuncs ...func(*http.Request)) (*HeadObjectResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, s3.URL(key), nil) + if err != nil { + return nil, err + } + + // apply optional request funcs + for _, fn := range optFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := s3.SignAndSend(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + result := &HeadObjectResponse{} + result.load(resp.Header) + + return result, nil +} diff --git a/tools/filesystem/internal/s3blob/s3/head_object_test.go b/tools/filesystem/internal/s3blob/s3/head_object_test.go new file mode 100644 index 0000000000..4cac81b569 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/head_object_test.go @@ -0,0 +1,77 @@ +package s3_test + +import ( + "context" + "encoding/json/v2" + "net/http" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3HeadObject(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodHead, + URL: "http://test_bucket.example.com/test_key", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Cache-Control": []string{"test_cache"}, + "Content-Disposition": []string{"test_disposition"}, + "Content-Encoding": []string{"test_encoding"}, + "Content-Language": []string{"test_language"}, + "Content-Type": []string{"test_type"}, + "Content-Range": []string{"test_range"}, + "Etag": []string{"test_etag"}, + "Content-Length": []string{"100"}, + "x-amz-meta-AbC": []string{"test_meta_a"}, + "x-amz-meta-Def": []string{"test_meta_b"}, + }, + Body: http.NoBody, + }, + }, + ) + + s3Client := &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + resp, err := s3Client.HeadObject(context.Background(), "test_key", func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + + raw, err := json.Marshal(resp, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + expected := `{"metadata":{"abc":"test_meta_a","def":"test_meta_b"},"lastModified":"2025-02-01T03:04:05Z","cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","contentRange":"test_range","etag":"test_etag","contentLength":100}` + + if rawStr != expected { + t.Fatalf("Expected response\n%s\ngot\n%s", expected, rawStr) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/list_objects.go b/tools/filesystem/internal/s3blob/s3/list_objects.go new file mode 100644 index 0000000000..77c2794ac5 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/list_objects.go @@ -0,0 +1,165 @@ +package s3 + +import ( + "context" + "encoding/xml" + "net/http" + "net/url" + "strconv" + "time" +) + +// ListParams defines optional parameters for the ListObject request. +type ListParams struct { + // ContinuationToken indicates that the list is being continued on this bucket with a token. + // ContinuationToken is obfuscated and is not a real key. + // You can use this ContinuationToken for pagination of the list results. + ContinuationToken string `json:"continuationToken"` + + // Delimiter is a character that you use to group keys. + // + // For directory buckets, "/" is the only supported delimiter. + Delimiter string `json:"delimiter"` + + // Prefix limits the response to keys that begin with the specified prefix. + Prefix string `json:"prefix"` + + // Encoding type is used to encode the object keys in the response. + // Responses are encoded only in UTF-8. + // An object key can contain any Unicode character. + // However, the XML 1.0 parser can't parse certain characters, + // such as characters with an ASCII value from 0 to 10. + // For characters that aren't supported in XML 1.0, you can add + // this parameter to request that S3 encode the keys in the response. + // + // Valid Values: url + EncodingType string `json:"encodingType"` + + // StartAfter is where you want S3 to start listing from. + // S3 starts listing after this specified key. + // StartAfter can be any key in the bucket. + // + // This functionality is not supported for directory buckets. + StartAfter string `json:"startAfter"` + + // MaxKeys Sets the maximum number of keys returned in the response. + // By default, the action returns up to 1,000 key names. + // The response might contain fewer keys but will never contain more. + MaxKeys int `json:"maxKeys"` + + // FetchOwner returns the owner field with each key in the result. + FetchOwner bool `json:"fetchOwner"` +} + +// Encode encodes the parameters in a properly formatted query string. +func (l *ListParams) Encode() string { + query := url.Values{} + + query.Add("list-type", "2") + + if l.ContinuationToken != "" { + query.Add("continuation-token", l.ContinuationToken) + } + + if l.Delimiter != "" { + query.Add("delimiter", l.Delimiter) + } + + if l.Prefix != "" { + query.Add("prefix", l.Prefix) + } + + if l.EncodingType != "" { + query.Add("encoding-type", l.EncodingType) + } + + if l.FetchOwner { + query.Add("fetch-owner", "true") + } + + if l.MaxKeys > 0 { + query.Add("max-keys", strconv.Itoa(l.MaxKeys)) + } + + if l.StartAfter != "" { + query.Add("start-after", l.StartAfter) + } + + return query.Encode() +} + +// ListObjects retrieves paginated objects list. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html +func (s3 *S3) ListObjects(ctx context.Context, params ListParams, optReqFuncs ...func(*http.Request)) (*ListObjectsResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s3.URL("?"+params.Encode()), nil) + if err != nil { + return nil, err + } + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := s3.SignAndSend(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + result := &ListObjectsResponse{} + + err = xml.NewDecoder(resp.Body).Decode(result) + if err != nil { + return nil, err + } + + return result, nil +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html#API_ListObjectsV2_ResponseSyntax +type ListObjectsResponse struct { + XMLName xml.Name `json:"-" xml:"ListBucketResult"` + EncodingType string `json:"encodingType" xml:"EncodingType"` + Name string `json:"name" xml:"Name"` + Prefix string `json:"prefix" xml:"Prefix"` + Delimiter string `json:"delimiter" xml:"Delimiter"` + ContinuationToken string `json:"continuationToken" xml:"ContinuationToken"` + NextContinuationToken string `json:"nextContinuationToken" xml:"NextContinuationToken"` + StartAfter string `json:"startAfter" xml:"StartAfter"` + + CommonPrefixes []*ListObjectCommonPrefix `json:"commonPrefixes" xml:"CommonPrefixes"` + + Contents []*ListObjectContent `json:"contents" xml:"Contents"` + + KeyCount int `json:"keyCount" xml:"KeyCount"` + MaxKeys int `json:"maxKeys" xml:"MaxKeys"` + IsTruncated bool `json:"isTruncated" xml:"IsTruncated"` +} + +type ListObjectCommonPrefix struct { + Prefix string `json:"prefix" xml:"Prefix"` +} + +type ListObjectContent struct { + Owner struct { + DisplayName string `json:"displayName" xml:"DisplayName"` + ID string `json:"id" xml:"ID"` + } `json:"owner" xml:"Owner"` + + ChecksumAlgorithm string `json:"checksumAlgorithm" xml:"ChecksumAlgorithm"` + ETag string `json:"etag" xml:"ETag"` + Key string `json:"key" xml:"Key"` + StorageClass string `json:"storageClass" xml:"StorageClass"` + LastModified time.Time `json:"lastModified" xml:"LastModified"` + + RestoreStatus struct { + RestoreExpiryDate time.Time `json:"restoreExpiryDate" xml:"RestoreExpiryDate"` + IsRestoreInProgress bool `json:"isRestoreInProgress" xml:"IsRestoreInProgress"` + } `json:"restoreStatus" xml:"RestoreStatus"` + + Size int64 `json:"size" xml:"Size"` +} diff --git a/tools/filesystem/internal/s3blob/s3/list_objects_test.go b/tools/filesystem/internal/s3blob/s3/list_objects_test.go new file mode 100644 index 0000000000..81ccd7b773 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/list_objects_test.go @@ -0,0 +1,157 @@ +package s3_test + +import ( + "context" + "encoding/json/v2" + "io" + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3ListParamsEncode(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + params s3.ListParams + expected string + }{ + { + "blank", + s3.ListParams{}, + "list-type=2", + }, + { + "filled", + s3.ListParams{ + ContinuationToken: "test_ct", + Delimiter: "test_delimiter", + Prefix: "test_prefix", + EncodingType: "test_et", + StartAfter: "test_sa", + MaxKeys: 1, + FetchOwner: true, + }, + "continuation-token=test_ct&delimiter=test_delimiter&encoding-type=test_et&fetch-owner=true&list-type=2&max-keys=1&prefix=test_prefix&start-after=test_sa", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := s.params.Encode() + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestS3ListObjects(t *testing.T) { + t.Parallel() + + listParams := s3.ListParams{ + ContinuationToken: "test_ct", + Delimiter: "test_delimiter", + Prefix: "test_prefix", + EncodingType: "test_et", + StartAfter: "test_sa", + MaxKeys: 10, + FetchOwner: true, + } + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodGet, + URL: "http://test_bucket.example.com/?" + listParams.Encode(), + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + + example + test_encoding + a/ + / + ct + nct + example0.txt + 1 + 3 + true + + example1.txt + 2025-01-01T01:02:03.123Z + test_ca + test_etag1 + 123 + STANDARD + + owner_dn + owner_id + + + 2025-01-02T01:02:03.123Z + true + + + + example2.txt + 2025-01-02T01:02:03.123Z + test_etag2 + 456 + STANDARD + + + a/b/ + + + a/c/ + + + `)), + }, + }, + ) + + s3Client := &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + resp, err := s3Client.ListObjects(context.Background(), listParams, func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + + raw, err := json.Marshal(resp, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + expected := `{"encodingType":"test_encoding","name":"example","prefix":"a/","delimiter":"/","continuationToken":"ct","nextContinuationToken":"nct","startAfter":"example0.txt","commonPrefixes":[{"prefix":"a/b/"},{"prefix":"a/c/"}],"contents":[{"owner":{"displayName":"owner_dn","id":"owner_id"},"checksumAlgorithm":"test_ca","etag":"test_etag1","key":"example1.txt","storageClass":"STANDARD","lastModified":"2025-01-01T01:02:03.123Z","restoreStatus":{"restoreExpiryDate":"2025-01-02T01:02:03.123Z","isRestoreInProgress":true},"size":123},{"owner":{"displayName":"","id":""},"checksumAlgorithm":"","etag":"test_etag2","key":"example2.txt","storageClass":"STANDARD","lastModified":"2025-01-02T01:02:03.123Z","restoreStatus":{"restoreExpiryDate":"0001-01-01T00:00:00Z","isRestoreInProgress":false},"size":456}],"keyCount":1,"maxKeys":3,"isTruncated":true}` + + if rawStr != expected { + t.Fatalf("Expected response\n%s\ngot\n%s", expected, rawStr) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/s3.go b/tools/filesystem/internal/s3blob/s3/s3.go new file mode 100644 index 0000000000..22122bd4ab --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/s3.go @@ -0,0 +1,400 @@ +// Package s3 implements a lightweight client for interacting with the +// REST APIs of any S3 compatible service. +// +// It implements only the minimal functionality required by PocketBase +// such as objects list, get, copy, delete and upload. +// +// For more details why we don't use the official aws-sdk-go-v2, you could check +// https://github.com/pocketbase/pocketbase/discussions/6562. +// +// Example: +// +// client := &s3.S3{ +// Endpoint: "example.com", +// Region: "us-east-1", +// Bucket: "test", +// AccessKey: "...", +// SecretKey: "...", +// UsePathStyle: true, +// } +// resp, err := client.GetObject(context.Background(), "abc.txt") +package s3 + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +const ( + awsS3ServiceCode = "s3" + awsSignAlgorithm = "AWS4-HMAC-SHA256" + awsTerminationString = "aws4_request" + metadataPrefix = "x-amz-meta-" + dateTimeFormat = "20060102T150405Z" +) + +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type S3 struct { + // Client specifies a custom HTTP client to send the request with. + // + // If not explicitly set, fallbacks to http.DefaultClient. + Client HTTPClient + + Bucket string + Region string + Endpoint string // can be with or without the schema + AccessKey string + SecretKey string + UsePathStyle bool +} + +// URL constructs an S3 request URL based on the current configuration. +// +// Note that the path will be URL escaped based on the AWS [UriEncode rules] +// for broader compatibility with some providers that expect the same +// path format as the one in the canonical signed header +// (see also https://github.com/pocketbase/pocketbase/issues/7153). +// +// [UriEncode rules]: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html +func (s3 *S3) URL(path string) string { + scheme := "https" + endpoint := strings.TrimRight(s3.Endpoint, "/") + if after, ok := strings.CutPrefix(endpoint, "https://"); ok { + endpoint = after + } else if after, ok := strings.CutPrefix(endpoint, "http://"); ok { + endpoint = after + scheme = "http" + } + + // to prevent double escaping we first parse/unescape it + parsed, err := url.Parse(path) + if err != nil { + // truly rare case, keep the path as it is + } else { + path = escapePath(parsed.Path) + + // the rest is usually not expected to be part of the S3 path but it is kept to avoid surprises + // (it will be further escaped if necessary by the Go HTTP client) + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.RawFragment != "" { + path += "#" + parsed.RawFragment + } + } + + path = strings.TrimLeft(path, "/") + + if s3.UsePathStyle { + return fmt.Sprintf("%s://%s/%s/%s", scheme, endpoint, s3.Bucket, path) + } + + return fmt.Sprintf("%s://%s.%s/%s", scheme, s3.Bucket, endpoint, path) +} + +// SignAndSend signs the provided request per AWS Signature v4 and sends it. +// +// It automatically normalizes all 40x/50x responses to ResponseError. +// +// Note: Don't forget to call resp.Body.Close() after done with the result. +func (s3 *S3) SignAndSend(req *http.Request) (*http.Response, error) { + s3.sign(req) + + client := s3.Client + if client == nil { + client = http.DefaultClient + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + defer resp.Body.Close() + + respErr := &ResponseError{ + Status: resp.StatusCode, + } + + respErr.Raw, err = io.ReadAll(resp.Body) + if err != nil && !errors.Is(err, io.EOF) { + return nil, errors.Join(err, respErr) + } + + if len(respErr.Raw) > 0 { + err = xml.Unmarshal(respErr.Raw, respErr) + if err != nil { + return nil, errors.Join(err, respErr) + } + } + + return nil, respErr + } + + return resp, nil +} + +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-signed-request-steps +func (s3 *S3) sign(req *http.Request) { + // explicitly set Accept-Encoding to avoid transparent decompression + // and Content-Length zeroing (https://github.com/pocketbase/pocketbase/issues/7523) + if req.Header.Get("Accept-Encoding") == "" { + req.Header.Set("Accept-Encoding", "identity") + } + + // fallback to the Unsigned payload option + // (data integrity checks could be still applied via the content-md5 or x-amz-checksum-* headers) + if req.Header.Get("x-amz-content-sha256") == "" { + req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + } + + reqDateTime, _ := time.Parse(dateTimeFormat, req.Header.Get("x-amz-date")) + if reqDateTime.IsZero() { + reqDateTime = time.Now().UTC() + req.Header.Set("x-amz-date", reqDateTime.Format(dateTimeFormat)) + } + + req.Header.Set("host", req.URL.Host) + + date := reqDateTime.Format("20060102") + + dateTime := reqDateTime.Format(dateTimeFormat) + + // 1. Create canonical request + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request + // --------------------------------------------------------------- + canonicalHeaders, signedHeaders := canonicalAndSignedHeaders(req) + + canonicalParts := []string{ + req.Method, + escapePath(req.URL.Path), + escapeQuery(req.URL.Query()), + canonicalHeaders, + signedHeaders, + req.Header.Get("x-amz-content-sha256"), + } + + // 2. Create a hash of the canonical request + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request-hash + // --------------------------------------------------------------- + hashedCanonicalRequest := sha256Hex([]byte(strings.Join(canonicalParts, "\n"))) + + // 3. Create a string to sign + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-string-to-sign + // --------------------------------------------------------------- + scope := strings.Join([]string{ + date, + s3.Region, + awsS3ServiceCode, + awsTerminationString, + }, "/") + + stringToSign := strings.Join([]string{ + awsSignAlgorithm, + dateTime, + scope, + hashedCanonicalRequest, + }, "\n") + + // 4. Derive a signing key for SigV4 + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#derive-signing-key + // --------------------------------------------------------------- + dateKey := hmacSHA256([]byte("AWS4"+s3.SecretKey), date) + dateRegionKey := hmacSHA256(dateKey, s3.Region) + dateRegionServiceKey := hmacSHA256(dateRegionKey, awsS3ServiceCode) + signingKey := hmacSHA256(dateRegionServiceKey, awsTerminationString) + signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) + + // 5. Add the signature to the request + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#add-signature-to-request + authorization := fmt.Sprintf( + "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + awsSignAlgorithm, + s3.AccessKey, + scope, + signedHeaders, + signature, + ) + + req.Header.Set("authorization", authorization) +} + +func sha256Hex(content []byte) string { + h := sha256.New() + h.Write(content) + return hex.EncodeToString(h.Sum(nil)) +} + +func hmacSHA256(key []byte, content string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(content)) + return mac.Sum(nil) +} + +func canonicalAndSignedHeaders(req *http.Request) (string, string) { + signed := []string{} + canonical := map[string]string{} + + for key, values := range req.Header { + normalizedKey := strings.ToLower(key) + + if normalizedKey != "host" && + normalizedKey != "content-type" && + !strings.HasPrefix(normalizedKey, "x-amz-") { + continue + } + + signed = append(signed, normalizedKey) + + // for each value: + // trim any leading or trailing spaces + // convert sequential spaces to a single space + normalizedValues := make([]string, len(values)) + for i, v := range values { + normalizedValues[i] = strings.ReplaceAll(strings.TrimSpace(v), " ", " ") + } + + canonical[normalizedKey] = strings.Join(normalizedValues, ",") + } + + slices.Sort(signed) + + var sortedCanonical strings.Builder + for _, key := range signed { + sortedCanonical.WriteString(key) + sortedCanonical.WriteString(":") + sortedCanonical.WriteString(canonical[key]) + sortedCanonical.WriteString("\n") + } + + return sortedCanonical.String(), strings.Join(signed, ";") +} + +// extractMetadata parses and extracts and the metadata from the specified request headers. +// +// The metadata keys are all lowercased and without the "x-amz-meta-" prefix. +func extractMetadata(headers http.Header) map[string]string { + result := map[string]string{} + + for k, v := range headers { + if len(v) == 0 { + continue + } + + metadataKey, ok := strings.CutPrefix(strings.ToLower(k), metadataPrefix) + if !ok { + continue + } + + result[metadataKey] = v[0] + } + + return result +} + +// escapeQuery returns the URI encoded request query parameters according to the AWS S3 spec requirements +// (it is similar to url.Values.Encode but instead of url.QueryEscape uses our own escape method). +func escapeQuery(values url.Values) string { + if len(values) == 0 { + return "" + } + + var buf strings.Builder + + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + slices.Sort(keys) + + for _, k := range keys { + vs := values[k] + keyEscaped := escape(k) + for _, values := range vs { + if buf.Len() > 0 { + buf.WriteByte('&') + } + buf.WriteString(keyEscaped) + buf.WriteByte('=') + buf.WriteString(escape(values)) + } + } + + return buf.String() +} + +// escapePath returns the URI encoded request path according to the AWS S3 spec requirements. +func escapePath(path string) string { + parts := strings.Split(path, "/") + + for i, part := range parts { + parts[i] = escape(part) + } + + return strings.Join(parts, "/") +} + +const upperhex = "0123456789ABCDEF" + +// escape is similar to the std url.escape but implements the AWS [UriEncode requirements]: +// - URI encode every byte except the unreserved characters: 'A'-'Z', 'a'-'z', '0'-'9', '-', '.', '_', and '~'. +// - The space character is a reserved character and must be encoded as "%20" (and not as "+"). +// - Each URI encoded byte is formed by a '%' and the two-digit hexadecimal value of the byte. +// - Letters in the hexadecimal value must be uppercase, for example "%1A". +// +// [UriEncode requirements]: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html +func escape(s string) string { + hexCount := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if shouldEscape(c) { + hexCount++ + } + } + + if hexCount == 0 { + return s + } + + result := make([]byte, len(s)+2*hexCount) + + j := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if shouldEscape(c) { + result[j] = '%' + result[j+1] = upperhex[c>>4] + result[j+2] = upperhex[c&15] + j += 3 + } else { + result[j] = c + j++ + } + } + + return string(result) +} + +// > "URI encode every byte except the unreserved characters: 'A'-'Z', 'a'-'z', '0'-'9', '-', '.', '_', and '~'." +func shouldEscape(c byte) bool { + isUnreserved := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' + + return !isUnreserved +} diff --git a/tools/filesystem/internal/s3blob/s3/s3_escape_test.go b/tools/filesystem/internal/s3blob/s3/s3_escape_test.go new file mode 100644 index 0000000000..fedc330ffb --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/s3_escape_test.go @@ -0,0 +1,35 @@ +package s3 + +import ( + "net/url" + "testing" +) + +func TestEscapePath(t *testing.T) { + t.Parallel() + + escaped := escapePath("/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~ !@#$%^&*()+={}[]?><\\|,`'\"/@sub1/@sub2/a/b/c/1/2/3") + + expected := "/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~%20%21%40%23%24%25%5E%26%2A%28%29%2B%3D%7B%7D%5B%5D%3F%3E%3C%5C%7C%2C%60%27%22/%40sub1/%40sub2/a/b/c/1/2/3" + + if escaped != expected { + t.Fatalf("Expected\n%s\ngot\n%s", expected, escaped) + } +} + +func TestEscapeQuery(t *testing.T) { + t.Parallel() + + escaped := escapeQuery(url.Values{ + "abc": []string{"123"}, + "/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~ !@#$%^&*()+={}[]?><\\|,`'\"": []string{ + "/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~ !@#$%^&*()+={}[]?><\\|,`'\"", + }, + }) + + expected := "%2FABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~%20%21%40%23%24%25%5E%26%2A%28%29%2B%3D%7B%7D%5B%5D%3F%3E%3C%5C%7C%2C%60%27%22=%2FABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~%20%21%40%23%24%25%5E%26%2A%28%29%2B%3D%7B%7D%5B%5D%3F%3E%3C%5C%7C%2C%60%27%22&abc=123" + + if escaped != expected { + t.Fatalf("Expected\n%s\ngot\n%s", expected, escaped) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/s3_test.go b/tools/filesystem/internal/s3blob/s3/s3_test.go new file mode 100644 index 0000000000..35dbd0502c --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/s3_test.go @@ -0,0 +1,267 @@ +package s3_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestS3URL(t *testing.T) { + t.Parallel() + + path := "/test_key/a/b c@d?a=@1&b=!2#@a b c" + + // note: query params and fragments are kept as it is + // since they are later escaped if necessary by the Go HTTP client + expectedPath := "/test_key/a/b%20c%40d?a=@1&b=!2#@a b c" + + scenarios := []struct { + name string + s3Client *s3.S3 + expected string + }{ + { + "no schema", + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "example.com/", + AccessKey: "123", + SecretKey: "abc", + }, + "https://test_bucket.example.com" + expectedPath, + }, + { + "with https schema", + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "https://example.com/", + AccessKey: "123", + SecretKey: "abc", + }, + "https://test_bucket.example.com" + expectedPath, + }, + { + "with http schema", + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com/", + AccessKey: "123", + SecretKey: "abc", + }, + "http://test_bucket.example.com" + expectedPath, + }, + { + "path style addressing (non-explicit schema)", + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "example.com/", + AccessKey: "123", + SecretKey: "abc", + UsePathStyle: true, + }, + "https://example.com/test_bucket" + expectedPath, + }, + { + "path style addressing (explicit schema)", + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com/", + AccessKey: "123", + SecretKey: "abc", + UsePathStyle: true, + }, + "http://example.com/test_bucket" + expectedPath, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := s.s3Client.URL(path) + if result != s.expected { + t.Fatalf("Expected URL\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestS3SignAndSend(t *testing.T) { + t.Parallel() + + testResponse := func() *http.Response { + return &http.Response{ + Body: io.NopCloser(strings.NewReader("test_response")), + } + } + + scenarios := []struct { + name string + path string + reqFunc func(req *http.Request) + s3Client *s3.S3 + }{ + { + "minimal", + "/test", + func(req *http.Request) { + req.Header.Set("x-amz-date", "20250102T150405Z") + }, + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "https://example.com/", + AccessKey: "123", + SecretKey: "abc", + Client: tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/test", + Response: testResponse(), + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ea093662bc1deef08dfb4ac35453dfaad5ea89edf102e9dd3b7156c9a27e4c1f", + "Host": "test_bucket.example.com", + "Accept-Encoding": "identity", + "X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD", + "X-Amz-Date": "20250102T150405Z", + }) + }, + }), + }, + }, + { + "minimal with different access and secret keys", + "/test", + func(req *http.Request) { + req.Header.Set("x-amz-date", "20250102T150405Z") + }, + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "https://example.com/", + AccessKey: "456", + SecretKey: "def", + Client: tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/test", + Response: testResponse(), + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=456/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=17510fa1f724403dd0a563b61c9b31d1d718f877fcbd75455620d17a8afce5fb", + "Host": "test_bucket.example.com", + "Accept-Encoding": "identity", + "X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD", + "X-Amz-Date": "20250102T150405Z", + }) + }, + }), + }, + }, + { + "minimal with special characters", + "/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -_.~!@&*():=$()?a=1&@b=@2#@a b c", + func(req *http.Request) { + req.Header.Set("x-amz-date", "20250102T150405Z") + }, + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "https://example.com/", + AccessKey: "456", + SecretKey: "def", + Client: tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%20-_.~%21%40%26%2A%28%29%3A%3D%24%28%29?a=1&@b=@2#@a%20b%20c", + Response: testResponse(), + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=456/20250102/test_region/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=9458a033554f52913801b3de16f54409b36ed25c6da3aed14e64439500e2c5e1", + "Host": "test_bucket.example.com", + "Accept-Encoding": "identity", + "X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD", + "X-Amz-Date": "20250102T150405Z", + }) + }, + }), + }, + }, + { + "with extra headers", + "/test", + func(req *http.Request) { + req.Header.Set("x-amz-date", "20250102T150405Z") + req.Header.Set("x-amz-content-sha256", "test_sha256") + req.Header.Set("x-amz-example", "123") + req.Header.Set("x-amz-meta-a", "456") + req.Header.Set("content-type", "image/png") + req.Header.Set("accept-encoding", "custom") + req.Header.Set("x-test", "789") // shouldn't be included in the signing headers + }, + &s3.S3{ + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "https://example.com/", + AccessKey: "123", + SecretKey: "abc", + Client: tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/test", + Response: testResponse(), + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "authorization": "AWS4-HMAC-SHA256 Credential=123/20250102/test_region/s3/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-example;x-amz-meta-a, Signature=86dccbcd012c33073dc99e9d0a9e0b717a4d8c11c37848cfa9a4a02716bc0db3", + "host": "test_bucket.example.com", + "accept-encoding": "custom", + "x-amz-date": "20250102T150405Z", + "x-amz-content-sha256": "test_sha256", + "x-amz-example": "123", + "x-amz-meta-a": "456", + "x-test": "789", + }) + }, + }), + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, s.s3Client.URL(s.path), strings.NewReader("test_request")) + if err != nil { + t.Fatal(err) + } + + if s.reqFunc != nil { + s.reqFunc(req) + } + + resp, err := s.s3Client.SignAndSend(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + err = s.s3Client.Client.(*tests.Client).AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + + expectedBody := "test_response" + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if str := string(body); str != expectedBody { + t.Fatalf("Expected body %q, got %q", expectedBody, str) + } + }) + } +} diff --git a/tools/filesystem/internal/s3blob/s3/tests/client.go b/tools/filesystem/internal/s3blob/s3/tests/client.go new file mode 100644 index 0000000000..b06fb22a26 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/tests/client.go @@ -0,0 +1,111 @@ +// Package tests contains various tests helpers and utilities to assist +// with the S3 client testing. +package tests + +import ( + "errors" + "fmt" + "io" + "net/http" + "regexp" + "slices" + "strings" + "sync" +) + +// NewClient creates a new test Client loaded with the specified RequestStubs. +func NewClient(stubs ...*RequestStub) *Client { + return &Client{stubs: stubs} +} + +type RequestStub struct { + Method string + URL string // plain string or regex pattern wrapped in "^pattern$" + Match func(req *http.Request) bool + Response *http.Response +} + +type Client struct { + stubs []*RequestStub + mu sync.Mutex +} + +// AssertNoRemaining asserts that current client has no unprocessed requests remaining. +func (c *Client) AssertNoRemaining() error { + c.mu.Lock() + defer c.mu.Unlock() + + if len(c.stubs) == 0 { + return nil + } + + msgParts := make([]string, 0, len(c.stubs)+1) + msgParts = append(msgParts, "not all stub requests were processed:") + for _, stub := range c.stubs { + msgParts = append(msgParts, "- "+stub.Method+" "+stub.URL) + } + + return errors.New(strings.Join(msgParts, "\n")) +} + +// Do implements the [s3.HTTPClient] interface. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + + for i, stub := range c.stubs { + if req.Method != stub.Method { + continue + } + + urlPattern := stub.URL + if !strings.HasPrefix(urlPattern, "^") && !strings.HasSuffix(urlPattern, "$") { + urlPattern = "^" + regexp.QuoteMeta(urlPattern) + "$" + } + + urlRegex, err := regexp.Compile(urlPattern) + if err != nil { + return nil, err + } + + if !urlRegex.MatchString(req.URL.String()) { + continue + } + + if stub.Match != nil && !stub.Match(req) { + continue + } + + // remove from the remaining stubs + c.stubs = slices.Delete(c.stubs, i, i+1) + + response := stub.Response + if response == nil { + response = &http.Response{} + } + if response.Header == nil { + response.Header = http.Header{} + } + if response.Body == nil { + response.Body = http.NoBody + } + + response.Request = req + + return response, nil + } + + var body []byte + if req.Body != nil { + defer req.Body.Close() + body, _ = io.ReadAll(req.Body) + } + + return nil, fmt.Errorf( + "the below request doesn't have a corresponding stub:\n%s %s\nHeaders: %v\nBody: %q", + req.Method, + req.URL.String(), + req.Header, + body, + ) +} diff --git a/tools/filesystem/internal/s3blob/s3/tests/headers.go b/tools/filesystem/internal/s3blob/s3/tests/headers.go new file mode 100644 index 0000000000..21464c7dad --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/tests/headers.go @@ -0,0 +1,33 @@ +package tests + +import ( + "net/http" + "regexp" + "strings" +) + +// ExpectHeaders checks whether specified headers match the expectations. +// The expectations map entry key is the header name. +// The expectations map entry value is the first header value. If wrapped with `^...$` +// it is compared as regular expression. +func ExpectHeaders(headers http.Header, expectations map[string]string) bool { + for h, expected := range expectations { + v := headers.Get(h) + + pattern := expected + if !strings.HasPrefix(pattern, "^") && !strings.HasSuffix(pattern, "$") { + pattern = "^" + regexp.QuoteMeta(pattern) + "$" + } + + expectedRegex, err := regexp.Compile(pattern) + if err != nil { + return false + } + + if !expectedRegex.MatchString(v) { + return false + } + } + + return true +} diff --git a/tools/filesystem/internal/s3blob/s3/uploader.go b/tools/filesystem/internal/s3blob/s3/uploader.go new file mode 100644 index 0000000000..5c7593929c --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/uploader.go @@ -0,0 +1,417 @@ +package s3 + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "sync" + + "github.com/pocketbase/pocketbase/tools/routine" + "golang.org/x/sync/errgroup" +) + +var ErrUsedUploader = errors.New("the Uploader has been already used") + +const ( + defaultMaxConcurrency int = 5 + defaultMinPartSize int = 6 << 20 +) + +// Uploader handles the upload of a single S3 object. +// +// If the Payload size is less than the configured MinPartSize it sends +// a single (PutObject) request, otherwise performs chunked/multipart upload. +type Uploader struct { + // S3 is the S3 client instance performing the upload object request (required). + S3 *S3 + + // Payload is the object content to upload (required). + Payload io.Reader + + // Key is the destination key of the uploaded object (required). + Key string + + // Metadata specifies the optional metadata to write with the object upload. + Metadata map[string]string + + // MaxConcurrency specifies the max number of workers to use when + // performing chunked/multipart upload. + // + // If zero or negative, defaults to 5. + // + // This option is used only when the Payload size is > MinPartSize. + MaxConcurrency int + + // MinPartSize specifies the min Payload size required to perform + // chunked/multipart upload. + // + // If zero or negative, defaults to ~6MB. + MinPartSize int + + uploadId string + uploadedParts []*mpPart + lastPartNumber int + mu sync.Mutex // guards lastPartNumber and the uploadedParts slice + used bool +} + +// Upload processes the current Uploader instance. +// +// Users can specify an optional optReqFuncs that will be passed down to all Upload internal requests +// (single upload, multipart init, multipart parts upload, multipart complete, multipart abort). +// +// Note that after this call the Uploader should be discarded (aka. no longer can be used). +func (u *Uploader) Upload(ctx context.Context, optReqFuncs ...func(*http.Request)) error { + if u.used { + return ErrUsedUploader + } + + err := u.validateAndNormalize() + if err != nil { + return err + } + + initPart, _, err := u.nextPart() + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + if len(initPart) < u.MinPartSize { + return u.singleUpload(ctx, initPart, optReqFuncs...) + } + + err = u.multipartInit(ctx, optReqFuncs...) + if err != nil { + return fmt.Errorf("multipart init error: %w", err) + } + + err = u.multipartUpload(ctx, initPart, optReqFuncs...) + if err != nil { + return errors.Join( + u.multipartAbort(ctx, optReqFuncs...), + fmt.Errorf("multipart upload error: %w", err), + ) + } + + err = u.multipartComplete(ctx, optReqFuncs...) + if err != nil { + return errors.Join( + u.multipartAbort(ctx, optReqFuncs...), + fmt.Errorf("multipart complete error: %w", err), + ) + } + + return nil +} + +// ------------------------------------------------------------------- + +func (u *Uploader) validateAndNormalize() error { + if u.S3 == nil { + return errors.New("Uploader.S3 must be a non-empty and properly initialized S3 client instance") + } + + if u.Key == "" { + return errors.New("Uploader.Key is required") + } + + if u.Payload == nil { + return errors.New("Uploader.Payload must be non-nill") + } + + if u.MaxConcurrency <= 0 { + u.MaxConcurrency = defaultMaxConcurrency + } + + if u.MinPartSize <= 0 { + u.MinPartSize = defaultMinPartSize + } + + return nil +} + +func (u *Uploader) singleUpload(ctx context.Context, part []byte, optReqFuncs ...func(*http.Request)) error { + if u.used { + return ErrUsedUploader + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.S3.URL(u.Key), bytes.NewReader(part)) + if err != nil { + return err + } + + req.Header.Set("Content-Length", strconv.Itoa(len(part))) + + for k, v := range u.Metadata { + req.Header.Set(metadataPrefix+k, v) + } + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := u.S3.SignAndSend(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// ------------------------------------------------------------------- + +type mpPart struct { + XMLName xml.Name `xml:"Part"` + ETag string `xml:"ETag"` + PartNumber int `xml:"PartNumber"` +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html +func (u *Uploader) multipartInit(ctx context.Context, optReqFuncs ...func(*http.Request)) error { + if u.used { + return ErrUsedUploader + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.S3.URL(u.Key+"?uploads"), nil) + if err != nil { + return err + } + + for k, v := range u.Metadata { + req.Header.Set(metadataPrefix+k, v) + } + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := u.S3.SignAndSend(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body := &struct { + XMLName xml.Name `xml:"InitiateMultipartUploadResult"` + UploadId string `xml:"UploadId"` + }{} + + err = xml.NewDecoder(resp.Body).Decode(body) + if err != nil { + return err + } + + u.uploadId = body.UploadId + + return nil +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html +func (u *Uploader) multipartAbort(ctx context.Context, optReqFuncs ...func(*http.Request)) error { + u.mu.Lock() + defer u.mu.Unlock() + + u.used = true + + // ensure that the specified abort context is always valid to allow cleanup + var abortCtx = ctx + if abortCtx.Err() != nil { + abortCtx = context.Background() + } + + query := url.Values{"uploadId": []string{u.uploadId}} + + req, err := http.NewRequestWithContext(abortCtx, http.MethodDelete, u.S3.URL(u.Key+"?"+query.Encode()), nil) + if err != nil { + return err + } + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := u.S3.SignAndSend(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html +func (u *Uploader) multipartComplete(ctx context.Context, optReqFuncs ...func(*http.Request)) error { + u.mu.Lock() + defer u.mu.Unlock() + + u.used = true + + // the list of parts must be sorted in ascending order + slices.SortFunc(u.uploadedParts, func(a, b *mpPart) int { + if a.PartNumber < b.PartNumber { + return -1 + } + if a.PartNumber > b.PartNumber { + return 1 + } + return 0 + }) + + // build a request payload with the uploaded parts + xmlParts := &struct { + XMLName xml.Name `xml:"CompleteMultipartUpload"` + Parts []*mpPart + }{ + Parts: u.uploadedParts, + } + rawXMLParts, err := xml.Marshal(xmlParts) + if err != nil { + return err + } + reqPayload := strings.NewReader(xml.Header + string(rawXMLParts)) + + query := url.Values{"uploadId": []string{u.uploadId}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.S3.URL(u.Key+"?"+query.Encode()), reqPayload) + if err != nil { + return err + } + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := u.S3.SignAndSend(req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (u *Uploader) nextPart() ([]byte, int, error) { + u.mu.Lock() + defer u.mu.Unlock() + + part := make([]byte, u.MinPartSize) + n, err := io.ReadFull(u.Payload, part) + + // normalize io.EOF errors and ensure that io.EOF is returned only when there were no read bytes + if err != nil && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) { + if n == 0 { + err = io.EOF + } else { + err = nil + } + } + + u.lastPartNumber++ + + return part[0:n], u.lastPartNumber, err +} + +func (u *Uploader) multipartUpload(ctx context.Context, initPart []byte, optReqFuncs ...func(*http.Request)) error { + var g errgroup.Group + g.SetLimit(u.MaxConcurrency) + + totalWorkers := u.MaxConcurrency + + if len(initPart) != 0 { + totalWorkers-- + initPartNumber := u.lastPartNumber + g.Go(routine.SafeWrap(func() error { + mp, err := u.uploadPart(ctx, initPartNumber, initPart, optReqFuncs...) + if err != nil { + return err + } + + u.mu.Lock() + u.uploadedParts = append(u.uploadedParts, mp) + u.mu.Unlock() + + return nil + })) + } + + totalWorkers = max(totalWorkers, 1) + + for i := 0; i < totalWorkers; i++ { + g.Go(routine.SafeWrap(func() error { + for { + part, num, err := u.nextPart() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + mp, err := u.uploadPart(ctx, num, part, optReqFuncs...) + if err != nil { + return err + } + + u.mu.Lock() + u.uploadedParts = append(u.uploadedParts, mp) + u.mu.Unlock() + } + + return nil + })) + } + + return g.Wait() +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html +func (u *Uploader) uploadPart(ctx context.Context, partNumber int, partData []byte, optReqFuncs ...func(*http.Request)) (*mpPart, error) { + query := url.Values{} + query.Set("uploadId", u.uploadId) + query.Set("partNumber", strconv.Itoa(partNumber)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.S3.URL(u.Key+"?"+query.Encode()), bytes.NewReader(partData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Length", strconv.Itoa(len(partData))) + + // apply optional request funcs + for _, fn := range optReqFuncs { + if fn != nil { + fn(req) + } + } + + resp, err := u.S3.SignAndSend(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return &mpPart{ + PartNumber: partNumber, + ETag: resp.Header.Get("ETag"), + }, nil +} diff --git a/tools/filesystem/internal/s3blob/s3/uploader_test.go b/tools/filesystem/internal/s3blob/s3/uploader_test.go new file mode 100644 index 0000000000..1665202260 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3/uploader_test.go @@ -0,0 +1,471 @@ +package s3_test + +import ( + "context" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestUploaderRequiredFields(t *testing.T) { + t.Parallel() + + s3Client := &s3.S3{ + Client: tests.NewClient(&tests.RequestStub{Method: "PUT", URL: `^.+$`}), // match every upload + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + } + + payload := strings.NewReader("test") + + scenarios := []struct { + name string + uploader *s3.Uploader + expectedError bool + }{ + { + "blank", + &s3.Uploader{}, + true, + }, + { + "no Key", + &s3.Uploader{S3: s3Client, Payload: payload}, + true, + }, + { + "no S3", + &s3.Uploader{Key: "abc", Payload: payload}, + true, + }, + { + "no Payload", + &s3.Uploader{S3: s3Client, Key: "abc"}, + true, + }, + { + "with S3, Key and Payload", + &s3.Uploader{S3: s3Client, Key: "abc", Payload: payload}, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + err := s.uploader.Upload(context.Background()) + + hasErr := err != nil + if hasErr != s.expectedError { + t.Fatalf("Expected hasErr %v, got %v", s.expectedError, hasErr) + } + }) + } +} + +func TestUploaderSingleUpload(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + + return string(body) == "abcdefg" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "7", + "x-amz-meta-a": "123", + "x-amz-meta-b": "456", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + }, + ) + + uploader := &s3.Uploader{ + S3: &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + }, + Key: "test_key", + Payload: strings.NewReader("abcdefg"), + Metadata: map[string]string{"a": "123", "b": "456"}, + MinPartSize: 8, + } + + err := uploader.Upload(context.Background(), func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestUploaderMultipartUploadSuccess(t *testing.T) { + t.Parallel() + + maxConcurrencies := []int{-1, 0, 1, 10} + + for _, mc := range maxConcurrencies { + t.Run("MaxConcurrency_"+strconv.Itoa(mc), func(t *testing.T) { + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPost, + URL: "http://test_bucket.example.com/test_key?uploads", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "x-amz-meta-a": "123", + "x-amz-meta-b": "456", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + + test_bucket + test_key + test_id + + `)), + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + + return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "3", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag1"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + + return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "3", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag2"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=3&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + return string(body) == "g" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "1", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag3"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPost, + URL: "http://test_bucket.example.com/test_key?uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + + expected := `etag11etag22etag33` + + return strings.Contains(string(body), expected) && tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + }, + ) + + uploader := &s3.Uploader{ + S3: &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + }, + Key: "test_key", + Payload: strings.NewReader("abcdefg"), + Metadata: map[string]string{"a": "123", "b": "456"}, + MinPartSize: 3, + MaxConcurrency: mc, + } + + err := uploader.Upload(context.Background(), func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestUploaderMultipartUploadPartFailure(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPost, + URL: "http://test_bucket.example.com/test_key?uploads", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "x-amz-meta-a": "123", + "x-amz-meta-b": "456", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + + test_bucket + test_key + test_id + + `)), + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "3", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag1"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + StatusCode: 400, + }, + }, + &tests.RequestStub{ + Method: http.MethodDelete, + URL: "http://test_bucket.example.com/test_key?uploadId=test_id", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + }, + ) + + uploader := &s3.Uploader{ + S3: &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + }, + Key: "test_key", + Payload: strings.NewReader("abcdefg"), + Metadata: map[string]string{"a": "123", "b": "456"}, + MinPartSize: 3, + } + + err := uploader.Upload(context.Background(), func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err == nil { + t.Fatal("Expected non-nil error") + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestUploaderMultipartUploadCompleteFailure(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPost, + URL: "http://test_bucket.example.com/test_key?uploads", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "x-amz-meta-a": "123", + "x-amz-meta-b": "456", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + + test_bucket + test_key + test_id + + `)), + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=1&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + return string(body) == "abc" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "3", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag1"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPut, + URL: "http://test_bucket.example.com/test_key?partNumber=2&uploadId=test_id", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + return string(body) == "def" && tests.ExpectHeaders(req.Header, map[string]string{ + "Content-Length": "3", + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + Header: http.Header{"Etag": []string{"etag2"}}, + }, + }, + &tests.RequestStub{ + Method: http.MethodPost, + URL: "http://test_bucket.example.com/test_key?uploadId=test_id", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + Response: &http.Response{ + StatusCode: 400, + }, + }, + &tests.RequestStub{ + Method: http.MethodDelete, + URL: "http://test_bucket.example.com/test_key?uploadId=test_id", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "test_header": "test", + "Authorization": "^.+Credential=123/.+$", + }) + }, + }, + ) + + uploader := &s3.Uploader{ + S3: &s3.S3{ + Client: httpClient, + Region: "test_region", + Bucket: "test_bucket", + Endpoint: "http://example.com", + AccessKey: "123", + SecretKey: "abc", + }, + Key: "test_key", + Payload: strings.NewReader("abcdef"), + Metadata: map[string]string{"a": "123", "b": "456"}, + MinPartSize: 3, + } + + err := uploader.Upload(context.Background(), func(r *http.Request) { + r.Header.Set("test_header", "test") + }) + if err == nil { + t.Fatal("Expected non-nil error") + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} diff --git a/tools/filesystem/internal/s3blob/s3blob.go b/tools/filesystem/internal/s3blob/s3blob.go new file mode 100644 index 0000000000..106e603f47 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3blob.go @@ -0,0 +1,489 @@ +// Package s3blob provides a blob.Bucket S3 driver implementation. +// +// NB! To minimize breaking changes with older PocketBase releases, +// the driver is based of the previously used gocloud.dev/blob/s3blob, +// hence many of the below doc comments, struct options and interface +// implementations are the same. +// +// The blob abstraction supports all UTF-8 strings; to make this work with services lacking +// full UTF-8 support, strings must be escaped (during writes) and unescaped +// (during reads). The following escapes are performed for s3blob: +// - Blob keys: ASCII characters 0-31 are escaped to "__0x__". +// Additionally, the "/" in "../" is escaped in the same way. +// - Metadata keys: Escaped using URL encoding, then additionally "@:=" are +// escaped using "__0x__". These characters were determined by +// experimentation. +// - Metadata values: Escaped using URL encoding. +// +// Example: +// +// drv, _ := s3blob.New(&s3.S3{ +// Bucket: "bucketName", +// Region: "region", +// Endpoint: "endpoint", +// AccessKey: "accessKey", +// SecretKey: "secretKey", +// }) +// bucket := blob.NewBucket(drv) +package s3blob + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/pocketbase/pocketbase/tools/filesystem/blob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/routine" +) + +const defaultPageSize = 1000 + +// New creates a new instance of the S3 driver backed by the internal S3 client. +func New(s3Client *s3.S3) (blob.Driver, error) { + if s3Client.Bucket == "" { + return nil, errors.New("s3blob.New: missing bucket name") + } + + if s3Client.Endpoint == "" { + return nil, errors.New("s3blob.New: missing endpoint") + } + + if s3Client.Region == "" { + return nil, errors.New("s3blob.New: missing region") + } + + return &driver{s3: s3Client}, nil +} + +type driver struct { + s3 *s3.S3 +} + +// Close implements [blob/Driver.Close]. +func (drv *driver) Close() error { + return nil // nothing to close +} + +// NormalizeError implements [blob/Driver.NormalizeError]. +func (drv *driver) NormalizeError(err error) error { + // already normalized + if errors.Is(err, blob.ErrNotFound) { + return err + } + + // normalize base on its S3 error status or code + var ae *s3.ResponseError + if errors.As(err, &ae) { + if ae.Status == 404 { + return errors.Join(err, blob.ErrNotFound) + } + + switch ae.Code { + case "NoSuchBucket", "NoSuchKey", "NotFound": + return errors.Join(err, blob.ErrNotFound) + } + } + + return err +} + +// ListPaged implements [blob/Driver.ListPaged]. +func (drv *driver) ListPaged(ctx context.Context, opts *blob.ListOptions) (*blob.ListPage, error) { + pageSize := opts.PageSize + if pageSize == 0 { + pageSize = defaultPageSize + } + + listParams := s3.ListParams{ + MaxKeys: pageSize, + } + if len(opts.PageToken) > 0 { + listParams.ContinuationToken = string(opts.PageToken) + } + if opts.Prefix != "" { + listParams.Prefix = escapeKey(opts.Prefix) + } + if opts.Delimiter != "" { + listParams.Delimiter = escapeKey(opts.Delimiter) + } + + resp, err := drv.s3.ListObjects(ctx, listParams) + if err != nil { + return nil, err + } + + page := blob.ListPage{} + if resp.NextContinuationToken != "" { + page.NextPageToken = []byte(resp.NextContinuationToken) + } + + if n := len(resp.Contents) + len(resp.CommonPrefixes); n > 0 { + page.Objects = make([]*blob.ListObject, n) + for i, obj := range resp.Contents { + page.Objects[i] = &blob.ListObject{ + Key: unescapeKey(obj.Key), + ModTime: obj.LastModified, + Size: obj.Size, + MD5: eTagToMD5(obj.ETag), + } + } + + for i, prefix := range resp.CommonPrefixes { + page.Objects[i+len(resp.Contents)] = &blob.ListObject{ + Key: unescapeKey(prefix.Prefix), + IsDir: true, + } + } + + if len(resp.Contents) > 0 && len(resp.CommonPrefixes) > 0 { + // S3 gives us blobs and "directories" in separate lists; sort them. + sort.Slice(page.Objects, func(i, j int) bool { + return page.Objects[i].Key < page.Objects[j].Key + }) + } + } + + return &page, nil +} + +// Attributes implements [blob/Driver.Attributes]. +func (drv *driver) Attributes(ctx context.Context, key string) (*blob.Attributes, error) { + key = escapeKey(key) + + resp, err := drv.s3.HeadObject(ctx, key) + if err != nil { + return nil, err + } + + md := make(map[string]string, len(resp.Metadata)) + for k, v := range resp.Metadata { + // See the package comments for more details on escaping of metadata keys & values. + md[blob.HexUnescape(urlUnescape(k))] = urlUnescape(v) + } + + return &blob.Attributes{ + CacheControl: resp.CacheControl, + ContentDisposition: resp.ContentDisposition, + ContentEncoding: resp.ContentEncoding, + ContentLanguage: resp.ContentLanguage, + ContentType: resp.ContentType, + Metadata: md, + // CreateTime not supported; left as the zero time. + ModTime: resp.LastModified, + Size: resp.ContentLength, + MD5: eTagToMD5(resp.ETag), + ETag: resp.ETag, + }, nil +} + +// NewRangeReader implements [blob/Driver.NewRangeReader]. +func (drv *driver) NewRangeReader(ctx context.Context, key string, offset, length int64) (blob.DriverReader, error) { + key = escapeKey(key) + + var byteRange string + if offset > 0 && length < 0 { + byteRange = fmt.Sprintf("bytes=%d-", offset) + } else if length == 0 { + // AWS doesn't support a zero-length read; we'll read 1 byte and then + // ignore it in favor of http.NoBody below. + byteRange = fmt.Sprintf("bytes=%d-%d", offset, offset) + } else if length >= 0 { + byteRange = fmt.Sprintf("bytes=%d-%d", offset, offset+length-1) + } + + reqOpt := func(req *http.Request) { + if byteRange != "" { + req.Header.Set("Range", byteRange) + } + } + + resp, err := drv.s3.GetObject(ctx, key, reqOpt) + if err != nil { + return nil, err + } + + body := resp.Body + if length == 0 { + body = http.NoBody + } + + return &reader{ + body: body, + attrs: &blob.ReaderAttributes{ + ContentType: resp.ContentType, + ModTime: resp.LastModified, + Size: getSize(resp.ContentLength, resp.ContentRange), + }, + }, nil +} + +// NewTypedWriter implements [blob/Driver.NewTypedWriter]. +func (drv *driver) NewTypedWriter(ctx context.Context, key string, contentType string, opts *blob.WriterOptions) (blob.DriverWriter, error) { + key = escapeKey(key) + + u := &s3.Uploader{ + S3: drv.s3, + Key: key, + } + + if opts.BufferSize != 0 { + u.MinPartSize = opts.BufferSize + } + + if opts.MaxConcurrency != 0 { + u.MaxConcurrency = opts.MaxConcurrency + } + + md := make(map[string]string, len(opts.Metadata)) + for k, v := range opts.Metadata { + // See the package comments for more details on escaping of metadata keys & values. + k = blob.HexEscape(url.PathEscape(k), func(runes []rune, i int) bool { + c := runes[i] + return c == '@' || c == ':' || c == '=' + }) + md[k] = url.PathEscape(v) + } + u.Metadata = md + + reqOptions := []func(*http.Request){ + func(r *http.Request) { + r.Header.Set("Content-Type", contentType) + + if opts.CacheControl != "" { + r.Header.Set("Cache-Control", opts.CacheControl) + } + if opts.ContentDisposition != "" { + r.Header.Set("Content-Disposition", opts.ContentDisposition) + } + if opts.ContentEncoding != "" { + r.Header.Set("Content-Encoding", opts.ContentEncoding) + } + if opts.ContentLanguage != "" { + r.Header.Set("Content-Language", opts.ContentLanguage) + } + if len(opts.ContentMD5) > 0 { + r.Header.Set("Content-MD5", base64.StdEncoding.EncodeToString(opts.ContentMD5)) + } + }, + } + + return &writer{ + ctx: ctx, + uploader: u, + donec: make(chan struct{}), + reqOptions: reqOptions, + }, nil +} + +// Copy implements [blob/Driver.Copy]. +func (drv *driver) Copy(ctx context.Context, dstKey, srcKey string) error { + dstKey = escapeKey(dstKey) + srcKey = escapeKey(srcKey) + _, err := drv.s3.CopyObject(ctx, srcKey, dstKey) + return err +} + +// Delete implements [blob/Driver.Delete]. +func (drv *driver) Delete(ctx context.Context, key string) error { + key = escapeKey(key) + return drv.s3.DeleteObject(ctx, key) +} + +// ------------------------------------------------------------------- + +// reader reads an S3 object. It implements io.ReadCloser. +type reader struct { + attrs *blob.ReaderAttributes + body io.ReadCloser +} + +// Read implements [io/ReadCloser.Read]. +func (r *reader) Read(p []byte) (int, error) { + return r.body.Read(p) +} + +// Close closes the reader itself. It must be called when done reading. +func (r *reader) Close() error { + return r.body.Close() +} + +// Attributes implements [blob/DriverReader.Attributes]. +func (r *reader) Attributes() *blob.ReaderAttributes { + return r.attrs +} + +// ------------------------------------------------------------------- + +// writer writes an S3 object, it implements io.WriteCloser. +type writer struct { + ctx context.Context + err error // written before donec closes + uploader *s3.Uploader + + // Ends of an io.Pipe, created when the first byte is written. + pw *io.PipeWriter + pr *io.PipeReader + + donec chan struct{} // closed when done writing + + reqOptions []func(*http.Request) +} + +// Write appends p to w.pw. User must call Close to close the w after done writing. +func (w *writer) Write(p []byte) (int, error) { + // Avoid opening the pipe for a zero-length write; + // the concrete can do these for empty blobs. + if len(p) == 0 { + return 0, nil + } + + if w.pw == nil { + // We'll write into pw and use pr as an io.Reader for the + // Upload call to S3. + w.pr, w.pw = io.Pipe() + w.open(w.pr, true) + } + + return w.pw.Write(p) +} + +// r may be nil if we're Closing and no data was written. +// If closePipeOnError is true, w.pr will be closed if there's an +// error uploading to S3. +func (w *writer) open(r io.Reader, closePipeOnError bool) { + // This goroutine will keep running until Close, unless there's an error. + routine.FireAndForget(func() { + defer func() { + close(w.donec) + }() + + if r == nil { + // AWS doesn't like a nil Body. + r = http.NoBody + } + + w.uploader.Payload = r + + err := w.uploader.Upload(w.ctx, w.reqOptions...) + if err != nil { + if closePipeOnError { + w.pr.CloseWithError(err) + } + w.err = err + } + }) +} + +// Close completes the writer and closes it. Any error occurring during write +// will be returned. If a writer is closed before any Write is called, Close +// will create an empty file at the given key. +func (w *writer) Close() error { + if w.pr != nil { + defer w.pr.Close() + } + + if w.pw == nil { + // We never got any bytes written. We'll write an http.NoBody. + w.open(nil, false) + } else if err := w.pw.Close(); err != nil { + return err + } + + <-w.donec + + return w.err +} + +// ------------------------------------------------------------------- + +// etagToMD5 processes an ETag header and returns an MD5 hash if possible. +// S3's ETag header is sometimes a quoted hexstring of the MD5. Other times, +// notably when the object was uploaded in multiple parts, it is not. +// We do the best we can. +// Some links about ETag: +// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html +// https://github.com/aws/aws-sdk-net/issues/815 +// https://teppen.io/2018/06/23/aws_s3_etags/ +func eTagToMD5(etag string) []byte { + // No header at all. + if etag == "" { + return nil + } + + // Strip the expected leading and trailing quotes. + if len(etag) < 2 || etag[0] != '"' || etag[len(etag)-1] != '"' { + return nil + } + unquoted := etag[1 : len(etag)-1] + + // Un-hex; we return nil on error. In particular, we'll get an error here + // for multi-part uploaded blobs, whose ETag will contain a "-" and so will + // never be a legal hex encoding. + md5, err := hex.DecodeString(unquoted) + if err != nil { + return nil + } + + return md5 +} + +func getSize(contentLength int64, contentRange string) int64 { + // Default size to ContentLength, but that's incorrect for partial-length reads, + // where ContentLength refers to the size of the returned Body, not the entire + // size of the blob. ContentRange has the full size. + size := contentLength + if contentRange != "" { + // Sample: bytes 10-14/27 (where 27 is the full size). + parts := strings.Split(contentRange, "/") + if len(parts) == 2 { + if i, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + size = i + } + } + } + + return size +} + +// escapeKey does all required escaping for UTF-8 strings to work with S3. +func escapeKey(key string) string { + return blob.HexEscape(key, func(r []rune, i int) bool { + c := r[i] + + // S3 doesn't handle these characters (determined via experimentation). + if c < 32 { + return true + } + + // For "../", escape the trailing slash. + if i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.' { + return true + } + + return false + }) +} + +// unescapeKey reverses escapeKey. +func unescapeKey(key string) string { + return blob.HexUnescape(key) +} + +// urlUnescape reverses URLEscape using url.PathUnescape. If the unescape +// returns an error, it returns s. +func urlUnescape(s string) string { + if u, err := url.PathUnescape(s); err == nil { + return u + } + + return s +} diff --git a/tools/filesystem/internal/s3blob/s3blob_test.go b/tools/filesystem/internal/s3blob/s3blob_test.go new file mode 100644 index 0000000000..c420ab2c26 --- /dev/null +++ b/tools/filesystem/internal/s3blob/s3blob_test.go @@ -0,0 +1,617 @@ +package s3blob_test + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/filesystem/blob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3" + "github.com/pocketbase/pocketbase/tools/filesystem/internal/s3blob/s3/tests" +) + +func TestNew(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + s3Client *s3.S3 + expectError bool + }{ + { + "blank", + &s3.S3{}, + true, + }, + { + "no bucket", + &s3.S3{Region: "b", Endpoint: "c"}, + true, + }, + { + "no endpoint", + &s3.S3{Bucket: "a", Region: "b"}, + true, + }, + { + "no region", + &s3.S3{Bucket: "a", Endpoint: "c"}, + true, + }, + { + "with bucket, endpoint and region", + &s3.S3{Bucket: "a", Region: "b", Endpoint: "c"}, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + drv, err := s3blob.New(s.s3Client) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if err == nil && drv == nil { + t.Fatal("Expected non-nil driver instance") + } + }) + } +} + +func TestDriverClose(t *testing.T) { + t.Parallel() + + drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"}) + if err != nil { + t.Fatal(err) + } + + err = drv.Close() + if err != nil { + t.Fatalf("Expected nil, got error %v", err) + } +} + +func TestDriverNormilizeError(t *testing.T) { + t.Parallel() + + drv, err := s3blob.New(&s3.S3{Bucket: "a", Region: "b", Endpoint: "c"}) + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + name string + err error + expectErrNotFound bool + }{ + { + "plain error", + errors.New("test"), + false, + }, + { + "response error with only status (non-404)", + &s3.ResponseError{Status: 123}, + false, + }, + { + "response error with only status (404)", + &s3.ResponseError{Status: 404}, + true, + }, + { + "response error with custom code", + &s3.ResponseError{Code: "test"}, + false, + }, + { + "response error with NoSuchBucket code", + &s3.ResponseError{Code: "NoSuchBucket"}, + true, + }, + { + "response error with NoSuchKey code", + &s3.ResponseError{Code: "NoSuchKey"}, + true, + }, + { + "response error with NotFound code", + &s3.ResponseError{Code: "NotFound"}, + true, + }, + { + "wrapped response error with NotFound code", // ensures that the entire error's tree is checked + fmt.Errorf("test: %w", &s3.ResponseError{Code: "NotFound"}), + true, + }, + { + "already normalized error", + fmt.Errorf("test: %w", blob.ErrNotFound), + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + err := drv.NormalizeError(s.err) + if err == nil { + t.Fatal("Expected non-nil error") + } + + isErrNotFound := errors.Is(err, blob.ErrNotFound) + if isErrNotFound != s.expectErrNotFound { + t.Fatalf("Expected isErrNotFound %v, got %v (%v)", s.expectErrNotFound, isErrNotFound, err) + } + }) + } +} + +func TestDriverDeleteEscaping(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient(&tests.RequestStub{ + Method: http.MethodDelete, + URL: "https://test_bucket.example.com/..__0x2f__abc/test/", + }) + + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "test_region", + Endpoint: "https://example.com", + Client: httpClient, + }) + if err != nil { + t.Fatal(err) + } + + err = drv.Delete(context.Background(), "../abc/test/") + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestDriverCopyEscaping(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient(&tests.RequestStub{ + Method: http.MethodPut, + URL: "https://test_bucket.example.com/..__0x2f__a/", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "x-amz-copy-source": "test_bucket%2F..__0x2f__b%2F", + }) + }, + Response: &http.Response{ + Body: io.NopCloser(strings.NewReader(``)), + }, + }) + + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "test_region", + Endpoint: "https://example.com", + Client: httpClient, + }) + if err != nil { + t.Fatal(err) + } + + err = drv.Copy(context.Background(), "../a/", "../b/") + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestDriverAttributes(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient(&tests.RequestStub{ + Method: http.MethodHead, + URL: "https://test_bucket.example.com/..__0x2f__a/", + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Cache-Control": []string{"test_cache"}, + "Content-Disposition": []string{"test_disposition"}, + "Content-Encoding": []string{"test_encoding"}, + "Content-Language": []string{"test_language"}, + "Content-Type": []string{"test_type"}, + "Content-Range": []string{"test_range"}, + "Etag": []string{`"ce5be8b6f53645c596306c4572ece521"`}, + "Content-Length": []string{"100"}, + "x-amz-meta-AbC%40": []string{"%40test_meta_a"}, + "x-amz-meta-Def": []string{"test_meta_b"}, + }, + Body: http.NoBody, + }, + }) + + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "test_region", + Endpoint: "https://example.com", + Client: httpClient, + }) + if err != nil { + t.Fatal(err) + } + + attrs, err := drv.Attributes(context.Background(), "../a/") + if err != nil { + t.Fatal(err) + } + + raw, err := json.Marshal( + attrs, + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + ) + if err != nil { + t.Fatal(err) + } + + expected := `{"cacheControl":"test_cache","contentDisposition":"test_disposition","contentEncoding":"test_encoding","contentLanguage":"test_language","contentType":"test_type","metadata":{"abc@":"@test_meta_a","def":"test_meta_b"},"createTime":"0001-01-01T00:00:00Z","modTime":"2025-02-01T03:04:05Z","size":100,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","etag":"\"ce5be8b6f53645c596306c4572ece521\""}` + if str := string(raw); str != expected { + t.Fatalf("Expected attributes\n%s\ngot\n%s", expected, str) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestDriverListPaged(t *testing.T) { + t.Parallel() + + listResponse := func() *http.Response { + return &http.Response{ + Body: io.NopCloser(strings.NewReader(` + + + example + ct + test_next + example0.txt + 1 + 3 + + ..__0x2f__prefixB/test/example.txt + 2025-01-01T01:02:03.123Z + "ce5be8b6f53645c596306c4572ece521" + 123 + + + prefixA/..__0x2f__escape.txt + 2025-01-02T01:02:03.123Z + 456 + + + prefixA + + + ..__0x2f__prefixB + + + `)), + } + } + + expectedPage := `{"objects":[{"key":"../prefixB","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"../prefixB/test/example.txt","modTime":"2025-01-01T01:02:03.123Z","size":123,"md5":"zlvotvU2RcWWMGxFcuzlIQ==","isDir":false},{"key":"prefixA","modTime":"0001-01-01T00:00:00Z","size":0,"md5":null,"isDir":true},{"key":"prefixA/../escape.txt","modTime":"2025-01-02T01:02:03.123Z","size":456,"md5":null,"isDir":false}],"nextPageToken":"dGVzdF9uZXh0"}` + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/?list-type=2&max-keys=1000", + Response: listResponse(), + }, + &tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/?continuation-token=test_token&delimiter=test_delimiter&list-type=2&max-keys=123&prefix=test_prefix", + Response: listResponse(), + }, + ) + + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "test_region", + Endpoint: "https://example.com", + Client: httpClient, + }) + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + name string + opts *blob.ListOptions + expected string + }{ + { + "empty options", + &blob.ListOptions{}, + expectedPage, + }, + { + "filled options", + &blob.ListOptions{Prefix: "test_prefix", Delimiter: "test_delimiter", PageSize: 123, PageToken: []byte("test_token")}, + expectedPage, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + page, err := drv.ListPaged(context.Background(), s.opts) + if err != nil { + t.Fatal(err) + } + + raw, err := json.Marshal( + page, + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + ) + if err != nil { + t.Fatal(err) + } + + if str := string(raw); s.expected != str { + t.Fatalf("Expected page result\n%s\ngot\n%s", s.expected, str) + } + }) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} + +func TestDriverNewRangeReader(t *testing.T) { + t.Parallel() + + scenarios := []struct { + offset int64 + length int64 + httpClient *tests.Client + expectedAttrs string + }{ + { + 0, + 0, + tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Range": "bytes=0-0", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Content-Type": []string{"test_ct"}, + "Content-Length": []string{"123"}, + }, + Body: io.NopCloser(strings.NewReader("test")), + }, + }), + `{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`, + }, + { + 10, + -1, + tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Range": "bytes=10-", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Content-Type": []string{"test_ct"}, + "Content-Range": []string{"bytes 1-1/456"}, // should take precedence over content-length + "Content-Length": []string{"123"}, + }, + Body: io.NopCloser(strings.NewReader("test")), + }, + }), + `{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":456}`, + }, + { + 10, + 0, + tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Range": "bytes=10-10", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Content-Type": []string{"test_ct"}, + // no range and length headers + // "Content-Range": []string{"bytes 1-1/456"}, + // "Content-Length": []string{"123"}, + }, + Body: io.NopCloser(strings.NewReader("test")), + }, + }), + `{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":0}`, + }, + { + 10, + 20, + tests.NewClient(&tests.RequestStub{ + Method: http.MethodGet, + URL: "https://test_bucket.example.com/..__0x2f__abc/test.txt", + Match: func(req *http.Request) bool { + return tests.ExpectHeaders(req.Header, map[string]string{ + "Range": "bytes=10-29", + }) + }, + Response: &http.Response{ + Header: http.Header{ + "Last-Modified": []string{"Mon, 01 Feb 2025 03:04:05 GMT"}, + "Content-Type": []string{"test_ct"}, + // with range header but invalid format -> content-length takes precedence + "Content-Range": []string{"bytes invalid-456"}, + "Content-Length": []string{"123"}, + }, + Body: io.NopCloser(strings.NewReader("test")), + }, + }), + `{"contentType":"test_ct","modTime":"2025-02-01T03:04:05Z","size":123}`, + }, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("offset_%d_length_%d", s.offset, s.length), func(t *testing.T) { + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "tesst_region", + Endpoint: "https://example.com", + Client: s.httpClient, + }) + if err != nil { + t.Fatal(err) + } + + r, err := drv.NewRangeReader(context.Background(), "../abc/test.txt", s.offset, s.length) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + // the response body should be always replaced with http.NoBody + if s.length == 0 { + body := make([]byte, 1) + n, err := r.Read(body) + if n != 0 || !errors.Is(err, io.EOF) { + t.Fatalf("Expected body to be http.NoBody, got %v (%v)", body, err) + } + } + + rawAttrs, err := json.Marshal( + r.Attributes(), + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + ) + if err != nil { + t.Fatal(err) + } + + if str := string(rawAttrs); str != s.expectedAttrs { + t.Fatalf("Expected attributes\n%s\ngot\n%s", s.expectedAttrs, str) + } + + err = s.httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDriverNewTypedWriter(t *testing.T) { + t.Parallel() + + httpClient := tests.NewClient( + &tests.RequestStub{ + Method: http.MethodPut, + URL: "https://test_bucket.example.com/..__0x2f__abc/test/", + Match: func(req *http.Request) bool { + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + + return string(body) == "test" && tests.ExpectHeaders(req.Header, map[string]string{ + "cache-control": "test_cache_control", + "content-disposition": "test_content_disposition", + "content-encoding": "test_content_encoding", + "content-language": "test_content_language", + "content-type": "test_ct", + "content-md5": "dGVzdA==", + }) + }, + }, + ) + + drv, err := s3blob.New(&s3.S3{ + Bucket: "test_bucket", + Region: "test_region", + Endpoint: "https://example.com", + Client: httpClient, + }) + if err != nil { + t.Fatal(err) + } + + options := &blob.WriterOptions{ + CacheControl: "test_cache_control", + ContentDisposition: "test_content_disposition", + ContentEncoding: "test_content_encoding", + ContentLanguage: "test_content_language", + ContentType: "test_content_type", // should be ignored + ContentMD5: []byte("test"), + Metadata: map[string]string{"@test_meta_a": "@test"}, + } + + w, err := drv.NewTypedWriter(context.Background(), "../abc/test/", "test_ct", options) + if err != nil { + t.Fatal(err) + } + + n, err := w.Write(nil) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("Expected nil write to result in %d written bytes, got %d", 0, n) + } + + n, err = w.Write([]byte("test")) + if err != nil { + t.Fatal(err) + } + if n != 4 { + t.Fatalf("Expected nil write to result in %d written bytes, got %d", 4, n) + } + + err = w.Close() + if err != nil { + t.Fatal(err) + } + + err = httpClient.AssertNoRemaining() + if err != nil { + t.Fatal(err) + } +} diff --git a/tools/hook/event.go b/tools/hook/event.go new file mode 100644 index 0000000000..12bceecca5 --- /dev/null +++ b/tools/hook/event.go @@ -0,0 +1,45 @@ +package hook + +// Resolver defines a common interface for a Hook event (see [Event]). +type Resolver interface { + // Next triggers the next handler in the hook's chain (if any). + Next() error + + // note: kept only for the generic interface; may get removed in the future + nextFunc() func() error + setNextFunc(f func() error) +} + +var _ Resolver = (*Event)(nil) + +// Event implements [Resolver] and it is intended to be used as a base +// Hook event that you can embed in your custom typed event structs. +// +// Example: +// +// type CustomEvent struct { +// hook.Event +// +// SomeField int +// } +type Event struct { + next func() error +} + +// Next calls the next hook handler. +func (e *Event) Next() error { + if e.next != nil { + return e.next() + } + return nil +} + +// nextFunc returns the function that Next calls. +func (e *Event) nextFunc() func() error { + return e.next +} + +// setNextFunc sets the function that Next calls. +func (e *Event) setNextFunc(f func() error) { + e.next = f +} diff --git a/tools/hook/event_test.go b/tools/hook/event_test.go new file mode 100644 index 0000000000..89b15efe6d --- /dev/null +++ b/tools/hook/event_test.go @@ -0,0 +1,29 @@ +package hook + +import "testing" + +func TestEventNext(t *testing.T) { + calls := 0 + + e := Event{} + + if e.nextFunc() != nil { + t.Fatalf("Expected nextFunc to be nil") + } + + e.setNextFunc(func() error { + calls++ + return nil + }) + + if e.nextFunc() == nil { + t.Fatalf("Expected nextFunc to be non-nil") + } + + e.Next() + e.Next() + + if calls != 2 { + t.Fatalf("Expected %d calls, got %d", 2, calls) + } +} diff --git a/tools/hook/hook.go b/tools/hook/hook.go index eb6780eea2..722ed9f270 100644 --- a/tools/hook/hook.go +++ b/tools/hook/hook.go @@ -1,126 +1,178 @@ package hook import ( - "errors" - "fmt" + "sort" "sync" "github.com/pocketbase/pocketbase/tools/security" ) -var StopPropagation = errors.New("Event hook propagation stopped") - -// Handler defines a hook handler function. -type Handler[T any] func(e T) error - -// handlerPair defines a pair of string id and Handler. -type handlerPair[T any] struct { - id string - handler Handler[T] +// Handler defines a single Hook handler. +// Multiple handlers can share the same id. +// If Id is not explicitly set it will be autogenerated by Hook.Add and Hook.AddHandler. +type Handler[T Resolver] struct { + // Func defines the handler function to execute. + // + // Note that users need to call e.Next() in order to proceed with + // the execution of the hook chain. + Func func(T) error + + // Id is the unique identifier of the handler. + // + // It could be used later to remove the handler from a hook via [Hook.Remove]. + // + // If missing, an autogenerated value will be assigned when adding + // the handler to a hook. + Id string + + // Priority allows changing the default exec priority of the handler within a hook. + // + // If 0, the handler will be executed in the same order it was registered. + Priority int } -// Hook defines a concurrent safe structure for handling event hooks -// (aka. callbacks propagation). -type Hook[T any] struct { - mux sync.RWMutex - handlers []*handlerPair[T] +// Hook defines a generic concurrent safe structure for managing event hooks. +// +// When using custom event it must embed the base [hook.Event]. +// +// Example: +// +// type CustomEvent struct { +// hook.Event +// SomeField int +// } +// +// h := Hook[*CustomEvent]{} +// +// h.BindFunc(func(e *CustomEvent) error { +// println(e.SomeField) +// +// return e.Next() +// }) +// +// h.Trigger(&CustomEvent{ SomeField: 123 }) +type Hook[T Resolver] struct { + handlers []*Handler[T] + mu sync.RWMutex } -// PreAdd registers a new handler to the hook by prepending it to the existing queue. +// Bind registers the provided handler to the current hooks queue. // -// Returns an autogenerated hook id that could be used later to remove the hook with Hook.Remove(id). -func (h *Hook[T]) PreAdd(fn Handler[T]) string { - h.mux.Lock() - defer h.mux.Unlock() +// If handler.Id is empty it is updated with autogenerated value. +// +// If a handler from the current hook list has Id matching handler.Id +// then the old handler is replaced with the new one. +func (h *Hook[T]) Bind(handler *Handler[T]) string { + h.mu.Lock() + defer h.mu.Unlock() + + var exists bool + + if handler.Id == "" { + handler.Id = generateHookId() + + // ensure that it doesn't exist + DUPLICATE_CHECK: + for _, existing := range h.handlers { + if existing.Id == handler.Id { + handler.Id = generateHookId() + goto DUPLICATE_CHECK + } + } + } else { + // replace existing + for i, existing := range h.handlers { + if existing.Id == handler.Id { + h.handlers[i] = handler + exists = true + break + } + } + } - id := generateHookId() + // append new + if !exists { + h.handlers = append(h.handlers, handler) + } - // minimize allocations by shifting the slice - h.handlers = append(h.handlers, nil) - copy(h.handlers[1:], h.handlers) - h.handlers[0] = &handlerPair[T]{id, fn} + // sort handlers by Priority, preserving the original order of equal items + sort.SliceStable(h.handlers, func(i, j int) bool { + return h.handlers[i].Priority < h.handlers[j].Priority + }) - return id + return handler.Id } -// Add registers a new handler to the hook by appending it to the existing queue. +// BindFunc is similar to Bind but registers a new handler from just the provided function. // -// Returns an autogenerated hook id that could be used later to remove the hook with Hook.Remove(id). -func (h *Hook[T]) Add(fn Handler[T]) string { - h.mux.Lock() - defer h.mux.Unlock() - - id := generateHookId() - - h.handlers = append(h.handlers, &handlerPair[T]{id, fn}) - - return id +// The registered handler is added with a default 0 priority and the id will be autogenerated. +// +// If you want to register a handler with custom priority or id use the [Hook.Bind] method. +func (h *Hook[T]) BindFunc(fn func(e T) error) string { + return h.Bind(&Handler[T]{Func: fn}) } -// Remove removes a single hook handler by its id. -func (h *Hook[T]) Remove(id string) { - h.mux.Lock() - defer h.mux.Unlock() - - for i := len(h.handlers) - 1; i >= 0; i-- { - if h.handlers[i].id == id { - h.handlers = append(h.handlers[:i], h.handlers[i+1:]...) - return +// Unbind removes one or many hook handler by their id. +func (h *Hook[T]) Unbind(idsToRemove ...string) { + h.mu.Lock() + defer h.mu.Unlock() + + for _, id := range idsToRemove { + for i := len(h.handlers) - 1; i >= 0; i-- { + if h.handlers[i].Id == id { + h.handlers = append(h.handlers[:i], h.handlers[i+1:]...) + break // for now stop on the first occurrence since we don't allow handlers with duplicated ids + } } } } -// RemoveAll removes all registered handlers. -func (h *Hook[T]) RemoveAll() { - h.mux.Lock() - defer h.mux.Unlock() +// UnbindAll removes all registered handlers. +func (h *Hook[T]) UnbindAll() { + h.mu.Lock() + defer h.mu.Unlock() h.handlers = nil } +// Length returns to total number of registered hook handlers. +func (h *Hook[T]) Length() int { + h.mu.RLock() + defer h.mu.RUnlock() + + return len(h.handlers) +} + // Trigger executes all registered hook handlers one by one -// with the specified `data` as an argument. +// with the specified event as an argument. // // Optionally, this method allows also to register additional one off -// handlers that will be temporary appended to the handlers queue. +// handler funcs that will be temporary appended to the handlers queue. // -// The execution stops when: -// - hook.StopPropagation is returned in one of the handlers -// - any non-nil error is returned in one of the handlers -func (h *Hook[T]) Trigger(data T, oneOffHandlers ...Handler[T]) error { - h.mux.RLock() - - handlers := make([]*handlerPair[T], 0, len(h.handlers)+len(oneOffHandlers)) - handlers = append(handlers, h.handlers...) - - // append the one off handlers - for i, oneOff := range oneOffHandlers { - handlers = append(handlers, &handlerPair[T]{ - id: fmt.Sprintf("@%d", i), - handler: oneOff, - }) +// NB! Each hook handler must call event.Next() in order the hook chain to proceed. +func (h *Hook[T]) Trigger(event T, oneOffHandlerFuncs ...func(T) error) error { + h.mu.RLock() + handlers := make([]func(T) error, 0, len(h.handlers)+len(oneOffHandlerFuncs)) + for _, handler := range h.handlers { + handlers = append(handlers, handler.Func) } + handlers = append(handlers, oneOffHandlerFuncs...) + h.mu.RUnlock() - // unlock is not deferred to avoid deadlocks in case Trigger - // is called recursively by the handlers - h.mux.RUnlock() - - for _, item := range handlers { - err := item.handler(data) - if err == nil { - continue - } + event.setNextFunc(nil) // reset in case the event is being reused - if errors.Is(err, StopPropagation) { - return nil - } - - return err + for i := len(handlers) - 1; i >= 0; i-- { + i := i + old := event.nextFunc() + event.setNextFunc(func() error { + event.setNextFunc(old) + return handlers[i](event) + }) } - return nil + return event.Next() } func generateHookId() string { - return security.PseudorandomString(8) + return security.PseudorandomString(20) } diff --git a/tools/hook/hook_test.go b/tools/hook/hook_test.go index a3ab688cff..36bd945f87 100644 --- a/tools/hook/hook_test.go +++ b/tools/hook/hook_test.go @@ -5,175 +5,158 @@ import ( "testing" ) -func TestHookAddAndPreAdd(t *testing.T) { - h := Hook[int]{} - - if total := len(h.handlers); total != 0 { - t.Fatalf("Expected no handlers, found %d", total) +func TestHookAddHandlerAndAdd(t *testing.T) { + calls := "" + + h := Hook[*Event]{} + + h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() }) + h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() }) + h3Id := h.BindFunc(func(e *Event) error { calls += "3"; return e.Next() }) + h.Bind(&Handler[*Event]{ + Id: h3Id, // should replace 3 + Func: func(e *Event) error { calls += "3'"; return e.Next() }, + }) + h.Bind(&Handler[*Event]{ + Func: func(e *Event) error { calls += "4"; return e.Next() }, + Priority: -2, + }) + h.Bind(&Handler[*Event]{ + Func: func(e *Event) error { calls += "5"; return e.Next() }, + Priority: -1, + }) + h.Bind(&Handler[*Event]{ + Func: func(e *Event) error { calls += "6"; return e.Next() }, + }) + h.Bind(&Handler[*Event]{ + Func: func(e *Event) error { calls += "7"; e.Next(); return errors.New("test") }, // error shouldn't stop the chain + }) + + h.Trigger( + &Event{}, + func(e *Event) error { calls += "8"; return e.Next() }, + func(e *Event) error { calls += "9"; return nil }, // skip next + func(e *Event) error { calls += "10"; return e.Next() }, + ) + + if total := len(h.handlers); total != 7 { + t.Fatalf("Expected %d handlers, found %d", 7, total) + } + + expectedCalls := "45123'6789" + + if calls != expectedCalls { + t.Fatalf("Expected calls sequence %q, got %q", expectedCalls, calls) } +} - triggerSequence := "" - - f1 := func(data int) error { triggerSequence += "f1"; return nil } - f2 := func(data int) error { triggerSequence += "f2"; return nil } - f3 := func(data int) error { triggerSequence += "f3"; return nil } - f4 := func(data int) error { triggerSequence += "f4"; return nil } - - h.Add(f1) - h.Add(f2) - h.PreAdd(f3) - h.PreAdd(f4) - h.Trigger(1) +func TestHookLength(t *testing.T) { + h := Hook[*Event]{} - if total := len(h.handlers); total != 4 { - t.Fatalf("Expected %d handlers, found %d", 4, total) + if l := h.Length(); l != 0 { + t.Fatalf("Expected 0 hook handlers, got %d", l) } - expectedTriggerSequence := "f4f3f1f2" + h.BindFunc(func(e *Event) error { return e.Next() }) + h.BindFunc(func(e *Event) error { return e.Next() }) - if triggerSequence != expectedTriggerSequence { - t.Fatalf("Expected trigger sequence %s, got %s", expectedTriggerSequence, triggerSequence) + if l := h.Length(); l != 2 { + t.Fatalf("Expected 2 hook handlers, got %d", l) } } -func TestHookRemove(t *testing.T) { - h := Hook[int]{} +func TestHookUnbind(t *testing.T) { + h := Hook[*Event]{} - h1Called := false - h2Called := false + calls := "" - id1 := h.Add(func(data int) error { h1Called = true; return nil }) - h.Add(func(data int) error { h2Called = true; return nil }) + id0 := h.BindFunc(func(e *Event) error { calls += "0"; return e.Next() }) + id1 := h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() }) + h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() }) + h.Bind(&Handler[*Event]{ + Func: func(e *Event) error { calls += "3"; return e.Next() }, + }) - h.Remove("missing") // should do nothing and not panic + h.Unbind("missing") // should do nothing and not panic - if total := len(h.handlers); total != 2 { - t.Fatalf("Expected %d handlers, got %d", 2, total) + if total := len(h.handlers); total != 4 { + t.Fatalf("Expected %d handlers, got %d", 4, total) } - h.Remove(id1) + h.Unbind(id1, id0) - if total := len(h.handlers); total != 1 { - t.Fatalf("Expected %d handlers, got %d", 1, total) + if total := len(h.handlers); total != 2 { + t.Fatalf("Expected %d handlers, got %d", 2, total) } - if err := h.Trigger(1); err != nil { + err := h.Trigger(&Event{}, func(e *Event) error { calls += "4"; return e.Next() }) + if err != nil { t.Fatal(err) } - if h1Called { - t.Fatalf("Expected hook 1 to be removed and not called") - } + expectedCalls := "234" - if !h2Called { - t.Fatalf("Expected hook 2 to be called") + if calls != expectedCalls { + t.Fatalf("Expected calls sequence %q, got %q", expectedCalls, calls) } } -func TestHookRemoveAll(t *testing.T) { - h := Hook[int]{} +func TestHookUnbindAll(t *testing.T) { + h := Hook[*Event]{} - h.RemoveAll() // should do nothing and not panic + h.UnbindAll() // should do nothing and not panic - h.Add(func(data int) error { return nil }) - h.Add(func(data int) error { return nil }) + h.BindFunc(func(e *Event) error { return nil }) + h.BindFunc(func(e *Event) error { return nil }) if total := len(h.handlers); total != 2 { - t.Fatalf("Expected 2 handlers before RemoveAll, found %d", total) + t.Fatalf("Expected %d handlers before UnbindAll, found %d", 2, total) } - h.RemoveAll() + h.UnbindAll() if total := len(h.handlers); total != 0 { - t.Fatalf("Expected no handlers after RemoveAll, found %d", total) + t.Fatalf("Expected no handlers after UnbindAll, found %d", total) } } -func TestHookTrigger(t *testing.T) { - err1 := errors.New("demo") - err2 := errors.New("demo") +func TestHookTriggerErrorPropagation(t *testing.T) { + err := errors.New("test") scenarios := []struct { - handlers []Handler[int] + name string + handlers []func(*Event) error expectedError error }{ { - []Handler[int]{ - func(data int) error { return nil }, - func(data int) error { return nil }, + "without error", + []func(*Event) error{ + func(e *Event) error { return e.Next() }, + func(e *Event) error { return e.Next() }, }, nil, }, { - []Handler[int]{ - func(data int) error { return nil }, - func(data int) error { return err1 }, - func(data int) error { return err2 }, + "with error", + []func(*Event) error{ + func(e *Event) error { return e.Next() }, + func(e *Event) error { e.Next(); return err }, + func(e *Event) error { return e.Next() }, }, - err1, + err, }, } - for i, scenario := range scenarios { - h := Hook[int]{} - for _, handler := range scenario.handlers { - h.Add(handler) - } - result := h.Trigger(1) - if result != scenario.expectedError { - t.Fatalf("(%d) Expected %v, got %v", i, scenario.expectedError, result) - } - } -} - -func TestHookTriggerStopPropagation(t *testing.T) { - called1 := false - f1 := func(data int) error { called1 = true; return nil } - - called2 := false - f2 := func(data int) error { called2 = true; return nil } - - called3 := false - f3 := func(data int) error { called3 = true; return nil } - - called4 := false - f4 := func(data int) error { called4 = true; return StopPropagation } - - called5 := false - f5 := func(data int) error { called5 = true; return nil } - - called6 := false - f6 := func(data int) error { called6 = true; return nil } - - h := Hook[int]{} - h.Add(f1) - h.Add(f2) - - result := h.Trigger(123, f3, f4, f5, f6) - - if result != nil { - t.Fatalf("Expected nil after StopPropagation, got %v", result) - } - - // ensure that the trigger handler were not persisted - if total := len(h.handlers); total != 2 { - t.Fatalf("Expected 2 handlers, found %d", total) - } - - scenarios := []struct { - called bool - expected bool - }{ - {called1, true}, - {called2, true}, - {called3, true}, - {called4, true}, // StopPropagation - {called5, false}, - {called6, false}, - } - for i, scenario := range scenarios { - if scenario.called != scenario.expected { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, scenario.called) - } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + h := Hook[*Event]{} + for _, handler := range s.handlers { + h.BindFunc(handler) + } + result := h.Trigger(&Event{}) + if result != s.expectedError { + t.Fatalf("Expected %v, got %v", s.expectedError, result) + } + }) } } diff --git a/tools/hook/tagged.go b/tools/hook/tagged.go index f0906f3f97..f6bfaba2d8 100644 --- a/tools/hook/tagged.go +++ b/tools/hook/tagged.go @@ -7,6 +7,8 @@ import ( // Tagger defines an interface for event data structs that support tags/groups/categories/etc. // Usually used together with TaggedHook. type Tagger interface { + Resolver + Tags() []string } @@ -33,12 +35,14 @@ type TaggedHook[T Tagger] struct { // CanTriggerOn checks if the current TaggedHook can be triggered with // the provided event data tags. -func (h *TaggedHook[T]) CanTriggerOn(tags []string) bool { +// +// It returns always true if the hook doesn't have any tags. +func (h *TaggedHook[T]) CanTriggerOn(tagsToCheck []string) bool { if len(h.tags) == 0 { return true // match all } - for _, t := range tags { + for _, t := range tagsToCheck { if list.ExistInSlice(t, h.tags) { return true } @@ -47,28 +51,34 @@ func (h *TaggedHook[T]) CanTriggerOn(tags []string) bool { return false } -// PreAdd registers a new handler to the hook by prepending it to the existing queue. +// Bind registers the provided handler to the current hooks queue. // -// The fn handler will be called only if the event data tags satisfy h.CanTriggerOn. -func (h *TaggedHook[T]) PreAdd(fn Handler[T]) string { - return h.mainHook.PreAdd(func(e T) error { +// It is similar to [Hook.Bind] with the difference that the handler +// function is invoked only if the event data tags satisfy h.CanTriggerOn. +func (h *TaggedHook[T]) Bind(handler *Handler[T]) string { + fn := handler.Func + + handler.Func = func(e T) error { if h.CanTriggerOn(e.Tags()) { return fn(e) } - return nil - }) + return e.Next() + } + + return h.mainHook.Bind(handler) } -// Add registers a new handler to the hook by appending it to the existing queue. +// BindFunc registers a new handler with the specified function. // -// The fn handler will be called only if the event data tags satisfy h.CanTriggerOn. -func (h *TaggedHook[T]) Add(fn Handler[T]) string { - return h.mainHook.Add(func(e T) error { +// It is similar to [Hook.Bind] with the difference that the handler +// function is invoked only if the event data tags satisfy h.CanTriggerOn. +func (h *TaggedHook[T]) BindFunc(fn func(e T) error) string { + return h.mainHook.BindFunc(func(e T) error { if h.CanTriggerOn(e.Tags()) { return fn(e) } - return nil + return e.Next() }) } diff --git a/tools/hook/tagged_test.go b/tools/hook/tagged_test.go index 84b2fa44f2..7fa4ce348a 100644 --- a/tools/hook/tagged_test.go +++ b/tools/hook/tagged_test.go @@ -1,69 +1,84 @@ package hook -import "testing" +import ( + "strings" + "testing" +) -type mockTagsData struct { +type mockTagsEvent struct { + Event tags []string } -func (m mockTagsData) Tags() []string { +func (m mockTagsEvent) Tags() []string { return m.tags } func TestTaggedHook(t *testing.T) { - triggerSequence := "" + calls := "" - base := &Hook[mockTagsData]{} - base.Add(func(data mockTagsData) error { triggerSequence += "f0"; return nil }) + base := &Hook[*mockTagsEvent]{} + base.BindFunc(func(e *mockTagsEvent) error { calls += "f0"; return e.Next() }) hA := NewTaggedHook(base) - hA.Add(func(data mockTagsData) error { triggerSequence += "a1"; return nil }) - hA.PreAdd(func(data mockTagsData) error { triggerSequence += "a2"; return nil }) + hA.BindFunc(func(e *mockTagsEvent) error { calls += "a1"; return e.Next() }) + hA.Bind(&Handler[*mockTagsEvent]{ + Func: func(e *mockTagsEvent) error { calls += "a2"; return e.Next() }, + Priority: -1, + }) hB := NewTaggedHook(base, "b1", "b2") - hB.Add(func(data mockTagsData) error { triggerSequence += "b1"; return nil }) - hB.PreAdd(func(data mockTagsData) error { triggerSequence += "b2"; return nil }) + hB.BindFunc(func(e *mockTagsEvent) error { calls += "b1"; return e.Next() }) + hB.Bind(&Handler[*mockTagsEvent]{ + Func: func(e *mockTagsEvent) error { calls += "b2"; return e.Next() }, + Priority: -2, + }) hC := NewTaggedHook(base, "c1", "c2") - hC.Add(func(data mockTagsData) error { triggerSequence += "c1"; return nil }) - hC.PreAdd(func(data mockTagsData) error { triggerSequence += "c2"; return nil }) + hC.BindFunc(func(e *mockTagsEvent) error { calls += "c1"; return e.Next() }) + hC.Bind(&Handler[*mockTagsEvent]{ + Func: func(e *mockTagsEvent) error { calls += "c2"; return e.Next() }, + Priority: -3, + }) scenarios := []struct { - data mockTagsData - expectedSequence string + event *mockTagsEvent + expectedCalls string }{ { - mockTagsData{}, + &mockTagsEvent{}, "a2f0a1", }, { - mockTagsData{[]string{"missing"}}, + &mockTagsEvent{tags: []string{"missing"}}, "a2f0a1", }, { - mockTagsData{[]string{"b2"}}, + &mockTagsEvent{tags: []string{"b2"}}, "b2a2f0a1b1", }, { - mockTagsData{[]string{"c1"}}, + &mockTagsEvent{tags: []string{"c1"}}, "c2a2f0a1c1", }, { - mockTagsData{[]string{"b1", "c2"}}, + &mockTagsEvent{tags: []string{"b1", "c2"}}, "c2b2a2f0a1b1c1", }, } - for i, s := range scenarios { - triggerSequence = "" // reset + for _, s := range scenarios { + t.Run(strings.Join(s.event.tags, "_"), func(t *testing.T) { + calls = "" // reset - err := hA.Trigger(s.data) - if err != nil { - t.Fatalf("[%d] Unexpected trigger error: %v", i, err) - } + err := base.Trigger(s.event) + if err != nil { + t.Fatalf("Unexpected trigger error: %v", err) + } - if triggerSequence != s.expectedSequence { - t.Fatalf("[%d] Expected trigger sequence %s, got %s", i, s.expectedSequence, triggerSequence) - } + if calls != s.expectedCalls { + t.Fatalf("Expected calls sequence %q, got %q", s.expectedCalls, calls) + } + }) } } diff --git a/tools/inflector/inflector.go b/tools/inflector/inflector.go index 3fa1ab13b7..6458af1bf2 100644 --- a/tools/inflector/inflector.go +++ b/tools/inflector/inflector.go @@ -83,3 +83,31 @@ func Snakecase(str string) string { return strings.ToLower(result.String()) } + +// Camelize converts the provided string to its "CamelCased" version +// (non alphanumeric characters are removed). +// +// For example: +// +// inflector.Camelize("send_email") // "SendEmail" +func Camelize(str string) string { + var result strings.Builder + + var isPrevSpecial bool + + for _, c := range str { + if !unicode.IsLetter(c) && !unicode.IsNumber(c) { + isPrevSpecial = true + continue + } + + if isPrevSpecial || result.Len() == 0 { + isPrevSpecial = false + result.WriteRune(unicode.ToUpper(c)) + } else { + result.WriteRune(c) + } + } + + return result.String() +} diff --git a/tools/inflector/inflector_test.go b/tools/inflector/inflector_test.go index a4d21223ad..0b96dd28db 100644 --- a/tools/inflector/inflector_test.go +++ b/tools/inflector/inflector_test.go @@ -1,6 +1,7 @@ package inflector_test import ( + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/inflector" @@ -18,10 +19,13 @@ func TestUcFirst(t *testing.T) { {"test test2", "Test test2"}, } - for i, scenario := range scenarios { - if result := inflector.UcFirst(scenario.val); result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result := inflector.UcFirst(s.val) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } @@ -42,10 +46,13 @@ func TestColumnify(t *testing.T) { {"test1--test2", "test1--test2"}, } - for i, scenario := range scenarios { - if result := inflector.Columnify(scenario.val); result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result := inflector.Columnify(s.val) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } @@ -67,10 +74,13 @@ func TestSentenize(t *testing.T) { {"hello world?", "Hello world?"}, } - for i, scenario := range scenarios { - if result := inflector.Sentenize(scenario.val); result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result := inflector.Sentenize(s.val) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } @@ -89,21 +99,19 @@ func TestSanitize(t *testing.T) { {"abcABC", `[A-Z`, "", true}, // invalid pattern } - for i, scenario := range scenarios { - result, err := inflector.Sanitize(scenario.val, scenario.pattern) - hasErr := err != nil + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result, err := inflector.Sanitize(s.val, s.pattern) + hasErr := err != nil - if scenario.expectErr != hasErr { - if scenario.expectErr { - t.Errorf("(%d) Expected error, got nil", i) - } else { - t.Errorf("(%d) Didn't expect error, got", err) + if s.expectErr != hasErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) } - } - if result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } @@ -126,9 +134,42 @@ func TestSnakecase(t *testing.T) { {"testABR", "test_abr"}, } - for i, scenario := range scenarios { - if result := inflector.Snakecase(scenario.val); result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result := inflector.Snakecase(s.val) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} + +func TestCamelize(t *testing.T) { + scenarios := []struct { + val string + expected string + }{ + {"", ""}, + {" ", ""}, + {"Test", "Test"}, + {"test", "Test"}, + {"testTest2", "TestTest2"}, + {"TestTest2", "TestTest2"}, + {"test test2", "TestTest2"}, + {"test-test2", "TestTest2"}, + {"test'test2", "TestTest2"}, + {"test1test2", "Test1test2"}, + {"1test-test2", "1testTest2"}, + {"123", "123"}, + {"123a", "123a"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) { + result := inflector.Camelize(s.val) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } diff --git a/tools/inflector/singularize.go b/tools/inflector/singularize.go new file mode 100644 index 0000000000..07aab24c38 --- /dev/null +++ b/tools/inflector/singularize.go @@ -0,0 +1,89 @@ +package inflector + +import ( + "log" + "regexp" + + "github.com/pocketbase/pocketbase/tools/store" +) + +var compiledPatterns = store.New[string, *regexp.Regexp](nil) + +// note: the patterns are extracted from popular Ruby/PHP/Node.js inflector packages +var singularRules = []struct { + pattern string // lazily compiled + replacement string +}{ + {"(?i)([nrlm]ese|deer|fish|sheep|measles|ois|pox|media|ss)$", "${1}"}, + {"(?i)^(sea[- ]bass)$", "${1}"}, + {"(?i)(s)tatuses$", "${1}tatus"}, + {"(?i)(f)eet$", "${1}oot"}, + {"(?i)(t)eeth$", "${1}ooth"}, + {"(?i)^(.*)(menu)s$", "${1}${2}"}, + {"(?i)(quiz)zes$", "${1}"}, + {"(?i)(matr)ices$", "${1}ix"}, + {"(?i)(vert|ind)ices$", "${1}ex"}, + {"(?i)^(ox)en", "${1}"}, + {"(?i)(alias)es$", "${1}"}, + {"(?i)(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|viri?)i$", "${1}us"}, + {"(?i)([ftw]ax)es", "${1}"}, + {"(?i)(cris|ax|test)es$", "${1}is"}, + {"(?i)(shoe)s$", "${1}"}, + {"(?i)(o)es$", "${1}"}, + {"(?i)ouses$", "ouse"}, + {"(?i)([^a])uses$", "${1}us"}, + {"(?i)([m|l])ice$", "${1}ouse"}, + {"(?i)(x|ch|ss|sh)es$", "${1}"}, + {"(?i)(m)ovies$", "${1}ovie"}, + {"(?i)(s)eries$", "${1}eries"}, + {"(?i)([^aeiouy]|qu)ies$", "${1}y"}, + {"(?i)([lr])ves$", "${1}f"}, + {"(?i)(tive)s$", "${1}"}, + {"(?i)(hive)s$", "${1}"}, + {"(?i)(drive)s$", "${1}"}, + {"(?i)([^fo])ves$", "${1}fe"}, + {"(?i)(^analy)ses$", "${1}sis"}, + {"(?i)(analy|diagno|^ba|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$", "${1}${2}sis"}, + {"(?i)([ti])a$", "${1}um"}, + {"(?i)(p)eople$", "${1}erson"}, + {"(?i)(m)en$", "${1}an"}, + {"(?i)(c)hildren$", "${1}hild"}, + {"(?i)(n)ews$", "${1}ews"}, + {"(?i)(n)etherlands$", "${1}etherlands"}, + {"(?i)eaus$", "eau"}, + {"(?i)(currenc)ies$", "${1}y"}, + {"(?i)^(.*us)$", "${1}"}, + {"(?i)s$", ""}, +} + +// Singularize converts the specified word into its singular version. +// +// For example: +// +// inflector.Singularize("people") // "person" +func Singularize(word string) string { + if word == "" { + return "" + } + + for _, rule := range singularRules { + re := compiledPatterns.GetOrSet(rule.pattern, func() *regexp.Regexp { + re, err := regexp.Compile(rule.pattern) + if err != nil { + return nil + } + return re + }) + if re == nil { + // log only for debug purposes + log.Println("[Singularize] failed to retrieve/compile rule pattern " + rule.pattern) + continue + } + + if re.MatchString(word) { + return re.ReplaceAllString(word, rule.replacement) + } + } + + return word +} diff --git a/tools/inflector/singularize_test.go b/tools/inflector/singularize_test.go new file mode 100644 index 0000000000..70ed2ab0dc --- /dev/null +++ b/tools/inflector/singularize_test.go @@ -0,0 +1,76 @@ +package inflector_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/inflector" +) + +func TestSingularize(t *testing.T) { + scenarios := []struct { + word string + expected string + }{ + {"abcnese", "abcnese"}, + {"deer", "deer"}, + {"sheep", "sheep"}, + {"measles", "measles"}, + {"pox", "pox"}, + {"media", "media"}, + {"bliss", "bliss"}, + {"sea-bass", "sea-bass"}, + {"Statuses", "Status"}, + {"Feet", "Foot"}, + {"Teeth", "Tooth"}, + {"abcmenus", "abcmenu"}, + {"Quizzes", "Quiz"}, + {"Matrices", "Matrix"}, + {"Vertices", "Vertex"}, + {"Indices", "Index"}, + {"Aliases", "Alias"}, + {"Alumni", "Alumnus"}, + {"Bacilli", "Bacillus"}, + {"Cacti", "Cactus"}, + {"Fungi", "Fungus"}, + {"Nuclei", "Nucleus"}, + {"Radii", "Radius"}, + {"Stimuli", "Stimulus"}, + {"Syllabi", "Syllabus"}, + {"Termini", "Terminus"}, + {"Viri", "Virus"}, + {"Faxes", "Fax"}, + {"Crises", "Crisis"}, + {"Axes", "Axis"}, + {"Shoes", "Shoe"}, + {"abcoes", "abco"}, + {"Houses", "House"}, + {"Mice", "Mouse"}, + {"abcxes", "abcx"}, + {"Movies", "Movie"}, + {"Series", "Series"}, + {"abcquies", "abcquy"}, + {"Relatives", "Relative"}, + {"Drives", "Drive"}, + {"aardwolves", "aardwolf"}, + {"Analyses", "Analysis"}, + {"Diagnoses", "Diagnosis"}, + {"People", "Person"}, + {"Men", "Man"}, + {"Children", "Child"}, + {"News", "News"}, + {"Netherlands", "Netherlands"}, + {"Tableaus", "Tableau"}, + {"Currencies", "Currency"}, + {"abcs", "abc"}, + {"abc", "abc"}, + } + + for _, s := range scenarios { + t.Run(s.word, func(t *testing.T) { + result := inflector.Singularize(s.word) + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} diff --git a/tools/list/list.go b/tools/list/list.go index 049fbdde17..27c4b9615f 100644 --- a/tools/list/list.go +++ b/tools/list/list.go @@ -1,7 +1,7 @@ package list import ( - "encoding/json" + "encoding/json/v2" "regexp" "strings" @@ -9,7 +9,7 @@ import ( "github.com/spf13/cast" ) -var cachedPatterns = store.New[*regexp.Regexp](nil) +var cachedPatterns = store.New[string, *regexp.Regexp](nil) // SubtractSlice returns a new slice with only the "base" elements // that don't exist in "subtract". @@ -39,7 +39,7 @@ func ExistInSlice[T comparable](item T, list []T) bool { // ExistInSliceWithRegex checks whether a string exists in a slice // either by direct match, or by a regular expression (eg. `^\w+$`). // -// _Note: Only list items starting with '^' and ending with '$' are treated as regular expressions!_ +// Note: Only list items starting with '^' and ending with '$' are treated as regular expressions! func ExistInSliceWithRegex(str string, list []string) bool { for _, field := range list { isRegex := strings.HasPrefix(field, "^") && strings.HasSuffix(field, "$") @@ -64,7 +64,7 @@ func ExistInSliceWithRegex(str string, list []string) bool { // (the limit size is arbitrary and it is there to prevent the cache growing too big) // // @todo consider replacing with TTL or LRU type cache - cachedPatterns.SetIfLessThanLimit(field, pattern, 5000) + cachedPatterns.SetIfLessThanLimit(field, pattern, 500) } if pattern != nil && pattern.MatchString(str) { @@ -129,7 +129,7 @@ func ToUniqueStringSlice(value any) (result []string) { // just add the string as single array element result = append(result, val) } - case json.Marshaler: // eg. JsonArray + case json.Marshaler: // eg. JSONArray raw, _ := val.MarshalJSON() _ = json.Unmarshal(raw, &result) default: @@ -138,3 +138,26 @@ func ToUniqueStringSlice(value any) (result []string) { return NonzeroUniques(result) } + +// ToChunks splits list into chunks. +// +// Zero or negative chunkSize argument is normalized to 1. +// +// See https://go.dev/wiki/SliceTricks#batching-with-minimal-allocation. +func ToChunks[T any](list []T, chunkSize int) [][]T { + if chunkSize <= 0 { + chunkSize = 1 + } + + chunks := make([][]T, 0, (len(list)+chunkSize-1)/chunkSize) + + if len(list) == 0 { + return chunks + } + + for chunkSize < len(list) { + list, chunks = list[chunkSize:], append(chunks, list[0:chunkSize:chunkSize]) + } + + return append(chunks, list) +} diff --git a/tools/list/list_test.go b/tools/list/list_test.go index 4270335edf..c1cd9b3ab8 100644 --- a/tools/list/list_test.go +++ b/tools/list/list_test.go @@ -1,7 +1,8 @@ package list_test import ( - "encoding/json" + "encoding/json/v2" + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/list" @@ -42,18 +43,20 @@ func TestSubtractSliceString(t *testing.T) { } for i, s := range scenarios { - result := list.SubtractSlice(s.base, s.subtract) + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result := list.SubtractSlice(s.base, s.subtract) - raw, err := json.Marshal(result) - if err != nil { - t.Fatalf("(%d) Failed to serialize: %v", i, err) - } + raw, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to serialize: %v", err) + } - strResult := string(raw) + strResult := string(raw) - if strResult != s.expected { - t.Fatalf("(%d) Expected %v, got %v", i, s.expected, strResult) - } + if strResult != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, strResult) + } + }) } } @@ -91,18 +94,20 @@ func TestSubtractSliceInt(t *testing.T) { } for i, s := range scenarios { - result := list.SubtractSlice(s.base, s.subtract) + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result := list.SubtractSlice(s.base, s.subtract) - raw, err := json.Marshal(result) - if err != nil { - t.Fatalf("(%d) Failed to serialize: %v", i, err) - } + raw, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to serialize: %v", err) + } - strResult := string(raw) + strResult := string(raw) - if strResult != s.expected { - t.Fatalf("(%d) Expected %v, got %v", i, s.expected, strResult) - } + if strResult != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, strResult) + } + }) } } @@ -120,15 +125,13 @@ func TestExistInSliceString(t *testing.T) { {"test", []string{"1", "2", "test"}, true}, } - for i, scenario := range scenarios { - result := list.ExistInSlice(scenario.item, scenario.list) - if result != scenario.expected { - if scenario.expected { - t.Errorf("(%d) Expected to exist in the list", i) - } else { - t.Errorf("(%d) Expected NOT to exist in the list", i) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.item), func(t *testing.T) { + result := list.ExistInSlice(s.item, s.list) + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) } - } + }) } } @@ -146,15 +149,13 @@ func TestExistInSliceInt(t *testing.T) { {-1, []int{0, -1, -2, -3, -4}, true}, } - for i, scenario := range scenarios { - result := list.ExistInSlice(scenario.item, scenario.list) - if result != scenario.expected { - if scenario.expected { - t.Errorf("(%d) Expected to exist in the list", i) - } else { - t.Errorf("(%d) Expected NOT to exist in the list", i) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%d", i, s.item), func(t *testing.T) { + result := list.ExistInSlice(s.item, s.list) + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) } - } + }) } } @@ -177,15 +178,13 @@ func TestExistInSliceWithRegex(t *testing.T) { {"!?@test", []string{`^\W+$`, "test"}, false}, } - for i, scenario := range scenarios { - result := list.ExistInSliceWithRegex(scenario.item, scenario.list) - if result != scenario.expected { - if scenario.expected { - t.Errorf("(%d) Expected the string to exist in the list", i) - } else { - t.Errorf("(%d) Expected the string NOT to exist in the list", i) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.item), func(t *testing.T) { + result := list.ExistInSliceWithRegex(s.item, s.list) + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) } - } + }) } } @@ -196,21 +195,23 @@ func TestToInterfaceSlice(t *testing.T) { {[]string{}}, {[]string{""}}, {[]string{"1", "test"}}, - {[]string{"test1", "test2", "test3"}}, + {[]string{"test1", "test1", "test2", "test3"}}, } - for i, scenario := range scenarios { - result := list.ToInterfaceSlice(scenario.items) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.items), func(t *testing.T) { + result := list.ToInterfaceSlice(s.items) - if len(result) != len(scenario.items) { - t.Errorf("(%d) Result list length doesn't match with the original list", i) - } + if len(result) != len(s.items) { + t.Fatalf("Expected length %d, got %d", len(s.items), len(result)) + } - for j, v := range result { - if v != scenario.items[j] { - t.Errorf("(%d:%d) Result list item should match with the original list item", i, j) + for j, v := range result { + if v != s.items[j] { + t.Fatalf("Result list item doesn't match with the original list item, got %v VS %v", v, s.items[j]) + } } - } + }) } } @@ -225,18 +226,20 @@ func TestNonzeroUniquesString(t *testing.T) { {[]string{"test1", "", "test2", "Test2", "test1", "test3"}, []string{"test1", "test2", "Test2", "test3"}}, } - for i, scenario := range scenarios { - result := list.NonzeroUniques(scenario.items) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.items), func(t *testing.T) { + result := list.NonzeroUniques(s.items) - if len(result) != len(scenario.expected) { - t.Errorf("(%d) Result list length doesn't match with the expected list", i) - } + if len(result) != len(s.expected) { + t.Fatalf("Expected length %d, got %d", len(s.expected), len(result)) + } - for j, v := range result { - if v != scenario.expected[j] { - t.Errorf("(%d:%d) Result list item should match with the expected list item", i, j) + for j, v := range result { + if v != s.expected[j] { + t.Fatalf("Result list item doesn't match with the expected list item, got %v VS %v", v, s.expected[j]) + } } - } + }) } } @@ -254,20 +257,54 @@ func TestToUniqueStringSlice(t *testing.T) { {[]any{0, 1, "test", ""}, []string{"0", "1", "test"}}, {[]string{"test1", "test2", "test1"}, []string{"test1", "test2"}}, {`["test1", "test2", "test2"]`, []string{"test1", "test2"}}, - {types.JsonArray[string]{"test1", "test2", "test1"}, []string{"test1", "test2"}}, + {types.JSONArray[string]{"test1", "test2", "test1"}, []string{"test1", "test2"}}, } - for i, scenario := range scenarios { - result := list.ToUniqueStringSlice(scenario.value) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + result := list.ToUniqueStringSlice(s.value) - if len(result) != len(scenario.expected) { - t.Errorf("(%d) Result list length doesn't match with the expected list", i) - } + if len(result) != len(s.expected) { + t.Fatalf("Expected length %d, got %d", len(s.expected), len(result)) + } + + for j, v := range result { + if v != s.expected[j] { + t.Fatalf("Result list item doesn't match with the expected list item, got %v vs %v", v, s.expected[j]) + } + } + }) + } +} + +func TestToChunks(t *testing.T) { + scenarios := []struct { + items []any + chunkSize int + expected string + }{ + {nil, 2, "[]"}, + {[]any{}, 2, "[]"}, + {[]any{1, 2, 3, 4}, -1, "[[1],[2],[3],[4]]"}, + {[]any{1, 2, 3, 4}, 0, "[[1],[2],[3],[4]]"}, + {[]any{1, 2, 3, 4}, 2, "[[1,2],[3,4]]"}, + {[]any{1, 2, 3, 4, 5}, 2, "[[1,2],[3,4],[5]]"}, + {[]any{1, 2, 3, 4, 5}, 10, "[[1,2,3,4,5]]"}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.items), func(t *testing.T) { + result := list.ToChunks(s.items, s.chunkSize) + + raw, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) - for j, v := range result { - if v != scenario.expected[j] { - t.Errorf("(%d:%d) Result list item should match with the expected list item", i, j) + if rawStr != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, rawStr) } - } + }) } } diff --git a/tools/logger/batch_handler.go b/tools/logger/batch_handler.go new file mode 100644 index 0000000000..cf72a4f533 --- /dev/null +++ b/tools/logger/batch_handler.go @@ -0,0 +1,332 @@ +package logger + +import ( + "context" + "encoding/json/v2" + "errors" + "log/slog" + "sync" + + validation "github.com/pocketbase/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/types" +) + +// contextKey is an alias type to prevent collisions with other log context keys. +type contextKey int + +// BlockKey is a context key usually used to indicate that the +// batched logs write should block until writes are completed. +var BlockKey contextKey + +var _ slog.Handler = (*BatchHandler)(nil) + +// BatchOptions are options for the BatchHandler. +type BatchOptions struct { + // WriteFunc processes the batched logs. + WriteFunc func(ctx context.Context, logs []*Log) error + + // BeforeAddFunc is optional function that is invoked every time + // before a new log is added to the batch queue. + // + // Return false to skip adding the log into the batch queue. + BeforeAddFunc func(ctx context.Context, log *Log) bool + + // Level reports the minimum level to log. + // Levels with lower levels are discarded. + // If nil, the Handler uses [slog.LevelInfo]. + Level slog.Leveler + + // BatchSize specifies how many logs to accumulate before calling WriteFunc. + // If not set or 0, fallback to 100 by default. + BatchSize int +} + +// NewBatchHandler creates a slog compatible handler that writes JSON +// logs on batches (default to 100), using the given options. +// +// Panics if [BatchOptions.WriteFunc] is not defined. +// +// Example: +// +// l := slog.New(logger.NewBatchHandler(logger.BatchOptions{ +// WriteFunc: func(ctx context.Context, logs []*Log) error { +// for _, l := range logs { +// fmt.Println(l.Level, l.Message, l.Data) +// } +// return nil +// } +// })) +// l.Info("Example message", "title", "lorem ipsum") +func NewBatchHandler(options BatchOptions) *BatchHandler { + h := &BatchHandler{ + mux: &sync.Mutex{}, + options: &options, + } + + if h.options.WriteFunc == nil { + panic("options.WriteFunc must be set") + } + + if h.options.Level == nil { + h.options.Level = slog.LevelInfo + } + + if h.options.BatchSize == 0 { + h.options.BatchSize = 100 + } + + h.logs = make([]*Log, 0, h.options.BatchSize) + + return h +} + +// BatchHandler is a slog handler that writes records on batches. +// +// The log records attributes are formatted in JSON. +// +// Requires the [BatchOptions.WriteFunc] option to be defined. +type BatchHandler struct { + mux *sync.Mutex + parent *BatchHandler + options *BatchOptions + group string + attrs []slog.Attr + logs []*Log +} + +// Enabled reports whether the handler handles records at the given level. +// +// The handler ignores records whose level is lower. +func (h *BatchHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= h.options.Level.Level() +} + +// WithGroup returns a new BatchHandler that starts a group. +// +// All logger attributes will be resolved under the specified group name. +func (h *BatchHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + + return &BatchHandler{ + parent: h, + mux: h.mux, + options: h.options, + group: name, + } +} + +// WithAttrs returns a new BatchHandler loaded with the specified attributes. +func (h *BatchHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + + return &BatchHandler{ + parent: h, + mux: h.mux, + options: h.options, + attrs: attrs, + } +} + +// Handle formats the slog.Record argument as JSON object and adds it +// to the batch queue. +// +// If the batch queue threshold has been reached, the WriteFunc option +// is invoked with the accumulated logs which in turn will reset the batch queue. +func (h *BatchHandler) Handle(ctx context.Context, r slog.Record) error { + if h.group != "" { + h.mux.Lock() + attrs := make([]any, 0, len(h.attrs)+r.NumAttrs()) + for _, a := range h.attrs { + attrs = append(attrs, a) + } + h.mux.Unlock() + + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, a) + return true + }) + + r = slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + r.AddAttrs(slog.Group(h.group, attrs...)) + } else if len(h.attrs) > 0 { + r = r.Clone() + + h.mux.Lock() + r.AddAttrs(h.attrs...) + h.mux.Unlock() + } + + if h.parent != nil { + return h.parent.Handle(ctx, r) + } + + data := make(map[string]any, r.NumAttrs()) + + r.Attrs(func(a slog.Attr) bool { + if err := h.resolveAttr(data, a); err != nil { + return false + } + return true + }) + + log := &Log{ + Time: r.Time, + Level: r.Level, + Message: r.Message, + Data: types.JSONMap[any](data), + } + + if h.options.BeforeAddFunc != nil && !h.options.BeforeAddFunc(ctx, log) { + return nil + } + + h.mux.Lock() + h.logs = append(h.logs, log) + totalLogs := len(h.logs) + h.mux.Unlock() + + if totalLogs >= h.options.BatchSize { + if err := h.WriteAll(ctx); err != nil { + return err + } + } + + return nil +} + +// SetLevel updates the handler options level to the specified one. +func (h *BatchHandler) SetLevel(level slog.Level) { + h.mux.Lock() + h.options.Level = level + h.mux.Unlock() +} + +// WriteAll writes all accumulated Log entries and resets the batch queue. +func (h *BatchHandler) WriteAll(ctx context.Context) error { + if h.parent != nil { + // invoke recursively the parent level handler since the most + // top level one is holding the logs queue. + return h.parent.WriteAll(ctx) + } + + h.mux.Lock() + + totalLogs := len(h.logs) + + // no logs to write + if totalLogs == 0 { + h.mux.Unlock() + return nil + } + + // create a copy of the logs slice to prevent blocking during write + logs := make([]*Log, totalLogs) + copy(logs, h.logs) + h.logs = h.logs[:0] // reset + + h.mux.Unlock() + + return h.options.WriteFunc(ctx, logs) +} + +// resolveAttr writes attr into data. +func (h *BatchHandler) resolveAttr(data map[string]any, attr slog.Attr) error { + // ensure that the attr value is resolved before doing anything else + attr.Value = attr.Value.Resolve() + + if attr.Equal(slog.Attr{}) { + return nil // ignore empty attrs + } + + switch attr.Value.Kind() { + case slog.KindGroup: + attrs := attr.Value.Group() + if len(attrs) == 0 { + return nil // ignore empty groups + } + + // create a submap to wrap the resolved group attributes + groupData := make(map[string]any, len(attrs)) + + for _, subAttr := range attrs { + h.resolveAttr(groupData, subAttr) + } + + if len(groupData) > 0 { + data[attr.Key] = groupData + } + default: + data[attr.Key] = normalizeLogAttrValue(attr.Value.Any()) + } + + return nil +} + +func normalizeLogAttrValue(rawAttrValue any) any { + switch attrV := rawAttrValue.(type) { + case validation.Errors: + out := make(map[string]any, len(attrV)) + for k, v := range attrV { + out[k] = serializeLogError(v) + } + return out + case map[string]validation.Error: + out := make(map[string]any, len(attrV)) + for k, v := range attrV { + out[k] = serializeLogError(v) + } + return out + case map[string]error: + out := make(map[string]any, len(attrV)) + for k, v := range attrV { + out[k] = serializeLogError(v) + } + return out + case map[string]any: + out := make(map[string]any, len(attrV)) + for k, v := range attrV { + switch vv := v.(type) { + case error: + out[k] = serializeLogError(vv) + default: + out[k] = normalizeLogAttrValue(vv) + } + } + return out + case error: + // check for wrapped validation.Errors + var ve validation.Errors + if errors.As(attrV, &ve) { + out := make(map[string]any, len(ve)) + for k, v := range ve { + out[k] = serializeLogError(v) + } + return map[string]any{ + "data": out, + "raw": serializeLogError(attrV), + } + } + return serializeLogError(attrV) + default: + return attrV + } +} + +func serializeLogError(err error) any { + if err == nil { + return nil + } + + // prioritize a json structured format (e.g. validation.Errors) + jsonErr, ok := err.(json.Marshaler) + if ok { + return jsonErr + } + + // fallback to its original string representation + return err.Error() +} diff --git a/tools/logger/batch_handler_test.go b/tools/logger/batch_handler_test.go new file mode 100644 index 0000000000..4248d03af1 --- /dev/null +++ b/tools/logger/batch_handler_test.go @@ -0,0 +1,355 @@ +package logger + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + validation "github.com/pocketbase/ozzo-validation/v4" +) + +func TestNewBatchHandlerPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected to panic.") + } + }() + + NewBatchHandler(BatchOptions{}) +} + +func TestNewBatchHandlerDefaults(t *testing.T) { + h := NewBatchHandler(BatchOptions{ + WriteFunc: func(ctx context.Context, logs []*Log) error { + return nil + }, + }) + + if h.options.BatchSize != 100 { + t.Fatalf("Expected default BatchSize %d, got %d", 100, h.options.BatchSize) + } + + if h.options.Level != slog.LevelInfo { + t.Fatalf("Expected default Level Info, got %v", h.options.Level) + } + + if h.options.BeforeAddFunc != nil { + t.Fatal("Expected default BeforeAddFunc to be nil") + } + + if h.options.WriteFunc == nil { + t.Fatal("Expected default WriteFunc to be set") + } + + if h.group != "" { + t.Fatalf("Expected empty group, got %s", h.group) + } + + if len(h.attrs) != 0 { + t.Fatalf("Expected empty attrs, got %v", h.attrs) + } + + if len(h.logs) != 0 { + t.Fatalf("Expected empty logs queue, got %v", h.logs) + } +} + +func TestBatchHandlerEnabled(t *testing.T) { + h := NewBatchHandler(BatchOptions{ + Level: slog.LevelWarn, + WriteFunc: func(ctx context.Context, logs []*Log) error { + return nil + }, + }) + + l := slog.New(h) + + scenarios := []struct { + level slog.Level + expected bool + }{ + {slog.LevelDebug, false}, + {slog.LevelInfo, false}, + {slog.LevelWarn, true}, + {slog.LevelError, true}, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("Level %v", s.level), func(t *testing.T) { + result := l.Enabled(context.Background(), s.level) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestBatchHandlerSetLevel(t *testing.T) { + h := NewBatchHandler(BatchOptions{ + Level: slog.LevelWarn, + WriteFunc: func(ctx context.Context, logs []*Log) error { + return nil + }, + }) + + if h.options.Level != slog.LevelWarn { + t.Fatalf("Expected the initial level to be %d, got %d", slog.LevelWarn, h.options.Level) + } + + h.SetLevel(slog.LevelDebug) + + if h.options.Level != slog.LevelDebug { + t.Fatalf("Expected the updated level to be %d, got %d", slog.LevelDebug, h.options.Level) + } +} + +func TestBatchHandlerWithAttrsAndWithGroup(t *testing.T) { + h0 := NewBatchHandler(BatchOptions{ + WriteFunc: func(ctx context.Context, logs []*Log) error { + return nil + }, + }) + + h1 := h0.WithAttrs([]slog.Attr{slog.Int("test1", 1)}).(*BatchHandler) + h2 := h1.WithGroup("h2_group").(*BatchHandler) + h3 := h2.WithAttrs([]slog.Attr{slog.Int("test2", 2)}).(*BatchHandler) + + scenarios := []struct { + name string + handler *BatchHandler + expectedParent *BatchHandler + expectedGroup string + expectedAttrs int + }{ + { + "h0", + h0, + nil, + "", + 0, + }, + { + "h1", + h1, + h0, + "", + 1, + }, + { + "h2", + h2, + h1, + "h2_group", + 0, + }, + { + "h3", + h3, + h2, + "", + 1, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + if s.handler.group != s.expectedGroup { + t.Fatalf("Expected group %q, got %q", s.expectedGroup, s.handler.group) + } + + if s.handler.parent != s.expectedParent { + t.Fatalf("Expected parent %v, got %v", s.expectedParent, s.handler.parent) + } + + if totalAttrs := len(s.handler.attrs); totalAttrs != s.expectedAttrs { + t.Fatalf("Expected %d attrs, got %d", s.expectedAttrs, totalAttrs) + } + }) + } +} + +func TestBatchHandlerHandle(t *testing.T) { + ctx := context.Background() + + beforeLogs := []*Log{} + writeLogs := []*Log{} + + h := NewBatchHandler(BatchOptions{ + BatchSize: 3, + BeforeAddFunc: func(_ context.Context, log *Log) bool { + beforeLogs = append(beforeLogs, log) + + // skip test2 log + return log.Message != "test2" + }, + WriteFunc: func(_ context.Context, logs []*Log) error { + writeLogs = logs + return nil + }, + }) + + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test1", 0)) + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test2", 0)) + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test3", 0)) + + // no batch write + { + checkLogMessages([]string{"test1", "test2", "test3"}, beforeLogs, t) + + checkLogMessages([]string{"test1", "test3"}, h.logs, t) + + // should be empty because no batch write has happened yet + if totalWriteLogs := len(writeLogs); totalWriteLogs != 0 { + t.Fatalf("Expected %d writeLogs, got %d", 0, totalWriteLogs) + } + } + + // add one more log to trigger the batch write + { + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test4", 0)) + + // should be empty after the batch write + checkLogMessages([]string{}, h.logs, t) + + checkLogMessages([]string{"test1", "test3", "test4"}, writeLogs, t) + } +} + +func TestBatchHandlerWriteAll(t *testing.T) { + ctx := context.Background() + + beforeLogs := []*Log{} + writeLogs := []*Log{} + + h := NewBatchHandler(BatchOptions{ + BatchSize: 3, + BeforeAddFunc: func(_ context.Context, log *Log) bool { + beforeLogs = append(beforeLogs, log) + return true + }, + WriteFunc: func(_ context.Context, logs []*Log) error { + writeLogs = logs + return nil + }, + }) + + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test1", 0)) + h.Handle(ctx, slog.NewRecord(time.Now(), slog.LevelInfo, "test2", 0)) + + checkLogMessages([]string{"test1", "test2"}, beforeLogs, t) + checkLogMessages([]string{"test1", "test2"}, h.logs, t) + checkLogMessages([]string{}, writeLogs, t) // empty because the batch size hasn't been reached + + // force trigger the batch write + h.WriteAll(ctx) + + checkLogMessages([]string{"test1", "test2"}, beforeLogs, t) + checkLogMessages([]string{}, h.logs, t) // reset + checkLogMessages([]string{"test1", "test2"}, writeLogs, t) +} + +func TestBatchHandlerAttrsFormat(t *testing.T) { + ctx := context.Background() + + beforeLogs := []*Log{} + + h0 := NewBatchHandler(BatchOptions{ + BeforeAddFunc: func(_ context.Context, log *Log) bool { + beforeLogs = append(beforeLogs, log) + return true + }, + WriteFunc: func(_ context.Context, logs []*Log) error { + return nil + }, + }) + + h1 := h0.WithAttrs([]slog.Attr{slog.Int("a", 1), slog.String("b", "123")}) + + h2 := h1.WithGroup("sub").WithAttrs([]slog.Attr{ + slog.Int("c", 3), + slog.Any("d", map[string]any{"d.1": 1}), + slog.Any("e", errors.New("example error")), + }) + + record := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0) + record.AddAttrs(slog.String("name", "test")) + + h0.Handle(ctx, record) + h1.Handle(ctx, record) + h2.Handle(ctx, record) + + // errors serialization checks + errorsRecord := slog.NewRecord(time.Now(), slog.LevelError, "details", 0) + errorsRecord.Add("validation.Errors", validation.Errors{ + "a": validation.NewError("validation_code", "validation_message"), + "b": errors.New("plain"), + }) + errorsRecord.Add("wrapped_validation.Errors", fmt.Errorf("wrapped: %w", validation.Errors{ + "a": validation.NewError("validation_code", "validation_message"), + "b": errors.New("plain"), + })) + errorsRecord.Add("map[string]any", map[string]any{ + "a": validation.NewError("validation_code", "validation_message"), + "b": errors.New("plain"), + "c": "test_any", + "d": map[string]any{ + "nestedA": validation.NewError("nested_code", "nested_message"), + "nestedB": errors.New("nested_plain"), + }, + }) + errorsRecord.Add("map[string]error", map[string]error{ + "a": validation.NewError("validation_code", "validation_message"), + "b": errors.New("plain"), + }) + errorsRecord.Add("map[string]validation.Error", map[string]validation.Error{ + "a": validation.NewError("validation_code", "validation_message"), + "b": nil, + }) + errorsRecord.Add("plain_error", errors.New("plain")) + h0.Handle(ctx, errorsRecord) + + expected := []string{ + `{"name":"test"}`, + `{"a":1,"b":"123","name":"test"}`, + `{"a":1,"b":"123","sub":{"c":3,"d":{"d.1":1},"e":"example error","name":"test"}}`, + `{"map[string]any":{"a":"validation_message","b":"plain","c":"test_any","d":{"nestedA":"nested_message","nestedB":"nested_plain"}},"map[string]error":{"a":"validation_message","b":"plain"},"map[string]validation.Error":{"a":"validation_message","b":null},"plain_error":"plain","validation.Errors":{"a":"validation_message","b":"plain"},"wrapped_validation.Errors":{"data":{"a":"validation_message","b":"plain"},"raw":"wrapped: a: validation_message; b: plain."}}`, + } + + if len(beforeLogs) != len(expected) { + t.Fatalf("Expected %d logs, got %d", len(expected), len(beforeLogs)) + } + + for i, data := range expected { + t.Run(fmt.Sprintf("log handler %d", i), func(t *testing.T) { + log := beforeLogs[i] + raw, _ := json.Marshal(log.Data, json.Deterministic(true)) + if string(raw) != data { + t.Fatalf("Expected \n%s \ngot \n%s", data, raw) + } + }) + } +} + +func checkLogMessages(expected []string, logs []*Log, t *testing.T) { + if len(logs) != len(expected) { + t.Fatalf("Expected %d batched logs, got %d (expected: %v)", len(expected), len(logs), expected) + } + + for _, message := range expected { + exists := false + for _, l := range logs { + if l.Message == message { + exists = true + continue + } + } + if !exists { + t.Fatalf("Missing %q log message", message) + } + } +} diff --git a/tools/logger/log.go b/tools/logger/log.go new file mode 100644 index 0000000000..e017ff1858 --- /dev/null +++ b/tools/logger/log.go @@ -0,0 +1,17 @@ +package logger + +import ( + "log/slog" + "time" + + "github.com/pocketbase/pocketbase/tools/types" +) + +// Log is similar to [slog.Record] bit contains the log attributes as +// preformatted JSON map. +type Log struct { + Time time.Time + Data types.JSONMap[any] + Message string + Level slog.Level +} diff --git a/tools/mailer/html2text.go b/tools/mailer/html2text.go index f045654ac7..1f1f02fadd 100644 --- a/tools/mailer/html2text.go +++ b/tools/mailer/html2text.go @@ -10,6 +10,16 @@ import ( var whitespaceRegex = regexp.MustCompile(`\s+`) +var tagsToSkip = []string{ + "style", "script", "iframe", "applet", "object", "svg", "img", + "button", "form", "textarea", "input", "select", "option", "template", +} + +var inlineTags = []string{ + "a", "span", "small", "strike", "strong", + "sub", "sup", "em", "b", "u", "i", +} + // Very rudimentary auto HTML to Text mail body converter. // // Caveats: @@ -20,32 +30,24 @@ var whitespaceRegex = regexp.MustCompile(`\s+`) // - Trailing spaces are preserved. // - Multiple consequence newlines are collapsed as one unless multiple
tags are used. func html2Text(htmlDocument string) (string, error) { - var builder strings.Builder - doc, err := html.Parse(strings.NewReader(htmlDocument)) if err != nil { return "", err } - tagsToSkip := []string{ - "style", "script", "iframe", "applet", "object", "svg", "img", - "button", "form", "textarea", "input", "select", "option", "template", - } - - inlineTags := []string{ - "a", "span", "small", "strike", "strong", - "sub", "sup", "em", "b", "u", "i", - } - + var builder strings.Builder var canAddNewLine bool // see https://pkg.go.dev/golang.org/x/net/html#Parse - var f func(*html.Node) - f = func(n *html.Node) { - // start link wrapping for producing "[text](link)" formatted string + var f func(*html.Node, *strings.Builder) + f = func(n *html.Node, activeBuilder *strings.Builder) { isLink := n.Type == html.ElementNode && n.Data == "a" + if isLink { - builder.WriteString("[") + var linkBuilder strings.Builder + activeBuilder = &linkBuilder + } else if activeBuilder == nil { + activeBuilder = &builder } switch n.Type { @@ -58,34 +60,43 @@ func html2Text(htmlDocument string) (string, error) { } if txt != "" { - builder.WriteString(txt) + activeBuilder.WriteString(txt) canAddNewLine = true } case html.ElementNode: if n.Data == "br" { // always write new lines when
tag is used - builder.WriteString("\r\n") + activeBuilder.WriteString("\r\n") canAddNewLine = false } else if canAddNewLine && !list.ExistInSlice(n.Data, inlineTags) { - builder.WriteString("\r\n") + activeBuilder.WriteString("\r\n") canAddNewLine = false } // prefix list items with dash if n.Data == "li" { - builder.WriteString("- ") + activeBuilder.WriteString("- ") } } for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type != html.ElementNode || !list.ExistInSlice(c.Data, tagsToSkip) { - f(c) + f(c, activeBuilder) } } - // end link wrapping + // format links as [label](href) if isLink { + linkTxt := strings.TrimSpace(activeBuilder.String()) + if linkTxt == "" { + linkTxt = "LINK" + } + + builder.WriteString("[") + builder.WriteString(linkTxt) builder.WriteString("]") + + // link href attr extraction for _, a := range n.Attr { if a.Key == "href" { if a.Val != "" { @@ -96,10 +107,12 @@ func html2Text(htmlDocument string) (string, error) { break } } + + activeBuilder.Reset() } } - f(doc) + f(doc, &builder) return strings.TrimSpace(builder.String()), nil } diff --git a/tools/mailer/html2text_test.go b/tools/mailer/html2text_test.go index eb8f4d4428..d6304d23b2 100644 --- a/tools/mailer/html2text_test.go +++ b/tools/mailer/html2text_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestHtml2Text(t *testing.T) { +func TestHTML2Text(t *testing.T) { scenarios := []struct { html string expected string diff --git a/tools/mailer/mailer.go b/tools/mailer/mailer.go index b64da89868..c87e2e8dfc 100644 --- a/tools/mailer/mailer.go +++ b/tools/mailer/mailer.go @@ -1,21 +1,26 @@ package mailer import ( + "bytes" "io" "net/mail" + + "github.com/gabriel-vasile/mimetype" + "github.com/pocketbase/pocketbase/tools/hook" ) // Message defines a generic email message struct. type Message struct { - From mail.Address `json:"from"` - To []mail.Address `json:"to"` - Bcc []mail.Address `json:"bcc"` - Cc []mail.Address `json:"cc"` - Subject string `json:"subject"` - HTML string `json:"html"` - Text string `json:"text"` - Headers map[string]string `json:"headers"` - Attachments map[string]io.Reader `json:"attachments"` + From mail.Address `json:"from"` + To []mail.Address `json:"to"` + Bcc []mail.Address `json:"bcc"` + Cc []mail.Address `json:"cc"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` + Headers map[string]string `json:"headers"` + Attachments map[string]io.Reader `json:"attachments"` + InlineAttachments map[string]io.Reader `json:"inlineAttachments"` } // Mailer defines a base mail client interface. @@ -24,6 +29,16 @@ type Mailer interface { Send(message *Message) error } +// SendInterceptor is optional interface for registering mail send hooks. +type SendInterceptor interface { + OnSend() *hook.Hook[*SendEvent] +} + +type SendEvent struct { + hook.Event + Message *Message +} + // addressesToStrings converts the provided address to a list of serialized RFC 5322 strings. // // To export only the email part of mail.Address, you can set withName to false. @@ -41,3 +56,17 @@ func addressesToStrings(addresses []mail.Address, withName bool) []string { return result } + +// detectReaderMimeType reads the first couple bytes of the reader to detect its MIME type. +// +// Returns a new combined reader from the partial read + the remaining of the original reader. +func detectReaderMimeType(r io.Reader) (io.Reader, string, error) { + readCopy := new(bytes.Buffer) + + mime, err := mimetype.DetectReader(io.TeeReader(r, readCopy)) + if err != nil { + return nil, "", err + } + + return io.MultiReader(readCopy, r), mime.String(), nil +} diff --git a/tools/mailer/mailer_test.go b/tools/mailer/mailer_test.go new file mode 100644 index 0000000000..571f9ead02 --- /dev/null +++ b/tools/mailer/mailer_test.go @@ -0,0 +1,77 @@ +package mailer + +import ( + "fmt" + "io" + "net/mail" + "strings" + "testing" +) + +func TestAddressesToStrings(t *testing.T) { + t.Parallel() + + scenarios := []struct { + withName bool + addresses []mail.Address + expected []string + }{ + { + true, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}}, + []string{`"John Doe" `, `"Jane Doe" `}, + }, + { + true, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Address: "test2@example.com"}}, + []string{`"John Doe" `, `test2@example.com`}, + }, + { + false, + []mail.Address{{Name: "John Doe", Address: "test1@example.com"}, {Name: "Jane Doe", Address: "test2@example.com"}}, + []string{`test1@example.com`, `test2@example.com`}, + }, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%v_%v", s.withName, s.addresses), func(t *testing.T) { + result := addressesToStrings(s.addresses, s.withName) + + if len(s.expected) != len(result) { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result) + } + + for k, v := range s.expected { + if v != result[k] { + t.Fatalf("Expected %d address %q, got %q", k, v, result[k]) + } + } + }) + } +} + +func TestDetectReaderMimeType(t *testing.T) { + t.Parallel() + + str := "#!/bin/node\n" + strings.Repeat("a", 10000) // ensure that it is large enough to remain after the signature sniffing + + r, mime, err := detectReaderMimeType(strings.NewReader(str)) + if err != nil { + t.Fatal(err) + } + + expectedMime := "text/javascript" + if mime != expectedMime { + t.Fatalf("Expected mime %q, got %q", expectedMime, mime) + } + + raw, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + if rawStr != str { + t.Fatalf("Expected content\n%s\ngot\n%s", str, rawStr) + } +} diff --git a/tools/mailer/sendmail.go b/tools/mailer/sendmail.go index 6a40523c79..035233ab55 100644 --- a/tools/mailer/sendmail.go +++ b/tools/mailer/sendmail.go @@ -7,6 +7,8 @@ import ( "net/http" "os/exec" "strings" + + "github.com/pocketbase/pocketbase/tools/hook" ) var _ Mailer = (*Sendmail)(nil) @@ -16,10 +18,29 @@ var _ Mailer = (*Sendmail)(nil) // // This client is usually recommended only for development and testing. type Sendmail struct { + onSend *hook.Hook[*SendEvent] +} + +// OnSend implements [mailer.SendInterceptor] interface. +func (c *Sendmail) OnSend() *hook.Hook[*SendEvent] { + if c.onSend == nil { + c.onSend = &hook.Hook[*SendEvent]{} + } + return c.onSend } -// Send implements `mailer.Mailer` interface. +// Send implements [mailer.Mailer] interface. func (c *Sendmail) Send(m *Message) error { + if c.onSend != nil { + return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error { + return c.send(e.Message) + }) + } + + return c.send(m) +} + +func (c *Sendmail) send(m *Message) error { toAddresses := addressesToStrings(m.To, false) headers := make(http.Header) @@ -28,6 +49,14 @@ func (c *Sendmail) Send(m *Message) error { headers.Set("Content-Type", "text/html; charset=UTF-8") headers.Set("To", strings.Join(toAddresses, ",")) + if len(m.Cc) > 0 { + headers.Set("Cc", strings.Join(addressesToStrings(m.Cc, false), ",")) + } + + if len(m.Bcc) > 0 { + headers.Set("Bcc", strings.Join(addressesToStrings(m.Bcc, false), ",")) + } + cmdPath, err := findSendmailPath() if err != nil { return err @@ -54,7 +83,7 @@ func (c *Sendmail) Send(m *Message) error { } // --- - sendmail := exec.Command(cmdPath, strings.Join(toAddresses, ",")) + sendmail := exec.Command(cmdPath, "-i", "-t") sendmail.Stdin = &buffer return sendmail.Run() @@ -74,5 +103,5 @@ func findSendmailPath() (string, error) { } } - return "", errors.New("Failed to locate a sendmail executable path.") + return "", errors.New("failed to locate a sendmail executable path") } diff --git a/tools/mailer/smtp.go b/tools/mailer/smtp.go index 8c97b4aa68..56e10448a7 100644 --- a/tools/mailer/smtp.go +++ b/tools/mailer/smtp.go @@ -3,47 +3,33 @@ package mailer import ( "errors" "fmt" + "net" "net/smtp" + "strconv" "strings" "github.com/domodwyer/mailyak/v3" + "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/security" ) -var _ Mailer = (*SmtpClient)(nil) +var _ Mailer = (*SMTPClient)(nil) const ( - SmtpAuthPlain = "PLAIN" - SmtpAuthLogin = "LOGIN" + SMTPAuthPlain = "PLAIN" + SMTPAuthLogin = "LOGIN" ) -// Deprecated: Use directly the SmtpClient struct literal. -// -// NewSmtpClient creates new SmtpClient with the provided configuration. -func NewSmtpClient( - host string, - port int, - username string, - password string, - tls bool, -) *SmtpClient { - return &SmtpClient{ - Host: host, - Port: port, - Username: username, - Password: password, - Tls: tls, - } -} - -// SmtpClient defines a SMTP mail client structure that implements +// SMTPClient defines a SMTP mail client structure that implements // `mailer.Mailer` interface. -type SmtpClient struct { - Host string +type SMTPClient struct { + onSend *hook.Hook[*SendEvent] + + TLS bool Port int + Host string Username string Password string - Tls bool // SMTP auth method to use // (if not explicitly set, defaults to "PLAIN") @@ -56,28 +42,48 @@ type SmtpClient struct { LocalName string } -// Send implements `mailer.Mailer` interface. -func (c *SmtpClient) Send(m *Message) error { +// OnSend implements [mailer.SendInterceptor] interface. +func (c *SMTPClient) OnSend() *hook.Hook[*SendEvent] { + if c.onSend == nil { + c.onSend = &hook.Hook[*SendEvent]{} + } + return c.onSend +} + +// Send implements [mailer.Mailer] interface. +func (c *SMTPClient) Send(m *Message) error { + if c.onSend != nil { + return c.onSend.Trigger(&SendEvent{Message: m}, func(e *SendEvent) error { + return c.send(e.Message) + }) + } + + return c.send(m) +} + +func (c *SMTPClient) send(m *Message) error { var smtpAuth smtp.Auth if c.Username != "" || c.Password != "" { switch c.AuthMethod { - case SmtpAuthLogin: + case SMTPAuthLogin: smtpAuth = &smtpLoginAuth{c.Username, c.Password} default: smtpAuth = smtp.PlainAuth("", c.Username, c.Password, c.Host) } } + hostWithPort := net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) + // create mail instance var yak *mailyak.MailYak - if c.Tls { + if c.TLS { var tlsErr error - yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth, nil) + yak, tlsErr = mailyak.NewWithTLS(hostWithPort, smtpAuth, nil) if tlsErr != nil { return tlsErr } } else { - yak = mailyak.New(fmt.Sprintf("%s:%d", c.Host, c.Port), smtpAuth) + yak = mailyak.New(hostWithPort, smtpAuth) } if c.LocalName != "" { @@ -112,21 +118,35 @@ func (c *SmtpClient) Send(m *Message) error { yak.Cc(addressesToStrings(m.Cc, true)...) } - // add attachements (if any) + // add regular attachements (if any) for name, data := range m.Attachments { - yak.Attach(name, data) + r, mime, err := detectReaderMimeType(data) + if err != nil { + return err + } + yak.AttachWithMimeType(name, r, mime) + } + + // add inline attachments (if any) + for name, data := range m.InlineAttachments { + r, mime, err := detectReaderMimeType(data) + if err != nil { + return err + } + yak.AttachInlineWithMimeType(name, r, mime) } // add custom headers (if any) - var hasMessageId bool + var hasMessageIdHeader bool for k, v := range m.Headers { - if strings.EqualFold(k, "Message-ID") { - hasMessageId = true + if !hasMessageIdHeader && strings.EqualFold(k, "Message-ID") { + hasMessageIdHeader = true } yak.AddHeader(k, v) } - if !hasMessageId { - // add a default message id if missing + + // add a default message id if missing + if !hasMessageIdHeader { fromParts := strings.Split(m.From.Address, "@") if len(fromParts) == 2 { yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>", diff --git a/tools/mailer/smtp_test.go b/tools/mailer/smtp_test.go index 072ae14df9..49797d5bed 100644 --- a/tools/mailer/smtp_test.go +++ b/tools/mailer/smtp_test.go @@ -56,24 +56,26 @@ func TestLoginAuthStart(t *testing.T) { } for _, s := range scenarios { - method, resp, err := auth.Start(s.serverInfo) - - hasErr := err != nil - if hasErr != s.expectError { - t.Fatalf("[%s] Expected hasErr %v, got %v", s.name, s.expectError, hasErr) - } - - if hasErr { - continue - } - - if len(resp) != 0 { - t.Fatalf("[%s] Expected empty data response, got %v", s.name, resp) - } - - if method != "LOGIN" { - t.Fatalf("[%s] Expected LOGIN, got %v", s.name, method) - } + t.Run(s.name, func(t *testing.T) { + method, resp, err := auth.Start(s.serverInfo) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + + if hasErr { + return + } + + if len(resp) != 0 { + t.Fatalf("Expected empty data response, got %v", resp) + } + + if method != "LOGIN" { + t.Fatalf("Expected LOGIN, got %v", method) + } + }) } } diff --git a/tools/migrate/list.go b/tools/migrate/list.go deleted file mode 100644 index 65d249920c..0000000000 --- a/tools/migrate/list.go +++ /dev/null @@ -1,59 +0,0 @@ -package migrate - -import ( - "path/filepath" - "runtime" - "sort" - - "github.com/pocketbase/dbx" -) - -type Migration struct { - File string - Up func(db dbx.Builder) error - Down func(db dbx.Builder) 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 -} - -// 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(db dbx.Builder) error, - down func(db dbx.Builder) 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.Slice(l.list, func(i int, j int) bool { - return l.list[i].File < l.list[j].File - }) -} diff --git a/tools/migrate/list_test.go b/tools/migrate/list_test.go deleted file mode 100644 index 5030e882db..0000000000 --- a/tools/migrate/list_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package migrate - -import ( - "testing" -) - -func TestMigrationsList(t *testing.T) { - l := MigrationsList{} - - l.Register(nil, nil, "3_test.go") - l.Register(nil, nil, "1_test.go") - l.Register(nil, nil, "2_test.go") - l.Register(nil, nil /* auto detect file name */) - - expected := []string{ - "1_test.go", - "2_test.go", - "3_test.go", - "list_test.go", - } - - items := l.Items() - if len(items) != len(expected) { - t.Fatalf("Expected %d items, got %d: \n%#v", len(expected), len(items), items) - } - - for i, name := range expected { - item := l.Item(i) - if item.File != name { - t.Fatalf("Expected name %s for index %d, got %s", name, i, item.File) - } - } -} diff --git a/tools/migrate/runner.go b/tools/migrate/runner.go deleted file mode 100644 index 0e23c1911a..0000000000 --- a/tools/migrate/runner.go +++ /dev/null @@ -1,275 +0,0 @@ -package migrate - -import ( - "fmt" - "strings" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/fatih/color" - "github.com/pocketbase/dbx" - "github.com/spf13/cast" -) - -const DefaultMigrationsTable = "_migrations" - -// Runner defines a simple struct for managing the execution of db migrations. -type Runner struct { - db *dbx.DB - migrationsList MigrationsList - tableName string -} - -// NewRunner creates and initializes a new db migrations Runner instance. -func NewRunner(db *dbx.DB, migrationsList MigrationsList) (*Runner, error) { - runner := &Runner{ - db: db, - migrationsList: migrationsList, - tableName: DefaultMigrationsTable, - } - - if err := runner.createMigrationsTable(); err != nil { - return nil, err - } - - return runner, nil -} - -// 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 applied migrations -func (r *Runner) Run(args ...string) error { - 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 := false - prompt := &survey.Confirm{ - Message: fmt.Sprintf( - "\n%v\nDo you really want to revert the last %d applied migration(s)?", - strings.Join(names, "\n"), - toRevertCount, - ), - } - survey.AskOne(prompt, &confirm) - 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\n", cmd) - } -} - -// Up executes all unapplied migrations for the provided runner. -// -// On success returns list with the applied migrations file names. -func (r *Runner) Up() ([]string, error) { - applied := []string{} - - err := r.db.Transactional(func(tx *dbx.Tx) error { - for _, m := range r.migrationsList.Items() { - // skip applied - if r.isMigrationApplied(tx, m.File) { - continue - } - - // ignore empty Up action - if m.Up != nil { - if err := m.Up(tx); err != nil { - return fmt.Errorf("Failed to apply migration %s: %w", m.File, err) - } - } - - if err := r.saveAppliedMigration(tx, 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 *Runner) Down(toRevertCount int) ([]string, error) { - reverted := make([]string, 0, toRevertCount) - - names, appliedErr := r.lastAppliedMigrations(toRevertCount) - if appliedErr != nil { - return nil, appliedErr - } - - err := r.db.Transactional(func(tx *dbx.Tx) 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(tx); err != nil { - return fmt.Errorf("Failed to revert migration %s: %w", m.File, err) - } - } - - if err := r.saveRevertedMigration(tx, 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 -} - -func (r *Runner) createMigrationsTable() error { - rawQuery := fmt.Sprintf( - "CREATE TABLE IF NOT EXISTS %v (file VARCHAR(255) PRIMARY KEY NOT NULL, applied INTEGER NOT NULL)", - r.db.QuoteTableName(r.tableName), - ) - - _, err := r.db.NewQuery(rawQuery).Execute() - - return err -} - -func (r *Runner) isMigrationApplied(tx dbx.Builder, file string) bool { - var exists bool - - err := tx.Select("count(*)"). - From(r.tableName). - Where(dbx.HashExp{"file": file}). - Limit(1). - Row(&exists) - - return err == nil && exists -} - -func (r *Runner) saveAppliedMigration(tx dbx.Builder, file string) error { - _, err := tx.Insert(r.tableName, dbx.Params{ - "file": file, - "applied": time.Now().UnixMicro(), - }).Execute() - - return err -} - -func (r *Runner) saveRevertedMigration(tx dbx.Builder, file string) error { - _, err := tx.Delete(r.tableName, dbx.HashExp{"file": file}).Execute() - - return err -} - -func (r *Runner) lastAppliedMigrations(limit int) ([]string, error) { - var files = make([]string, 0, limit) - - err := r.db.Select("file"). - From(r.tableName). - Where(dbx.Not(dbx.HashExp{"applied": nil})). - // 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 -} - -func (r *Runner) removeMissingAppliedMigrations() error { - loadedMigrations := r.migrationsList.Items() - - names := make([]any, len(loadedMigrations)) - for i, migration := range loadedMigrations { - names[i] = migration.File - } - - _, err := r.db.Delete(r.tableName, dbx.Not(dbx.HashExp{ - "file": names, - })).Execute() - - return err -} diff --git a/tools/migrate/runner_test.go b/tools/migrate/runner_test.go deleted file mode 100644 index 935199f670..0000000000 --- a/tools/migrate/runner_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package migrate - -import ( - "context" - "database/sql" - "encoding/json" - "testing" - "time" - - "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/tools/list" - _ "modernc.org/sqlite" -) - -func TestNewRunner(t *testing.T) { - testDB, err := createTestDB() - if err != nil { - t.Fatal(err) - } - defer testDB.Close() - - l := MigrationsList{} - l.Register(nil, nil, "1_test.go") - l.Register(nil, nil, "2_test.go") - l.Register(nil, nil, "3_test.go") - - r, err := NewRunner(testDB.DB, l) - if err != nil { - t.Fatal(err) - } - - if len(r.migrationsList.Items()) != len(l.Items()) { - t.Fatalf("Expected the same migrations list to be assigned, got \n%#v", r.migrationsList) - } - - expectedQueries := []string{ - "CREATE TABLE IF NOT EXISTS `_migrations` (file VARCHAR(255) PRIMARY KEY NOT NULL, applied INTEGER NOT NULL)", - } - if len(expectedQueries) != len(testDB.CalledQueries) { - t.Fatalf("Expected %d queries, got %d: \n%v", len(expectedQueries), len(testDB.CalledQueries), testDB.CalledQueries) - } - for _, q := range expectedQueries { - if !list.ExistInSlice(q, testDB.CalledQueries) { - t.Fatalf("Query %s was not found in \n%v", q, testDB.CalledQueries) - } - } -} - -func TestRunnerUpAndDown(t *testing.T) { - testDB, err := createTestDB() - if err != nil { - t.Fatal(err) - } - defer testDB.Close() - - callsOrder := []string{} - - l := MigrationsList{} - l.Register(func(db dbx.Builder) error { - callsOrder = append(callsOrder, "up2") - return nil - }, func(db dbx.Builder) error { - callsOrder = append(callsOrder, "down2") - return nil - }, "2_test") - l.Register(func(db dbx.Builder) error { - callsOrder = append(callsOrder, "up3") - return nil - }, func(db dbx.Builder) error { - callsOrder = append(callsOrder, "down3") - return nil - }, "3_test") - l.Register(func(db dbx.Builder) error { - callsOrder = append(callsOrder, "up1") - return nil - }, func(db dbx.Builder) error { - callsOrder = append(callsOrder, "down1") - return nil - }, "1_test") - - r, err := NewRunner(testDB.DB, l) - if err != nil { - t.Fatal(err) - } - - // simulate partially out-of-order run migration - r.saveAppliedMigration(testDB, "2_test") - - // --------------------------------------------------------------- - // Up() - // --------------------------------------------------------------- - - if _, err := r.Up(); err != nil { - t.Fatal(err) - } - - expectedUpCallsOrder := `["up1","up3"]` // skip up2 since it was applied previously - - 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 - r.migrationsList.Register(nil, func(db dbx.Builder) error { - callsOrder = append(callsOrder, "down4") - return nil - }, "4_test") - - // --------------------------------------------------------------- - - // --------------------------------------------------------------- - // Down() - // --------------------------------------------------------------- - - if _, err := r.Down(2); err != nil { - t.Fatal(err) - } - - expectedDownCallsOrder := `["down3","down1"]` // 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 TestHistorySync(t *testing.T) { - testDB, err := createTestDB() - if err != nil { - t.Fatal(err) - } - defer testDB.Close() - - // mock migrations history - l := MigrationsList{} - l.Register(func(db dbx.Builder) error { - return nil - }, func(db dbx.Builder) error { - return nil - }, "1_test") - l.Register(func(db dbx.Builder) error { - return nil - }, func(db dbx.Builder) error { - return nil - }, "2_test") - l.Register(func(db dbx.Builder) error { - return nil - }, func(db dbx.Builder) error { - return nil - }, "3_test") - - r, err := NewRunner(testDB.DB, l) - if err != nil { - t.Fatalf("Failed to initialize the runner: %v", err) - } - - if _, err := r.Up(); err != nil { - t.Fatalf("Failed to apply the mock migrations: %v", err) - } - - if !r.isMigrationApplied(testDB.DB, "2_test") { - t.Fatalf("Expected 2_test migration to be applied") - } - - // mock deleted migrations - r.migrationsList.list = []*Migration{r.migrationsList.list[0], r.migrationsList.list[2]} - - if err := r.removeMissingAppliedMigrations(); err != nil { - t.Fatalf("Failed to remove missing applied migrations: %v", err) - } - - if r.isMigrationApplied(testDB.DB, "2_test") { - t.Fatalf("Expected 2_test migration to NOT be applied") - } -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -type testDB struct { - *dbx.DB - CalledQueries []string -} - -// NB! Don't forget to call `db.Close()` at the end of the test. -func createTestDB() (*testDB, error) { - sqlDB, err := sql.Open("sqlite", ":memory:") - if err != nil { - return nil, err - } - - db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")} - db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { - db.CalledQueries = append(db.CalledQueries, sql) - } - db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { - db.CalledQueries = append(db.CalledQueries, sql) - } - - return &db, nil -} diff --git a/tools/osutils/cmd.go b/tools/osutils/cmd.go new file mode 100644 index 0000000000..c6a8f1461c --- /dev/null +++ b/tools/osutils/cmd.go @@ -0,0 +1,65 @@ +package osutils + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/pocketbase/ozzo-validation/v4/is" +) + +// LaunchURL attempts to open the provided url in the user's default browser. +// +// It is platform dependent and it uses: +// - "open" on macOS +// - "rundll32" on Windows +// - "xdg-open" on everything else (Linux, FreeBSD, etc.) +func LaunchURL(url string) error { + if err := is.URL.Validate(url); err != nil { + return err + } + + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "windows": + // not sure if this is the best command but seems to be the most reliable based on the comments in + // https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945 + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} + +// YesNoPrompt performs a console prompt that asks the user for Yes/No answer. +// +// If the user just press Enter (aka. doesn't type anything) it returns the fallback value. +func YesNoPrompt(message string, fallback bool) bool { + options := "Y/n" + if !fallback { + options = "y/N" + } + + r := bufio.NewReader(os.Stdin) + + var s string + for { + fmt.Fprintf(os.Stderr, "%s (%s) ", message, options) + + s, _ = r.ReadString('\n') + + s = strings.ToLower(strings.TrimSpace(s)) + + switch s { + case "": + return fallback + case "y", "yes": + return true + case "n", "no": + return false + } + } +} diff --git a/tools/osutils/cmd_test.go b/tools/osutils/cmd_test.go new file mode 100644 index 0000000000..38beeab165 --- /dev/null +++ b/tools/osutils/cmd_test.go @@ -0,0 +1,66 @@ +package osutils_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/osutils" +) + +func TestYesNoPrompt(t *testing.T) { + scenarios := []struct { + stdin string + fallback bool + expected bool + }{ + {"", false, false}, + {"", true, true}, + + // yes + {"y", false, true}, + {"Y", false, true}, + {"Yes", false, true}, + {"yes", false, true}, + + // no + {"n", true, false}, + {"N", true, false}, + {"No", true, false}, + {"no", true, false}, + + // invalid -> no/yes + {"invalid|no", true, false}, + {"invalid|yes", false, true}, + } + + for _, s := range scenarios { + t.Run(fmt.Sprintf("%s_%v", s.stdin, s.fallback), func(t *testing.T) { + stdinread, stdinwrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + parts := strings.Split(s.stdin, "|") + for _, p := range parts { + if _, err := stdinwrite.WriteString(p + "\n"); err != nil { + t.Fatalf("Failed to write test stdin part %q: %v", p, err) + } + } + + if err = stdinwrite.Close(); err != nil { + t.Fatal(err) + } + + defer func(oldStdin *os.File) { os.Stdin = oldStdin }(os.Stdin) + os.Stdin = stdinread + + result := osutils.YesNoPrompt("test", s.fallback) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/tools/osutils/dir.go b/tools/osutils/dir.go index 1b4dfc43b8..2d2c8191ce 100644 --- a/tools/osutils/dir.go +++ b/tools/osutils/dir.go @@ -1,14 +1,14 @@ package osutils import ( - "log" + "errors" "os" "path/filepath" "github.com/pocketbase/pocketbase/tools/list" ) -// MoveDirContent moves the src dir content, that is not listed in the exclide list, +// MoveDirContent moves the src dir content, that is not listed in the exclude list, // to dest dir (it will be created if missing). // // The rootExclude argument is used to specify a list of src root entries to exclude. @@ -65,9 +65,8 @@ func MoveDirContent(src string, dest string, rootExclude ...string) error { if err := os.Rename(old, new); err != nil { if errs := tryRollback(); len(errs) > 0 { - // currently just log the rollback errors - // in the future we may require go 1.20+ to use errors.Join() - log.Println(errs) + errs = append(errs, err) + err = errors.Join(errs...) } return err diff --git a/tools/osutils/run.go b/tools/osutils/run.go new file mode 100644 index 0000000000..4253cb820d --- /dev/null +++ b/tools/osutils/run.go @@ -0,0 +1,32 @@ +package osutils + +import ( + "os" + "strings" +) + +var runDirs = []string{os.TempDir(), cacheDir()} + +// IsProbablyGoRun loosely checks if the current program was started with "go run". +func IsProbablyGoRun() bool { + for _, dir := range runDirs { + if dir != "" && strings.HasPrefix(os.Args[0], dir) { + return true + } + } + + return false +} + +func cacheDir() string { + dir := os.Getenv("GOCACHE") + if dir == "off" { + return "" + } + + if dir == "" { + dir, _ = os.UserCacheDir() + } + + return dir +} diff --git a/tools/osutils/run_test.go b/tools/osutils/run_test.go new file mode 100644 index 0000000000..c7265569f6 --- /dev/null +++ b/tools/osutils/run_test.go @@ -0,0 +1,45 @@ +package osutils + +import ( + "os" + "strconv" + "testing" +) + +func TestIsProbablyGoRun(t *testing.T) { + scenarios := []struct { + arg0 string + runDirs []string + expected bool + }{ + {"", nil, false}, + {"/a/b", nil, false}, + {"/a/b", []string{""}, false}, + {"/a/b", []string{"/b/"}, false}, + {"/a/b", []string{"/a/"}, true}, + {"/a/b", []string{"", "/b/", "/a/"}, true}, + } + + originalArgs := os.Args + defer func() { + os.Args = originalArgs + }() + + originalRunDirs := runDirs + defer func() { + runDirs = originalRunDirs + }() + + for i, s := range scenarios { + t.Run(strconv.Itoa(i)+"_"+s.arg0, func(t *testing.T) { + os.Args = []string{s.arg0} + runDirs = s.runDirs + + result := IsProbablyGoRun() + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/tools/picker/excerpt_modifier.go b/tools/picker/excerpt_modifier.go new file mode 100644 index 0000000000..182d2ac37a --- /dev/null +++ b/tools/picker/excerpt_modifier.go @@ -0,0 +1,150 @@ +package picker + +import ( + "errors" + "regexp" + "strings" + + "github.com/pocketbase/pocketbase/tools/list" + "github.com/spf13/cast" + "golang.org/x/net/html" +) + +func init() { + Modifiers["excerpt"] = func(args ...string) (Modifier, error) { + return newExcerptModifier(args...) + } +} + +var whitespaceRegex = regexp.MustCompile(`\s+`) + +var excludeTags = []string{ + "head", "style", "script", "iframe", "embed", "applet", "object", + "svg", "img", "picture", "dialog", "template", "button", "form", + "textarea", "input", "select", "option", +} + +var inlineTags = []string{ + "a", "abbr", "acronym", "b", "bdo", "big", "br", "button", + "cite", "code", "em", "i", "label", "q", "small", "span", + "strong", "strike", "sub", "sup", "time", +} + +var _ Modifier = (*excerptModifier)(nil) + +type excerptModifier struct { + max int // approximate max excerpt length + withEllipsis bool // if enabled will add ellipsis when the plain text length > max +} + +// newExcerptModifier validates the specified raw string arguments and +// initializes a new excerptModifier. +// +// This method is usually invoked in initModifer(). +func newExcerptModifier(args ...string) (*excerptModifier, error) { + totalArgs := len(args) + + if totalArgs == 0 { + return nil, errors.New("max argument is required - expected (max, withEllipsis?)") + } + + if totalArgs > 2 { + return nil, errors.New("too many arguments - expected (max, withEllipsis?)") + } + + max := cast.ToInt(args[0]) + if max <= 0 { + return nil, errors.New("max argument must be > 0") + } + + var withEllipsis bool + if totalArgs > 1 { + withEllipsis = cast.ToBool(args[1]) + } + + return &excerptModifier{max, withEllipsis}, nil +} + +// Modify implements the [Modifier.Modify] interface method. +// +// It returns a plain text excerpt/short-description from a formatted +// html string (non-string values are kept untouched). +func (m *excerptModifier) Modify(value any) (any, error) { + strValue, ok := value.(string) + if !ok { + // not a string -> return as it is without applying the modifier + // (we don't throw an error because the modifier could be applied for a missing expand field) + return value, nil + } + + var builder strings.Builder + + doc, err := html.Parse(strings.NewReader(strValue)) + if err != nil { + return "", err + } + + var hasPrevSpace bool + + // for all node types and more details check + // https://pkg.go.dev/golang.org/x/net/html#Parse + var stripTags func(*html.Node) + stripTags = func(n *html.Node) { + switch n.Type { + case html.TextNode: + // collapse multiple spaces into one + txt := whitespaceRegex.ReplaceAllString(n.Data, " ") + + if hasPrevSpace { + txt = strings.TrimLeft(txt, " ") + } + + if txt != "" { + hasPrevSpace = strings.HasSuffix(txt, " ") + + builder.WriteString(txt) + } + } + + // excerpt max has been reached => no need to further iterate + // (+2 for the extra whitespace suffix/prefix that will be trimmed later) + if builder.Len() > m.max+2 { + return + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type != html.ElementNode || !list.ExistInSlice(c.Data, excludeTags) { + isBlock := c.Type == html.ElementNode && !list.ExistInSlice(c.Data, inlineTags) + + if isBlock && !hasPrevSpace { + builder.WriteString(" ") + hasPrevSpace = true + } + + stripTags(c) + + if isBlock && !hasPrevSpace { + builder.WriteString(" ") + hasPrevSpace = true + } + } + } + } + stripTags(doc) + + result := strings.TrimSpace(builder.String()) + + if len(result) > m.max { + // note: casted to []rune to properly account for multi-byte chars + runes := []rune(result) + if len(runes) > m.max { + result = string(runes[:m.max]) + result = strings.TrimSpace(result) + if m.withEllipsis { + result += "..." + } + } + } + + return result, nil +} diff --git a/tools/picker/excerpt_modifier_test.go b/tools/picker/excerpt_modifier_test.go new file mode 100644 index 0000000000..ed1a6c1765 --- /dev/null +++ b/tools/picker/excerpt_modifier_test.go @@ -0,0 +1,180 @@ +package picker + +import ( + "fmt" + "testing" + + "github.com/spf13/cast" +) + +func TestNewExcerptModifier(t *testing.T) { + scenarios := []struct { + name string + args []string + expectError bool + }{ + { + "no arguments", + nil, + true, + }, + { + "too many arguments", + []string{"12", "false", "something"}, + true, + }, + { + "non-numeric max argument", + []string{"something"}, // should fallback to 0 which is not allowed + true, + }, + { + "negative max argument", + []string{"-1"}, + true, + }, + { + "zero max argument", + []string{"0"}, + true, + }, + { + "numeric max argument", + []string{"12"}, + false, + }, + { + "non-bool withEllipsis argument", + []string{"12", "something"}, // should fallback to false which is allowed + false, + }, + { + "truthy withEllipsis argument", + []string{"12", "t"}, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + m, err := newExcerptModifier(s.args...) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + if m != nil { + t.Fatalf("Expected nil modifier, got %v", m) + } + + return + } + + var argMax int + if len(s.args) > 0 { + argMax = cast.ToInt(s.args[0]) + } + + var argWithEllipsis bool + if len(s.args) > 1 { + argWithEllipsis = cast.ToBool(s.args[1]) + } + + if m.max != argMax { + t.Fatalf("Expected max %d, got %d", argMax, m.max) + } + + if m.withEllipsis != argWithEllipsis { + t.Fatalf("Expected withEllipsis %v, got %v", argWithEllipsis, m.withEllipsis) + } + }) + } +} + +func TestExcerptModifierModify(t *testing.T) { + html := `

Hello

t est12 + 3456
word 7 89!? a b c#

title

` + + plainText := "Hello t est12 3456 word 7 89!? a b c# title" + + scenarios := []struct { + name string + args []string + value string + expected string + }{ + // without ellipsis + { + "only max < len(plainText)", + []string{"2"}, + html, + plainText[:2], + }, + { + "only max = len(plainText)", + []string{fmt.Sprint(len(plainText))}, + html, + plainText, + }, + { + "only max > len(plainText)", + []string{fmt.Sprint(len(plainText) + 5)}, + html, + plainText, + }, + + // with ellipsis + { + "with ellipsis and max < len(plainText)", + []string{"2", "t"}, + html, + plainText[:2] + "...", + }, + { + "with ellipsis and max = len(plainText)", + []string{fmt.Sprint(len(plainText)), "t"}, + html, + plainText, + }, + { + "with ellipsis and max > len(plainText)", + []string{fmt.Sprint(len(plainText) + 5), "t"}, + html, + plainText, + }, + + // multibyte chars + { + "mutibyte chars <= max", + []string{"4", "t"}, + "аб\nв ", + "аб в", + }, + { + "mutibyte chars > max", + []string{"3", "t"}, + "аб\nв ", + "аб...", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + m, err := newExcerptModifier(s.args...) + if err != nil { + t.Fatal(err) + } + + raw, err := m.Modify(s.value) + if err != nil { + t.Fatal(err) + } + + if v := cast.ToString(raw); v != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, v) + } + }) + } +} diff --git a/tools/picker/modifiers.go b/tools/picker/modifiers.go new file mode 100644 index 0000000000..6572e0c443 --- /dev/null +++ b/tools/picker/modifiers.go @@ -0,0 +1,41 @@ +package picker + +import ( + "fmt" + + "github.com/pocketbase/pocketbase/tools/tokenizer" +) + +var Modifiers = map[string]ModifierFactoryFunc{} + +type ModifierFactoryFunc func(args ...string) (Modifier, error) + +type Modifier interface { + // Modify executes the modifier and returns a new modified value. + Modify(value any) (any, error) +} + +func initModifer(rawModifier string) (Modifier, error) { + t := tokenizer.NewFromString(rawModifier) + t.Separators('(', ')', ',', ' ') + t.IgnoreParenthesis(true) + + parts, err := t.ScanAll() + if err != nil { + return nil, err + } + + if len(parts) == 0 { + return nil, fmt.Errorf("invalid or empty modifier expression %q", rawModifier) + } + + name := parts[0] + args := parts[1:] + + factory, ok := Modifiers[name] + if !ok { + return nil, fmt.Errorf("missing or invalid modifier %q", name) + } + + return factory(args...) +} diff --git a/tools/picker/pick.go b/tools/picker/pick.go new file mode 100644 index 0000000000..887e151d25 --- /dev/null +++ b/tools/picker/pick.go @@ -0,0 +1,210 @@ +package picker + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "strings" + + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/tokenizer" +) + +var ErrInvalidModifierData = errors.New("failed to apply some of the field modifiers for the provided data") + +// Pick converts data into a []any, map[string]any, etc. (using json marshal->unmarshal) +// containing only the fields from the parsed rawFields expression. +// +// rawFields is a comma separated string of the fields to include. +// Nested fields should be listed with dot-notation. +// Fields value modifiers are also supported using the `:modifier(args)` format (see Modifiers). +// +// In case some data fails to apply against the registered modifiers +// a wrapped [ErrInvalidModifierData] is returned that you can inspect and +// decide to ignore or propagate further up the execution chain. +// +// Example: +// +// data := map[string]any{"a": 1, "b": 2, "c": map[string]any{"c1": 11, "c2": 22}} +// Pick(data, "a,c.c1") // map[string]any{"a": 1, "c": map[string]any{"c1": 11}} +func Pick(data any, rawFields string) (any, error) { + parsedFields, err := parseFields(rawFields) + if err != nil { + return nil, err + } + + // marshalize the provided data to ensure that the related json.Marshaler + // implementations are invoked, and then convert it back to a plain + // json value that we can further operate on. + // + // Note that invalid UTF8 characters are mangled for compatibility + // with earlier versions and to prevent unnecessary causing an error. + // + // Duplicated keys are also enabled for just in case of old jsonv1 data + // (new db entries are usually validated against the jsonv2 semantics). + // + // @todo research other approaches to avoid the double serialization + // --- + encoded, err := json.Marshal(data, jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true)) + if err != nil { + return nil, err + } + + var decoded any + + err = json.Unmarshal(encoded, &decoded, jsontext.AllowDuplicateNames(true)) + if err != nil { + return nil, err + } + // --- + + // special cases to preserve the same fields format when used with single item or search results data. + var isSearchResult bool + switch data.(type) { + case search.Result, *search.Result: + isSearchResult = true + } + + if isSearchResult { + if decodedMap, ok := decoded.(map[string]any); ok { + err = pickParsedFields(decodedMap["items"], parsedFields) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err) + } + } + } else { + err = pickParsedFields(decoded, parsedFields) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidModifierData, err) + } + } + + return decoded, nil +} + +func parseFields(rawFields string) (map[string]Modifier, error) { + t := tokenizer.NewFromString(rawFields) + + fields, err := t.ScanAll() + if err != nil { + return nil, err + } + + result := make(map[string]Modifier, len(fields)) + + for _, f := range fields { + parts := strings.SplitN(strings.TrimSpace(f), ":", 2) + + if len(parts) > 1 { + m, err := initModifer(parts[1]) + if err != nil { + return nil, err + } + result[parts[0]] = m + } else { + result[parts[0]] = nil + } + } + + return result, nil +} + +func pickParsedFields(data any, fields map[string]Modifier) error { + switch v := data.(type) { + case map[string]any: + err := pickMapFields(v, fields) + if err != nil { + return err + } + case []map[string]any: + for _, item := range v { + if err := pickMapFields(item, fields); err != nil { + return err + } + } + case []any: + if len(v) == 0 { + return nil // nothing to pick + } + + if _, ok := v[0].(map[string]any); !ok { + return nil // for now ignore non-map values + } + + for _, item := range v { + if err := pickMapFields(item.(map[string]any), fields); err != nil { + return err + } + } + } + + return nil +} + +func pickMapFields(data map[string]any, fields map[string]Modifier) error { + if len(fields) == 0 { + return nil // nothing to pick + } + + if m, ok := fields["*"]; ok { + // append all missing root level data keys + for k := range data { + var exists bool + + for f := range fields { + if strings.HasPrefix(f+".", k+".") { + exists = true + break + } + } + + if !exists { + fields[k] = m + } + } + } + +DataLoop: + for k := range data { + matchingFields := make(map[string]Modifier, len(fields)) + for f, m := range fields { + if strings.HasPrefix(f+".", k+".") { + matchingFields[f] = m + continue + } + } + + if len(matchingFields) == 0 { + delete(data, k) + continue DataLoop + } + + // remove the current key from the matching fields path + for f, m := range matchingFields { + remains := strings.TrimSuffix(strings.TrimPrefix(f+".", k+"."), ".") + + // final key + if remains == "" { + if m != nil { + var err error + data[k], err = m.Modify(data[k]) + if err != nil { + return err + } + } + continue DataLoop + } + + // cleanup the old field key and continue with the rest of the field path + delete(matchingFields, f) + matchingFields[remains] = m + } + + if err := pickParsedFields(data[k], matchingFields); err != nil { + return err + } + } + + return nil +} diff --git a/tools/picker/pick_test.go b/tools/picker/pick_test.go new file mode 100644 index 0000000000..590c841520 --- /dev/null +++ b/tools/picker/pick_test.go @@ -0,0 +1,310 @@ +package picker_test + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "testing" + + "github.com/pocketbase/pocketbase/tools/picker" + "github.com/pocketbase/pocketbase/tools/search" +) + +type brokenModifier struct { +} + +func (m *brokenModifier) Modify(val any) (any, error) { + return nil, errors.New("test_error") +} + +func init() { + picker.Modifiers["broken"] = func(args ...string) (picker.Modifier, error) { + return &brokenModifier{}, nil + } +} + +func TestPickFields(t *testing.T) { + scenarios := []struct { + name string + data any + fields string + expectError bool + result string + }{ + { + "empty fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "", + false, + `{"a":1,"b":2,"c":"test"}`, + }, + { + "missing fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "missing", + false, + `{}`, + }, + { + "non map data", + "test", + "a,b,test", + false, + `"test"`, + }, + { + "non slice of map data", + []any{"a", "b", "test"}, + "a,test", + false, + `["a","b","test"]`, + }, + { + "map with no matching field", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "missing", // test individual fields trim + false, + `{}`, + }, + { + "map with existing and missing fields", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "a, c ,missing", // test individual fields trim + false, + `{"a":1,"c":"test"}`, + }, + { + "slice of maps with existing and missing fields", + []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "\xc3" /* test invalid utf8 mangling */}, + }, + "a, c ,missing", // test individual fields trim + false, + `[{"a":11,"c":"test1"},{"a":22,"c":"�"}]`, + }, + { + "nested fields with mixed map and any slices", + map[string]any{ + "a": 1, + "b": 2, + "c": "test", + "anySlice": []any{ + map[string]any{ + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + "D": map[string]any{ + "DA": 1, + "DB": 2, + }, + }, + map[string]any{ + "A": "test", + }, + }, + "mapSlice": []map[string]any{ + { + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + "D": []any{ + map[string]any{"DA": 1}, + }, + }, + { + "B": []any{"1", "2", 3}, + "D": []any{ + map[string]any{"DA": 2}, + map[string]any{"DA": 3}, + map[string]any{"DB": 4}, // will result to empty since there is no DA + }, + }, + }, + "fullMap": []map[string]any{ + { + "A": []int{1, 2, 3}, + "B": []any{"1", "2", 3}, + "C": "test", + }, + { + "B": []any{"1", "2", 3}, + "D": []any{ + map[string]any{"DA": 2}, + map[string]any{"DA": 3}, // will result to empty since there is no DA + }, + }, + }, + }, + "a, c, anySlice.A, mapSlice.C, mapSlice.D.DA, anySlice.D,fullMap", + false, + `{"a":1,"anySlice":[{"A":[1,2,3],"D":{"DA":1,"DB":2}},{"A":"test"}],"c":"test","fullMap":[{"A":[1,2,3],"B":["1","2",3],"C":"test"},{"B":["1","2",3],"D":[{"DA":2},{"DA":3}]}],"mapSlice":[{"C":"test","D":[{"DA":1}]},{"D":[{"DA":2},{"DA":3},{}]}]}`, + }, + { + "SearchResult", + search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "a,c,missing", + false, + `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "*SearchResult", + &search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "a,c", + false, + `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "root wildcard", + &search.Result{ + Page: 1, + PerPage: 10, + TotalItems: 20, + TotalPages: 30, + Items: []any{ + map[string]any{"a": 11, "b": 11, "c": "test1"}, + map[string]any{"a": 22, "b": 22, "c": "test2"}, + }, + }, + "*", + false, + `{"items":[{"a":11,"b":11,"c":"test1"},{"a":22,"b":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, + }, + { + "root wildcard with nested exception", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + }, + }, + "*,rel.id", + false, + `{"id":"123","rel":{"id":"456"},"title":"lorem"}`, + }, + { + "sub wildcard", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "id,rel.*", + false, + `{"id":"123","rel":{"id":"456","sub":{"id":"789","title":"sub_title"},"title":"rel_title"}}`, + }, + { + "sub wildcard with nested exception", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "rel_title", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "id,rel.*,rel.sub.id", + false, + `{"id":"123","rel":{"id":"456","sub":{"id":"789"},"title":"rel_title"}}`, + }, + { + "with modifer.Modify error", + map[string]any{"a": 1}, + "*:broken", + true, + `{"a":1}`, + }, + { + "invalid excerpt modifier", + map[string]any{"a": 1, "b": 2, "c": "test"}, + "*:excerpt", + true, + `{"a":1,"b":2,"c":"test"}`, + }, + { + "valid excerpt modifier", + map[string]any{ + "id": "123", + "title": "lorem", + "rel": map[string]any{ + "id": "456", + "title": "

rel_title

", + "sub": map[string]any{ + "id": "789", + "title": "sub_title", + }, + }, + }, + "*:excerpt(2),rel.title:excerpt(3, true)", + false, + `{"id":"12","rel":{"title":"rel..."},"title":"lo"}`, + }, + { + "jsonv1 values", + map[string]any{"a": "test\xc3", "b": jsontext.Value(`{"a":1,"a":2}`)}, + "a, b", + false, + `{"a":"test�","b":{"a":2}}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := picker.Pick(s.data, s.fields) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + serialized, err := json.Marshal( + result, + json.Deterministic(true), + jsontext.AllowInvalidUTF8(true), + jsontext.AllowDuplicateNames(true), + ) + if err != nil { + t.Fatal(err) + } + + if v := string(serialized); v != s.result { + t.Fatalf("Expected body\n%s \ngot \n%s", s.result, v) + } + }) + } +} diff --git a/tools/rest/json_serializer.go b/tools/rest/json_serializer.go deleted file mode 100644 index cda7b42243..0000000000 --- a/tools/rest/json_serializer.go +++ /dev/null @@ -1,142 +0,0 @@ -package rest - -import ( - "encoding/json" - "strings" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/tools/list" - "github.com/pocketbase/pocketbase/tools/search" -) - -// Serializer represents custom REST JSON serializer based on echo.DefaultJSONSerializer, -// with support for additional generic response data transformation (eg. fields picker). -type Serializer struct { - echo.DefaultJSONSerializer - - FieldsParam string -} - -// Serialize converts an interface into a json and writes it to the response. -// -// It also provides a generic response data fields picker via the FieldsParam query parameter (default to "fields"). -func (s *Serializer) Serialize(c echo.Context, i any, indent string) error { - fieldsParam := s.FieldsParam - if fieldsParam == "" { - fieldsParam = "fields" - } - - statusCode := c.Response().Status - - param := c.QueryParam(fieldsParam) - if param == "" || statusCode < 200 || statusCode > 299 { - return s.DefaultJSONSerializer.Serialize(c, i, indent) - } - - fields := strings.Split(param, ",") - for i, f := range fields { - fields[i] = strings.TrimSpace(f) - } - - encoded, err := json.Marshal(i) - if err != nil { - return err - } - - var decoded any - - if err := json.Unmarshal(encoded, &decoded); err != nil { - return err - } - - var isSearchResult bool - - switch i.(type) { - case search.Result, *search.Result: - isSearchResult = true - } - - if isSearchResult { - if decodedMap, ok := decoded.(map[string]any); ok { - pickFields(decodedMap["items"], fields) - } - } else { - pickFields(decoded, fields) - } - - return s.DefaultJSONSerializer.Serialize(c, decoded, indent) -} - -func pickFields(data any, fields []string) { - switch v := data.(type) { - case map[string]any: - pickMapFields(v, fields) - case []map[string]any: - for _, item := range v { - pickMapFields(item, fields) - } - case []any: - if len(v) == 0 { - return // nothing to pick - } - - if _, ok := v[0].(map[string]any); !ok { - return // for now ignore non-map values - } - - for _, item := range v { - pickMapFields(item.(map[string]any), fields) - } - } -} - -func pickMapFields(data map[string]any, fields []string) { - if len(fields) == 0 { - return // nothing to pick - } - - if list.ExistInSlice("*", fields) { - // append all missing root level data keys - for k := range data { - var exists bool - - for _, f := range fields { - if strings.HasPrefix(f+".", k+".") { - exists = true - break - } - } - - if !exists { - fields = append(fields, k) - } - } - } - -DataLoop: - for k := range data { - matchingFields := make([]string, 0, len(fields)) - for _, f := range fields { - if strings.HasPrefix(f+".", k+".") { - matchingFields = append(matchingFields, f) - continue - } - } - - if len(matchingFields) == 0 { - delete(data, k) - continue DataLoop - } - - // trim the key from the fields - for i, v := range matchingFields { - trimmed := strings.TrimSuffix(strings.TrimPrefix(v+".", k+"."), ".") - if trimmed == "" { - continue DataLoop - } - matchingFields[i] = trimmed - } - - pickFields(data[k], matchingFields) - } -} diff --git a/tools/rest/json_serializer_test.go b/tools/rest/json_serializer_test.go deleted file mode 100644 index 6c13f500e8..0000000000 --- a/tools/rest/json_serializer_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package rest_test - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/tools/rest" - "github.com/pocketbase/pocketbase/tools/search" -) - -func TestSerialize(t *testing.T) { - scenarios := []struct { - name string - serializer rest.Serializer - statusCode int - data any - query string - expected string - }{ - { - "empty query", - rest.Serializer{}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "", - `{"a":1,"b":2,"c":"test"}`, - }, - { - "empty fields", - rest.Serializer{}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=", - `{"a":1,"b":2,"c":"test"}`, - }, - { - "missing fields", - rest.Serializer{}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=missing", - `{}`, - }, - { - "non map response", - rest.Serializer{}, - 200, - "test", - "fields=a,b,test", - `"test"`, - }, - { - "non slice of map response", - rest.Serializer{}, - 200, - []any{"a", "b", "test"}, - "fields=a,test", - `["a","b","test"]`, - }, - { - "map with no matching field", - rest.Serializer{}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=missing", // test individual fields trim - `{}`, - }, - { - ">299 response", - rest.Serializer{}, - 300, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=missing", - `{"a":1,"b":2,"c":"test"}`, - }, - { - "<200 response", - rest.Serializer{}, - 199, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=missing", - `{"a":1,"b":2,"c":"test"}`, - }, - { - "map with existing and missing fields", - rest.Serializer{}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "fields=a, c ,missing", // test individual fields trim - `{"a":1,"c":"test"}`, - }, - { - "custom fields param", - rest.Serializer{FieldsParam: "custom"}, - 200, - map[string]any{"a": 1, "b": 2, "c": "test"}, - "custom=a, c ,missing", // test individual fields trim - `{"a":1,"c":"test"}`, - }, - { - "slice of maps with existing and missing fields", - rest.Serializer{}, - 200, - []any{ - map[string]any{"a": 11, "b": 11, "c": "test1"}, - map[string]any{"a": 22, "b": 22, "c": "test2"}, - }, - "fields=a, c ,missing", // test individual fields trim - `[{"a":11,"c":"test1"},{"a":22,"c":"test2"}]`, - }, - { - "nested fields with mixed map and any slices", - rest.Serializer{}, - 200, - map[string]any{ - "a": 1, - "b": 2, - "c": "test", - "anySlice": []any{ - map[string]any{ - "A": []int{1, 2, 3}, - "B": []any{"1", "2", 3}, - "C": "test", - "D": map[string]any{ - "DA": 1, - "DB": 2, - }, - }, - map[string]any{ - "A": "test", - }, - }, - "mapSlice": []map[string]any{ - { - "A": []int{1, 2, 3}, - "B": []any{"1", "2", 3}, - "C": "test", - "D": []any{ - map[string]any{"DA": 1}, - }, - }, - { - "B": []any{"1", "2", 3}, - "D": []any{ - map[string]any{"DA": 2}, - map[string]any{"DA": 3}, - map[string]any{"DB": 4}, // will result to empty since there is no DA - }, - }, - }, - "fullMap": []map[string]any{ - { - "A": []int{1, 2, 3}, - "B": []any{"1", "2", 3}, - "C": "test", - }, - { - "B": []any{"1", "2", 3}, - "D": []any{ - map[string]any{"DA": 2}, - map[string]any{"DA": 3}, // will result to empty since there is no DA - }, - }, - }, - }, - "fields=a, c, anySlice.A, mapSlice.C, mapSlice.D.DA, anySlice.D,fullMap", - `{"a":1,"anySlice":[{"A":[1,2,3],"D":{"DA":1,"DB":2}},{"A":"test"}],"c":"test","fullMap":[{"A":[1,2,3],"B":["1","2",3],"C":"test"},{"B":["1","2",3],"D":[{"DA":2},{"DA":3}]}],"mapSlice":[{"C":"test","D":[{"DA":1}]},{"D":[{"DA":2},{"DA":3},{}]}]}`, - }, - { - "SearchResult", - rest.Serializer{}, - 200, - search.Result{ - Page: 1, - PerPage: 10, - TotalItems: 20, - TotalPages: 30, - Items: []any{ - map[string]any{"a": 11, "b": 11, "c": "test1"}, - map[string]any{"a": 22, "b": 22, "c": "test2"}, - }, - }, - "fields=a,c,missing", - `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, - }, - { - "*SearchResult", - rest.Serializer{}, - 200, - &search.Result{ - Page: 1, - PerPage: 10, - TotalItems: 20, - TotalPages: 30, - Items: []any{ - map[string]any{"a": 11, "b": 11, "c": "test1"}, - map[string]any{"a": 22, "b": 22, "c": "test2"}, - }, - }, - "fields=a,c", - `{"items":[{"a":11,"c":"test1"},{"a":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, - }, - { - "root wildcard", - rest.Serializer{}, - 200, - &search.Result{ - Page: 1, - PerPage: 10, - TotalItems: 20, - TotalPages: 30, - Items: []any{ - map[string]any{"a": 11, "b": 11, "c": "test1"}, - map[string]any{"a": 22, "b": 22, "c": "test2"}, - }, - }, - "fields=*", - `{"items":[{"a":11,"b":11,"c":"test1"},{"a":22,"b":22,"c":"test2"}],"page":1,"perPage":10,"totalItems":20,"totalPages":30}`, - }, - { - "root wildcard with nested exception", - rest.Serializer{}, - 200, - map[string]any{ - "id": "123", - "title": "lorem", - "rel": map[string]any{ - "id": "456", - "title": "rel_title", - }, - }, - "fields=*,rel.id", - `{"id":"123","rel":{"id":"456"},"title":"lorem"}`, - }, - { - "sub wildcard", - rest.Serializer{}, - 200, - map[string]any{ - "id": "123", - "title": "lorem", - "rel": map[string]any{ - "id": "456", - "title": "rel_title", - "sub": map[string]any{ - "id": "789", - "title": "sub_title", - }, - }, - }, - "fields=id,rel.*", - `{"id":"123","rel":{"id":"456","sub":{"id":"789","title":"sub_title"},"title":"rel_title"}}`, - }, - { - "sub wildcard with nested exception", - rest.Serializer{}, - 200, - map[string]any{ - "id": "123", - "title": "lorem", - "rel": map[string]any{ - "id": "456", - "title": "rel_title", - "sub": map[string]any{ - "id": "789", - "title": "sub_title", - }, - }, - }, - "fields=id,rel.*,rel.sub.id", - `{"id":"123","rel":{"id":"456","sub":{"id":"789"},"title":"rel_title"}}`, - }, - } - - for _, s := range scenarios { - t.Run(s.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.URL.RawQuery = s.query - rec := httptest.NewRecorder() - - e := echo.New() - c := e.NewContext(req, rec) - c.Response().Status = s.statusCode - - if err := s.serializer.Serialize(c, s.data, ""); err != nil { - t.Fatalf("Serialize failure: %v", err) - } - - rawBody, err := io.ReadAll(rec.Result().Body) - if err != nil { - t.Fatalf("Failed to read request body: %v", err) - } - - if v := strings.TrimSpace(string(rawBody)); v != s.expected { - t.Fatalf("Expected body\n%v \ngot: \n%v", s.expected, v) - } - }) - } -} diff --git a/tools/rest/multi_binder.go b/tools/rest/multi_binder.go deleted file mode 100644 index 188a58113a..0000000000 --- a/tools/rest/multi_binder.go +++ /dev/null @@ -1,131 +0,0 @@ -package rest - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "reflect" - "strings" - - "github.com/labstack/echo/v5" - "github.com/spf13/cast" -) - -// BindBody binds request body content to i. -// -// This is similar to `echo.BindBody()`, but for JSON requests uses -// custom json reader that **copies** the request body, allowing multiple reads. -func BindBody(c echo.Context, i interface{}) error { - req := c.Request() - if req.ContentLength == 0 { - return nil - } - - ctype := req.Header.Get(echo.HeaderContentType) - switch { - case strings.HasPrefix(ctype, echo.MIMEApplicationJSON): - err := CopyJsonBody(c.Request(), i) - if err != nil { - return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error()) - } - return nil - case strings.HasPrefix(ctype, echo.MIMEApplicationForm), strings.HasPrefix(ctype, echo.MIMEMultipartForm): - return bindFormData(c, i) - } - - // fallback to the default binder - return echo.BindBody(c, i) -} - -// CopyJsonBody reads the request body into i by -// creating a copy of `r.Body` to allow multiple reads. -func CopyJsonBody(r *http.Request, i interface{}) error { - body := r.Body - - // this usually shouldn't be needed because the Server calls close for us - // but we are changing the request body with a new reader - defer body.Close() - - limitReader := io.LimitReader(body, DefaultMaxMemory) - - bodyBytes, readErr := io.ReadAll(limitReader) - if readErr != nil { - return readErr - } - - err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(i) - - // set new body reader - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - - return err -} - -// This is temp hotfix for properly binding multipart/form-data array values -// when a map destination is used. -// -// It should be replaced with echo.BindBody(c, i) once the issue is fixed in echo. -func bindFormData(c echo.Context, i interface{}) error { - if i == nil { - return nil - } - - values, err := c.FormValues() - if err != nil { - return echo.NewHTTPErrorWithInternal(http.StatusBadRequest, err, err.Error()) - } - - if len(values) == 0 { - return nil - } - - rt := reflect.TypeOf(i).Elem() - - // map - if rt.Kind() == reflect.Map { - rv := reflect.ValueOf(i).Elem() - - for k, v := range values { - if total := len(v); total == 1 { - rv.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalizeMultipartValue(v[0]))) - } else { - normalized := make([]any, total) - for i, vItem := range v { - normalized[i] = normalizeMultipartValue(vItem) - } - rv.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalized)) - } - } - - return nil - } - - // anything else - return echo.BindBody(c, i) -} - -// In order to support more seamlessly both json and multipart/form-data requests, -// the following normalization rules are applied for plain multipart string values: -// - "true" is converted to the json `true` -// - "false" is converted to the json `false` -// - numeric (non-scientific) strings are converted to json number -// - any other string (empty string too) is left as it is -func normalizeMultipartValue(raw string) any { - switch raw { - case "": - return raw - case "true": - return true - case "false": - return false - default: - if raw[0] == '-' || (raw[0] >= '0' && raw[0] <= '9') { - if v, err := cast.ToFloat64E(raw); err == nil { - return v - } - } - - return raw - } -} diff --git a/tools/rest/multi_binder_test.go b/tools/rest/multi_binder_test.go deleted file mode 100644 index 48d635197e..0000000000 --- a/tools/rest/multi_binder_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package rest_test - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/labstack/echo/v5" - "github.com/pocketbase/pocketbase/tools/rest" -) - -func TestBindBody(t *testing.T) { - scenarios := []struct { - body io.Reader - contentType string - expectBody string - expectError bool - }{ - { - strings.NewReader(""), - echo.MIMEApplicationJSON, - `{}`, - false, - }, - { - strings.NewReader(`{"test":"invalid`), - echo.MIMEApplicationJSON, - `{}`, - true, - }, - { - strings.NewReader(`{"test":123}`), - echo.MIMEApplicationJSON, - `{"test":123}`, - false, - }, - { - strings.NewReader( - url.Values{ - "string": []string{"str"}, - "stings": []string{"str1", "str2", ""}, - "number": []string{"-123"}, - "numbers": []string{"123", "456.789"}, - "bool": []string{"true"}, - "bools": []string{"true", "false"}, - }.Encode(), - ), - echo.MIMEApplicationForm, - `{"bool":true,"bools":[true,false],"number":-123,"numbers":[123,456.789],"stings":["str1","str2",""],"string":"str"}`, - false, - }, - } - - for i, scenario := range scenarios { - e := echo.New() - req := httptest.NewRequest(http.MethodPost, "/", scenario.body) - req.Header.Set(echo.HeaderContentType, scenario.contentType) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - data := map[string]any{} - err := rest.BindBody(c, &data) - - hasErr := err != nil - if hasErr != scenario.expectError { - t.Errorf("[%d] Expected hasErr %v, got %v", i, scenario.expectError, hasErr) - } - - rawBody, err := json.Marshal(data) - if err != nil { - t.Errorf("[%d] Failed to marshal binded body: %v", i, err) - } - - if scenario.expectBody != string(rawBody) { - t.Errorf("[%d] Expected body \n%s, \ngot \n%s", i, scenario.expectBody, rawBody) - } - } -} - -func TestCopyJsonBody(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(`{"test":"test123"}`)) - - // simulate multiple reads from the same request - result1 := map[string]string{} - rest.CopyJsonBody(req, &result1) - result2 := map[string]string{} - rest.CopyJsonBody(req, &result2) - - if len(result1) == 0 { - t.Error("Expected result1 to be filled") - } - - if len(result2) == 0 { - t.Error("Expected result2 to be filled") - } - - if v, ok := result1["test"]; !ok || v != "test123" { - t.Errorf("Expected result1.test to be %q, got %q", "test123", v) - } - - if v, ok := result2["test"]; !ok || v != "test123" { - t.Errorf("Expected result2.test to be %q, got %q", "test123", v) - } -} diff --git a/tools/rest/uploaded_file.go b/tools/rest/uploaded_file.go deleted file mode 100644 index c7d2f6bc57..0000000000 --- a/tools/rest/uploaded_file.go +++ /dev/null @@ -1,39 +0,0 @@ -package rest - -import ( - "net/http" - - "github.com/pocketbase/pocketbase/tools/filesystem" -) - -// DefaultMaxMemory defines the default max memory bytes that -// will be used when parsing a form request body. -const DefaultMaxMemory = 32 << 20 // 32mb - -// FindUploadedFiles extracts all form files of "key" from a http request -// and returns a slice with filesystem.File instances (if any). -func FindUploadedFiles(r *http.Request, key string) ([]*filesystem.File, error) { - if r.MultipartForm == nil { - err := r.ParseMultipartForm(DefaultMaxMemory) - if err != nil { - return nil, err - } - } - - if r.MultipartForm == nil || r.MultipartForm.File == nil || len(r.MultipartForm.File[key]) == 0 { - return nil, http.ErrMissingFile - } - - result := make([]*filesystem.File, 0, len(r.MultipartForm.File[key])) - - for _, fh := range r.MultipartForm.File[key] { - file, err := filesystem.NewFileFromMultipart(fh) - if err != nil { - return nil, err - } - - result = append(result, file) - } - - return result, nil -} diff --git a/tools/rest/uploaded_file_test.go b/tools/rest/uploaded_file_test.go deleted file mode 100644 index 156e280d11..0000000000 --- a/tools/rest/uploaded_file_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package rest_test - -import ( - "bytes" - "mime/multipart" - "net/http" - "net/http/httptest" - "regexp" - "strings" - "testing" - - "github.com/pocketbase/pocketbase/tools/rest" -) - -func TestFindUploadedFiles(t *testing.T) { - scenarios := []struct { - filename string - expectedPattern string - }{ - {"ab.png", `^ab\w{10}_\w{10}\.png$`}, - {"test", `^test_\w{10}\.txt$`}, - {"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`}, - {strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`}, - } - - for i, s := range scenarios { - // create multipart form file body - body := new(bytes.Buffer) - mp := multipart.NewWriter(body) - w, err := mp.CreateFormFile("test", s.filename) - if err != nil { - t.Fatal(err) - } - w.Write([]byte("test")) - mp.Close() - // --- - - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Add("Content-Type", mp.FormDataContentType()) - - result, err := rest.FindUploadedFiles(req, "test") - if err != nil { - t.Fatal(err) - } - - if len(result) != 1 { - t.Errorf("[%d] Expected 1 file, got %d", i, len(result)) - } - - if result[0].Size != 4 { - t.Errorf("[%d] Expected the file size to be 4 bytes, got %d", i, result[0].Size) - } - - pattern, err := regexp.Compile(s.expectedPattern) - if err != nil { - t.Errorf("[%d] Invalid filename pattern %q: %v", i, s.expectedPattern, err) - } - if !pattern.MatchString(result[0].Name) { - t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name) - } - } -} - -func TestFindUploadedFilesMissing(t *testing.T) { - body := new(bytes.Buffer) - mp := multipart.NewWriter(body) - mp.Close() - - req := httptest.NewRequest(http.MethodPost, "/", body) - req.Header.Add("Content-Type", mp.FormDataContentType()) - - result, err := rest.FindUploadedFiles(req, "test") - if err == nil { - t.Error("Expected error, got nil") - } - - if result != nil { - t.Errorf("Expected result to be nil, got %v", result) - } -} diff --git a/tools/rest/url.go b/tools/rest/url.go deleted file mode 100644 index 87dd6b21c1..0000000000 --- a/tools/rest/url.go +++ /dev/null @@ -1,29 +0,0 @@ -package rest - -import ( - "net/url" - "path" - "strings" -) - -// NormalizeUrl removes duplicated slashes from a url path. -func NormalizeUrl(originalUrl string) (string, error) { - u, err := url.Parse(originalUrl) - if err != nil { - return "", err - } - - hasSlash := strings.HasSuffix(u.Path, "/") - - // clean up path by removing duplicated / - u.Path = path.Clean(u.Path) - u.RawPath = path.Clean(u.RawPath) - - // restore original trailing slash - if hasSlash && !strings.HasSuffix(u.Path, "/") { - u.Path += "/" - u.RawPath += "/" - } - - return u.String(), nil -} diff --git a/tools/rest/url_test.go b/tools/rest/url_test.go deleted file mode 100644 index 091d5bc4e1..0000000000 --- a/tools/rest/url_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package rest_test - -import ( - "testing" - - "github.com/pocketbase/pocketbase/tools/rest" -) - -func TestNormalizeUrl(t *testing.T) { - scenarios := []struct { - url string - expectError bool - expectUrl string - }{ - {":/", true, ""}, - {"./", false, "./"}, - {"../../test////", false, "../../test/"}, - {"/a/b/c", false, "/a/b/c"}, - {"a/////b//c/", false, "a/b/c/"}, - {"/a/////b//c", false, "/a/b/c"}, - {"///a/b/c", false, "/a/b/c"}, - {"//a/b/c", false, "//a/b/c"}, // preserve "auto-schema" - {"http://a/b/c", false, "http://a/b/c"}, - {"a//bc?test=1//dd", false, "a/bc?test=1//dd"}, // only the path is normalized - {"a//bc?test=1#12///3", false, "a/bc?test=1#12///3"}, // only the path is normalized - } - - for i, s := range scenarios { - result, err := rest.NormalizeUrl(s.url) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v", i, s.expectError, hasErr) - } - - if result != s.expectUrl { - t.Errorf("(%d) Expected url %q, got %q", i, s.expectUrl, result) - } - } -} diff --git a/tools/router/buffer_with_file.go b/tools/router/buffer_with_file.go new file mode 100644 index 0000000000..627db4ada6 --- /dev/null +++ b/tools/router/buffer_with_file.go @@ -0,0 +1,125 @@ +package router + +import ( + "bytes" + "errors" + "io" + "os" +) + +var _ io.ReadWriteCloser = (*bufferWithFile)(nil) + +// newBufferWithFile initializes and returns a new bufferWithFile with the specified memoryLimit. +// +// If memoryLimit is negative or zero, defaults to [DefaultMaxMemory]. +func newBufferWithFile(memoryLimit int64) *bufferWithFile { + if memoryLimit <= 0 { + memoryLimit = DefaultMaxMemory + } + + return &bufferWithFile{ + buf: new(bytes.Buffer), + memoryLimit: memoryLimit, + } +} + +// bufferWithFile is similar to [bytes.Buffer] but after the limit it +// fallbacks to a temporary file to minimize excessive memory usage. +type bufferWithFile struct { + buf *bytes.Buffer + file *os.File + memoryLimit int64 + fileReadOffset int64 +} + +// Read implements the standard [io.Reader] interface by reading +// up to len(p) bytes into p. +func (b *bufferWithFile) Read(p []byte) (n int, err error) { + if b.buf == nil { + return 0, errors.New("[bufferWithFile.Read] not initialized or already closed") + } + + // eagerly get length because bytes.Buffer may resize and change it + maxToRead := len(p) + + // read first from the memory buffer + if b.buf.Len() > 0 { + n, err = b.buf.Read(p) + if err != nil && err != io.EOF { + return n, err + } + } + + // continue reading from the file to fill the remaining bytes + if n < maxToRead && b.file != nil { + fileN, fileErr := b.file.ReadAt(p[n:maxToRead], b.fileReadOffset) + b.fileReadOffset += int64(fileN) + n += fileN + err = fileErr + } + + // return EOF if the buffers are empty and nothing has been read + // (to minimize potential breaking changes and for consistency with the bytes.Buffer rules) + if n == 0 && maxToRead > 0 && err == nil { + return 0, io.EOF + } + + return n, err +} + +// Write implements the standard [io.Writer] interface by writing the +// content of p into the buffer. +// +// If the current memory buffer doesn't have enough space to hold len(p), +// it write p into a temp disk file. +func (b *bufferWithFile) Write(p []byte) (int, error) { + if b.buf == nil { + return 0, errors.New("[bufferWithFile.Write] not initialized or already closed") + } + + // already above the limit -> continue with the file + if b.file != nil { + return b.file.Write(p) + } + + // above limit -> create and write to file + if int64(b.buf.Len()+len(p)) > b.memoryLimit { + if b.file == nil { + var err error + b.file, err = os.CreateTemp("", "pb_buffer_file_*") + if err != nil { + return 0, err + } + } + + return b.file.Write(p) + } + + // write in memory + return b.buf.Write(p) +} + +// Close implements the standard [io.Closer] interface. +// +// It unsets the memory buffer and will cleanup after the fallback +// temporary file (if exists). +// +// It is safe to call Close multiple times. +// Once Close is invoked the buffer no longer can be used and should be discarded. +func (b *bufferWithFile) Close() error { + if b.file != nil { + err := errors.Join( + b.file.Close(), + os.Remove(b.file.Name()), + ) + if err != nil { + return err + } + + b.file = nil + } + + b.buf = nil + + return nil +} diff --git a/tools/router/buffer_with_file_test.go b/tools/router/buffer_with_file_test.go new file mode 100644 index 0000000000..88d1c46063 --- /dev/null +++ b/tools/router/buffer_with_file_test.go @@ -0,0 +1,193 @@ +package router + +import ( + "errors" + "io" + "io/fs" + "os" + "testing" +) + +func TestNewBufferWithFile(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + limit int64 + expected int64 + }{ + {"negative limit", -1, DefaultMaxMemory}, + {"zero limit", 0, DefaultMaxMemory}, + {"> 0", 1, 1}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + b := newBufferWithFile(s.limit) + + if b.file != nil { + t.Fatalf("Expected no file descriptor to be open, got %v", b.file) + } + + if b.buf == nil { + t.Fatal("Expected buf to be initialized, got nil") + } + + if b.memoryLimit != s.expected { + t.Fatalf("Expected %d limit, got %d", 10, b.memoryLimit) + } + }) + } +} + +func TestBufferWithFile_WriteReadClose(t *testing.T) { + t.Parallel() + + b := newBufferWithFile(4) + + t.Run("write under limit", func(t *testing.T) { + n, err := b.Write([]byte("ab")) + if err != nil { + t.Fatal(err) + } + + if n != 2 { + t.Fatalf("Expected %d bytes to be written, got %v", 2, n) + } + + if l := b.buf.Len(); l != 2 { + t.Fatalf("Expected memory buf lenth %d, got %d", 2, l) + } + + if b.file != nil { + t.Fatalf("Expected temp file to remain nil, got %v", b.file) + } + }) + + t.Run("write under limit (again)", func(t *testing.T) { + n, err := b.Write([]byte("c")) + if err != nil { + t.Fatal(err) + } + + if n != 1 { + t.Fatalf("Expected %d bytes to be written, got %v", 1, n) + } + + if l := b.buf.Len(); l != 3 { + t.Fatalf("Expected memory buf lenth %d, got %d", 3, l) + } + + if b.file != nil { + t.Fatalf("Expected temp file to remain nil, got %v", b.file) + } + }) + + t.Run("write beyound limit (aka. skip memory buf and write into file)", func(t *testing.T) { + n, err := b.Write([]byte("de")) + if err != nil { + t.Fatal(err) + } + + if n != 2 { + t.Fatalf("Expected %d bytes to be written, got %v", 2, n) + } + + if l := b.buf.Len(); l != 3 { + t.Fatalf("Expected memory buf lenth to be unchanged (%d), got %d", 3, l) + } + + if b.file == nil { + t.Fatal("Expected temp file to be initialized") + } + }) + + t.Run("read 0 bytes fromm non-empty buffer", func(t *testing.T) { + temp := []byte{} + + n, err := b.Read(temp) + if err != nil { // should return nil for consistency with bytes.Buffer + t.Fatalf("Expected nil, got %v", err) + } + + if n != 0 { + t.Fatalf("Expected 0 bytes to be read, got %d (%q)", n, temp) + } + }) + + t.Run("read under limit", func(t *testing.T) { + expected := "ab" + temp := make([]byte, 2) + + n, err := b.Read(temp) + if err != nil && err != io.EOF { + t.Fatal(err) + } + + if n != len(temp) { + t.Fatalf("Expected %d bytes to be read, got %d (%q)", len(temp), n, temp) + } + + if str := string(temp); str != expected { + t.Fatalf("Expected to read %q, got %q", expected, str) + } + }) + + t.Run("read beyound limit", func(t *testing.T) { + expected := "cde" + temp := make([]byte, 3) + + n, err := b.Read(temp) + if err != nil && err != io.EOF { + t.Fatal(err) + } + + if n != len(temp) { + t.Fatalf("Expected %d bytes to be read, got %d (%q)", len(temp), n, temp) + } + + if str := string(temp); str != expected { + t.Fatalf("Expected to read %q, got %q", expected, str) + } + }) + + t.Run("read from empty buffers", func(t *testing.T) { + temp := make([]byte, 3) + + n, err := b.Read(temp) + if err != io.EOF { + t.Fatalf("Expected EOF, got %v", err) + } + + if n != 0 { + t.Fatalf("Expected 0 bytes to be read, got %d (%q)", n, temp) + } + }) + + t.Run("close cleanup", func(t *testing.T) { + if b.file == nil { + t.Fatal("Expected temp file to be initialized, got nil") + } + + filename := b.file.Name() + + _, err := os.Stat(filename) + if err != nil || errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Expected the temp file to exist and be accessible, got %v", err) + } + + err = b.Close() + if err != nil { + t.Fatal(err) + } + + info, err := os.Stat(filename) + if err == nil { + t.Fatalf("Expected the temp file to be deleted after close, got %v", info) + } + + if b.buf != nil || b.file != nil { + t.Fatal("Expected the internal buffers to be nil after close") + } + }) +} diff --git a/tools/router/error.go b/tools/router/error.go new file mode 100644 index 0000000000..ac46146f9a --- /dev/null +++ b/tools/router/error.go @@ -0,0 +1,231 @@ +package router + +import ( + "database/sql" + "errors" + "io/fs" + "net/http" + "strings" + + validation "github.com/pocketbase/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/inflector" +) + +// SafeErrorItem defines a common error interface for a printable public safe error. +type SafeErrorItem interface { + // Code represents a fixed unique identifier of the error (usually used as translation key). + Code() string + + // Error is the default English human readable error message that will be returned. + Error() string +} + +// SafeErrorParamsResolver defines an optional interface for specifying dynamic error parameters. +type SafeErrorParamsResolver interface { + // Params defines a map with dynamic parameters to return as part of the public safe error view. + Params() map[string]any +} + +// SafeErrorResolver defines an error interface for resolving the public safe error fields. +type SafeErrorResolver interface { + // Resolve allows modifying and returning a new public safe error data map. + Resolve(errData map[string]any) any +} + +// ApiError defines the struct for a basic api error response. +type ApiError struct { + rawData any + + Data map[string]any `json:"data"` + Message string `json:"message"` + Status int `json:"status"` +} + +// 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 +} + +// Is reports whether the current ApiError wraps the target. +func (e *ApiError) Is(target error) bool { + err, ok := e.rawData.(error) + if ok { + return errors.Is(err, target) + } + + apiErr, ok := target.(*ApiError) + + return ok && e == apiErr +} + +// NewNotFoundError creates and returns 404 ApiError. +func NewNotFoundError(message string, rawErrData any) *ApiError { + if message == "" { + message = "The requested resource wasn't found." + } + + return NewApiError(http.StatusNotFound, message, rawErrData) +} + +// NewBadRequestError creates and returns 400 ApiError. +func NewBadRequestError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Something went wrong while processing your request." + } + + return NewApiError(http.StatusBadRequest, message, rawErrData) +} + +// NewForbiddenError creates and returns 403 ApiError. +func NewForbiddenError(message string, rawErrData any) *ApiError { + if message == "" { + message = "You are not allowed to perform this request." + } + + return NewApiError(http.StatusForbidden, message, rawErrData) +} + +// NewUnauthorizedError creates and returns 401 ApiError. +func NewUnauthorizedError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Missing or invalid authentication." + } + + return NewApiError(http.StatusUnauthorized, message, rawErrData) +} + +// NewInternalServerError creates and returns 500 ApiError. +func NewInternalServerError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Something went wrong while processing your request." + } + + return NewApiError(http.StatusInternalServerError, message, rawErrData) +} + +func NewTooManyRequestsError(message string, rawErrData any) *ApiError { + if message == "" { + message = "Too Many Requests." + } + + return NewApiError(http.StatusTooManyRequests, message, rawErrData) +} + +// NewApiError creates and returns new normalized ApiError instance. +func NewApiError(status int, message string, rawErrData any) *ApiError { + if message == "" { + message = http.StatusText(status) + } + + return &ApiError{ + rawData: rawErrData, + Data: safeErrorsData(rawErrData), + Status: status, + Message: strings.TrimSpace(inflector.Sentenize(message)), + } +} + +// ToApiError wraps err into ApiError instance (if not already). +func ToApiError(err error) *ApiError { + var apiErr *ApiError + + if !errors.As(err, &apiErr) { + // no ApiError found -> assign a generic one + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, fs.ErrNotExist) { + apiErr = NewNotFoundError("", err) + } else { + apiErr = NewBadRequestError("", err) + } + } + + return apiErr +} + +// ------------------------------------------------------------------- + +func safeErrorsData(data any) map[string]any { + switch v := data.(type) { + case validation.Errors: + return resolveSafeErrorsData(v) + case error: + validationErrors := validation.Errors{} + if errors.As(v, &validationErrors) { + return resolveSafeErrorsData(validationErrors) + } + return map[string]any{} // not nil to ensure that is json serialized as object + case map[string]validation.Error: + return resolveSafeErrorsData(v) + case map[string]SafeErrorItem: + return resolveSafeErrorsData(v) + case map[string]error: + return resolveSafeErrorsData(v) + case map[string]string: + return resolveSafeErrorsData(v) + case map[string]any: + return resolveSafeErrorsData(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) + } else { + result[name] = resolveSafeErrorItem(err) + } + } + + return result +} + +func isNestedError(err any) bool { + switch err.(type) { + case validation.Errors, + map[string]validation.Error, + map[string]SafeErrorItem, + map[string]error, + map[string]string, + map[string]any: + return true + } + + return false +} + +// resolveSafeErrorItem extracts from each validation error its +// public safe error code and message. +func resolveSafeErrorItem(err any) any { + data := map[string]any{} + + if obj, ok := err.(SafeErrorItem); ok { + // extract the specific error code and message + data["code"] = obj.Code() + data["message"] = inflector.Sentenize(obj.Error()) + } else { + // fallback to the default public safe values + data["code"] = "validation_invalid_value" + data["message"] = "Invalid value." + } + + if s, ok := err.(SafeErrorParamsResolver); ok { + params := s.Params() + if len(params) > 0 { + data["params"] = params + } + } + + if s, ok := err.(SafeErrorResolver); ok { + return s.Resolve(data) + } + + return data +} diff --git a/tools/router/error_test.go b/tools/router/error_test.go new file mode 100644 index 0000000000..a5e397bdc5 --- /dev/null +++ b/tools/router/error_test.go @@ -0,0 +1,358 @@ +package router_test + +import ( + "database/sql" + "encoding/json/v2" + "errors" + "fmt" + "io/fs" + "strconv" + "testing" + + validation "github.com/pocketbase/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/router" +) + +func TestNewApiErrorWithRawData(t *testing.T) { + t.Parallel() + + e := router.NewApiError( + 300, + "message_test", + "rawData_test", + ) + + result, _ := json.Marshal(e, json.Deterministic(true)) + expected := `{"data":{},"message":"Message_test.","status":300}` + + 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() != "rawData_test" { + t.Errorf("Expected rawData\n%v\ngot\n%v", "rawData_test", e.RawData()) + } +} + +func TestNewApiErrorWithValidationData(t *testing.T) { + t.Parallel() + + e := router.NewApiError( + 300, + "message_test", + map[string]any{ + "err1": errors.New("test error"), // should be normalized + "err2": validation.ErrRequired, + "err3": validation.Errors{ + "err3.1": errors.New("test error"), // should be normalized + "err3.2": validation.ErrRequired, + "err3.3": validation.Errors{ + "err3.3.1": validation.ErrRequired, + }, + }, + "err4": &mockSafeErrorItem{}, + "err5": map[string]error{ + "err5.1": validation.ErrRequired, + }, + }, + ) + + result, _ := json.Marshal(e, json.Deterministic(true)) + expected := `{"data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"err3.1":{"code":"validation_invalid_value","message":"Invalid value."},"err3.2":{"code":"validation_required","message":"Cannot be blank."},"err3.3":{"err3.3.1":{"code":"validation_required","message":"Cannot be blank."}}},"err4":{"code":"mock_code","message":"Mock_error.","mock_resolve":123},"err5":{"err5.1":{"code":"validation_required","message":"Cannot be blank."}}},"message":"Message_test.","status":300}` + + 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) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"The requested resource wasn't found.","status":404}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":404}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":404}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewNotFoundError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewBadRequestError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Something went wrong while processing your request.","status":400}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":400}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":400}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewBadRequestError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewForbiddenError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"You are not allowed to perform this request.","status":403}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":403}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":403}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewForbiddenError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewUnauthorizedError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Missing or invalid authentication.","status":401}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":401}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":401}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewUnauthorizedError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewInternalServerError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Something went wrong while processing your request.","status":500}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":500}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"data":{"err1":{"code":"test_code","message":"Test_message."}},"message":"Demo.","status":500}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewInternalServerError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestNewTooManyRequestsError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + message string + data any + expected string + }{ + {"", nil, `{"data":{},"message":"Too Many Requests.","status":429}`}, + {"demo", "rawData_test", `{"data":{},"message":"Demo.","status":429}`}, + {"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message").SetParams(map[string]any{"test": 123})}, `{"data":{"err1":{"code":"test_code","message":"Test_message.","params":{"test":123}}},"message":"Demo.","status":429}`}, + } + + for i, s := range scenarios { + t.Run(strconv.Itoa(i), func(t *testing.T) { + e := router.NewTooManyRequestsError(s.message, s.data) + result, _ := json.Marshal(e, json.Deterministic(true)) + + if str := string(result); str != s.expected { + t.Fatalf("Expected\n%v\ngot\n%v", s.expected, str) + } + }) + } +} + +func TestApiErrorIs(t *testing.T) { + t.Parallel() + + err0 := router.NewInternalServerError("", nil) + err1 := router.NewInternalServerError("", nil) + err2 := errors.New("test") + err3 := fmt.Errorf("wrapped: %w", err0) + + scenarios := []struct { + name string + err error + target error + expected bool + }{ + { + "nil error", + err0, + nil, + false, + }, + { + "non ApiError", + err0, + err1, + false, + }, + { + "different ApiError", + err0, + err2, + false, + }, + { + "same ApiError", + err0, + err0, + true, + }, + { + "wrapped ApiError", + err3, + err0, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + is := errors.Is(s.err, s.target) + + if is != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, is) + } + }) + } +} + +func TestToApiError(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + err error + expected string + }{ + { + "regular error", + errors.New("test"), + `{"data":{},"message":"Something went wrong while processing your request.","status":400}`, + }, + { + "fs.ErrNotExist", + fs.ErrNotExist, + `{"data":{},"message":"The requested resource wasn't found.","status":404}`, + }, + { + "sql.ErrNoRows", + sql.ErrNoRows, + `{"data":{},"message":"The requested resource wasn't found.","status":404}`, + }, + { + "ApiError", + router.NewForbiddenError("test", nil), + `{"data":{},"message":"Test.","status":403}`, + }, + { + "wrapped ApiError", + fmt.Errorf("wrapped: %w", router.NewForbiddenError("test", nil)), + `{"data":{},"message":"Test.","status":403}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + raw, err := json.Marshal(router.ToApiError(s.err), json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + rawStr := string(raw) + + if rawStr != s.expected { + t.Fatalf("Expected error\n%vgot\n%v", s.expected, rawStr) + } + }) + } +} + +// ------------------------------------------------------------------- + +var ( + _ router.SafeErrorItem = (*mockSafeErrorItem)(nil) + _ router.SafeErrorResolver = (*mockSafeErrorItem)(nil) +) + +type mockSafeErrorItem struct { +} + +func (m *mockSafeErrorItem) Code() string { + return "mock_code" +} + +func (m *mockSafeErrorItem) Error() string { + return "mock_error" +} + +func (m *mockSafeErrorItem) Resolve(errData map[string]any) any { + errData["mock_resolve"] = 123 + return errData +} diff --git a/tools/router/event.go b/tools/router/event.go new file mode 100644 index 0000000000..5c00e4ca78 --- /dev/null +++ b/tools/router/event.go @@ -0,0 +1,423 @@ +package router + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "encoding/xml" + "errors" + "io" + "io/fs" + "net" + "net/http" + "net/netip" + "path/filepath" + "strings" + + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/picker" + "github.com/pocketbase/pocketbase/tools/store" +) + +var ErrUnsupportedContentType = NewBadRequestError("Unsupported Content-Type", nil) +var ErrInvalidRedirectStatusCode = NewInternalServerError("Invalid redirect status code", nil) +var ErrFileNotFound = NewNotFoundError("File not found", nil) + +const IndexPage = "index.html" + +// Event specifies based Route handler event that is usually intended +// to be embedded as part of a custom event struct. +// +// NB! It is expected that the Response and Request fields are always set. +type Event struct { + Response http.ResponseWriter + Request *http.Request + + hook.Event + + data store.Store[string, any] +} + +// RWUnwrapper specifies that an http.ResponseWriter could be "unwrapped" +// (usually used with [http.ResponseController]). +type RWUnwrapper interface { + Unwrap() http.ResponseWriter +} + +// Written reports whether the current response has already been written. +// +// This method always returns false if e.ResponseWritter doesn't implement the WriteTracker interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Written() bool { + written, _ := getWritten(e.Response) + return written +} + +// Status reports the status code of the current response. +// +// This method always returns 0 if e.Response doesn't implement the StatusTracker interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Status() int { + status, _ := getStatus(e.Response) + return status +} + +// Flush flushes buffered data to the current response. +// +// Returns [http.ErrNotSupported] if e.Response doesn't implement the [http.Flusher] interface +// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one). +func (e *Event) Flush() error { + return http.NewResponseController(e.Response).Flush() +} + +// IsTLS reports whether the connection on which the request was received is TLS. +func (e *Event) IsTLS() bool { + return e.Request.TLS != nil +} + +// SetCookie is an alias for [http.SetCookie]. +// +// SetCookie adds a Set-Cookie header to the current response's headers. +// The provided cookie must have a valid Name. +// Invalid cookies may be silently dropped. +func (e *Event) SetCookie(cookie *http.Cookie) { + http.SetCookie(e.Response, cookie) +} + +// RemoteIP returns the IP address of the client that sent the request. +// +// IPv6 addresses are returned expanded. +// For example, "2001:db8::1" becomes "2001:0db8:0000:0000:0000:0000:0000:0001". +// +// Note that if you are behind reverse proxy(ies), this method returns +// the IP of the last connecting proxy. +func (e *Event) RemoteIP() string { + ip, _, _ := net.SplitHostPort(e.Request.RemoteAddr) + parsed, _ := netip.ParseAddr(ip) + return parsed.StringExpanded() +} + +// FindUploadedFiles extracts all form files of "key" from a http request +// and returns a slice with filesystem.File instances (if any). +func (e *Event) FindUploadedFiles(key string) ([]*filesystem.File, error) { + if e.Request.MultipartForm == nil { + err := e.Request.ParseMultipartForm(DefaultMaxMemory) + if err != nil { + return nil, err + } + } + + if e.Request.MultipartForm == nil || e.Request.MultipartForm.File == nil || len(e.Request.MultipartForm.File[key]) == 0 { + return nil, http.ErrMissingFile + } + + result := make([]*filesystem.File, 0, len(e.Request.MultipartForm.File[key])) + + for _, fh := range e.Request.MultipartForm.File[key] { + file, err := filesystem.NewFileFromMultipart(fh) + if err != nil { + return nil, err + } + + result = append(result, file) + } + + return result, nil +} + +// Store +// ------------------------------------------------------------------- + +// Get retrieves single value from the current event data store. +func (e *Event) Get(key string) any { + return e.data.Get(key) +} + +// GetAll returns a copy of the current event data store. +func (e *Event) GetAll() map[string]any { + return e.data.GetAll() +} + +// Set saves single value into the current event data store. +func (e *Event) Set(key string, value any) { + e.data.Set(key, value) +} + +// SetAll saves all items from m into the current event data store. +func (e *Event) SetAll(m map[string]any) { + for k, v := range m { + e.Set(k, v) + } +} + +// Response writers +// ------------------------------------------------------------------- + +const headerContentType = "Content-Type" + +func (e *Event) setResponseHeaderIfEmpty(key, value string) { + header := e.Response.Header() + if header.Get(key) == "" { + header.Set(key, value) + } +} + +// String writes a plain string response. +func (e *Event) String(status int, data string) error { + e.setResponseHeaderIfEmpty(headerContentType, "text/plain; charset=utf-8") + e.Response.WriteHeader(status) + _, err := e.Response.Write([]byte(data)) + return err +} + +// HTML writes an HTML response. +func (e *Event) HTML(status int, data string) error { + e.setResponseHeaderIfEmpty(headerContentType, "text/html; charset=utf-8") + e.Response.WriteHeader(status) + _, err := e.Response.Write([]byte(data)) + return err +} + +const jsonFieldsParam = "fields" + +// JSON writes a JSON response. +// +// It also provides a generic response data fields picker if the "fields" query parameter is set. +// For example, if you are requesting `?fields=a,b` for `e.JSON(200, map[string]int{ "a":1, "b":2, "c":3 })`, +// it should result in a JSON response like: `{"a":1, "b": 2}`. +// +// Note that invalid UTF8 characters are mangled for compatibility +// with earlier versions and to prevent unnecessary causing a response error. +func (e *Event) JSON(status int, data any) error { + // try to pick only the requested fields (currently allowed only for "success" responses) + rawFields := e.Request.URL.Query().Get(jsonFieldsParam) + if rawFields != "" && status >= 200 && status <= 299 { + modified, err := picker.Pick(data, rawFields) + if err == nil { + data = modified + } else if !errors.Is(err, picker.ErrInvalidModifierData) { + // @todo ignore for now modifier data errors to avoid introducing + // breaking changes but once the router is merged with core consider + // at least logging for dev purposes + return err + } + } + + e.setResponseHeaderIfEmpty(headerContentType, "application/json") + e.Response.WriteHeader(status) + + return json.MarshalWrite( + e.Response, + data, + // 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), + ) +} + +// XML writes an XML response. +// It automatically prepends the generic [xml.Header] string to the response. +func (e *Event) XML(status int, data any) error { + e.setResponseHeaderIfEmpty(headerContentType, "application/xml; charset=utf-8") + e.Response.WriteHeader(status) + if _, err := e.Response.Write([]byte(xml.Header)); err != nil { + return err + } + return xml.NewEncoder(e.Response).Encode(data) +} + +// Stream streams the specified reader into the response. +func (e *Event) Stream(status int, contentType string, reader io.Reader) error { + e.Response.Header().Set(headerContentType, contentType) + e.Response.WriteHeader(status) + _, err := io.Copy(e.Response, reader) + return err +} + +// Blob writes a blob (bytes slice) response. +func (e *Event) Blob(status int, contentType string, b []byte) error { + e.setResponseHeaderIfEmpty(headerContentType, contentType) + e.Response.WriteHeader(status) + _, err := e.Response.Write(b) + return err +} + +// FileFS serves the specified filename from fsys. +// +// It is similar to [echo.FileFS] for consistency with earlier versions. +func (e *Event) FileFS(fsys fs.FS, filename string) error { + f, err := fsys.Open(filename) + if err != nil { + return ErrFileNotFound + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + // if it is a directory try to open its index.html file + if fi.IsDir() { + filename = filepath.ToSlash(filepath.Join(filename, IndexPage)) + f, err = fsys.Open(filename) + if err != nil { + return ErrFileNotFound + } + defer f.Close() + + fi, err = f.Stat() + if err != nil { + return err + } + } + + ff, ok := f.(io.ReadSeeker) + if !ok { + return errors.New("[FileFS] file does not implement io.ReadSeeker") + } + + http.ServeContent(e.Response, e.Request, fi.Name(), fi.ModTime(), ff) + + return nil +} + +// NoContent writes a response with no body (ex. 204). +func (e *Event) NoContent(status int) error { + e.Response.WriteHeader(status) + return nil +} + +// Redirect writes a redirect response to the specified url. +// The status code must be in between 300 – 399 range. +func (e *Event) Redirect(status int, url string) error { + if status < 300 || status > 399 { + return ErrInvalidRedirectStatusCode + } + e.Response.Header().Set("Location", url) + e.Response.WriteHeader(status) + return nil +} + +// ApiError helpers +// ------------------------------------------------------------------- + +func (e *Event) Error(status int, message string, errData any) *ApiError { + return NewApiError(status, message, errData) +} + +func (e *Event) BadRequestError(message string, errData any) *ApiError { + return NewBadRequestError(message, errData) +} + +func (e *Event) NotFoundError(message string, errData any) *ApiError { + return NewNotFoundError(message, errData) +} + +func (e *Event) ForbiddenError(message string, errData any) *ApiError { + return NewForbiddenError(message, errData) +} + +func (e *Event) UnauthorizedError(message string, errData any) *ApiError { + return NewUnauthorizedError(message, errData) +} + +func (e *Event) TooManyRequestsError(message string, errData any) *ApiError { + return NewTooManyRequestsError(message, errData) +} + +func (e *Event) InternalServerError(message string, errData any) *ApiError { + return NewInternalServerError(message, errData) +} + +// Binders +// ------------------------------------------------------------------- + +const DefaultMaxMemory = 16 << 20 // 16mb + +// BindBody unmarshal the request body into the provided dst. +// +// dst must be either a struct pointer or map[string]any. +// +// The rules how the body will be scanned depends on the request Content-Type. +// +// Currently the following Content-Types are supported: +// - application/json +// - text/xml, application/xml +// - multipart/form-data, application/x-www-form-urlencoded +// +// Respectively the following struct tags are supported (again, which one will be used depends on the Content-Type): +// - "json" (json body)- uses the builtin Go json package for unmarshaling. +// - "xml" (xml body) - uses the builtin Go xml package for unmarshaling. +// - "form" (form data) - utilizes the custom [router.UnmarshalRequestData] method. +// +// NB! When dst is a struct make sure that it doesn't have public fields +// that shouldn't be bindable and it is advisible such fields to be unexported +// or have a separate struct just for the binding. For example: +// +// data := struct{ +// somethingPrivate string +// +// Title string `json:"title" form:"title"` +// Total int `json:"total" form:"total"` +// }{} +// err := e.BindBody(&data) +func (e *Event) BindBody(dst any) error { + if e.Request.ContentLength == 0 { + return nil + } + + contentType := e.Request.Header.Get(headerContentType) + + if strings.HasPrefix(contentType, "application/json") { + // note: don't use json.UnmarshalRead because it perfoms an extra + // whitespace scanning which will trigger the auto reread and + // will start again from the beginning and causing an error + // + // (technically json.UnmarshalRead is the better option here but to make + // it work it will require extra interface or other mechanism to temp disable + // the auto reread functionality and this could be a footgun for middlewares + // that wraps the body so for now it is kept as it is and it should + // be similar to the old json.NewDecoder(e.Request.Body).Decode(dst)) + dec := jsontext.NewDecoder(e.Request.Body) + err := json.UnmarshalDecode(dec, dst, + // minimize breaking changes with earlier version + // @todo remove with the "Stage 2" refactoring + json.MatchCaseInsensitiveNames(true), + ) + if err == nil { + // manually call Reread because single call of json.UnmarshalDecode + // doesn't ensure that the entire body is a valid json string + // and it is not guaranteed that it will reach EOF to trigger the reread reset + // (ex. in case of trailing spaces or invalid trailing parts like: `{"test":1},something`) + if body, ok := e.Request.Body.(Rereader); ok { + body.Reread() + } + } + return err + } + + if strings.HasPrefix(contentType, "multipart/form-data") { + if err := e.Request.ParseMultipartForm(DefaultMaxMemory); err != nil { + return err + } + + return UnmarshalRequestData(e.Request.Form, dst, "", "") + } + + if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + if err := e.Request.ParseForm(); err != nil { + return err + } + + return UnmarshalRequestData(e.Request.Form, dst, "", "") + } + + if strings.HasPrefix(contentType, "text/xml") || + strings.HasPrefix(contentType, "application/xml") { + return xml.NewDecoder(e.Request.Body).Decode(dst) + } + + return ErrUnsupportedContentType +} diff --git a/tools/router/event_test.go b/tools/router/event_test.go new file mode 100644 index 0000000000..542ed98e60 --- /dev/null +++ b/tools/router/event_test.go @@ -0,0 +1,1099 @@ +package router_test + +import ( + "bytes" + "crypto/tls" + "encoding/json/jsontext" + "encoding/json/v2" + "encoding/xml" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + validation "github.com/pocketbase/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/picker" + "github.com/pocketbase/pocketbase/tools/router" + "github.com/pocketbase/pocketbase/tools/types" +) + +type unwrapTester struct { + http.ResponseWriter +} + +func (ut unwrapTester) Unwrap() http.ResponseWriter { + return ut.ResponseWriter +} + +func TestEventWritten(t *testing.T) { + t.Parallel() + + res1 := httptest.NewRecorder() + + res2 := httptest.NewRecorder() + res2.Write([]byte("test")) + + res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + + res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + res4.Write([]byte("test")) + + scenarios := []struct { + name string + response http.ResponseWriter + expected bool + }{ + { + name: "non-written non-WriteTracker", + response: res1, + expected: false, + }, + { + name: "written non-WriteTracker", + response: res2, + expected: false, + }, + { + name: "non-written WriteTracker", + response: res3, + expected: false, + }, + { + name: "written WriteTracker", + response: res4, + expected: true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + event := router.Event{ + Response: s.response, + } + + result := event.Written() + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestEventStatus(t *testing.T) { + t.Parallel() + + res1 := httptest.NewRecorder() + + res2 := httptest.NewRecorder() + res2.WriteHeader(123) + + res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + + res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}} + res4.WriteHeader(123) + + scenarios := []struct { + name string + response http.ResponseWriter + expected int + }{ + { + name: "non-written non-StatusTracker", + response: res1, + expected: 0, + }, + { + name: "written non-StatusTracker", + response: res2, + expected: 0, + }, + { + name: "non-written StatusTracker", + response: res3, + expected: 0, + }, + { + name: "written StatusTracker", + response: res4, + expected: 123, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + event := router.Event{ + Response: s.response, + } + + result := event.Status() + + if result != s.expected { + t.Fatalf("Expected %d, got %d", s.expected, result) + } + }) + } +} + +func TestEventIsTLS(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + event := router.Event{Request: req} + + // without TLS + if event.IsTLS() { + t.Fatalf("Expected IsTLS false") + } + + // dummy TLS state + req.TLS = new(tls.ConnectionState) + + // with TLS + if !event.IsTLS() { + t.Fatalf("Expected IsTLS true") + } +} + +func TestEventSetCookie(t *testing.T) { + t.Parallel() + + event := router.Event{ + Response: httptest.NewRecorder(), + } + + cookie := event.Response.Header().Get("set-cookie") + if cookie != "" { + t.Fatalf("Expected empty cookie string, got %q", cookie) + } + + event.SetCookie(&http.Cookie{Name: "test", Value: "a"}) + + expected := "test=a" + + cookie = event.Response.Header().Get("set-cookie") + if cookie != expected { + t.Fatalf("Expected cookie %q, got %q", expected, cookie) + } +} + +func TestEventRemoteIP(t *testing.T) { + t.Parallel() + + scenarios := []struct { + remoteAddr string + expected string + }{ + {"", "invalid IP"}, + {"1.2.3.4", "invalid IP"}, + {"1.2.3.4:8090", "1.2.3.4"}, + {"[0000:0000:0000:0000:0000:0000:0000:0002]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, + {"[::2]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, // should always return the expanded version + } + + for _, s := range scenarios { + t.Run(s.remoteAddr, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + req.RemoteAddr = s.remoteAddr + + event := router.Event{Request: req} + + ip := event.RemoteIP() + + if ip != s.expected { + t.Fatalf("Expected IP %q, got %q", s.expected, ip) + } + }) + } +} + +func TestFindUploadedFiles(t *testing.T) { + t.Parallel() + + scenarios := []struct { + filename string + expectedPattern string + }{ + {"ab.png", `^ab\w{10}_\w{10}\.png$`}, + {"test", `^test_\w{10}\.txt$`}, + {"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`}, + {strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`}, + } + + for _, s := range scenarios { + t.Run(s.filename, func(t *testing.T) { + // create multipart form file body + body := new(bytes.Buffer) + mp := multipart.NewWriter(body) + w, err := mp.CreateFormFile("test", s.filename) + if err != nil { + t.Fatal(err) + } + w.Write([]byte("test")) + mp.Close() + // --- + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + event := router.Event{Request: req} + + result, err := event.FindUploadedFiles("test") + if err != nil { + t.Fatal(err) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 file, got %d", len(result)) + } + + if result[0].Size != 4 { + t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Size) + } + + pattern, err := regexp.Compile(s.expectedPattern) + if err != nil { + t.Fatalf("Invalid filename pattern %q: %v", s.expectedPattern, err) + } + if !pattern.MatchString(result[0].Name) { + t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name) + } + }) + } +} + +func TestFindUploadedFilesMissing(t *testing.T) { + t.Parallel() + + body := new(bytes.Buffer) + mp := multipart.NewWriter(body) + mp.Close() + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + event := router.Event{Request: req} + + result, err := event.FindUploadedFiles("test") + if err == nil { + t.Error("Expected error, got nil") + } + + if result != nil { + t.Errorf("Expected result to be nil, got %v", result) + } +} + +func TestEventSetGet(t *testing.T) { + t.Parallel() + + event := router.Event{} + + // get before any set (ensures that doesn't panic) + if v := event.Get("test"); v != nil { + t.Fatalf("Expected nil value, got %v", v) + } + + event.Set("a", 123) + event.Set("b", 456) + + scenarios := []struct { + key string + expected any + }{ + {"", nil}, + {"missing", nil}, + {"a", 123}, + {"b", 456}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result := event.Get(s.key) + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestEventSetAllGetAll(t *testing.T) { + t.Parallel() + + data := map[string]any{ + "a": 123, + "b": 456, + } + rawData, err := json.Marshal(data, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + event := router.Event{} + event.SetAll(data) + + // modify the data to ensure that the map was shallow copied + data["c"] = 789 + + result := event.GetAll() + rawResult, err := json.Marshal(result, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + if len(rawResult) == 0 || !bytes.Equal(rawData, rawResult) { + t.Fatalf("Expected\n%v\ngot\n%v", rawData, rawResult) + } +} + +func TestEventString(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 234, + headers: nil, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/plain; charset=utf-8"}, + expectedBody: "test", + }, + { + name: "with explicit content-type", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.String(s.status, s.body) + }) + } +} + +func TestEventHTML(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 234, + headers: nil, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/html; charset=utf-8"}, + expectedBody: "test", + }, + { + name: "with explicit content-type", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.HTML(s.status, s.body) + }) + } +} + +func TestEventJSON(t *testing.T) { + t.Parallel() + + body := map[string]any{ + "a": 123, + "b": true, + "c": "test", + "d": "\xc3", // invalid utf8 char to test mangling + "e": types.JSONRaw(`{"a":1,"a":2}`), // duplicated keys to ensure compliance with old data + } + expectedPickedBody := `{"a":123,"c":"test"}` + expectedFullBody := `{"a":123,"b":true,"c":"test","d":"�","e":{"a":2}}` + + scenarios := []testResponseWriteScenario[any]{ + { + name: "no explicit content-type", + status: 200, + headers: nil, + body: body, + expectedStatus: 200, + expectedHeaders: map[string]string{"content-type": "application/json"}, + expectedBody: expectedPickedBody, + }, + { + name: "with explicit content-type (200)", + status: 200, + headers: map[string]string{"content-type": "application/test"}, + body: body, + expectedStatus: 200, + expectedHeaders: map[string]string{"content-type": "application/test"}, + expectedBody: expectedPickedBody, + }, + { + name: "with explicit content-type (400)", // no fields picker + status: 400, + headers: map[string]string{"content-type": "application/test"}, + body: body, + expectedStatus: 400, + expectedHeaders: map[string]string{"content-type": "application/test"}, + expectedBody: expectedFullBody, + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + e.Request.URL.RawQuery = "fields=a,c" // ensures that the picker is invoked + return e.JSON(s.status, s.body) + }) + } +} + +func TestEventJSONPickError(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodGet, "/?fields=a:excerpt(-1)", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + event := &router.Event{ + Request: req, + Response: &router.ResponseWriter{ResponseWriter: rec}, + } + + err = event.JSON(200, map[string]any{"a": "test"}) + if err == nil { + t.Fatal("Expected JSON to return modifier args err, got nil") + } + + // ensure that no explicit status code was written yet by attempting to write one + // (should do nothing if it was already written) + rec.WriteHeader(567) + + result := rec.Result() + defer result.Body.Close() + + if result.StatusCode != 567 { + t.Fatalf("Expected custom status code, got %d", result.StatusCode) + } +} + +type brokenModifier struct { +} + +func (m *brokenModifier) Modify(val any) (any, error) { + return nil, errors.New("test_error") +} + +func TestEventJSONIgnoredError(t *testing.T) { + t.Parallel() + + picker.Modifiers["broken_ignore"] = func(args ...string) (picker.Modifier, error) { + return &brokenModifier{}, nil + } + + req, err := http.NewRequest(http.MethodGet, "/?fields=a:broken_ignore", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + event := &router.Event{ + Request: req, + Response: &router.ResponseWriter{ResponseWriter: rec}, + } + + err = event.JSON(200, map[string]any{"a": "test"}) + if err != nil { + t.Fatalf("Expected nil JSON result, got error %v", err) + } + + // (should do nothing if it was already written) + rec.WriteHeader(567) + + result := rec.Result() + defer result.Body.Close() + + rawBody, err := io.ReadAll(result.Body) + if err != nil { + t.Fatal(err) + } + + if result.StatusCode != http.StatusOK { + t.Fatalf("Expected %d status code, got %d", http.StatusOK, result.StatusCode) + } + + expected := `{"a":"test"}` + if string(rawBody) != expected { + t.Fatalf("Expected json\n%s\ngot\n%s", expected, rawBody) + } +} + +func TestEventXML(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[string]{ + { + name: "no explicit content-type", + status: 234, + headers: nil, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "application/xml; charset=utf-8"}, + expectedBody: xml.Header + "test", + }, + { + name: "with explicit content-type", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: xml.Header + "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.XML(s.status, s.body) + }) + } +} + +func TestEventStream(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[string]{ + { + name: "stream", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: "test", + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Stream(s.status, s.headers["content-type"], strings.NewReader(s.body)) + }) + } +} + +func TestEventBlob(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[[]byte]{ + { + name: "blob", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: []byte("test"), + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "test", + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Blob(s.status, s.headers["content-type"], s.body) + }) + } +} + +func TestEventNoContent(t *testing.T) { + t.Parallel() + + s := testResponseWriteScenario[any]{ + name: "no content", + status: 234, + headers: map[string]string{"content-type": "text/test"}, + body: nil, + expectedStatus: 234, + expectedHeaders: map[string]string{"content-type": "text/test"}, + expectedBody: "", + } + + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.NoContent(s.status) + }) +} + +func TestEventFlush(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + + event := &router.Event{ + Response: unwrapTester{&router.ResponseWriter{ResponseWriter: rec}}, + } + event.Response.Write([]byte("test")) + event.Flush() + + if !rec.Flushed { + t.Fatal("Expected response to be flushed") + } +} + +func TestEventRedirect(t *testing.T) { + t.Parallel() + + scenarios := []testResponseWriteScenario[any]{ + { + name: "non-30x status", + status: 200, + expectedStatus: 200, + expectedError: router.ErrInvalidRedirectStatusCode, + }, + { + name: "30x status", + status: 302, + headers: map[string]string{"location": "test"}, // should be overwritten with the argument + expectedStatus: 302, + expectedHeaders: map[string]string{"location": "example"}, + }, + } + + for _, s := range scenarios { + testEventResponseWrite(t, s, func(e *router.Event) error { + return e.Redirect(s.status, "example") + }) + } +} + +func TestEventFileFS(t *testing.T) { + t.Parallel() + + // stub test files + // --- + dir, err := os.MkdirTemp("", "EventFileFS") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + err = os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0644) + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644) + if err != nil { + t.Fatal(err) + } + + // create sub directory with an index.html file inside it + err = os.MkdirAll(filepath.Join(dir, "sub1"), os.ModePerm) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(dir, "sub1", "index.html"), []byte("sub1 index"), 0644) + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Join(dir, "sub2"), os.ModePerm) + if err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(dir, "sub2", "test.txt"), []byte("sub2 test"), 0644) + if err != nil { + t.Fatal(err) + } + // --- + + scenarios := []struct { + name string + path string + expected string + }{ + {"missing file", "", ""}, + {"root with no explicit file", "", ""}, + {"root with explicit file", "test.txt", "test"}, + {"sub dir with no explicit file", "sub1", "sub1 index"}, + {"sub dir with no explicit file (no index.html)", "sub2", ""}, + {"sub dir explicit file", "sub2/test.txt", "sub2 test"}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + + event := &router.Event{ + Request: req, + Response: rec, + } + + err = event.FileFS(os.DirFS(dir), s.path) + + hasErr := err != nil + expectErr := s.expected == "" + if hasErr != expectErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", expectErr, hasErr, err) + } + + result := rec.Result() + + raw, err := io.ReadAll(result.Body) + result.Body.Close() + if err != nil { + t.Fatal(err) + } + + if string(raw) != s.expected { + t.Fatalf("Expected body\n%s\ngot\n%s", s.expected, raw) + } + + // ensure that the proper file headers are added + // (aka. http.ServeContent is invoked) + length, _ := strconv.Atoi(result.Header.Get("content-length")) + if length != len(s.expected) { + t.Fatalf("Expected Content-Length %d, got %d", len(s.expected), length) + } + }) + } +} + +func TestEventError(t *testing.T) { + t.Parallel() + + err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":123}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventBadRequestError(t *testing.T) { + t.Parallel() + + err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":400}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventNotFoundError(t *testing.T) { + t.Parallel() + + err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":404}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventForbiddenError(t *testing.T) { + t.Parallel() + + err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":403}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventUnauthorizedError(t *testing.T) { + t.Parallel() + + err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":401}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventTooManyRequestsError(t *testing.T) { + t.Parallel() + + err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":429}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventInternalServerError(t *testing.T) { + t.Parallel() + + err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"}) + + result, _ := json.Marshal(err, json.Deterministic(true)) + expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":500}` + + if string(result) != expected { + t.Errorf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestEventBindBody(t *testing.T) { + t.Parallel() + + type testDstStruct struct { + A int `json:"a" xml:"a" form:"a"` + B int `json:"b" xml:"b" form:"b"` + C string `json:"c" xml:"c" form:"c"` + } + + emptyDst := `{"a":0,"b":0,"c":""}` + + queryDst := `a=123&b=-456&c=test` + + xmlDst := ` + + + 123 + -456 + test + + ` + + jsonDst := `{"a":123,"b":-456,"c":"test"}` + + // multipart + mpBody := &bytes.Buffer{} + mpWriter := multipart.NewWriter(mpBody) + mpWriter.WriteField("@jsonPayload", `{"a":123}`) + mpWriter.WriteField("b", "-456") + mpWriter.WriteField("c", "test") + if err := mpWriter.Close(); err != nil { + t.Fatal(err) + } + + scenarios := []struct { + contentType string + body io.Reader + expectDst string + expectError bool + }{ + { + contentType: "", + body: strings.NewReader(jsonDst), + expectDst: emptyDst, + expectError: true, + }, + { + contentType: "application/rtf", // unsupported + body: strings.NewReader(jsonDst), + expectDst: emptyDst, + expectError: true, + }, + // empty body + { + contentType: "application/json;charset=emptybody", + body: strings.NewReader(""), + expectDst: emptyDst, + }, + // json + { + contentType: "application/json", + body: strings.NewReader(jsonDst), + expectDst: jsonDst, + }, + { + contentType: "application/json;charset=abc", + body: strings.NewReader(jsonDst), + expectDst: jsonDst, + }, + // xml + { + contentType: "text/xml", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "text/xml;charset=abc", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "application/xml", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + { + contentType: "application/xml;charset=abc", + body: strings.NewReader(xmlDst), + expectDst: jsonDst, + }, + // x-www-form-urlencoded + { + contentType: "application/x-www-form-urlencoded", + body: strings.NewReader(queryDst), + expectDst: jsonDst, + }, + { + contentType: "application/x-www-form-urlencoded;charset=abc", + body: strings.NewReader(queryDst), + expectDst: jsonDst, + }, + // multipart + { + contentType: mpWriter.FormDataContentType(), + body: mpBody, + expectDst: jsonDst, + }, + } + + for _, s := range scenarios { + t.Run(s.contentType, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/", s.body) + if err != nil { + t.Fatal(err) + } + req.Header.Add("content-type", s.contentType) + + event := &router.Event{Request: req} + + dst := testDstStruct{} + + err = event.BindBody(&dst) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + dstRaw, err := json.Marshal(dst, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + if string(dstRaw) != s.expectDst { + t.Fatalf("Expected dst\n%s\ngot\n%s", s.expectDst, dstRaw) + } + }) + } +} + +// ------------------------------------------------------------------- + +type testResponseWriteScenario[T any] struct { + name string + status int + headers map[string]string + body T + expectedStatus int + expectedHeaders map[string]string + expectedBody string + expectedError error +} + +func testEventResponseWrite[T any]( + t *testing.T, + scenario testResponseWriteScenario[T], + writeFunc func(e *router.Event) error, +) { + t.Run(scenario.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + event := &router.Event{ + Request: req, + Response: &router.ResponseWriter{ResponseWriter: rec}, + } + + for k, v := range scenario.headers { + event.Response.Header().Add(k, v) + } + + err = writeFunc(event) + if (scenario.expectedError != nil || err != nil) && !errors.Is(err, scenario.expectedError) { + t.Fatalf("Expected error %v, got %v", scenario.expectedError, err) + } + + result := rec.Result() + defer result.Body.Close() + + if result.StatusCode != scenario.expectedStatus { + t.Fatalf("Expected status code %d, got %d", scenario.expectedStatus, result.StatusCode) + } + + rawBody, err := io.ReadAll(result.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + strBody := string(rawBody) + + // try to deserialize into a map and then serialize again in a + // deterministic manner in case it is json + var jsonBody any + err = json.Unmarshal(rawBody, &jsonBody, jsontext.AllowDuplicateNames(true)) + if err == nil { + normalized, err := json.Marshal(jsonBody, json.Deterministic(true), jsontext.AllowDuplicateNames(true)) + if err != nil { + t.Fatalf("Failed to deterministicly serialize json body\n%s\ngot\n%v", jsonBody, err) + } + strBody = string(normalized) + } + + if scenario.expectedBody != strBody { + t.Fatalf("Expected body\n%s\ngot\n%s", scenario.expectedBody, strBody) + } + + for k, ev := range scenario.expectedHeaders { + if v := result.Header.Get(k); v != ev { + t.Fatalf("Expected %q header to be %q, got %q", k, ev, v) + } + } + }) +} diff --git a/tools/router/group.go b/tools/router/group.go new file mode 100644 index 0000000000..b6f75248ce --- /dev/null +++ b/tools/router/group.go @@ -0,0 +1,231 @@ +package router + +import ( + "net/http" + "regexp" + "strings" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +// (note: the struct is named RouterGroup instead of Group so that it can +// be embedded in the Router without conflicting with the Group method) + +// RouterGroup represents a collection of routes and other sub groups +// that share common pattern prefix and middlewares. +type RouterGroup[T hook.Resolver] struct { + excludedMiddlewares map[string]struct{} + children []any // Route or RouterGroup + + Prefix string + Middlewares []*hook.Handler[T] +} + +// Group creates and register a new child Group into the current one +// with the specified prefix. +// +// The prefix follows the standard Go net/http ServeMux pattern format ("[HOST]/[PATH]") +// and will be concatenated recursively into the final route path, meaning that +// only the root level group could have HOST as part of the prefix. +// +// Returns the newly created group to allow chaining and registering +// sub-routes and group specific middlewares. +func (group *RouterGroup[T]) Group(prefix string) *RouterGroup[T] { + newGroup := &RouterGroup[T]{} + newGroup.Prefix = prefix + + group.children = append(group.children, newGroup) + + return newGroup +} + +// BindFunc registers one or multiple middleware functions to the current group. +// +// The registered middleware functions are "anonymous" and with default priority, +// aka. executes in the order they were registered. +// +// If you need to specify a named middleware (ex. so that it can be removed) +// or middleware with custom exec prirority, use [RouterGroup.Bind] method. +func (group *RouterGroup[T]) BindFunc(middlewareFuncs ...func(e T) error) *RouterGroup[T] { + for _, m := range middlewareFuncs { + group.Middlewares = append(group.Middlewares, &hook.Handler[T]{Func: m}) + } + + return group +} + +// Bind registers one or multiple middleware handlers to the current group. +func (group *RouterGroup[T]) Bind(middlewares ...*hook.Handler[T]) *RouterGroup[T] { + group.Middlewares = append(group.Middlewares, middlewares...) + + // unmark the newly added middlewares in case they were previously "excluded" + if group.excludedMiddlewares != nil { + for _, m := range middlewares { + if m.Id != "" { + delete(group.excludedMiddlewares, m.Id) + } + } + } + + return group +} + +// Unbind removes one or more middlewares with the specified id(s) +// from the current group and its children (if any). +// +// Anonymous middlewares are not removable, aka. this method does nothing +// if the middleware id is an empty string. +func (group *RouterGroup[T]) Unbind(middlewareIds ...string) *RouterGroup[T] { + for _, middlewareId := range middlewareIds { + if middlewareId == "" { + continue + } + + // remove from the group middlwares + for i := len(group.Middlewares) - 1; i >= 0; i-- { + if group.Middlewares[i].Id == middlewareId { + group.Middlewares = append(group.Middlewares[:i], group.Middlewares[i+1:]...) + } + } + + // remove from the group children + for i := len(group.children) - 1; i >= 0; i-- { + switch v := group.children[i].(type) { + case *RouterGroup[T]: + v.Unbind(middlewareId) + case *Route[T]: + v.Unbind(middlewareId) + } + } + + // add to the exclude list + if group.excludedMiddlewares == nil { + group.excludedMiddlewares = map[string]struct{}{} + } + group.excludedMiddlewares[middlewareId] = struct{}{} + } + + return group +} + +// Route registers a single route into the current group. +// +// Note that the final route path will be the concatenation of all parent groups prefixes + the route path. +// The path follows the standard Go net/http ServeMux format ("[HOST]/[PATH]"), +// meaning that only a top level group route could have HOST as part of the prefix. +// +// Returns the newly created route to allow attaching route-only middlewares. +func (group *RouterGroup[T]) Route(method string, path string, action func(e T) error) *Route[T] { + route := &Route[T]{ + Method: method, + Path: path, + Action: action, + } + + group.children = append(group.children, route) + + return route +} + +// Any is a shorthand for [RouterGroup.AddRoute] with "" as route method (aka. matches any method). +func (group *RouterGroup[T]) Any(path string, action func(e T) error) *Route[T] { + return group.Route("", path, action) +} + +// GET is a shorthand for [RouterGroup.AddRoute] with GET as route method. +func (group *RouterGroup[T]) GET(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodGet, path, action) +} + +// SEARCH is a shorthand for [RouterGroup.AddRoute] with SEARCH as route method. +func (group *RouterGroup[T]) SEARCH(path string, action func(e T) error) *Route[T] { + return group.Route("SEARCH", path, action) +} + +// POST is a shorthand for [RouterGroup.AddRoute] with POST as route method. +func (group *RouterGroup[T]) POST(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPost, path, action) +} + +// DELETE is a shorthand for [RouterGroup.AddRoute] with DELETE as route method. +func (group *RouterGroup[T]) DELETE(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodDelete, path, action) +} + +// PATCH is a shorthand for [RouterGroup.AddRoute] with PATCH as route method. +func (group *RouterGroup[T]) PATCH(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPatch, path, action) +} + +// PUT is a shorthand for [RouterGroup.AddRoute] with PUT as route method. +func (group *RouterGroup[T]) PUT(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodPut, path, action) +} + +// HEAD is a shorthand for [RouterGroup.AddRoute] with HEAD as route method. +func (group *RouterGroup[T]) HEAD(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodHead, path, action) +} + +// OPTIONS is a shorthand for [RouterGroup.AddRoute] with OPTIONS as route method. +func (group *RouterGroup[T]) OPTIONS(path string, action func(e T) error) *Route[T] { + return group.Route(http.MethodOptions, path, action) +} + +// HasRoute checks whether the specified route pattern (method + path) +// is registered in the current group or its children. +// +// This could be useful to conditionally register and checks for routes +// in order prevent panic on duplicated routes. +// +// Note that routes with anonymous and named wildcard placeholder are treated as equal, +// aka. "GET /abc/" is considered the same as "GET /abc/{something...}". +func (group *RouterGroup[T]) HasRoute(method string, path string) bool { + pattern := path + if method != "" { + pattern = strings.ToUpper(method) + " " + pattern + } + + return group.hasRoute(pattern, nil) +} + +func (group *RouterGroup[T]) hasRoute(pattern string, parents []*RouterGroup[T]) bool { + for _, child := range group.children { + switch v := child.(type) { + case *RouterGroup[T]: + if v.hasRoute(pattern, append(parents, group)) { + return true + } + case *Route[T]: + var result string + + if v.Method != "" { + result += v.Method + " " + } + + // add parent groups prefixes + for _, p := range parents { + result += p.Prefix + } + + // add current group prefix + result += group.Prefix + + // add current route path + result += v.Path + + if result == pattern || // direct match + // compares without the named wildcard, aka. /abc/{test...} is equal to /abc/ + stripWildcard(result) == stripWildcard(pattern) { + return true + } + } + } + return false +} + +var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`) + +func stripWildcard(pattern string) string { + return wildcardPlaceholderRegex.ReplaceAllString(pattern, "/") +} diff --git a/tools/router/group_test.go b/tools/router/group_test.go new file mode 100644 index 0000000000..aeafca44a3 --- /dev/null +++ b/tools/router/group_test.go @@ -0,0 +1,430 @@ +package router + +import ( + "errors" + "fmt" + "net/http" + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +func TestRouterGroupGroup(t *testing.T) { + t.Parallel() + + g0 := RouterGroup[*Event]{} + + g1 := g0.Group("test1") + g2 := g0.Group("test2") + + if total := len(g0.children); total != 2 { + t.Fatalf("Expected %d child groups, got %d", 2, total) + } + + if g1.Prefix != "test1" { + t.Fatalf("Expected g1 with prefix %q, got %q", "test1", g1.Prefix) + } + if g2.Prefix != "test2" { + t.Fatalf("Expected g2 with prefix %q, got %q", "test2", g2.Prefix) + } +} + +func TestRouterGroupBindFunc(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{} + + calls := "" + + // append one function + g.BindFunc(func(e *Event) error { + calls += "a" + return nil + }) + + // append multiple functions + g.BindFunc( + func(e *Event) error { + calls += "b" + return nil + }, + func(e *Event) error { + calls += "c" + return nil + }, + ) + + if total := len(g.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range g.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls sequence %q, got %q", "abc", calls) + } +} + +func TestRouterGroupBind(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{ + // mock excluded middlewares to check whether the entry will be deleted + excludedMiddlewares: map[string]struct{}{"test2": {}}, + } + + calls := "" + + // append one handler + g.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil + }, + }) + + // append multiple handlers + g.Bind( + &hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil + }, + }, + &hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil + }, + }, + ) + + if total := len(g.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range g.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls %q, got %q", "abc", calls) + } + + // ensures that the previously excluded middleware was removed + if len(g.excludedMiddlewares) != 0 { + t.Fatalf("Expected test2 to be removed from the excludedMiddlewares list, got %v", g.excludedMiddlewares) + } +} + +func TestRouterGroupUnbind(t *testing.T) { + t.Parallel() + + g := RouterGroup[*Event]{} + + calls := "" + + // anonymous middlewares + g.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil // unused value + }, + }) + + // middlewares with id + g.Bind(&hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil // unused value + }, + }) + g.Bind(&hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil // unused value + }, + }) + g.Bind(&hook.Handler[*Event]{ + Id: "test3", + Func: func(e *Event) error { + calls += "d" + return nil // unused value + }, + }) + + // remove + g.Unbind("") // should be no-op + g.Unbind("test1", "test3") + + if total := len(g.Middlewares); total != 2 { + t.Fatalf("Expected %d middlewares, got %v", 2, total) + } + + for _, h := range g.Middlewares { + if err := h.Func(nil); err != nil { + continue + } + } + + if calls != "ac" { + t.Fatalf("Expected calls %q, got %q", "ac", calls) + } + + // ensure that the ids were added in the exclude list + excluded := []string{"test1", "test3"} + if len(g.excludedMiddlewares) != len(excluded) { + t.Fatalf("Expected excludes %v, got %v", excluded, g.excludedMiddlewares) + } + for id := range g.excludedMiddlewares { + if !slices.Contains(excluded, id) { + t.Fatalf("Expected %q to be marked as excluded", id) + } + } +} + +func TestRouterGroupRoute(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + sub := group.Group("sub") + + var called bool + route := group.Route(http.MethodPost, "/test", func(e *Event) error { + called = true + return nil + }) + + // ensure that the route was registered only to the main one + // --- + if len(sub.children) != 0 { + t.Fatalf("Expected no sub children, got %d", len(sub.children)) + } + + if len(group.children) != 2 { + t.Fatalf("Expected %d group children, got %d", 2, len(group.children)) + } + // --- + + // check the registered route + // --- + if route != group.children[1] { + t.Fatalf("Expected group children %v, got %v", route, group.children[1]) + } + + if route.Method != http.MethodPost { + t.Fatalf("Expected route method %q, got %q", http.MethodPost, route.Method) + } + + if route.Path != "/test" { + t.Fatalf("Expected route path %q, got %q", "/test", route.Path) + } + + route.Action(nil) + if !called { + t.Fatal("Expected route action to be called") + } +} + +func TestRouterGroupRouteAliases(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + testErr := errors.New("test") + + testAction := func(e *Event) error { + return testErr + } + + scenarios := []struct { + route *Route[*Event] + expectMethod string + expectPath string + }{ + { + group.Any("/test", testAction), + "", + "/test", + }, + { + group.GET("/test", testAction), + http.MethodGet, + "/test", + }, + { + group.SEARCH("/test", testAction), + "SEARCH", + "/test", + }, + { + group.POST("/test", testAction), + http.MethodPost, + "/test", + }, + { + group.DELETE("/test", testAction), + http.MethodDelete, + "/test", + }, + { + group.PATCH("/test", testAction), + http.MethodPatch, + "/test", + }, + { + group.PUT("/test", testAction), + http.MethodPut, + "/test", + }, + { + group.HEAD("/test", testAction), + http.MethodHead, + "/test", + }, + { + group.OPTIONS("/test", testAction), + http.MethodOptions, + "/test", + }, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s_%s", i, s.expectMethod, s.expectPath), func(t *testing.T) { + if s.route.Method != s.expectMethod { + t.Fatalf("Expected method %q, got %q", s.expectMethod, s.route.Method) + } + + if s.route.Path != s.expectPath { + t.Fatalf("Expected path %q, got %q", s.expectPath, s.route.Path) + } + + if err := s.route.Action(nil); !errors.Is(err, testErr) { + t.Fatal("Expected test action") + } + }) + } +} + +func TestRouterGroupHasRoute(t *testing.T) { + t.Parallel() + + group := RouterGroup[*Event]{} + + group.Any("/any", nil) + + group.GET("/base", nil) + group.DELETE("/base", nil) + + sub := group.Group("/sub1") + sub.GET("/a", nil) + sub.POST("/a", nil) + + sub2 := sub.Group("/sub2") + sub2.GET("/b", nil) + sub2.GET("/b/{test}", nil) + + // special cases to test the normalizations + group.GET("/c/", nil) // the same as /c/{test...} + group.GET("/d/{test...}", nil) // the same as /d/ + + scenarios := []struct { + method string + path string + expected bool + }{ + { + http.MethodGet, + "", + false, + }, + { + "", + "/any", + true, + }, + { + http.MethodPost, + "/base", + false, + }, + { + http.MethodGet, + "/base", + true, + }, + { + http.MethodDelete, + "/base", + true, + }, + { + http.MethodGet, + "/sub1", + false, + }, + { + http.MethodGet, + "/sub1/a", + true, + }, + { + http.MethodPost, + "/sub1/a", + true, + }, + { + http.MethodDelete, + "/sub1/a", + false, + }, + { + http.MethodGet, + "/sub2/b", + false, + }, + { + http.MethodGet, + "/sub1/sub2/b", + true, + }, + { + http.MethodGet, + "/sub1/sub2/b/{test}", + true, + }, + { + http.MethodGet, + "/sub1/sub2/b/{test2}", + false, + }, + { + http.MethodGet, + "/c/{test...}", + true, + }, + { + http.MethodGet, + "/d/", + true, + }, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + has := group.HasRoute(s.method, s.path) + + if has != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, has) + } + }) + } +} diff --git a/tools/router/rereadable_read_closer.go b/tools/router/rereadable_read_closer.go new file mode 100644 index 0000000000..25ce758a88 --- /dev/null +++ b/tools/router/rereadable_read_closer.go @@ -0,0 +1,96 @@ +package router + +import ( + "errors" + "io" +) + +var ( + _ Rereader = (*RereadableReadCloser)(nil) + _ io.ReadCloser = (*RereadableReadCloser)(nil) +) + +// Rereader defines an interface for rewindable readers. +type Rereader interface { + Reread() +} + +// RereadableReadCloser defines a wrapper around a [io.ReadCloser] reader +// allowing to read the original reader multiple times. +// +// NB! Make sure to call Close after done working with the reader. +type RereadableReadCloser struct { + io.ReadCloser + + copy io.ReadWriteCloser + closeErrors []error + + // MaxMemory specifies the max size of the in memory copy buffer + // before switching to read/write from temp disk file. + // + // If negative or zero, defaults to [DefaultMaxMemory]. + MaxMemory int64 +} + +// Read implements the standard [io.Reader] interface. +// +// It reads up to len(p) bytes into p and at the same time copies +// the read data into an internal buffer (memory + temp file). +// +// On EOF r is "rewinded" to allow reading multiple times. +func (r *RereadableReadCloser) Read(p []byte) (int, error) { + n, err := r.ReadCloser.Read(p) + + // copy the read bytes into the internal buffer + if n > 0 { + if r.copy == nil { + r.copy = newBufferWithFile(r.MaxMemory) + } + + if n, err := r.copy.Write(p[:n]); err != nil { + return n, err + } + } + + // end reached -> reset for the next read + if err == io.EOF { + r.Reread() + } + + return n, err +} + +// Reread satisfies the [Rereader] interface and resets the r internal state to allow rereads. +// +// note: not named Reset to avoid conflicts with other reader interfaces. +func (r *RereadableReadCloser) Reread() { + if r.copy == nil { + return // nothing to reset + } + + // eagerly close the old reader to prevent accumulating too much memory or temp files + if err := r.ReadCloser.Close(); err != nil { + r.closeErrors = append(r.closeErrors, err) + } + + r.ReadCloser = r.copy + r.copy = nil +} + +// Close implements the standard [io.Closer] interface by cleaning up related resources. +// +// It is safe to call Close multiple times. +// Once Close is invoked the reader no longer can be used and should be discarded. +func (r *RereadableReadCloser) Close() error { + if r.copy != nil { + if err := r.copy.Close(); err != nil { + r.closeErrors = append(r.closeErrors, err) + } + } + + if err := r.ReadCloser.Close(); err != nil { + r.closeErrors = append(r.closeErrors, err) + } + + return errors.Join(r.closeErrors...) +} diff --git a/tools/router/rereadable_read_closer_test.go b/tools/router/rereadable_read_closer_test.go new file mode 100644 index 0000000000..5a45fc75a3 --- /dev/null +++ b/tools/router/rereadable_read_closer_test.go @@ -0,0 +1,69 @@ +package router + +import ( + "errors" + "io" + "io/fs" + "os" + "strconv" + "strings" + "testing" +) + +func TestRereadableReadCloser(t *testing.T) { + content := "test" + + rereadable := &RereadableReadCloser{ + ReadCloser: io.NopCloser(strings.NewReader(content)), + MaxMemory: 2, // should store the rest 2 bytes in temp file + } + + totalRereads := 5 + + tempFilenames := make([]string, 0, totalRereads) + + // reread multiple times + for i := 0; i < totalRereads; i++ { + t.Run("run_"+strconv.Itoa(i), func(t *testing.T) { + if i > 3 { + // test allso with manual Reread calls to ensure that + // r.copy is reseted and written to only when there are n>0 bytes + rereadable.Reread() + } + + result, err := io.ReadAll(rereadable) + if err != nil { + t.Fatalf("[read:%d] %v", i, err) + } + if str := string(result); str != content { + t.Fatalf("[read:%d] Expected %q, got %q", i, content, result) + } + + b, ok := rereadable.ReadCloser.(*bufferWithFile) + if !ok { + t.Fatalf("Expected bufferWithFile replacement, got %v", b) + } + + if b.file != nil { + tempFilenames = append(tempFilenames, b.file.Name()) + } + }) + } + + if v := len(tempFilenames); v != totalRereads { + t.Fatalf("Expected %d temp files to have been created during the previous rereads, got %d", totalRereads, v) + } + + err := rereadable.Close() + if err != nil { + t.Fatalf("Expected no close errors, got %v", err) + } + + // ensure that no lingering temp files are left after close + for _, name := range tempFilenames { + info, err := os.Stat(name) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Expected file name %q to be deleted, got %v (%v)", name, info, err) + } + } +} diff --git a/tools/router/route.go b/tools/router/route.go new file mode 100644 index 0000000000..351096dfaf --- /dev/null +++ b/tools/router/route.go @@ -0,0 +1,73 @@ +package router + +import "github.com/pocketbase/pocketbase/tools/hook" + +type Route[T hook.Resolver] struct { + excludedMiddlewares map[string]struct{} + + Action func(e T) error + Method string + Path string + Middlewares []*hook.Handler[T] +} + +// BindFunc registers one or multiple middleware functions to the current route. +// +// The registered middleware functions are "anonymous" and with default priority, +// aka. executes in the order they were registered. +// +// If you need to specify a named middleware (ex. so that it can be removed) +// or middleware with custom exec prirority, use the [Route.Bind] method. +func (route *Route[T]) BindFunc(middlewareFuncs ...func(e T) error) *Route[T] { + for _, m := range middlewareFuncs { + route.Middlewares = append(route.Middlewares, &hook.Handler[T]{Func: m}) + } + + return route +} + +// Bind registers one or multiple middleware handlers to the current route. +func (route *Route[T]) Bind(middlewares ...*hook.Handler[T]) *Route[T] { + route.Middlewares = append(route.Middlewares, middlewares...) + + // unmark the newly added middlewares in case they were previously "excluded" + if route.excludedMiddlewares != nil { + for _, m := range middlewares { + if m.Id != "" { + delete(route.excludedMiddlewares, m.Id) + } + } + } + + return route +} + +// Unbind removes one or more middlewares with the specified id(s) from the current route. +// +// It also adds the removed middleware ids to an exclude list so that they could be skipped from +// the execution chain in case the middleware is registered in a parent group. +// +// Anonymous middlewares are considered non-removable, aka. this method +// does nothing if the middleware id is an empty string. +func (route *Route[T]) Unbind(middlewareIds ...string) *Route[T] { + for _, middlewareId := range middlewareIds { + if middlewareId == "" { + continue + } + + // remove from the route's middlewares + for i := len(route.Middlewares) - 1; i >= 0; i-- { + if route.Middlewares[i].Id == middlewareId { + route.Middlewares = append(route.Middlewares[:i], route.Middlewares[i+1:]...) + } + } + + // add to the exclude list + if route.excludedMiddlewares == nil { + route.excludedMiddlewares = map[string]struct{}{} + } + route.excludedMiddlewares[middlewareId] = struct{}{} + } + + return route +} diff --git a/tools/router/route_test.go b/tools/router/route_test.go new file mode 100644 index 0000000000..ef6e54160c --- /dev/null +++ b/tools/router/route_test.go @@ -0,0 +1,168 @@ +package router + +import ( + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +func TestRouteBindFunc(t *testing.T) { + t.Parallel() + + r := Route[*Event]{} + + calls := "" + + // append one function + r.BindFunc(func(e *Event) error { + calls += "a" + return nil + }) + + // append multiple functions + r.BindFunc( + func(e *Event) error { + calls += "b" + return nil + }, + func(e *Event) error { + calls += "c" + return nil + }, + ) + + if total := len(r.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range r.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls sequence %q, got %q", "abc", calls) + } +} + +func TestRouteBind(t *testing.T) { + t.Parallel() + + r := Route[*Event]{ + // mock excluded middlewares to check whether the entry will be deleted + excludedMiddlewares: map[string]struct{}{"test2": {}}, + } + + calls := "" + + // append one handler + r.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil + }, + }) + + // append multiple handlers + r.Bind( + &hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil + }, + }, + &hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil + }, + }, + ) + + if total := len(r.Middlewares); total != 3 { + t.Fatalf("Expected %d middlewares, got %v", 3, total) + } + + for _, h := range r.Middlewares { + _ = h.Func(nil) + } + + if calls != "abc" { + t.Fatalf("Expected calls %q, got %q", "abc", calls) + } + + // ensures that the previously excluded middleware was removed + if len(r.excludedMiddlewares) != 0 { + t.Fatalf("Expected test2 to be removed from the excludedMiddlewares list, got %v", r.excludedMiddlewares) + } +} + +func TestRouteUnbind(t *testing.T) { + t.Parallel() + + r := Route[*Event]{} + + calls := "" + + // anonymous middlewares + r.Bind(&hook.Handler[*Event]{ + Func: func(e *Event) error { + calls += "a" + return nil // unused value + }, + }) + + // middlewares with id + r.Bind(&hook.Handler[*Event]{ + Id: "test1", + Func: func(e *Event) error { + calls += "b" + return nil // unused value + }, + }) + r.Bind(&hook.Handler[*Event]{ + Id: "test2", + Func: func(e *Event) error { + calls += "c" + return nil // unused value + }, + }) + r.Bind(&hook.Handler[*Event]{ + Id: "test3", + Func: func(e *Event) error { + calls += "d" + return nil // unused value + }, + }) + + // remove + r.Unbind("") // should be no-op + r.Unbind("test1", "test3") + + if total := len(r.Middlewares); total != 2 { + t.Fatalf("Expected %d middlewares, got %v", 2, total) + } + + for _, h := range r.Middlewares { + if err := h.Func(nil); err != nil { + continue + } + } + + if calls != "ac" { + t.Fatalf("Expected calls %q, got %q", "ac", calls) + } + + // ensure that the id was added in the exclude list + excluded := []string{"test1", "test3"} + if len(r.excludedMiddlewares) != len(excluded) { + t.Fatalf("Expected excludes %v, got %v", excluded, r.excludedMiddlewares) + } + for id := range r.excludedMiddlewares { + if !slices.Contains(excluded, id) { + t.Fatalf("Expected %q to be marked as excluded", id) + } + } +} diff --git a/tools/router/router.go b/tools/router/router.go new file mode 100644 index 0000000000..06cc50e600 --- /dev/null +++ b/tools/router/router.go @@ -0,0 +1,340 @@ +package router + +import ( + "bufio" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "io" + "log" + "net" + "net/http" + + "github.com/pocketbase/pocketbase/tools/hook" +) + +type EventCleanupFunc func() + +// EventFactoryFunc defines the function responsible for creating a Route specific event +// based on the provided request handler ServeHTTP data. +// +// Optionally return a clean up function that will be invoked right after the route execution. +type EventFactoryFunc[T hook.Resolver] func(w http.ResponseWriter, r *http.Request) (T, EventCleanupFunc) + +// Router defines a thin wrapper around the standard Go [http.ServeMux] by +// adding support for routing sub-groups, middlewares and other common utils. +// +// Example: +// +// r := NewRouter[*MyEvent](eventFactory) +// +// // middlewares +// r.BindFunc(m1, m2) +// +// // routes +// r.GET("/test", handler1) +// +// // sub-routers/groups +// api := r.Group("/api") +// api.GET("/admins", handler2) +// +// // generate a http.ServeMux instance based on the router configurations +// mux, _ := r.BuildMux() +// +// http.ListenAndServe("localhost:8090", mux) +type Router[T hook.Resolver] struct { + // @todo consider renaming the type to just Group and replace the embed type + // with an alias after Go 1.24 adds support for generic type aliases + *RouterGroup[T] + + eventFactory EventFactoryFunc[T] +} + +// NewRouter creates a new Router instance with the provided event factory function. +func NewRouter[T hook.Resolver](eventFactory EventFactoryFunc[T]) *Router[T] { + return &Router[T]{ + RouterGroup: &RouterGroup[T]{}, + eventFactory: eventFactory, + } +} + +// BuildMux constructs a new mux [http.Handler] instance from the current router configurations. +func (r *Router[T]) BuildMux() (http.Handler, error) { + // Note that some of the default std Go handlers like the [http.NotFoundHandler] + // cannot be currently extended and requires defining a custom "catch-all" route + // so that the group middlewares could be executed. + // + // https://github.com/golang/go/issues/65648 + if !r.HasRoute("", "/") { + r.Route("", "/", func(e T) error { + return NewNotFoundError("", nil) + }) + } + + mux := http.NewServeMux() + + if err := r.loadMux(mux, r.RouterGroup, nil); err != nil { + return nil, err + } + + return mux, nil +} + +func (r *Router[T]) loadMux(mux *http.ServeMux, group *RouterGroup[T], parents []*RouterGroup[T]) error { + for _, child := range group.children { + switch v := child.(type) { + case *RouterGroup[T]: + if err := r.loadMux(mux, v, append(parents, group)); err != nil { + return err + } + case *Route[T]: + routeHook := &hook.Hook[T]{} + + var pattern string + + if v.Method != "" { + pattern = v.Method + " " + } + + // add parent groups middlewares + for _, p := range parents { + pattern += p.Prefix + for _, h := range p.Middlewares { + if _, ok := p.excludedMiddlewares[h.Id]; !ok { + if _, ok = group.excludedMiddlewares[h.Id]; !ok { + if _, ok = v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + } + } + } + + // add current groups middlewares + pattern += group.Prefix + for _, h := range group.Middlewares { + if _, ok := group.excludedMiddlewares[h.Id]; !ok { + if _, ok = v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + } + + // add current route middlewares + pattern += v.Path + for _, h := range v.Middlewares { + if _, ok := v.excludedMiddlewares[h.Id]; !ok { + routeHook.Bind(h) + } + } + + mux.HandleFunc(pattern, func(resp http.ResponseWriter, req *http.Request) { + // wrap the response to add write and status tracking + resp = &ResponseWriter{ResponseWriter: resp} + + // wrap the request body to allow multiple reads + body := &RereadableReadCloser{ReadCloser: req.Body} + defer body.Close() + + req.Body = body + + event, cleanupFunc := r.eventFactory(resp, req) + + // trigger the handler hook chain + err := routeHook.Trigger(event, v.Action) + if err != nil { + ErrorHandler(resp, req, err) + } + + if cleanupFunc != nil { + cleanupFunc() + } + }) + default: + return errors.New("invalid Group item type") + } + } + + return nil +} + +func ErrorHandler(resp http.ResponseWriter, req *http.Request, err error) { + if err == nil { + return + } + + if ok, _ := getWritten(resp); ok { + return // a response was already written (aka. already handled) + } + + header := resp.Header() + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "application/json") + } + + apiErr := ToApiError(err) + + resp.WriteHeader(apiErr.Status) + + if req.Method != http.MethodHead { + jsonErr := json.MarshalWrite( + resp, + apiErr, + // note: deterministic because some logs may depend on the exact serialized error + json.Deterministic(true), + jsontext.AllowInvalidUTF8(false), + ) + if jsonErr != nil { + log.Println(jsonErr) // truly rare case, log to stderr only for dev purposes + } + } +} + +// ------------------------------------------------------------------- + +type WriteTracker interface { + // Written reports whether a write operation has occurred. + Written() bool +} + +type StatusTracker interface { + // Status reports the written response status code. + Status() int +} + +type flushErrorer interface { + FlushError() error +} + +var ( + _ WriteTracker = (*ResponseWriter)(nil) + _ StatusTracker = (*ResponseWriter)(nil) + _ http.Flusher = (*ResponseWriter)(nil) + _ http.Hijacker = (*ResponseWriter)(nil) + _ http.Pusher = (*ResponseWriter)(nil) + _ io.ReaderFrom = (*ResponseWriter)(nil) + _ flushErrorer = (*ResponseWriter)(nil) +) + +// ResponseWriter wraps a http.ResponseWriter to track its write state. +type ResponseWriter struct { + http.ResponseWriter + + written bool + status int +} + +func (rw *ResponseWriter) WriteHeader(status int) { + if rw.written { + return + } + + rw.written = true + rw.status = status + rw.ResponseWriter.WriteHeader(status) +} + +func (rw *ResponseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + + return rw.ResponseWriter.Write(b) +} + +// Written implements [WriteTracker] and returns whether the current response body has been already written. +func (rw *ResponseWriter) Written() bool { + return rw.written +} + +// Written implements [StatusTracker] and returns the written status code of the current response. +func (rw *ResponseWriter) Status() int { + return rw.status +} + +// Flush implements [http.Flusher] and allows an HTTP handler to flush buffered data to the client. +// This method is no-op if the wrapped writer doesn't support it. +func (rw *ResponseWriter) Flush() { + _ = rw.FlushError() +} + +// FlushError is similar to [Flush] but returns [http.ErrNotSupported] +// if the wrapped writer doesn't support it. +func (rw *ResponseWriter) FlushError() error { + err := http.NewResponseController(rw.ResponseWriter).Flush() + if err == nil || !errors.Is(err, http.ErrNotSupported) { + rw.written = true + } + return err +} + +// Hijack implements [http.Hijacker] and allows an HTTP handler to take over the current connection. +func (rw *ResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return http.NewResponseController(rw.ResponseWriter).Hijack() +} + +// Pusher implements [http.Pusher] to indicate HTTP/2 server push support. +func (rw *ResponseWriter) Push(target string, opts *http.PushOptions) error { + w := rw.ResponseWriter + for { + switch p := w.(type) { + case http.Pusher: + return p.Push(target, opts) + case RWUnwrapper: + w = p.Unwrap() + default: + return http.ErrNotSupported + } + } +} + +// ReaderFrom implements [io.ReaderFrom] by checking if the underlying writer supports it. +// Otherwise calls [io.Copy]. +func (rw *ResponseWriter) ReadFrom(r io.Reader) (n int64, err error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + + w := rw.ResponseWriter + for { + switch rf := w.(type) { + case io.ReaderFrom: + return rf.ReadFrom(r) + case RWUnwrapper: + w = rf.Unwrap() + default: + return io.Copy(rw.ResponseWriter, r) + } + } +} + +// Unwrap returns the underlying ResponseWritter instance (usually used by [http.ResponseController]). +func (rw *ResponseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} + +func getWritten(rw http.ResponseWriter) (bool, error) { + for { + switch w := rw.(type) { + case WriteTracker: + return w.Written(), nil + case RWUnwrapper: + rw = w.Unwrap() + default: + return false, http.ErrNotSupported + } + } +} + +func getStatus(rw http.ResponseWriter) (int, error) { + for { + switch w := rw.(type) { + case StatusTracker: + return w.Status(), nil + case RWUnwrapper: + rw = w.Unwrap() + default: + return 0, http.ErrNotSupported + } + } +} diff --git a/tools/router/router_test.go b/tools/router/router_test.go new file mode 100644 index 0000000000..c71cb708de --- /dev/null +++ b/tools/router/router_test.go @@ -0,0 +1,253 @@ +package router_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/router" +) + +func TestRouter(t *testing.T) { + calls := "" + + r := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*router.Event, router.EventCleanupFunc) { + return &router.Event{ + Response: w, + Request: r, + }, + func() { + calls += ":cleanup" + } + }) + + r.BindFunc(func(e *router.Event) error { + calls += "root_m:" + + err := e.Next() + + if err != nil { + calls += "/error" + } + + return err + }) + + r.Any("/any", func(e *router.Event) error { + calls += "/any" + return nil + }) + + r.GET("/a", func(e *router.Event) error { + calls += "/a" + return nil + }) + + g1 := r.Group("/a/b").BindFunc(func(e *router.Event) error { + calls += "a_b_group_m:" + return e.Next() + }) + g1.GET("/1", func(e *router.Event) error { + calls += "/1_get" + return nil + }).BindFunc(func(e *router.Event) error { + calls += "1_get_m:" + return e.Next() + }) + g1.POST("/1", func(e *router.Event) error { + calls += "/1_post" + return nil + }) + g1.GET("/{param}", func(e *router.Event) error { + calls += "/" + e.Request.PathValue("param") + return errors.New("test") // should be normalized to an ApiError + }) + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + + scenarios := []struct { + method string + path string + calls string + }{ + {http.MethodGet, "/any", "root_m:/any:cleanup"}, + {http.MethodOptions, "/any", "root_m:/any:cleanup"}, + {http.MethodPatch, "/any", "root_m:/any:cleanup"}, + {http.MethodPut, "/any", "root_m:/any:cleanup"}, + {http.MethodPost, "/any", "root_m:/any:cleanup"}, + {http.MethodDelete, "/any", "root_m:/any:cleanup"}, + // --- + {http.MethodPost, "/a", "root_m:/error:cleanup"}, // missing + {http.MethodGet, "/a", "root_m:/a:cleanup"}, + {http.MethodHead, "/a", "root_m:/a:cleanup"}, // auto registered with the GET + {http.MethodGet, "/a/b/1", "root_m:a_b_group_m:1_get_m:/1_get:cleanup"}, + {http.MethodHead, "/a/b/1", "root_m:a_b_group_m:1_get_m:/1_get:cleanup"}, + {http.MethodPost, "/a/b/1", "root_m:a_b_group_m:/1_post:cleanup"}, + {http.MethodGet, "/a/b/456", "root_m:a_b_group_m:/456/error:cleanup"}, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + calls = "" // reset + + req, err := http.NewRequest(s.method, ts.URL+s.path, nil) + if err != nil { + t.Fatal(err) + } + + _, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + + if calls != s.calls { + t.Fatalf("Expected calls\n%q\ngot\n%q", s.calls, calls) + } + }) + } +} + +func TestRouterUnbind(t *testing.T) { + calls := "" + + r := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*router.Event, router.EventCleanupFunc) { + return &router.Event{ + Response: w, + Request: r, + }, + func() { + calls += ":cleanup" + } + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_1", + Func: func(e *router.Event) error { + calls += "root_1:" + return e.Next() + }, + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_2", + Func: func(e *router.Event) error { + calls += "root_2:" + return e.Next() + }, + }) + r.Bind(&hook.Handler[*router.Event]{ + Id: "root_3", + Func: func(e *router.Event) error { + calls += "root_3:" + return e.Next() + }, + }) + r.GET("/action", func(e *router.Event) error { + calls += "root_action" + return nil + }).Unbind("root_1") + + ga := r.Group("/group_a") + ga.Unbind("root_1") + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_1", + Func: func(e *router.Event) error { + calls += "group_a_1:" + return e.Next() + }, + }) + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_2", + Func: func(e *router.Event) error { + calls += "group_a_2:" + return e.Next() + }, + }) + ga.Bind(&hook.Handler[*router.Event]{ + Id: "group_a_3", + Func: func(e *router.Event) error { + calls += "group_a_3:" + return e.Next() + }, + }) + ga.GET("/action", func(e *router.Event) error { + calls += "group_a_action" + return nil + }).Unbind("root_2", "group_b_1", "group_a_1") + + gb := r.Group("/group_b") + gb.Unbind("root_2") + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_1", + Func: func(e *router.Event) error { + calls += "group_b_1:" + return e.Next() + }, + }) + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_2", + Func: func(e *router.Event) error { + calls += "group_b_2:" + return e.Next() + }, + }) + gb.Bind(&hook.Handler[*router.Event]{ + Id: "group_b_3", + Func: func(e *router.Event) error { + calls += "group_b_3:" + return e.Next() + }, + }) + gb.GET("/action", func(e *router.Event) error { + calls += "group_b_action" + return nil + }).Unbind("group_b_3", "group_a_3", "root_3") + + mux, err := r.BuildMux() + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + + scenarios := []struct { + method string + path string + calls string + }{ + {http.MethodGet, "/action", "root_2:root_3:root_action:cleanup"}, + {http.MethodGet, "/group_a/action", "root_3:group_a_2:group_a_3:group_a_action:cleanup"}, + {http.MethodGet, "/group_b/action", "root_1:group_b_1:group_b_2:group_b_action:cleanup"}, + } + + for _, s := range scenarios { + t.Run(s.method+"_"+s.path, func(t *testing.T) { + calls = "" // reset + + req, err := http.NewRequest(s.method, ts.URL+s.path, nil) + if err != nil { + t.Fatal(err) + } + + _, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + + if calls != s.calls { + t.Fatalf("Expected calls\n%q\ngot\n%q", s.calls, calls) + } + }) + } +} diff --git a/tools/router/unmarshal_request_data.go b/tools/router/unmarshal_request_data.go new file mode 100644 index 0000000000..3c10b2cd09 --- /dev/null +++ b/tools/router/unmarshal_request_data.go @@ -0,0 +1,342 @@ +package router + +import ( + "encoding" + "encoding/json/v2" + "errors" + "reflect" + "regexp" + "strconv" +) + +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +// JSONPayloadKey is the key for the special UnmarshalRequestData case +// used for reading serialized json payload without normalization. +const JSONPayloadKey string = "@jsonPayload" + +// UnmarshalRequestData unmarshals url.Values type of data (query, multipart/form-data, etc.) into dst. +// +// dst must be a pointer to a map[string]any or struct. +// +// If dst is a map[string]any, each data value will be inferred and +// converted to its bool, numeric, or string equivalent value +// (refer to inferValue() for the exact rules). +// +// If dst is a struct, the following field types are supported: +// - bool +// - string +// - int, int8, int16, int32, int64 +// - uint, uint8, uint16, uint32, uint64 +// - float32, float64 +// - serialized json string if submitted under the special "@jsonPayload" key +// - encoding.TextUnmarshaler +// - pointer and slice variations of the above primitives (ex. *string, []string, *[]string []*string, etc.) +// - named/anonymous struct fields +// Dot-notation is used to target nested fields, ex. "nestedStructField.title". +// - embedded struct fields +// The embedded struct fields are treated by default as if they were defined in their parent struct. +// If the embedded struct has a tag matching structTagKey then to set its fields the data keys must be prefixed with that tag +// similar to the regular nested struct fields. +// +// structTagKey and structPrefix are used only when dst is a struct. +// +// structTagKey represents the tag to use to match a data entry with a struct field (defaults to "form"). +// If the struct field doesn't have the structTagKey tag, then the exported struct field name will be used as it is. +// +// structPrefix could be provided if all of the data keys are prefixed with a common string +// and you want the struct field to match only the value without the structPrefix +// (ex. for "user.name", "user.email" data keys and structPrefix "user", it will match "name" and "email" struct fields). +// +// Note that while the method was inspired by binders from echo, gorrila/schema, ozzo-routing +// and other similar common routing packages, it is not intended to be a drop-in replacement. +// +// @todo Consider adding support for dot-notation keys, in addition to the prefix, (ex. parent.child.title) to express nested object keys. +func UnmarshalRequestData(data map[string][]string, dst any, structTagKey string, structPrefix string) error { + if len(data) == 0 { + return nil // nothing to unmarshal + } + + dstValue := reflect.ValueOf(dst) + if dstValue.Kind() != reflect.Pointer { + return errors.New("dst must be a pointer") + } + + dstValue = dereference(dstValue) + + dstType := dstValue.Type() + + switch dstType.Kind() { + case reflect.Map: // map[string]any + if dstType.Elem().Kind() != reflect.Interface { + return errors.New("dst map value type must be any/interface{}") + } + + for k, v := range data { + if k == JSONPayloadKey { + continue // unmarshaled separately + } + + total := len(v) + + if total == 1 { + dstValue.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(inferValue(v[0]))) + } else { + normalized := make([]any, total) + for i, vItem := range v { + normalized[i] = inferValue(vItem) + } + dstValue.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(normalized)) + } + } + case reflect.Struct: + // set a default tag key + if structTagKey == "" { + structTagKey = "form" + } + + err := unmarshalInStructValue(data, dstValue, structTagKey, structPrefix) + if err != nil { + return err + } + default: + return errors.New("dst must be a map[string]any or struct") + } + + // @jsonPayload + // + // Special case to scan serialized json string without + // normalization alongside the other data values + // --------------------------------------------------------------- + jsonPayloadValues := data[JSONPayloadKey] + for _, payload := range jsonPayloadValues { + if err := json.Unmarshal([]byte(payload), dst); err != nil { + return err + } + } + + return nil +} + +// unmarshalInStructValue unmarshals data into the provided struct reflect.Value fields. +func unmarshalInStructValue( + data map[string][]string, + dstStructValue reflect.Value, + structTagKey string, + structPrefix string, +) error { + dstStructType := dstStructValue.Type() + + for i := 0; i < dstStructValue.NumField(); i++ { + fieldType := dstStructType.Field(i) + + tag := fieldType.Tag.Get(structTagKey) + + if tag == "-" || (!fieldType.Anonymous && !fieldType.IsExported()) { + continue // disabled or unexported non-anonymous struct field + } + + fieldValue := dereference(dstStructValue.Field(i)) + + ft := fieldType.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + + isSlice := ft.Kind() == reflect.Slice + if isSlice { + ft = ft.Elem() + } + + name := tag + if name == "" && !fieldType.Anonymous { + name = fieldType.Name + } + if name != "" && structPrefix != "" { + name = structPrefix + "." + name + } + + // (*)encoding.TextUnmarshaler field + // --- + if ft.Implements(textUnmarshalerType) || reflect.PointerTo(ft).Implements(textUnmarshalerType) { + values, ok := data[name] + if !ok || len(values) == 0 || !fieldValue.CanSet() { + continue // no value to load or the field cannot be set + } + + if isSlice { + n := len(values) + slice := reflect.MakeSlice(fieldValue.Type(), n, n) + for i, v := range values { + unmarshaler, ok := dereference(slice.Index(i)).Addr().Interface().(encoding.TextUnmarshaler) + if ok { + if err := unmarshaler.UnmarshalText([]byte(v)); err != nil { + return err + } + } + } + fieldValue.Set(slice) + } else { + unmarshaler, ok := fieldValue.Addr().Interface().(encoding.TextUnmarshaler) + if ok { + if err := unmarshaler.UnmarshalText([]byte(values[0])); err != nil { + return err + } + } + } + continue + } + + // "regular" field + // --- + if ft.Kind() != reflect.Struct { + values, ok := data[name] + if !ok || len(values) == 0 || !fieldValue.CanSet() { + continue // no value to load + } + + if isSlice { + n := len(values) + slice := reflect.MakeSlice(fieldValue.Type(), n, n) + for i, v := range values { + if err := setRegularReflectedValue(dereference(slice.Index(i)), v); err != nil { + return err + } + } + fieldValue.Set(slice) + } else { + if err := setRegularReflectedValue(fieldValue, values[0]); err != nil { + return err + } + } + continue + } + + // structs (embedded or nested) + // --- + // slice of structs + if isSlice { + // populating slice of structs is not supported at the moment + // because the filling rules are ambiguous + continue + } + + if tag != "" { + structPrefix = tag + } else { + structPrefix = name // name is empty for anonymous structs -> no prefix + } + + if err := unmarshalInStructValue(data, fieldValue, structTagKey, structPrefix); err != nil { + return err + } + } + + return nil +} + +// 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 +} + +// setRegularReflectedValue sets and casts value into rv. +func setRegularReflectedValue(rv reflect.Value, value string) error { + switch rv.Kind() { + case reflect.String: + rv.SetString(value) + case reflect.Bool: + if value == "" { + value = "f" + } + + v, err := strconv.ParseBool(value) + if err != nil { + return err + } + + rv.SetBool(v) + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + if value == "" { + value = "0" + } + + v, err := strconv.ParseInt(value, 0, 64) + if err != nil { + return err + } + + rv.SetInt(v) + case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint: + if value == "" { + value = "0" + } + + v, err := strconv.ParseUint(value, 0, 64) + if err != nil { + return err + } + + rv.SetUint(v) + case reflect.Float32, reflect.Float64: + if value == "" { + value = "0" + } + + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return err + } + + rv.SetFloat(v) + case reflect.Interface: + if rv.CanSet() { + rv.Set(reflect.ValueOf(inferValue(value))) + } + default: + return errors.New("unknown value type " + rv.Kind().String()) + } + + return nil +} + +var inferNumberCharsRegex = regexp.MustCompile(`^[\-\.\d]+$`) + +// In order to support more seamlessly both json and multipart/form-data requests, +// the following normalization rules are applied for plain multipart string values: +// - "true" is converted to the json "true" +// - "false" is converted to the json "false" +// - numeric strings are converted to json number ONLY if the resulted +// minimal number string representation is the same as the provided raw string +// (aka. scientific notations, "Infinity", "0.0", "0001", etc. are kept as string) +// - any other string (empty string too) is left as it is +func inferValue(raw string) any { + switch raw { + case "": + return raw + case "true": + return true + case "false": + return false + default: + // try to convert to number + // + // note: expects the provided raw string to match exactly with the minimal string representation of the parsed float + if (raw[0] == '-' || (raw[0] >= '0' && raw[0] <= '9')) && + inferNumberCharsRegex.Match([]byte(raw)) { + v, err := strconv.ParseFloat(raw, 64) + if err == nil && strconv.FormatFloat(v, 'f', -1, 64) == raw { + return v + } + } + + return raw + } +} diff --git a/tools/router/unmarshal_request_data_test.go b/tools/router/unmarshal_request_data_test.go new file mode 100644 index 0000000000..94d77bb6f9 --- /dev/null +++ b/tools/router/unmarshal_request_data_test.go @@ -0,0 +1,483 @@ +package router_test + +import ( + "bytes" + "encoding/json/v2" + "testing" + "time" + + "github.com/pocketbase/pocketbase/tools/router" +) + +func pointer[T any](val T) *T { + return &val +} + +func TestUnmarshalRequestData(t *testing.T) { + t.Parallel() + + mapData := map[string][]string{ + "number1": {"1"}, + "number2": {"2", "3"}, + "number3": {"2.1", "-3.4"}, + "number4": {"0", "-0", "0.0001"}, + "string0": {""}, + "string1": {"a"}, + "string2": {"b", "c"}, + "string3": { + "0.0", + "-0.0", + "000.1", + "000001", + "-000001", + "1.6E-35", + "-1.6E-35", + "10e100", + "1_000_000", + "1.000.000", + " 123 ", + "0b1", + "0xFF", + "1234A", + "Infinity", + "-Infinity", + "undefined", + "null", + }, + "bool1": {"true"}, + "bool2": {"true", "false"}, + "mixed": {"true", "123", "test"}, + "@jsonPayload": {`{"json_a":null,"json_b":123}`, `{"json_c":[1,2,3]}`}, + } + + structData := map[string][]string{ + "anyTag": {"a", "b"}, + "AnyPtr": {"b"}, + "AnySlice": {"a", "b", "false", ""}, + "anySlicePtrTag": {"d", "true"}, + "AnySliceOfPtr": {"f", "123.456"}, + + "stringTag": {"a", "b"}, + "StringPtr": {"b"}, + "StringSlice": {"a", "b", "c", ""}, + "stringSlicePtrTag": {"d", "e"}, + "StringSliceOfPtr": {"f", "g"}, + + "boolTag": {"true"}, + "BoolPtr": {"true"}, + "BoolSlice": {"true", "false", ""}, + "boolSlicePtrTag": {"false", "false", "true"}, + "BoolSliceOfPtr": {"false", "true", "false"}, + + "int8Tag": {"-1", "2"}, + "Int8Ptr": {"3"}, + "Int8Slice": {"4", "5", ""}, + "int8SlicePtrTag": {"5", "6"}, + "Int8SliceOfPtr": {"7", "8"}, + + "int16Tag": {"-1", "2"}, + "Int16Ptr": {"3"}, + "Int16Slice": {"4", "5", ""}, + "int16SlicePtrTag": {"5", "6"}, + "Int16SliceOfPtr": {"7", "8"}, + + "int32Tag": {"-1", "2"}, + "Int32Ptr": {"3"}, + "Int32Slice": {"4", "5", ""}, + "int32SlicePtrTag": {"5", "6"}, + "Int32SliceOfPtr": {"7", "8"}, + + "int64Tag": {"-1", "2"}, + "Int64Ptr": {"3"}, + "Int64Slice": {"4", "5", ""}, + "int64SlicePtrTag": {"5", "6"}, + "Int64SliceOfPtr": {"7", "8"}, + + "intTag": {"-1", "2"}, + "IntPtr": {"3"}, + "IntSlice": {"4", "5", ""}, + "intSlicePtrTag": {"5", "6"}, + "IntSliceOfPtr": {"7", "8"}, + + "uint8Tag": {"1", "2"}, + "Uint8Ptr": {"3"}, + "Uint8Slice": {"4", "5", ""}, + "uint8SlicePtrTag": {"5", "6"}, + "Uint8SliceOfPtr": {"7", "8"}, + + "uint16Tag": {"1", "2"}, + "Uint16Ptr": {"3"}, + "Uint16Slice": {"4", "5", ""}, + "uint16SlicePtrTag": {"5", "6"}, + "Uint16SliceOfPtr": {"7", "8"}, + + "uint32Tag": {"1", "2"}, + "Uint32Ptr": {"3"}, + "Uint32Slice": {"4", "5", ""}, + "uint32SlicePtrTag": {"5", "6"}, + "Uint32SliceOfPtr": {"7", "8"}, + + "uint64Tag": {"1", "2"}, + "Uint64Ptr": {"3"}, + "Uint64Slice": {"4", "5", ""}, + "uint64SlicePtrTag": {"5", "6"}, + "Uint64SliceOfPtr": {"7", "8"}, + + "uintTag": {"1", "2"}, + "UintPtr": {"3"}, + "UintSlice": {"4", "5", ""}, + "uintSlicePtrTag": {"5", "6"}, + "UintSliceOfPtr": {"7", "8"}, + + "float32Tag": {"-1.2"}, + "Float32Ptr": {"1.5", "2.0"}, + "Float32Slice": {"1", "2.3", "-0.3", ""}, + "float32SlicePtrTag": {"-1.3", "3"}, + "Float32SliceOfPtr": {"0", "1.2"}, + + "float64Tag": {"-1.2"}, + "Float64Ptr": {"1.5", "2.0"}, + "Float64Slice": {"1", "2.3", "-0.3", ""}, + "float64SlicePtrTag": {"-1.3", "3"}, + "Float64SliceOfPtr": {"0", "1.2"}, + + "timeTag": {"2009-11-10T15:00:00Z"}, + "TimePtr": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"}, + "TimeSlice": {"2009-11-10T14:00:00Z", "2009-11-10T15:00:00Z"}, + "timeSlicePtrTag": {"2009-11-10T15:00:00Z", "2009-11-10T16:00:00Z"}, + "TimeSliceOfPtr": {"2009-11-10T17:00:00Z", "2009-11-10T18:00:00Z"}, + + // @jsonPayload fields + "@jsonPayload": { + `{"payloadA":"test", "shouldBeIgnored": "abc"}`, + `{"payloadB":[1,2,3], "payloadC":true}`, + }, + + // unexported fields or `-` tags + "unexperted": {"test"}, + "SkipExported": {"test"}, + "unexportedStructFieldWithoutTag.Name": {"test"}, + "unexportedStruct.Name": {"test"}, + + // structs + "StructWithoutTag.Name": {"test1"}, + "exportedStruct.Name": {"test2"}, + + // embedded + "embed_name": {"test3"}, + "embed2.embed_name2": {"test4"}, + } + + type embed1 struct { + Name string `form:"embed_name" json:"embed_name"` + } + + type embed2 struct { + Name string `form:"embed_name2" json:"embed_name2"` + } + + //nolint + type TestStruct struct { + Any any `form:"anyTag" query:"anyTag2"` + AnyPtr *any + AnySlice []any + AnySlicePtr *[]any `form:"anySlicePtrTag"` + AnySliceOfPtr []*any + + String string `form:"stringTag" query:"stringTag2"` + StringPtr *string + StringSlice []string + StringSlicePtr *[]string `form:"stringSlicePtrTag"` + StringSliceOfPtr []*string + + Bool bool `form:"boolTag" query:"boolTag2"` + BoolPtr *bool + BoolSlice []bool + BoolSlicePtr *[]bool `form:"boolSlicePtrTag"` + BoolSliceOfPtr []*bool + + Int8 int8 `form:"int8Tag" query:"int8Tag2"` + Int8Ptr *int8 + Int8Slice []int8 + Int8SlicePtr *[]int8 `form:"int8SlicePtrTag"` + Int8SliceOfPtr []*int8 + + Int16 int16 `form:"int16Tag" query:"int16Tag2"` + Int16Ptr *int16 + Int16Slice []int16 + Int16SlicePtr *[]int16 `form:"int16SlicePtrTag"` + Int16SliceOfPtr []*int16 + + Int32 int32 `form:"int32Tag" query:"int32Tag2"` + Int32Ptr *int32 + Int32Slice []int32 + Int32SlicePtr *[]int32 `form:"int32SlicePtrTag"` + Int32SliceOfPtr []*int32 + + Int64 int64 `form:"int64Tag" query:"int64Tag2"` + Int64Ptr *int64 + Int64Slice []int64 + Int64SlicePtr *[]int64 `form:"int64SlicePtrTag"` + Int64SliceOfPtr []*int64 + + Int int `form:"intTag" query:"intTag2"` + IntPtr *int + IntSlice []int + IntSlicePtr *[]int `form:"intSlicePtrTag"` + IntSliceOfPtr []*int + + Uint8 uint8 `form:"uint8Tag" query:"uint8Tag2"` + Uint8Ptr *uint8 + Uint8Slice []uint8 + Uint8SlicePtr *[]uint8 `form:"uint8SlicePtrTag"` + Uint8SliceOfPtr []*uint8 + + Uint16 uint16 `form:"uint16Tag" query:"uint16Tag2"` + Uint16Ptr *uint16 + Uint16Slice []uint16 + Uint16SlicePtr *[]uint16 `form:"uint16SlicePtrTag"` + Uint16SliceOfPtr []*uint16 + + Uint32 uint32 `form:"uint32Tag" query:"uint32Tag2"` + Uint32Ptr *uint32 + Uint32Slice []uint32 + Uint32SlicePtr *[]uint32 `form:"uint32SlicePtrTag"` + Uint32SliceOfPtr []*uint32 + + Uint64 uint64 `form:"uint64Tag" query:"uint64Tag2"` + Uint64Ptr *uint64 + Uint64Slice []uint64 + Uint64SlicePtr *[]uint64 `form:"uint64SlicePtrTag"` + Uint64SliceOfPtr []*uint64 + + Uint uint `form:"uintTag" query:"uintTag2"` + UintPtr *uint + UintSlice []uint + UintSlicePtr *[]uint `form:"uintSlicePtrTag"` + UintSliceOfPtr []*uint + + Float32 float32 `form:"float32Tag" query:"float32Tag2"` + Float32Ptr *float32 + Float32Slice []float32 + Float32SlicePtr *[]float32 `form:"float32SlicePtrTag"` + Float32SliceOfPtr []*float32 + + Float64 float64 `form:"float64Tag" query:"float64Tag2"` + Float64Ptr *float64 + Float64Slice []float64 + Float64SlicePtr *[]float64 `form:"float64SlicePtrTag"` + Float64SliceOfPtr []*float64 + + // encoding.TextUnmarshaler + Time time.Time `form:"timeTag" query:"timeTag2"` + TimePtr *time.Time + TimeSlice []time.Time + TimeSlicePtr *[]time.Time `form:"timeSlicePtrTag"` + TimeSliceOfPtr []*time.Time + + // @jsonPayload fields + JSONPayloadA string `form:"shouldBeIgnored" json:"payloadA"` + JSONPayloadB []int `json:"payloadB"` + JSONPayloadC bool `json:"-"` + + // unexported fields or `-` tags + unexported string + SkipExported string `form:"-"` + unexportedStructFieldWithoutTag struct { + Name string `json:"unexportedStructFieldWithoutTag_name"` + } + unexportedStructFieldWithTag struct { + Name string `json:"unexportedStructFieldWithTag_name"` + } `form:"unexportedStruct"` + + // structs + StructWithoutTag struct { + Name string `json:"StructWithoutTag_name"` + } + StructWithTag struct { + Name string `json:"StructWithTag_name"` + } `form:"exportedStruct"` + + // embedded + embed1 + embed2 `form:"embed2"` + } + + scenarios := []struct { + name string + data map[string][]string + dst any + tag string + prefix string + error bool + result string + }{ + { + name: "nil data", + data: nil, + dst: pointer(map[string]any{}), + error: false, + result: `{}`, + }, + { + name: "non-pointer map[string]any", + data: mapData, + dst: map[string]any{}, + error: true, + }, + { + name: "unsupported *map[string]string", + data: mapData, + dst: pointer(map[string]string{}), + error: true, + }, + { + name: "unsupported *map[string][]string", + data: mapData, + dst: pointer(map[string][]string{}), + error: true, + }, + { + name: "*map[string]any", + data: mapData, + dst: pointer(map[string]any{}), + result: `{"bool1":true,"bool2":[true,false],"json_a":null,"json_b":123,"json_c":[1,2,3],"mixed":[true,123,"test"],"number1":1,"number2":[2,3],"number3":[2.1,-3.4],"number4":[0,-0,0.0001],"string0":"","string1":"a","string2":["b","c"],"string3":["0.0","-0.0","000.1","000001","-000001","1.6E-35","-1.6E-35","10e100","1_000_000","1.000.000"," 123 ","0b1","0xFF","1234A","Infinity","-Infinity","undefined","null"]}`, + }, + { + name: "valid pointer struct (all fields)", + data: structData, + dst: &TestStruct{}, + result: `{"Any":"a","AnyPtr":"b","AnySlice":["a","b",false,""],"AnySlicePtr":["d",true],"AnySliceOfPtr":["f",123.456],"String":"a","StringPtr":"b","StringSlice":["a","b","c",""],"StringSlicePtr":["d","e"],"StringSliceOfPtr":["f","g"],"Bool":true,"BoolPtr":true,"BoolSlice":[true,false,false],"BoolSlicePtr":[false,false,true],"BoolSliceOfPtr":[false,true,false],"Int8":-1,"Int8Ptr":3,"Int8Slice":[4,5,0],"Int8SlicePtr":[5,6],"Int8SliceOfPtr":[7,8],"Int16":-1,"Int16Ptr":3,"Int16Slice":[4,5,0],"Int16SlicePtr":[5,6],"Int16SliceOfPtr":[7,8],"Int32":-1,"Int32Ptr":3,"Int32Slice":[4,5,0],"Int32SlicePtr":[5,6],"Int32SliceOfPtr":[7,8],"Int64":-1,"Int64Ptr":3,"Int64Slice":[4,5,0],"Int64SlicePtr":[5,6],"Int64SliceOfPtr":[7,8],"Int":-1,"IntPtr":3,"IntSlice":[4,5,0],"IntSlicePtr":[5,6],"IntSliceOfPtr":[7,8],"Uint8":1,"Uint8Ptr":3,"Uint8Slice":"BAUA","Uint8SlicePtr":"BQY=","Uint8SliceOfPtr":[7,8],"Uint16":1,"Uint16Ptr":3,"Uint16Slice":[4,5,0],"Uint16SlicePtr":[5,6],"Uint16SliceOfPtr":[7,8],"Uint32":1,"Uint32Ptr":3,"Uint32Slice":[4,5,0],"Uint32SlicePtr":[5,6],"Uint32SliceOfPtr":[7,8],"Uint64":1,"Uint64Ptr":3,"Uint64Slice":[4,5,0],"Uint64SlicePtr":[5,6],"Uint64SliceOfPtr":[7,8],"Uint":1,"UintPtr":3,"UintSlice":[4,5,0],"UintSlicePtr":[5,6],"UintSliceOfPtr":[7,8],"Float32":-1.2,"Float32Ptr":1.5,"Float32Slice":[1,2.3,-0.3,0],"Float32SlicePtr":[-1.3,3],"Float32SliceOfPtr":[0,1.2],"Float64":-1.2,"Float64Ptr":1.5,"Float64Slice":[1,2.3,-0.3,0],"Float64SlicePtr":[-1.3,3],"Float64SliceOfPtr":[0,1.2],"Time":"2009-11-10T15:00:00Z","TimePtr":"2009-11-10T14:00:00Z","TimeSlice":["2009-11-10T14:00:00Z","2009-11-10T15:00:00Z"],"TimeSlicePtr":["2009-11-10T15:00:00Z","2009-11-10T16:00:00Z"],"TimeSliceOfPtr":["2009-11-10T17:00:00Z","2009-11-10T18:00:00Z"],"payloadA":"test","payloadB":[1,2,3],"SkipExported":"","StructWithoutTag":{"StructWithoutTag_name":"test1"},"StructWithTag":{"StructWithTag_name":"test2"},"embed_name":"test3","embed_name2":"test4"}`, + }, + { + name: "non-pointer struct", + data: structData, + dst: TestStruct{}, + error: true, + }, + { + name: "invalid struct uint value", + data: map[string][]string{"uintTag": {"-1"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct int value", + data: map[string][]string{"intTag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct bool value", + data: map[string][]string{"boolTag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct float value", + data: map[string][]string{"float64Tag": {"abc"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "invalid struct TextUnmarshaler value", + data: map[string][]string{"timeTag": {"123"}}, + dst: &TestStruct{}, + error: true, + }, + { + name: "custom tagKey", + data: map[string][]string{ + "tag1": {"a"}, + "tag2": {"b"}, + "tag3": {"c"}, + "Item": {"d"}, + }, + dst: &struct { + Item string `form:"tag1" query:"tag2" json:"tag2"` + }{}, + tag: "query", + result: `{"tag2":"b"}`, + }, + { + name: "custom prefix", + data: map[string][]string{ + "test.A": {"1"}, + "A": {"2"}, + "test.alias": {"3"}, + }, + dst: &struct { + A string + B string `form:"alias"` + }{}, + prefix: "test", + result: `{"A":"1","B":"3"}`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + err := router.UnmarshalRequestData(s.data, s.dst, s.tag, s.prefix) + + hasErr := err != nil + if hasErr != s.error { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.error, hasErr, err) + } + + if hasErr { + return + } + + raw, err := json.Marshal(s.dst, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(raw, []byte(s.result)) { + t.Fatalf("Expected dst \n%s\ngot\n%s", s.result, raw) + } + }) + } +} + +// note: extra unexported checks in addition to the above test as there +// is no easy way to print nested structs with all their fields. +func TestUnmarshalRequestDataUnexportedFields(t *testing.T) { + t.Parallel() + + //nolint:all + type TestStruct struct { + Exported string + + unexported string + // to ensure that the reflection doesn't take tags with higher priority than the exported state + unexportedWithTag string `form:"unexportedWithTag" json:"unexportedWithTag"` + } + + dst := &TestStruct{} + + err := router.UnmarshalRequestData(map[string][]string{ + "Exported": {"test"}, // just for reference + + "Unexported": {"test"}, + "unexported": {"test"}, + "UnexportedWithTag": {"test"}, + "unexportedWithTag": {"test"}, + }, dst, "", "") + + if err != nil { + t.Fatal(err) + } + + if dst.Exported != "test" { + t.Fatalf("Expected the Exported field to be %q, got %q", "test", dst.Exported) + } + + if dst.unexported != "" { + t.Fatalf("Expected the unexported field to remain empty, got %q", dst.unexported) + } + + if dst.unexportedWithTag != "" { + t.Fatalf("Expected the unexportedWithTag field to remain empty, got %q", dst.unexportedWithTag) + } +} diff --git a/tools/routine/routine.go b/tools/routine/routine.go index 7974d58b74..2735d6ca1e 100644 --- a/tools/routine/routine.go +++ b/tools/routine/routine.go @@ -1,14 +1,15 @@ package routine import ( + "fmt" "log" - "runtime/debug" + "runtime" "sync" ) -// FireAndForget executes `f()` in a new go routine and auto recovers if panic. +// FireAndForget executes f() in a new go routine and auto recovers if panic. // -// **Note:** Use this only if you are not interested in the result of `f()` +// **Note:** Use this only if you are not interested in the result of f() // and don't want to block the parent go routine. func FireAndForget(f func(), wg ...*sync.WaitGroup) { if len(wg) > 0 && wg[0] != nil { @@ -21,12 +22,31 @@ func FireAndForget(f func(), wg ...*sync.WaitGroup) { } defer func() { - if err := recover(); err != nil { - log.Printf("RECOVERED FROM PANIC: %v", err) - log.Printf("%s\n", string(debug.Stack())) + if rec := recover(); rec != nil { + log.Println("[FireAndForget] RECOVERED FROM PANIC:", rec) + + stack := make([]byte, 2<<10) // 2 KB + length := runtime.Stack(stack, false) + log.Println(string(stack[:length])) } }() f() }() } + +// SafeWrap wraps the provided function with auto panic recover handling +// and returns any eventual panic as regular error. +func SafeWrap(f func() error) func() error { + return func() (err error) { + defer func() { + if rec := recover(); rec != nil { + stack := make([]byte, 2<<10) // 2 KB + length := runtime.Stack(stack, false) + err = fmt.Errorf("[SafeWrap] recovered from panic: %v\n%s", rec, string(stack[:length])) + } + }() + + return f() + } +} diff --git a/tools/routine/routine_test.go b/tools/routine/routine_test.go index dcb6ace60b..bb58845602 100644 --- a/tools/routine/routine_test.go +++ b/tools/routine/routine_test.go @@ -1,6 +1,8 @@ package routine_test import ( + "errors" + "strings" "sync" "testing" @@ -12,7 +14,7 @@ func TestFireAndForget(t *testing.T) { fn := func() { called = true - panic("test") + panic("test_recover") } wg := &sync.WaitGroup{} @@ -22,6 +24,56 @@ func TestFireAndForget(t *testing.T) { wg.Wait() if !called { - t.Error("Expected fn to be called.") + t.Fatal("Expected fn to be called.") } } + +func TestSafeWrap(t *testing.T) { + t.Run("panic", func(t *testing.T) { + called := false + + fn := func() error { + called = true + panic("test_recover") + } + + err := routine.SafeWrap(fn)() + + if !called { + t.Fatal("Expected fn to be called.") + } + + if err == nil { + t.Fatal("Expected fn panic to be converted to error") + } + + if !strings.Contains(err.Error(), "test_recover") { + t.Fatal("Expected the returned error to contain the recovered panic value") + } + }) + + t.Run("regular error", func(t *testing.T) { + called := false + + fn := func() error { + called = true + return errors.New("test_error") + } + + err := routine.SafeWrap(fn)() + + if !called { + t.Fatal("Expected fn to be called.") + } + + if err == nil { + t.Fatal("Expected to return the wrapped fn error") + } + + errStr := err.Error() + expected := "test_error" + if errStr != expected { + t.Fatalf("Expected %s error, got %s", expected, errStr) + } + }) +} diff --git a/tools/search/filter.go b/tools/search/filter.go index 18447af632..f8f043656b 100644 --- a/tools/search/filter.go +++ b/tools/search/filter.go @@ -1,7 +1,7 @@ package search import ( - "encoding/json" + "encoding/json/v2" "errors" "fmt" "strconv" @@ -34,52 +34,91 @@ var parsedFilterData = store.New(make(map[string][]fexpr.ExprGroup, 50)) // // The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"), // that will be safely replaced and properly quoted inplace with the placeholderReplacements values. +// +// The parsed expressions are limited up to DefaultFilterExprLimit. +// Use [FilterData.BuildExprWithLimit] if you want to set a custom limit. func (f FilterData) BuildExpr( fieldResolver FieldResolver, placeholderReplacements ...dbx.Params, +) (dbx.Expression, error) { + return f.BuildExprWithLimit(fieldResolver, DefaultFilterExprLimit, placeholderReplacements...) +} + +// BuildExpr parses the current filter data and returns a new db WHERE expression. +// +// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"), +// that will be safely replaced and properly quoted inplace with the placeholderReplacements values. +func (f FilterData) BuildExprWithLimit( + fieldResolver FieldResolver, + maxExpressions int, + placeholderReplacements ...dbx.Params, ) (dbx.Expression, error) { raw := string(f) // replace the placeholder params in the raw string filter - for _, p := range placeholderReplacements { - for key, value := range p { - var replacement string - switch v := value.(type) { - case nil: - replacement = "null" - case bool, float64, float32, int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8: - replacement = cast.ToString(v) - default: - replacement = cast.ToString(v) - - // try to json serialize as fallback - if replacement == "" { - raw, _ := json.Marshal(v) - replacement = string(raw) + if len(placeholderReplacements) > 0 { + replacements := make([]string, 0, len(placeholderReplacements[0])*2) + + for _, p := range placeholderReplacements { + for key, value := range p { + var replacement string + + switch v := value.(type) { + case nil: + replacement = "null" + case bool, float64, float32, int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8: + replacement = cast.ToString(v) + default: + casted, err := cast.ToStringE(v) + + // try to json serialize as fallback + if err != nil { + fallback, err := json.Marshal(v, json.Deterministic(true)) + if err != nil { + return nil, fmt.Errorf("failed to serialize param %q: %w", key, err) + } + casted = string(fallback) + } + + replacement = strconv.Quote(casted) } - replacement = strconv.Quote(replacement) + replacements = append(replacements, "{:"+key+"}", replacement) } - raw = strings.ReplaceAll(raw, "{:"+key+"}", replacement) } + + replacer := strings.NewReplacer(replacements...) + raw = replacer.Replace(raw) } - if parsedFilterData.Has(raw) { - return buildParsedFilterExpr(parsedFilterData.Get(raw), fieldResolver) + cacheKey := raw + "/" + strconv.Itoa(maxExpressions) + + if data, ok := parsedFilterData.GetOk(cacheKey); ok { + return buildParsedFilterExpr(data, fieldResolver, &maxExpressions) } + data, err := fexpr.Parse(raw) if err != nil { + // depending on the users demand we may allow empty expressions + // (aka. expressions consisting only of whitespaces or comments) + // but for now disallow them as it seems unnecessary + // if errors.Is(err, fexpr.ErrEmpty) { + // return dbx.NewExp("1=1"), nil + // } + return nil, err } + // store in cache // (the limit size is arbitrary and it is there to prevent the cache growing too big) - parsedFilterData.SetIfLessThanLimit(raw, data, 500) - return buildParsedFilterExpr(data, fieldResolver) + parsedFilterData.SetIfLessThanLimit(cacheKey, data, 500) + + return buildParsedFilterExpr(data, fieldResolver, &maxExpressions) } -func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver) (dbx.Expression, error) { +func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver, maxExpressions *int) (dbx.Expression, error) { if len(data) == 0 { - return nil, errors.New("empty filter expression") + return nil, fexpr.ErrEmpty } result := &concatExpr{separator: " "} @@ -90,11 +129,17 @@ func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver) switch item := group.Item.(type) { case fexpr.Expr: + if *maxExpressions <= 0 { + return nil, ErrFilterExprLimit + } + + *maxExpressions-- + expr, exprErr = resolveTokenizedExpr(item, fieldResolver) case fexpr.ExprGroup: - expr, exprErr = buildParsedFilterExpr([]fexpr.ExprGroup{item}, fieldResolver) + expr, exprErr = buildParsedFilterExpr([]fexpr.ExprGroup{item}, fieldResolver, maxExpressions) case []fexpr.ExprGroup: - expr, exprErr = buildParsedFilterExpr(item, fieldResolver) + expr, exprErr = buildParsedFilterExpr(item, fieldResolver, maxExpressions) default: exprErr = errors.New("unsupported expression item") } @@ -177,14 +222,15 @@ func buildResolversExpr( if !isAnyMatchOp(op) { if left.MultiMatchSubQuery != nil && right.MultiMatchSubQuery != nil { mm := &manyVsManyExpr{ - leftSubQuery: left.MultiMatchSubQuery, - rightSubQuery: right.MultiMatchSubQuery, - op: op, + left: left, + right: right, + op: op, } expr = dbx.Enclose(dbx.And(expr, mm)) } else if left.MultiMatchSubQuery != nil { mm := &manyVsOneExpr{ + nullFallback: left.NullFallback, subQuery: left.MultiMatchSubQuery, op: op, otherOperand: right, @@ -193,6 +239,7 @@ func buildResolversExpr( expr = dbx.Enclose(dbx.And(expr, mm)) } else if right.MultiMatchSubQuery != nil { mm := &manyVsOneExpr{ + nullFallback: right.NullFallback, subQuery: right.MultiMatchSubQuery, op: op, otherOperand: left, @@ -214,13 +261,22 @@ func buildResolversExpr( return expr, nil } +var normalizedIdentifiers = map[string]string{ + // if `null` field is missing, treat `null` identifier as NULL token + "null": "NULL", + // if `true` field is missing, treat `true` identifier as TRUE token + "true": "1", + // if `false` field is missing, treat `false` identifier as FALSE token + "false": "0", +} + func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResult, error) { switch token.Type { case fexpr.TokenIdentifier: // check for macros // --- if macroFunc, ok := identifierMacros[token.Literal]; ok { - placeholder := "t" + security.PseudorandomString(5) + placeholder := "t" + security.PseudorandomString(8) macroValue, err := macroFunc() if err != nil { @@ -236,40 +292,43 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu // custom resolver // --- result, err := fieldResolver.Resolve(token.Literal) - if err != nil || result.Identifier == "" { - m := map[string]string{ - // if `null` field is missing, treat `null` identifier as NULL token - "null": "NULL", - // if `true` field is missing, treat `true` identifier as TRUE token - "true": "1", - // if `false` field is missing, treat `false` identifier as FALSE token - "false": "0", - } - if v, ok := m[strings.ToLower(token.Literal)]; ok { - return &ResolverResult{Identifier: v}, nil + for k, v := range normalizedIdentifiers { + if strings.EqualFold(k, token.Literal) { + return &ResolverResult{Identifier: v}, nil + } } return nil, err } return result, err case fexpr.TokenText: - placeholder := "t" + security.PseudorandomString(5) + placeholder := "t" + security.PseudorandomString(8) return &ResolverResult{ Identifier: "{:" + placeholder + "}", Params: dbx.Params{placeholder: token.Literal}, }, nil case fexpr.TokenNumber: - placeholder := "t" + security.PseudorandomString(5) + placeholder := "t" + security.PseudorandomString(8) return &ResolverResult{ Identifier: "{:" + placeholder + "}", Params: dbx.Params{placeholder: cast.ToFloat64(token.Literal)}, }, nil + case fexpr.TokenFunction: + fn, ok := TokenFunctions[token.Literal] + if !ok { + return nil, fmt.Errorf("unknown function %q", token.Literal) + } + + args, _ := token.Meta.([]fexpr.Token) + return fn(func(argToken fexpr.Token) (*ResolverResult, error) { + return resolveToken(argToken, fieldResolver) + }, args...) } - return nil, errors.New("unresolvable token type") + return nil, fmt.Errorf("unsupported token type %q", token.Type) } // Resolves = and != expressions in an attempt to minimize the COALESCE @@ -279,18 +338,37 @@ func resolveToken(token fexpr.Token, fieldResolver FieldResolver) (*ResolverResu // `COALESCE(a, "") = ""` since the direct match can be accomplished // with a seek while the COALESCE will induce a table scan. func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression { - isLeftEmpty := isEmptyIdentifier(left) || (len(left.Params) == 1 && hasEmptyParamValue(left)) - isRightEmpty := isEmptyIdentifier(right) || (len(right.Params) == 1 && hasEmptyParamValue(right)) - equalOp := "=" + nullEqualOp := "IS" concatOp := "OR" nullExpr := "IS NULL" if !equal { - equalOp = "!=" + // always use `IS NOT` instead of `!=` because direct non-equal comparisons + // to nullable column values that are actually NULL yields to NULL instead of TRUE, eg.: + // `'example' != nullableColumn` -> NULL even if nullableColumn row value is NULL + equalOp = "IS NOT" + nullEqualOp = equalOp concatOp = "AND" nullExpr = "IS NOT NULL" } + // no coalesce fallback (eg. compare to a json field) + // a IS b + // a IS NOT b + if left.NullFallback == NullFallbackDisabled || + right.NullFallback == NullFallbackDisabled { + return dbx.NewExp( + fmt.Sprintf("%s %s %s", left.Identifier, nullEqualOp, right.Identifier), + mergeParams(left.Params, right.Params), + ) + } + + isLeftEmpty := isEmptyIdentifier(left) || + (left.NullFallback == NullFallbackAuto && len(left.Params) == 1 && hasEmptyParamValue(left)) + + isRightEmpty := isEmptyIdentifier(right) || + (right.NullFallback == NullFallbackAuto && len(right.Params) == 1 && hasEmptyParamValue(right)) + // both operands are empty if isLeftEmpty && isRightEmpty { return dbx.NewExp(fmt.Sprintf("'' %s ''", equalOp), mergeParams(left.Params, right.Params)) @@ -314,7 +392,7 @@ func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression { } // "" = b OR b IS NULL - // "" != b AND b IS NOT NULL + // "" IS NOT b AND b IS NOT NULL if isLeftEmpty { return dbx.NewExp( fmt.Sprintf("('' %s %s %s %s %s)", equalOp, right.Identifier, concatOp, right.Identifier, nullExpr), @@ -323,7 +401,7 @@ func resolveEqualExpr(equal bool, left, right *ResolverResult) dbx.Expression { } // a = "" OR a IS NULL - // a != "" AND a IS NOT NULL + // a IS NOT "" AND a IS NOT NULL if isRightEmpty { return dbx.NewExp( fmt.Sprintf("(%s %s '' %s %s %s)", left.Identifier, equalOp, concatOp, left.Identifier, nullExpr), @@ -359,6 +437,10 @@ func hasEmptyParamValue(result *ResolverResult) bool { } func isKnownNonEmptyIdentifier(result *ResolverResult) bool { + if result.NullFallback == NullFallbackEnforced { + return false + } + switch strings.ToLower(result.Identifier) { case "1", "0", "false", `true`: return true @@ -407,17 +489,18 @@ func mergeParams(params ...dbx.Params) dbx.Params { return result } +// @todo consider adding support for custom single character wildcard +// // wrapLikeParams wraps each provided param value string with `%` -// if the string doesn't contains the `%` char (including its escape sequence). +// if the param doesn't contain an explicit wildcard (`%`) character already. func wrapLikeParams(params dbx.Params) dbx.Params { result := dbx.Params{} for k, v := range params { vStr := cast.ToString(v) - if !strings.Contains(vStr, "%") { - for i := 0; i < len(dbx.DefaultLikeEscape); i += 2 { - vStr = strings.ReplaceAll(vStr, dbx.DefaultLikeEscape[i], dbx.DefaultLikeEscape[i+1]) - } + if !containsUnescapedChar(vStr, '%') { + // note: this is done to minimize the breaking changes and to preserve the original autoescape behavior + vStr = escapeUnescapedChars(vStr, '\\', '%', '_') vStr = "%" + vStr + "%" } result[k] = vStr @@ -426,6 +509,63 @@ func wrapLikeParams(params dbx.Params) dbx.Params { return result } +func escapeUnescapedChars(str string, escapeChars ...rune) string { + rs := []rune(str) + total := len(rs) + result := make([]rune, 0, total) + + var match bool + + for i := total - 1; i >= 0; i-- { + if match { + // check if already escaped + if rs[i] != '\\' { + result = append(result, '\\') + } + match = false + } else { + for _, ec := range escapeChars { + if rs[i] == ec { + match = true + break + } + } + } + + result = append(result, rs[i]) + + // in case the matching char is at the beginning + if i == 0 && match { + result = append(result, '\\') + } + } + + // reverse + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return string(result) +} + +func containsUnescapedChar(str string, ch rune) bool { + var prev rune + + for _, c := range str { + if c == ch && prev != '\\' { + return true + } + + if c == '\\' && prev == '\\' { + prev = rune(0) // reset escape sequence + } else { + prev = c + } + } + + return false +} + // ------------------------------------------------------------------- var _ dbx.Expression = (*opExpr)(nil) @@ -449,8 +589,8 @@ var _ dbx.Expression = (*concatExpr)(nil) // concatExpr defines an expression that concatenates multiple // other expressions with a specified separator. type concatExpr struct { - parts []dbx.Expression separator string + parts []dbx.Expression } // Build converts the expression into a SQL fragment. @@ -493,32 +633,34 @@ var _ dbx.Expression = (*manyVsManyExpr)(nil) // Expects leftSubQuery and rightSubQuery to return a subquery with a // single "multiMatchValue" column. type manyVsManyExpr struct { - leftSubQuery dbx.Expression - rightSubQuery dbx.Expression - op fexpr.SignOp + left *ResolverResult + right *ResolverResult + op fexpr.SignOp } // Build converts the expression into a SQL fragment. // // Implements [dbx.Expression] interface. func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string { - if e.leftSubQuery == nil || e.rightSubQuery == nil { + if e.left.MultiMatchSubQuery == nil || e.right.MultiMatchSubQuery == nil { return "0=1" } - lAlias := "__ml" + security.PseudorandomString(5) - rAlias := "__mr" + security.PseudorandomString(5) + lAlias := "__ml" + security.PseudorandomString(8) + rAlias := "__mr" + security.PseudorandomString(8) whereExpr, buildErr := buildResolversExpr( &ResolverResult{ - Identifier: "[[" + lAlias + ".multiMatchValue]]", + NullFallback: e.left.NullFallback, + Identifier: "[[" + lAlias + ".multiMatchValue]]", }, e.op, &ResolverResult{ - Identifier: "[[" + rAlias + ".multiMatchValue]]", + NullFallback: e.right.NullFallback, + Identifier: "[[" + rAlias + ".multiMatchValue]]", // note: the AfterBuild needs to be handled only once and it // doesn't matter whether it is applied on the left or right subquery operand - AfterBuild: multiMatchAfterBuildFunc(e.op, lAlias, rAlias), + AfterBuild: dbx.Not, // inverse for the not-exist expression }, ) @@ -528,9 +670,9 @@ func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string { return fmt.Sprintf( "NOT EXISTS (SELECT 1 FROM (%s) {{%s}} LEFT JOIN (%s) {{%s}} WHERE %s)", - e.leftSubQuery.Build(db, params), + e.left.MultiMatchSubQuery.Build(db, params), lAlias, - e.rightSubQuery.Build(db, params), + e.right.MultiMatchSubQuery.Build(db, params), rAlias, whereExpr.Build(db, params), ) @@ -540,16 +682,17 @@ func (e *manyVsManyExpr) Build(db *dbx.DB, params dbx.Params) string { var _ dbx.Expression = (*manyVsOneExpr)(nil) -// manyVsManyExpr constructs a multi-match many<->one db where expression. +// manyVsOneExpr constructs a multi-match many<->one db where expression. // // Expects subQuery to return a subquery with a single "multiMatchValue" column. // // You can set inverse=false to reverse the condition sides (aka. one<->many). type manyVsOneExpr struct { + otherOperand *ResolverResult subQuery dbx.Expression op fexpr.SignOp - otherOperand *ResolverResult inverse bool + nullFallback NullFallbackPreference } // Build converts the expression into a SQL fragment. @@ -560,11 +703,12 @@ func (e *manyVsOneExpr) Build(db *dbx.DB, params dbx.Params) string { return "0=1" } - alias := "__sm" + security.PseudorandomString(5) + alias := "__sm" + security.PseudorandomString(8) r1 := &ResolverResult{ - Identifier: "[[" + alias + ".multiMatchValue]]", - AfterBuild: multiMatchAfterBuildFunc(e.op, alias), + NullFallback: e.nullFallback, + Identifier: "[[" + alias + ".multiMatchValue]]", + AfterBuild: dbx.Not, // inverse for the not-exist expression } r2 := &ResolverResult{ @@ -592,31 +736,3 @@ func (e *manyVsOneExpr) Build(db *dbx.DB, params dbx.Params) string { whereExpr.Build(db, params), ) } - -func multiMatchAfterBuildFunc(op fexpr.SignOp, multiMatchAliases ...string) func(dbx.Expression) dbx.Expression { - return func(expr dbx.Expression) dbx.Expression { - expr = dbx.Not(expr) // inverse for the not-exist expression - - if op == fexpr.SignEq { - return expr - } - - orExprs := make([]dbx.Expression, len(multiMatchAliases)+1) - orExprs[0] = expr - - // Add an optional "IS NULL" condition(s) to handle the empty rows result. - // - // For example, let's assume that some "rel" field is [nonemptyRel1, nonemptyRel2, emptyRel3], - // The filter "rel.total > 0" will ensures that the above will return true only if all relations - // are existing and match the condition. - // - // The "=" operator is excluded because it will never equal directly with NULL anyway - // and also because we want in case "rel.id = ''" is specified to allow - // matching the empty relations (they will match due to the applied COALESCE). - for i, mAlias := range multiMatchAliases { - orExprs[i+1] = dbx.NewExp("[[" + mAlias + ".multiMatchValue]] IS NULL") - } - - return dbx.Enclose(dbx.Or(orExprs...)) - } -} diff --git a/tools/search/filter_test.go b/tools/search/filter_test.go index 7b45065c41..9322bdd510 100644 --- a/tools/search/filter_test.go +++ b/tools/search/filter_test.go @@ -3,6 +3,8 @@ package search_test import ( "context" "database/sql" + "errors" + "fmt" "regexp" "strings" "testing" @@ -13,7 +15,7 @@ import ( ) func TestFilterDataBuildExpr(t *testing.T) { - resolver := search.NewSimpleFieldResolver("test1", "test2", "test3", `^test4.\w+$`) + resolver := search.NewSimpleFieldResolver("test1", "test2", "test3", `^test4_\w+$`, `^test5\.[\w\.\:]*\w+$`) scenarios := []struct { name string @@ -55,7 +57,7 @@ func TestFilterDataBuildExpr(t *testing.T) { "empty string vs null", "'' = null && null != ''", false, - "('' = '' AND '' != '')", + "('' = '' AND '' IS NOT '')", }, { "like with 2 columns", @@ -93,44 +95,56 @@ func TestFilterDataBuildExpr(t *testing.T) { false, "[[test1]] NOT LIKE {:TEST} ESCAPE '\\'", }, + { + "nested json no coalesce", + "test5.a = test5.b || test5.c != test5.d", + false, + "(JSON_EXTRACT([[test5]], '$.a') IS JSON_EXTRACT([[test5]], '$.b') OR JSON_EXTRACT([[test5]], '$.c') IS NOT JSON_EXTRACT([[test5]], '$.d'))", + }, { "macros", ` - test4.1 > @now && - test4.2 > @second && - test4.3 > @minute && - test4.4 > @hour && - test4.5 > @day && - test4.6 > @year && - test4.7 > @month && - test4.9 > @weekday && - test4.9 > @todayStart && - test4.10 > @todayEnd && - test4.11 > @monthStart && - test4.12 > @monthEnd && - test4.13 > @yearStart && - test4.14 > @yearEnd + test4_1 > @now && + test4_2 > @second && + test4_3 > @minute && + test4_4 > @hour && + test4_5 > @day && + test4_6 > @year && + test4_7 > @month && + test4_9 > @weekday && + test4_9 > @todayStart && + test4_10 > @todayEnd && + test4_11 > @monthStart && + test4_12 > @monthEnd && + test4_13 > @yearStart && + test4_14 > @yearEnd `, false, - "([[test4.1]] > {:TEST} AND [[test4.2]] > {:TEST} AND [[test4.3]] > {:TEST} AND [[test4.4]] > {:TEST} AND [[test4.5]] > {:TEST} AND [[test4.6]] > {:TEST} AND [[test4.7]] > {:TEST} AND [[test4.9]] > {:TEST} AND [[test4.9]] > {:TEST} AND [[test4.10]] > {:TEST} AND [[test4.11]] > {:TEST} AND [[test4.12]] > {:TEST} AND [[test4.13]] > {:TEST} AND [[test4.14]] > {:TEST})", + "([[test4_1]] > {:TEST} AND [[test4_2]] > {:TEST} AND [[test4_3]] > {:TEST} AND [[test4_4]] > {:TEST} AND [[test4_5]] > {:TEST} AND [[test4_6]] > {:TEST} AND [[test4_7]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_9]] > {:TEST} AND [[test4_10]] > {:TEST} AND [[test4_11]] > {:TEST} AND [[test4_12]] > {:TEST} AND [[test4_13]] > {:TEST} AND [[test4_14]] > {:TEST})", }, { "complex expression", - "((test1 > 1) || (test2 != 2)) && test3 ~ '%%example' && test4.sub = null", + "((test1 > 1) || (test2 != 2)) && test3 ~ '%%example' && test4_sub = null", false, - "(([[test1]] > {:TEST} OR [[test2]] != {:TEST}) AND [[test3]] LIKE {:TEST} ESCAPE '\\' AND ([[test4.sub]] = '' OR [[test4.sub]] IS NULL))", + "(([[test1]] > {:TEST} OR [[test2]] IS NOT {:TEST}) AND [[test3]] LIKE {:TEST} ESCAPE '\\' AND ([[test4_sub]] = '' OR [[test4_sub]] IS NULL))", }, { "combination of special literals (null, true, false)", - "test1=true && test2 != false && null = test3 || null != test4.sub", + "test1=true && test2 != false && null = test3 || null != test4_sub", false, - "([[test1]] = 1 AND [[test2]] != 0 AND ('' = [[test3]] OR [[test3]] IS NULL) OR ('' != [[test4.sub]] AND [[test4.sub]] IS NOT NULL))", + "([[test1]] = 1 AND [[test2]] IS NOT 0 AND ('' = [[test3]] OR [[test3]] IS NULL) OR ('' IS NOT [[test4_sub]] AND [[test4_sub]] IS NOT NULL))", }, { "all operators", "(test1 = test2 || test2 != test3) && (test2 ~ 'example' || test2 !~ '%%abc') && 'switch1%%' ~ test1 && 'switch2' !~ test2 && test3 > 1 && test3 >= 0 && test3 <= 4 && 2 < 5", false, - "((COALESCE([[test1]], '') = COALESCE([[test2]], '') OR COALESCE([[test2]], '') != COALESCE([[test3]], '')) AND ([[test2]] LIKE {:TEST} ESCAPE '\\' OR [[test2]] NOT LIKE {:TEST} ESCAPE '\\') AND {:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\\' AND {:TEST} NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\' AND [[test3]] > {:TEST} AND [[test3]] >= {:TEST} AND [[test3]] <= {:TEST} AND {:TEST} < {:TEST})", + "((COALESCE([[test1]], '') = COALESCE([[test2]], '') OR COALESCE([[test2]], '') IS NOT COALESCE([[test3]], '')) AND ([[test2]] LIKE {:TEST} ESCAPE '\\' OR [[test2]] NOT LIKE {:TEST} ESCAPE '\\') AND {:TEST} LIKE ('%' || [[test1]] || '%') ESCAPE '\\' AND {:TEST} NOT LIKE ('%' || [[test2]] || '%') ESCAPE '\\' AND [[test3]] > {:TEST} AND [[test3]] >= {:TEST} AND [[test3]] <= {:TEST} AND {:TEST} < {:TEST})", + }, + { + "geoDistance function", + "geoDistance(1,2,3,4) < 567", + false, + "(6371 * acos(min(1, max(-1, cos(radians({:TEST})) * cos(radians({:TEST})) * cos(radians({:TEST}) - radians({:TEST})) + sin(radians({:TEST})) * sin(radians({:TEST})))))) < {:TEST}", }, } @@ -202,7 +216,8 @@ func TestFilterDataBuildExprWithParams(t *testing.T) { test9 = {:test9} || test10 = {:test10} || test11 = {:test11} || - test12 = {:test12} + test12 = {:test12} || + test13 = {:test13} `) replacements := []dbx.Params{ @@ -210,10 +225,140 @@ func TestFilterDataBuildExprWithParams(t *testing.T) { {"test2": false}, {"test3": 123.456}, {"test4": nil}, - {"test5": "", "test6": "simple", "test7": `'single_quotes'`, "test8": `"double_quotes"`, "test9": `escape\"quote`}, + {"test5": "", "test6": "simple", "test7": `'single_quotes'`, "test8": `"double_quotes"`, "test9": "'\"quote_with_backslash\\"}, {"test10": date}, - {"test11": []string{"a", "b", `"quote`}}, + {"test11": []string{"a", "'quote", `"quote`}}, {"test12": map[string]any{"a": 123, "b": `quote"`}}, + {"test13": "a\nb"}, + } + + expr, err := filter.BuildExpr(resolver, replacements...) + if err != nil { + t.Fatal(err) + } + + db.Select().Where(expr).Build().Execute() + + if len(calledQueries) != 1 { + t.Fatalf("Expected 1 query, got %d", len(calledQueries)) + } + + expectedQuery := `SELECT * WHERE ([[test1]] = 1 OR [[test2]] = 0 OR [[test3a]] = 123.456 OR [[test3b]] = 123.456 OR ([[test4]] = '' OR [[test4]] IS NULL) OR ([[test5]] = '' OR [[test5]] IS NULL) OR [[test6]] = 'simple' OR [[test7]] = '''single_quotes''' OR [[test8]] = '"double_quotes"' OR [[test9]] = '''"quote_with_backslash\' OR [[test10]] = '2023-01-01 00:00:00 +0000 UTC' OR [[test11]] = '["a","''quote","\"quote"]' OR [[test12]] = '{"a":123,"b":"quote\""}' OR [[test13]] = 'a` + expectedQuery += "\nb')" + if expectedQuery != calledQueries[0] { + t.Fatalf("Expected query \n%s, \ngot \n%s", expectedQuery, calledQueries[0]) + } +} + +type brokenJSON struct{} + +func (j brokenJSON) MarshalJSON() ([]byte, error) { + return nil, errors.New("test_error") +} + +func TestFilterDataBuildExprWithParamsFallbackError(t *testing.T) { + t.Parallel() + + resolver := search.NewSimpleFieldResolver("test") + + filter := search.FilterData(`test = {:test}`) + + t.Run("non-string type but valid json", func(t *testing.T) { + _, err := filter.BuildExpr(resolver, dbx.Params{ + "test": map[string]any{"a": "123"}, + }) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("non-string type but invalid json", func(t *testing.T) { + _, err := filter.BuildExpr(resolver, dbx.Params{ + "test": brokenJSON{}, + }) + if err == nil { + t.Fatal("Expected filter build error, got nil") + } + }) +} + +func TestFilterDataBuildExprWithLimit(t *testing.T) { + resolver := search.NewSimpleFieldResolver(`^\w+$`) + + scenarios := []struct { + limit int + filter search.FilterData + expectError bool + }{ + {1, "1 = 1", false}, + {0, "1 = 1", true}, // new cache entry should be created + {2, "1 = 1 || 1 = 1", false}, + {1, "1 = 1 || 1 = 1", true}, + {3, "1 = 1 || 1 = 1", false}, + {6, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", false}, + {5, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", true}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("limit_%d:%d", i, s.limit), func(t *testing.T) { + _, err := s.filter.BuildExprWithLimit(resolver, s.limit) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + }) + } +} + +func TestLikeParamsWrapping(t *testing.T) { + // create a dummy db + sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + db := dbx.NewFromDB(sqlDB, "sqlite") + + calledQueries := []string{} + db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + calledQueries = append(calledQueries, sql) + } + db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { + calledQueries = append(calledQueries, sql) + } + + resolver := search.NewSimpleFieldResolver(`^test\w+$`) + + filter := search.FilterData(` + test1 ~ {:p1} || + test2 ~ {:p2} || + test3 ~ {:p3} || + test4 ~ {:p4} || + test5 ~ {:p5} || + test6 ~ {:p6} || + test7 ~ {:p7} || + test8 ~ {:p8} || + test9 ~ {:p9} || + test10 ~ {:p10} || + test11 ~ {:p11} || + test12 ~ {:p12} || + test13 ~ {:p13} + `) + + replacements := []dbx.Params{ + {"p1": `abc`}, + {"p2": `ab%c`}, + {"p3": `ab\%c`}, + {"p4": `%ab\%c`}, + {"p5": `ab\\%c`}, + {"p6": `ab\\\%c`}, + {"p7": `ab_c`}, + {"p8": `ab\_c`}, + {"p9": `%ab_c`}, + {"p10": `ab\c`}, + {"p11": `_ab\c_`}, + {"p12": `ab\c%`}, + {"p13": "a\nb\\"}, } expr, err := filter.BuildExpr(resolver, replacements...) @@ -227,7 +372,8 @@ func TestFilterDataBuildExprWithParams(t *testing.T) { t.Fatalf("Expected 1 query, got %d", len(calledQueries)) } - expectedQuery := `SELECT * WHERE ([[test1]] = 1 OR [[test2]] = 0 OR [[test3a]] = 123.456 OR [[test3b]] = 123.456 OR ([[test4]] = '' OR [[test4]] IS NULL) OR [[test5]] = '""' OR [[test6]] = 'simple' OR [[test7]] = '''single_quotes''' OR [[test8]] = '"double_quotes"' OR [[test9]] = 'escape\\"quote' OR [[test10]] = '2023-01-01 00:00:00 +0000 UTC' OR [[test11]] = '["a","b","\\"quote"]' OR [[test12]] = '{"a":123,"b":"quote\\""}')` + expectedQuery := `SELECT * WHERE ([[test1]] LIKE '%abc%' ESCAPE '\' OR [[test2]] LIKE 'ab%c' ESCAPE '\' OR [[test3]] LIKE '%ab\%c%' ESCAPE '\' OR [[test4]] LIKE '%ab\%c' ESCAPE '\' OR [[test5]] LIKE 'ab\\%c' ESCAPE '\' OR [[test6]] LIKE '%ab\\\%c%' ESCAPE '\' OR [[test7]] LIKE '%ab\_c%' ESCAPE '\' OR [[test8]] LIKE '%ab\_c%' ESCAPE '\' OR [[test9]] LIKE '%ab_c' ESCAPE '\' OR [[test10]] LIKE '%ab\\c%' ESCAPE '\' OR [[test11]] LIKE '%\_ab\\c\_%' ESCAPE '\' OR [[test12]] LIKE 'ab\c%' ESCAPE '\' OR [[test13]] LIKE '%a` + expectedQuery += "\n" + `b\\%' ESCAPE '\')` if expectedQuery != calledQueries[0] { t.Fatalf("Expected query \n%s, \ngot \n%s", expectedQuery, calledQueries[0]) } diff --git a/tools/search/identifier_macros.go b/tools/search/identifier_macros.go index 9cbb2edf50..b0314de423 100644 --- a/tools/search/identifier_macros.go +++ b/tools/search/identifier_macros.go @@ -23,17 +23,37 @@ var identifierMacros = map[string]func() (any, error){ return d.String(), nil }, + "@yesterday": func() (any, error) { + yesterday := timeNow().UTC().AddDate(0, 0, -1) + + d, err := types.ParseDateTime(yesterday) + if err != nil { + return "", fmt.Errorf("@yesterday: %w", err) + } + + return d.String(), nil + }, + "@tomorrow": func() (any, error) { + tomorrow := timeNow().UTC().AddDate(0, 0, 1) + + d, err := types.ParseDateTime(tomorrow) + if err != nil { + return "", fmt.Errorf("@tomorrow: %w", err) + } + + return d.String(), nil + }, "@second": func() (any, error) { - return int(timeNow().UTC().Second()), nil + return timeNow().UTC().Second(), nil }, "@minute": func() (any, error) { - return int(timeNow().UTC().Minute()), nil + return timeNow().UTC().Minute(), nil }, "@hour": func() (any, error) { - return int(timeNow().UTC().Hour()), nil + return timeNow().UTC().Hour(), nil }, "@day": func() (any, error) { - return int(timeNow().UTC().Day()), nil + return timeNow().UTC().Day(), nil }, "@month": func() (any, error) { return int(timeNow().UTC().Month()), nil @@ -42,7 +62,7 @@ var identifierMacros = map[string]func() (any, error){ return int(timeNow().UTC().Weekday()), nil }, "@year": func() (any, error) { - return int(timeNow().UTC().Year()), nil + return timeNow().UTC().Year(), nil }, "@todayStart": func() (any, error) { today := timeNow().UTC() diff --git a/tools/search/identifier_macros_test.go b/tools/search/identifier_macros_test.go index 19a515c5bb..0c58df4b79 100644 --- a/tools/search/identifier_macros_test.go +++ b/tools/search/identifier_macros_test.go @@ -14,6 +14,8 @@ func TestIdentifierMacros(t *testing.T) { testMacros := map[string]any{ "@now": "2023-02-03 04:05:06.000Z", + "@yesterday": "2023-02-02 04:05:06.000Z", + "@tomorrow": "2023-02-04 04:05:06.000Z", "@second": 6, "@minute": 5, "@hour": 4, diff --git a/tools/search/multi_match_subquery.go b/tools/search/multi_match_subquery.go new file mode 100644 index 0000000000..d1ed8baccc --- /dev/null +++ b/tools/search/multi_match_subquery.go @@ -0,0 +1,70 @@ +package search + +import ( + "fmt" + "strings" + + "github.com/pocketbase/dbx" +) + +var _ dbx.Expression = (*MultiMatchSubquery)(nil) + +// Join defines common fields required for a single SQL JOIN clause. +type Join struct { + TableName string + TableAlias string + On dbx.Expression +} + +// MultiMatchSubquery defines a multi-match record subquery expression. +type MultiMatchSubquery struct { + TargetTableAlias string + FromTableName string + FromTableAlias string + ValueIdentifier string + Joins []*Join + Params dbx.Params +} + +// Build converts the expression into a SQL fragment. +// +// Implements [dbx.Expression] interface. +func (m *MultiMatchSubquery) Build(db *dbx.DB, params dbx.Params) string { + if m.TargetTableAlias == "" || m.FromTableName == "" || m.FromTableAlias == "" { + return "0=1" + } + + if params == nil { + params = m.Params + } else { + // merge by updating the parent params + for k, v := range m.Params { + params[k] = v + } + } + + var mergedJoins strings.Builder + for i, j := range m.Joins { + if i > 0 { + mergedJoins.WriteString(" ") + } + mergedJoins.WriteString("LEFT JOIN ") + mergedJoins.WriteString(db.QuoteTableName(j.TableName)) + mergedJoins.WriteString(" ") + mergedJoins.WriteString(db.QuoteTableName(j.TableAlias)) + if j.On != nil { + mergedJoins.WriteString(" ON ") + mergedJoins.WriteString(j.On.Build(db, params)) + } + } + + return fmt.Sprintf( + `SELECT %s as [[multiMatchValue]] FROM %s %s %s WHERE %s = %s`, + db.QuoteColumnName(m.ValueIdentifier), + db.QuoteTableName(m.FromTableName), + db.QuoteTableName(m.FromTableAlias), + mergedJoins.String(), + db.QuoteColumnName(m.FromTableAlias+".id"), + db.QuoteColumnName(m.TargetTableAlias+".id"), + ) +} diff --git a/tools/search/multi_match_subquery_test.go b/tools/search/multi_match_subquery_test.go new file mode 100644 index 0000000000..f24564d4f9 --- /dev/null +++ b/tools/search/multi_match_subquery_test.go @@ -0,0 +1,52 @@ +package search_test + +import ( + "bytes" + "database/sql" + "encoding/json/v2" + "testing" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/search" +) + +func TestMultiMatchSubqueryBuild(t *testing.T) { + // create a dummy db + sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + db := dbx.NewFromDB(sqlDB, "sqlite") + + mm := search.MultiMatchSubquery{ + TargetTableAlias: "test_TargetTableAlias", + FromTableName: "test_FromTableName", + FromTableAlias: "test_FromTableAlias", + ValueIdentifier: "({:mm},{:external})", + Joins: []*search.Join{ + {TableName: "join_table1", TableAlias: "join_alias1"}, + {TableName: "join_table2", TableAlias: "join_alias2", On: dbx.NewExp("123={:join}", dbx.Params{"join": "test_join"})}, + }, + Params: dbx.Params{"mm": "test_mm"}, + } + + params := dbx.Params{"external": "test_external"} + + result := mm.Build(db, params) + + expectedResult := "SELECT ({:mm},{:external}) as [[multiMatchValue]] FROM `test_FromTableName` `test_FromTableAlias` LEFT JOIN `join_table1` `join_alias1` LEFT JOIN `join_table2` `join_alias2` ON 123={:join} WHERE `test_FromTableAlias`.`id` = `test_TargetTableAlias`.`id`" + if expectedResult != result { + t.Fatalf("Expected build result\n%v\ngot\n%v", expectedResult, result) + } + + // the params from all expressions should be merged in the root + rawParams, err := json.Marshal(params, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + expectedParams := []byte(`{"external":"test_external","join":"test_join","mm":"test_mm"}`) + if !bytes.Equal(rawParams, expectedParams) { + t.Fatalf("Expected final params\n%s\ngot\n%s", expectedParams, rawParams) + } +} diff --git a/tools/search/provider.go b/tools/search/provider.go index 06aa2053bc..aa3bb4299d 100644 --- a/tools/search/provider.go +++ b/tools/search/provider.go @@ -5,18 +5,45 @@ import ( "math" "net/url" "strconv" + "strings" "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/dbutils" + "github.com/pocketbase/pocketbase/tools/inflector" + "github.com/pocketbase/pocketbase/tools/routine" "golang.org/x/sync/errgroup" ) -// DefaultPerPage specifies the default returned search result items. -const DefaultPerPage int = 30 +const ( + // DefaultPerPage specifies the default number of returned search result items. + DefaultPerPage int = 30 + + // DefaultFilterExprLimit specifies the default filter expressions limit. + DefaultFilterExprLimit int = 200 + + // DefaultSortExprLimit specifies the default sort expressions limit. + DefaultSortExprLimit int = 8 + + // MaxPerPage specifies the max allowed search result items returned in a single page. + MaxPerPage int = 1000 + + // MaxFilterLength specifies the max allowed individual search filter parsable length. + MaxFilterLength int = 3500 + + // MaxSortFieldLength specifies the max allowed individual sort field parsable length. + MaxSortFieldLength int = 255 +) -// MaxPerPage specifies the maximum allowed search result items returned in a single page. -const MaxPerPage int = 500 +// Common search errors. +var ( + ErrEmptyQuery = errors.New("search query is not set") + ErrSortExprLimit = errors.New("max sort expressions limit reached") + ErrFilterExprLimit = errors.New("max filter expressions limit reached") + ErrFilterLengthLimit = errors.New("max filter length limit reached") + ErrSortFieldLengthLimit = errors.New("max sort field length limit reached") +) -// url search query params +// URL search query params const ( PageQueryParam string = "page" PerPageQueryParam string = "perPage" @@ -27,26 +54,28 @@ const ( // Result defines the returned search result structure. type Result struct { + Items any `json:"items"` Page int `json:"page"` PerPage int `json:"perPage"` TotalItems int `json:"totalItems"` TotalPages int `json:"totalPages"` - Items any `json:"items"` } // Provider represents a single configured search provider instance. type Provider struct { - fieldResolver FieldResolver - query *dbx.SelectQuery - skipTotal bool - countCol string - page int - perPage int - sort []SortField - filter []FilterData + fieldResolver FieldResolver + query *dbx.SelectQuery + countCol string + sort []SortField + filter []FilterData + page int + perPage int + skipTotal bool + maxFilterExprLimit int + maxSortExprLimit int } -// NewProvider creates and returns a new search provider. +// NewProvider initializes and returns a new search provider. // // Example: // @@ -59,15 +88,31 @@ type Provider struct { // ParseAndExec("page=2&filter=id>0&sort=-email", &models) func NewProvider(fieldResolver FieldResolver) *Provider { return &Provider{ - fieldResolver: fieldResolver, - countCol: "id", - page: 1, - perPage: DefaultPerPage, - sort: []SortField{}, - filter: []FilterData{}, + fieldResolver: fieldResolver, + countCol: "id", + page: 1, + perPage: DefaultPerPage, + sort: []SortField{}, + filter: []FilterData{}, + maxFilterExprLimit: DefaultFilterExprLimit, + maxSortExprLimit: DefaultSortExprLimit, } } +// MaxFilterExprLimit changes the default max allowed filter expressions. +// +// Note that currently the limit is applied individually for each separate filter. +func (s *Provider) MaxFilterExprLimit(max int) *Provider { + s.maxFilterExprLimit = max + return s +} + +// MaxSortExprLimit changes the default max allowed sort expressions. +func (s *Provider) MaxSortExprLimit(max int) *Provider { + s.maxSortExprLimit = max + return s +} + // Query sets the base query that will be used to fetch the search items. func (s *Provider) Query(query *dbx.SelectQuery) *Provider { s.query = query @@ -81,7 +126,7 @@ func (s *Provider) SkipTotal(skipTotal bool) *Provider { } // CountCol allows changing the default column (id) that is used -// to generated the COUNT SQL query statement. +// to generate the COUNT SQL query statement. // // This field is ignored if skipTotal is true. func (s *Provider) CountCol(name string) *Provider { @@ -184,7 +229,7 @@ func (s *Provider) Parse(urlQuery string) error { // the provided `items` slice with the found models. func (s *Provider) Exec(items any) (*Result, error) { if s.query == nil { - return nil, errors.New("query is not set") + return nil, ErrEmptyQuery } // shallow clone the provider's query @@ -192,7 +237,10 @@ func (s *Provider) Exec(items any) (*Result, error) { // build filters for _, f := range s.filter { - expr, err := f.BuildExpr(s.fieldResolver) + if len(f) > MaxFilterLength { + return nil, ErrFilterLengthLimit + } + expr, err := f.BuildExprWithLimit(s.fieldResolver, s.maxFilterExprLimit) if err != nil { return nil, err } @@ -202,12 +250,26 @@ func (s *Provider) Exec(items any) (*Result, error) { } // apply sorting + if len(s.sort) > s.maxSortExprLimit { + return nil, ErrSortExprLimit + } for _, sortField := range s.sort { + if len(sortField.Name) > MaxSortFieldLength { + return nil, ErrSortFieldLengthLimit + } expr, err := sortField.BuildExpr(s.fieldResolver) if err != nil { return nil, err } if expr != "" { + // ensure that _rowid_ expressions are always prefixed with the first FROM table + if sortField.Name == rowidSortKey && !strings.Contains(expr, ".") { + queryInfo := modelsQuery.Info() + if len(queryInfo.From) > 0 { + expr = "[[" + inflector.Columnify(queryInfo.From[0]) + "]]." + expr + } + } + modelsQuery.AndOrderBy(expr) } } @@ -238,13 +300,21 @@ func (s *Provider) Exec(items any) (*Result, error) { countExec := func() error { queryInfo := countQuery.Info() countCol := s.countCol + if len(queryInfo.From) > 0 { - countCol = queryInfo.From[0] + "." + countCol + firstFrom := dbutils.AliasOrIdentifier(queryInfo.From[0]) + countCol = firstFrom + "." + countCol } + // @todo while currently there is no such use case, evaluate if + // wrapping as a subquery would be more suitable for the cases + // when there is "Group By" different from the default deduplication one + // added by RecordFieldResolver.UpdateQuery + // note: countQuery is shallow cloned and slice/map in-place modifications should be avoided err := countQuery.Distinct(false). Select("COUNT(DISTINCT [[" + countCol + "]])"). + GroupBy( /* reset */ ). OrderBy( /* reset */ ). Row(&totalCount) if err != nil { @@ -267,9 +337,8 @@ func (s *Provider) Exec(items any) (*Result, error) { if !s.skipTotal { // execute the 2 queries concurrently errg := new(errgroup.Group) - errg.SetLimit(2) - errg.Go(countExec) - errg.Go(modelsExec) + errg.Go(routine.SafeWrap(countExec)) + errg.Go(routine.SafeWrap(modelsExec)) if err := errg.Wait(); err != nil { return nil, err } diff --git a/tools/search/provider_test.go b/tools/search/provider_test.go index d2ca1cb06b..f654f0fcbe 100644 --- a/tools/search/provider_test.go +++ b/tools/search/provider_test.go @@ -3,9 +3,12 @@ package search import ( "context" "database/sql" - "encoding/json" + "encoding/json/v2" "errors" "fmt" + "strconv" + "strings" + "sync" "testing" "time" @@ -25,6 +28,46 @@ func TestNewProvider(t *testing.T) { if p.perPage != DefaultPerPage { t.Fatalf("Expected perPage %d, got %d", DefaultPerPage, p.perPage) } + + if p.maxFilterExprLimit != DefaultFilterExprLimit { + t.Fatalf("Expected maxFilterExprLimit %d, got %d", DefaultFilterExprLimit, p.maxFilterExprLimit) + } + + if p.maxSortExprLimit != DefaultSortExprLimit { + t.Fatalf("Expected maxSortExprLimit %d, got %d", DefaultSortExprLimit, p.maxSortExprLimit) + } +} + +func TestMaxFilterExprLimit(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + testVals := []int{0, -10, 10} + + for _, val := range testVals { + t.Run("max_"+strconv.Itoa(val), func(t *testing.T) { + p.MaxFilterExprLimit(val) + + if p.maxFilterExprLimit != val { + t.Fatalf("Expected maxFilterExprLimit to change to %d, got %d", val, p.maxFilterExprLimit) + } + }) + } +} + +func TestMaxSortExprLimit(t *testing.T) { + p := NewProvider(&testFieldResolver{}) + + testVals := []int{0, -10, 10} + + for _, val := range testVals { + t.Run("max_"+strconv.Itoa(val), func(t *testing.T) { + p.MaxSortExprLimit(val) + + if p.maxSortExprLimit != val { + t.Fatalf("Expected maxSortExprLimit to change to %d, got %d", val, p.maxSortExprLimit) + } + }) + } } func TestProviderQuery(t *testing.T) { @@ -95,7 +138,7 @@ func TestProviderSort(t *testing.T) { Sort(initialSort). AddSort(SortField{"test3", SortDesc}) - encoded, _ := json.Marshal(p.sort) + encoded, _ := json.Marshal(p.sort, json.Deterministic(true)) expected := `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"ASC"},{"name":"test3","direction":"DESC"}]` if string(encoded) != expected { @@ -110,7 +153,7 @@ func TestProviderFilter(t *testing.T) { Filter(initialFilter). AddFilter("test3") - encoded, _ := json.Marshal(p.filter) + encoded, _ := json.Marshal(p.filter, json.Deterministic(true)) expected := `["test1","test2","test3"]` if string(encoded) != expected { @@ -180,38 +223,39 @@ func TestProviderParse(t *testing.T) { } for i, s := range scenarios { - r := &testFieldResolver{} - p := NewProvider(r). - Page(initialPage). - PerPage(initialPerPage). - Sort(initialSort). - Filter(initialFilter) - - err := p.Parse(s.query) - - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err) - continue - } - - if p.page != s.expectPage { - t.Errorf("(%d) Expected page %v, got %v", i, s.expectPage, p.page) - } - - if p.perPage != s.expectPerPage { - t.Errorf("(%d) Expected perPage %v, got %v", i, s.expectPerPage, p.perPage) - } - - encodedSort, _ := json.Marshal(p.sort) - if string(encodedSort) != s.expectSort { - t.Errorf("(%d) Expected sort %v, got \n%v", i, s.expectSort, string(encodedSort)) - } - - encodedFilter, _ := json.Marshal(p.filter) - if string(encodedFilter) != s.expectFilter { - t.Errorf("(%d) Expected filter %v, got \n%v", i, s.expectFilter, string(encodedFilter)) - } + t.Run(fmt.Sprintf("%d_%s", i, s.query), func(t *testing.T) { + r := &testFieldResolver{} + p := NewProvider(r). + Page(initialPage). + PerPage(initialPerPage). + Sort(initialSort). + Filter(initialFilter) + + err := p.Parse(s.query) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if p.page != s.expectPage { + t.Fatalf("Expected page %v, got %v", s.expectPage, p.page) + } + + if p.perPage != s.expectPerPage { + t.Fatalf("Expected perPage %v, got %v", s.expectPerPage, p.perPage) + } + + encodedSort, _ := json.Marshal(p.sort, json.Deterministic(true)) + if string(encodedSort) != s.expectSort { + t.Fatalf("Expected sort %v, got \n%v", s.expectSort, string(encodedSort)) + } + + encodedFilter, _ := json.Marshal(p.filter, json.Deterministic(true)) + if string(encodedFilter) != s.expectFilter { + t.Fatalf("Expected filter %v, got \n%v", s.expectFilter, string(encodedFilter)) + } + }) } } @@ -256,7 +300,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{}, false, false, - `{"page":1,"perPage":10,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":10,"totalItems":2,"totalPages":1}`, []string{ "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10", @@ -270,7 +314,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{}, false, false, - `{"page":10,"perPage":30,"totalItems":2,"totalPages":1,"items":[]}`, + `{"items":[],"page":10,"perPage":30,"totalItems":2,"totalPages":1}`, []string{ "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30 OFFSET 270", @@ -306,10 +350,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{"test2 != null", "test1 >= 2"}, false, false, - `{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1}`, []string{ - "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 != '' AND test2 IS NOT NULL)))) AND (test1 >= 2)", - "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 != '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2)", + "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), }, }, { @@ -320,9 +364,9 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{"test2 != null", "test1 >= 2"}, true, false, - `{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":-1,"totalPages":-1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":-1,"totalPages":-1}`, []string{ - "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 != '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), + "SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (((test2 IS NOT '' AND test2 IS NOT NULL)))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT " + fmt.Sprint(MaxPerPage), }, }, { @@ -333,10 +377,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{"test3 != ''"}, false, false, - `{"page":1,"perPage":10,"totalItems":0,"totalPages":0,"items":[]}`, + `{"items":[],"page":1,"perPage":10,"totalItems":0,"totalPages":0}`, []string{ - "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 != '' AND test3 IS NOT NULL)))", - "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 != '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", + "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL)))", + "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", }, }, { @@ -347,9 +391,9 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{"test3 != ''"}, true, false, - `{"page":1,"perPage":10,"totalItems":-1,"totalPages":-1,"items":[]}`, + `{"items":[],"page":1,"perPage":10,"totalItems":-1,"totalPages":-1}`, []string{ - "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 != '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", + "SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (((test3 IS NOT '' AND test3 IS NOT NULL))) ORDER BY `test1` ASC, `test3` ASC LIMIT 10", }, }, { @@ -360,7 +404,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{}, false, false, - `{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":2,"perPage":1,"totalItems":2,"totalPages":2}`, []string{ "SELECT COUNT(DISTINCT [[test.id]]) FROM `test` WHERE NOT (`test1` IS NULL)", "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1", @@ -374,7 +418,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { []FilterData{}, true, false, - `{"page":2,"perPage":1,"totalItems":-1,"totalPages":-1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":2,"perPage":1,"totalItems":-1,"totalPages":-1}`, []string{ "SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1", }, @@ -382,48 +426,183 @@ func TestProviderExecNonEmptyQuery(t *testing.T) { } for _, s := range scenarios { - testDB.CalledQueries = []string{} // reset - - testResolver := &testFieldResolver{} - p := NewProvider(testResolver). - Query(query). - Page(s.page). - PerPage(s.perPage). - Sort(s.sort). - SkipTotal(s.skipTotal). - Filter(s.filter) - - result, err := p.Exec(&[]testTableStruct{}) - - 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 testResolver.UpdateQueryCalls != 1 { - t.Errorf("[%s] Expected resolver.Update to be called %d, got %d", s.name, 1, testResolver.UpdateQueryCalls) - } - - encoded, _ := json.Marshal(result) - if string(encoded) != s.expectResult { - t.Errorf("[%s] Expected result %v, got \n%v", s.name, s.expectResult, string(encoded)) - } - - if len(s.expectQueries) != len(testDB.CalledQueries) { - t.Errorf("[%s] Expected %d queries, got %d: \n%v", s.name, len(s.expectQueries), len(testDB.CalledQueries), testDB.CalledQueries) - continue - } - - for _, q := range testDB.CalledQueries { - if !list.ExistInSliceWithRegex(q, s.expectQueries) { - t.Fatalf("[%s] Didn't expect query \n%v \nin \n%v", s.name, q, s.expectQueries) + t.Run(s.name, func(t *testing.T) { + testDB.CalledQueries = []string{} // reset + + testResolver := &testFieldResolver{} + p := NewProvider(testResolver). + Query(query). + Page(s.page). + PerPage(s.perPage). + Sort(s.sort). + SkipTotal(s.skipTotal). + Filter(s.filter) + + result, err := p.Exec(&[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } - } + + if hasErr { + return + } + + if testResolver.UpdateQueryCalls != 1 { + t.Fatalf("Expected resolver.Update to be called %d, got %d", 1, testResolver.UpdateQueryCalls) + } + + encoded, _ := json.Marshal(result, json.Deterministic(true)) + if string(encoded) != s.expectResult { + t.Fatalf("Expected result %v, got \n%v", s.expectResult, string(encoded)) + } + + if len(s.expectQueries) != len(testDB.CalledQueries) { + t.Fatalf("Expected %d queries, got %d: \n%v", len(s.expectQueries), len(testDB.CalledQueries), testDB.CalledQueries) + } + + for _, q := range testDB.CalledQueries { + if !list.ExistInSliceWithRegex(q, s.expectQueries) { + t.Fatalf("Didn't expect query \n%v \nin \n%v", q, s.expectQueries) + } + } + }) + } +} + +func TestProviderFilterAndSortLimits(t *testing.T) { + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + query := testDB.Select("*"). + From("test"). + Where(dbx.Not(dbx.HashExp{"test1": nil})). + OrderBy("test1 ASC") + + scenarios := []struct { + name string + filter []FilterData + sort []SortField + maxFilterExprLimit int + maxSortExprLimit int + expectError bool + }{ + // filter + { + "<= max filter length", + []FilterData{ + "1=2", + FilterData("1='" + strings.Repeat("a", MaxFilterLength-4) + "'"), + }, + []SortField{}, + 1, + 0, + false, + }, + { + "> max filter length", + []FilterData{ + "1=2", + FilterData("1='" + strings.Repeat("a", MaxFilterLength-3) + "'"), + }, + []SortField{}, + 1, + 0, + true, + }, + { + "<= max filter exprs", + []FilterData{ + "1=2", + "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", + }, + []SortField{}, + 6, + 0, + false, + }, + { + "> max filter exprs", + []FilterData{ + "1=2", + "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", + }, + []SortField{}, + 5, + 0, + true, + }, + + // sort + { + "<= max sort field length", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + {strings.Repeat("a", MaxSortFieldLength), SortDesc}, + }, + 0, + 10, + false, + }, + { + "> max sort field length", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + {strings.Repeat("b", MaxSortFieldLength+1), SortDesc}, + }, + 0, + 10, + true, + }, + { + "<= max sort exprs", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + }, + 0, + 2, + false, + }, + { + "> max sort exprs", + []FilterData{}, + []SortField{ + {"id", SortAsc}, + {"test1", SortDesc}, + }, + 0, + 1, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + testResolver := &testFieldResolver{} + p := NewProvider(testResolver). + Query(query). + Sort(s.sort). + Filter(s.filter). + MaxFilterExprLimit(s.maxFilterExprLimit). + MaxSortExprLimit(s.maxSortExprLimit) + + _, err := p.Exec(&[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) + } + }) } } @@ -449,7 +628,7 @@ func TestProviderParseAndExec(t *testing.T) { "no extra query params (aka. use the provider presets)", "", false, - `{"page":2,"perPage":123,"totalItems":2,"totalPages":1,"items":[]}`, + `{"items":[],"page":2,"perPage":123,"totalItems":2,"totalPages":1}`, }, { "invalid query", @@ -491,62 +670,63 @@ func TestProviderParseAndExec(t *testing.T) { "page > existing", "page=3&perPage=9999", false, - `{"page":3,"perPage":500,"totalItems":2,"totalPages":1,"items":[]}`, + `{"items":[],"page":3,"perPage":1000,"totalItems":2,"totalPages":1}`, }, { "valid query params", "page=1&perPage=9999&filter=test1>1&sort=-test2,test3", false, - `{"page":1,"perPage":500,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":1000,"totalItems":1,"totalPages":1}`, }, { "valid query params with skipTotal=1", "page=1&perPage=9999&filter=test1>1&sort=-test2,test3&skipTotal=1", false, - `{"page":1,"perPage":500,"totalItems":-1,"totalPages":-1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`, + `{"items":[{"test1":2,"test2":"test2.2","test3":""}],"page":1,"perPage":1000,"totalItems":-1,"totalPages":-1}`, }, } for _, s := range scenarios { - testDB.CalledQueries = []string{} // reset - - testResolver := &testFieldResolver{} - provider := NewProvider(testResolver). - Query(query). - Page(2). - PerPage(123). - Sort([]SortField{{"test2", SortAsc}}). - Filter([]FilterData{"test1 > 0"}) - - result, err := provider.ParseAndExec(s.queryString, &[]testTableStruct{}) - - 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 testResolver.UpdateQueryCalls != 1 { - t.Errorf("[%s] Expected resolver.Update to be called %d, got %d", s.name, 1, testResolver.UpdateQueryCalls) - } - - expectedQueries := 2 - if provider.skipTotal { - expectedQueries = 1 - } - - if len(testDB.CalledQueries) != expectedQueries { - t.Errorf("[%s] Expected %d db queries, got %d: \n%v", s.name, expectedQueries, len(testDB.CalledQueries), testDB.CalledQueries) - } - - encoded, _ := json.Marshal(result) - if string(encoded) != s.expectResult { - t.Errorf("[%s] Expected result %v, got \n%v", s.name, s.expectResult, string(encoded)) - } + t.Run(s.name, func(t *testing.T) { + testDB.CalledQueries = []string{} // reset + + testResolver := &testFieldResolver{} + provider := NewProvider(testResolver). + Query(query). + Page(2). + PerPage(123). + Sort([]SortField{{"test2", SortAsc}}). + Filter([]FilterData{"test1 > 0"}) + + result, err := provider.ParseAndExec(s.queryString, &[]testTableStruct{}) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if testResolver.UpdateQueryCalls != 1 { + t.Fatalf("Expected resolver.Update to be called %d, got %d", 1, testResolver.UpdateQueryCalls) + } + + expectedQueries := 2 + if provider.skipTotal { + expectedQueries = 1 + } + + if len(testDB.CalledQueries) != expectedQueries { + t.Fatalf("Expected %d db queries, got %d: \n%v", expectedQueries, len(testDB.CalledQueries), testDB.CalledQueries) + } + + encoded, _ := json.Marshal(result, json.Deterministic(true)) + if string(encoded) != s.expectResult { + t.Fatalf("Expected result \n%v\ngot\n%v", s.expectResult, string(encoded)) + } + }) } } @@ -562,6 +742,7 @@ type testTableStruct struct { type testDB struct { *dbx.DB + mu sync.Mutex CalledQueries []string } @@ -575,10 +756,19 @@ func createTestDB() (*testDB, error) { } db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")} - db.CreateTable("test", map[string]string{"id": "int default 0", "test1": "int default 0", "test2": "text default ''", "test3": "text default ''"}).Execute() + db.CreateTable("test", map[string]string{ + "id": "int default 0", + "test1": "int default 0", + "test2": "text default ''", + "test3": "text default ''", + strings.Repeat("a", MaxSortFieldLength): "text default ''", + strings.Repeat("b", MaxSortFieldLength+1): "text default ''", + }).Execute() db.Insert("test", dbx.Params{"id": 1, "test1": 1, "test2": "test2.1"}).Execute() db.Insert("test", dbx.Params{"id": 2, "test1": 2, "test2": "test2.2"}).Execute() db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + db.mu.Lock() + defer db.mu.Unlock() db.CalledQueries = append(db.CalledQueries, sql) } diff --git a/tools/search/simple_field_resolver.go b/tools/search/simple_field_resolver.go index da5d771a43..f19600c721 100644 --- a/tools/search/simple_field_resolver.go +++ b/tools/search/simple_field_resolver.go @@ -2,25 +2,42 @@ package search import ( "fmt" + "strconv" + "strings" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/list" ) +type NullFallbackPreference int + +const ( + NullFallbackAuto NullFallbackPreference = 0 + NullFallbackDisabled NullFallbackPreference = 1 + NullFallbackEnforced NullFallbackPreference = 2 +) + // ResolverResult defines a single FieldResolver.Resolve() successfully parsed result. type ResolverResult struct { // Identifier is the plain SQL identifier/column that will be used // in the final db expression as left or right operand. Identifier string + // NullFallback specify the preference for how NULL or empty values + // should be resolved (default to "auto"). + // + // Set to NullFallbackDisabled to prevent any COALESCE or NULL fallbacks. + // Set to NullFallbackEnforced to prefer COALESCE or NULL fallbacks when needed. + NullFallback NullFallbackPreference + // Params is a map with db placeholder->value pairs that will be added // to the query when building both resolved operands/sides in a single expression. Params dbx.Params // MultiMatchSubQuery is an optional sub query expression that will be added // in addition to the combined ResolverResult expression during build. - MultiMatchSubQuery dbx.Expression + MultiMatchSubQuery *MultiMatchSubquery // AfterBuild is an optional function that will be called after building // and combining the result of both resolved operands/sides in a single expression. @@ -70,10 +87,38 @@ func (r *SimpleFieldResolver) UpdateQuery(query *dbx.SelectQuery) error { // Returns error if `field` is not in `r.allowedFields`. func (r *SimpleFieldResolver) Resolve(field string) (*ResolverResult, error) { if !list.ExistInSliceWithRegex(field, r.allowedFields) { - return nil, fmt.Errorf("Failed to resolve field %q.", field) + return nil, fmt.Errorf("failed to resolve field %q", field) + } + + parts := strings.Split(field, ".") + + // single regular field + if len(parts) == 1 { + return &ResolverResult{ + Identifier: "[[" + inflector.Columnify(parts[0]) + "]]", + }, nil + } + + // treat as json path + var jsonPath strings.Builder + jsonPath.WriteString("$") + for _, part := range parts[1:] { + if _, err := strconv.Atoi(part); err == nil { + jsonPath.WriteString("[") + jsonPath.WriteString(inflector.Columnify(part)) + jsonPath.WriteString("]") + } else { + jsonPath.WriteString(".") + jsonPath.WriteString(inflector.Columnify(part)) + } } return &ResolverResult{ - Identifier: "[[" + inflector.Columnify(field) + "]]", + NullFallback: NullFallbackDisabled, + Identifier: fmt.Sprintf( + "JSON_EXTRACT([[%s]], '%s')", + inflector.Columnify(parts[0]), + jsonPath.String(), + ), }, nil } diff --git a/tools/search/simple_field_resolver_test.go b/tools/search/simple_field_resolver_test.go index bba96aea88..c5543e0832 100644 --- a/tools/search/simple_field_resolver_test.go +++ b/tools/search/simple_field_resolver_test.go @@ -1,6 +1,7 @@ package search_test import ( + "fmt" "testing" "github.com/pocketbase/dbx" @@ -23,27 +24,27 @@ func TestSimpleFieldResolverUpdateQuery(t *testing.T) { } for i, s := range scenarios { - db := dbx.NewFromDB(nil, "") - query := db.Select("id").From("test") + t.Run(fmt.Sprintf("%d_%s", i, s.fieldName), func(t *testing.T) { + db := dbx.NewFromDB(nil, "") + query := db.Select("id").From("test") - r.Resolve(s.fieldName) + r.Resolve(s.fieldName) - if err := r.UpdateQuery(nil); err != nil { - t.Errorf("(%d) UpdateQuery failed with error %v", i, err) - continue - } + if err := r.UpdateQuery(nil); err != nil { + t.Fatalf("UpdateQuery failed with error %v", err) + } - rawQuery := query.Build().SQL() - // rawQuery := s.expectQuery + rawQuery := query.Build().SQL() - if rawQuery != s.expectQuery { - t.Errorf("(%d) Expected query %v, got \n%v", i, s.expectQuery, rawQuery) - } + if rawQuery != s.expectQuery { + t.Fatalf("Expected query %v, got \n%v", s.expectQuery, rawQuery) + } + }) } } func TestSimpleFieldResolverResolve(t *testing.T) { - r := search.NewSimpleFieldResolver("test", `^test_regex\d+$`, "Test columnify!") + r := search.NewSimpleFieldResolver("test", `^test_regex\d+$`, "Test columnify!", "data.test") scenarios := []struct { fieldName string @@ -58,28 +59,29 @@ func TestSimpleFieldResolverResolve(t *testing.T) { {"test_regex", true, ""}, {"test_regex1", false, "[[test_regex1]]"}, {"Test columnify!", false, "[[Testcolumnify]]"}, + {"data.test", false, "JSON_EXTRACT([[data]], '$.test')"}, } for i, s := range scenarios { - r, err := r.Resolve(s.fieldName) - - 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 r.Identifier != s.expectName { - t.Errorf("(%d) Expected r.Identifier %q, got %q", i, s.expectName, r.Identifier) - } - - // params should be empty - if len(r.Params) != 0 { - t.Errorf("(%d) Expected 0 r.Params, got %v", i, r.Params) - } + t.Run(fmt.Sprintf("%d_%s", i, 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 %q, got %q", s.expectName, r.Identifier) + } + + if len(r.Params) != 0 { + t.Fatalf("r.Params should be empty, got %v", r.Params) + } + }) } } diff --git a/tools/search/sort.go b/tools/search/sort.go index 7dd2880415..73da604b7d 100644 --- a/tools/search/sort.go +++ b/tools/search/sort.go @@ -5,7 +5,10 @@ import ( "strings" ) -const randomSortKey string = "@random" +const ( + randomSortKey string = "@random" + rowidSortKey string = "@rowid" +) // sort field directions const ( @@ -26,10 +29,15 @@ func (s *SortField) BuildExpr(fieldResolver FieldResolver) (string, error) { return "RANDOM()", nil } + // special case for the builtin SQLite rowid column + if s.Name == rowidSortKey { + return fmt.Sprintf("[[_rowid_]] %s", s.Direction), nil + } + result, err := fieldResolver.Resolve(s.Name) // invalidate empty fields and non-column identifiers - if err != nil || len(result.Params) > 0 || result.Identifier == "" || strings.ToLower(result.Identifier) == "null" { + if err != nil || len(result.Params) > 0 || result.Identifier == "" || strings.EqualFold(result.Identifier, "null") { return "", fmt.Errorf("invalid sort field %q", s.Name) } diff --git a/tools/search/sort_test.go b/tools/search/sort_test.go index d6bb6b1169..e47ad6cb0d 100644 --- a/tools/search/sort_test.go +++ b/tools/search/sort_test.go @@ -1,7 +1,8 @@ package search_test import ( - "encoding/json" + "encoding/json/v2" + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/search" @@ -29,27 +30,30 @@ func TestSortFieldBuildExpr(t *testing.T) { {search.SortField{"test1", search.SortDesc}, false, "[[test1]] DESC"}, // special @random field (ignore direction) {search.SortField{"@random", search.SortDesc}, false, "RANDOM()"}, + // special _rowid_ field + {search.SortField{"@rowid", search.SortDesc}, false, "[[_rowid_]] DESC"}, } - for i, s := range scenarios { - result, err := s.sortField.BuildExpr(resolver) + for _, s := range scenarios { + t.Run(fmt.Sprintf("%s_%s", s.sortField.Name, s.sortField.Name), func(t *testing.T) { + result, err := s.sortField.BuildExpr(resolver) - hasErr := err != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err) - continue - } + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } - if result != s.expectExpression { - t.Errorf("(%d) Expected expression %v, got %v", i, s.expectExpression, result) - } + if result != s.expectExpression { + t.Fatalf("Expected expression %v, got %v", s.expectExpression, result) + } + }) } } func TestParseSortFromString(t *testing.T) { scenarios := []struct { - value string - expectedJson string + value string + expected string }{ {"", `[{"name":"","direction":"ASC"}]`}, {"test", `[{"name":"test","direction":"ASC"}]`}, @@ -57,14 +61,18 @@ func TestParseSortFromString(t *testing.T) { {"-test", `[{"name":"test","direction":"DESC"}]`}, {"test1,-test2,+test3", `[{"name":"test1","direction":"ASC"},{"name":"test2","direction":"DESC"},{"name":"test3","direction":"ASC"}]`}, {"@random,-test", `[{"name":"@random","direction":"ASC"},{"name":"test","direction":"DESC"}]`}, + {"-@rowid,-test", `[{"name":"@rowid","direction":"DESC"},{"name":"test","direction":"DESC"}]`}, } - for i, s := range scenarios { - result := search.ParseSortFromString(s.value) - encoded, _ := json.Marshal(result) + for _, s := range scenarios { + t.Run(s.value, func(t *testing.T) { + result := search.ParseSortFromString(s.value) + encoded, _ := json.Marshal(result, json.Deterministic(true)) + encodedStr := string(encoded) - if string(encoded) != s.expectedJson { - t.Errorf("(%d) Expected expression %v, got %v", i, s.expectedJson, string(encoded)) - } + if encodedStr != s.expected { + t.Fatalf("Expected expression %s, got %s", s.expected, encodedStr) + } + }) } } diff --git a/tools/search/token_functions.go b/tools/search/token_functions.go new file mode 100644 index 0000000000..03edd5e63e --- /dev/null +++ b/tools/search/token_functions.go @@ -0,0 +1,198 @@ +package search + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/ganigeorgiev/fexpr" + "github.com/pocketbase/dbx" +) + +var TokenFunctions = map[string]func( + argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), + args ...fexpr.Token, +) (*ResolverResult, error){ + // geoDistance(lonA, latA, lonB, latB) calculates the Haversine + // distance between 2 points in kilometres (https://www.movable-type.co.uk/scripts/latlong.html). + // + // The accepted arguments at the moment could be either a plain number or a column identifier (including NULL). + // If the column identifier cannot be resolved and converted to a numeric value, it resolves to NULL. + // + // Similar to the built-in SQLite functions, geoDistance doesn't apply + // a "match-all" constraints in case there are multiple relation fields arguments. + // Or in other words, if a collection has "orgs" multiple relation field pointing to "orgs" collection that has "office" as "geoPoint" field, + // then the filter: `geoDistance(orgs.office.lon, orgs.office.lat, 1, 2) < 200` + // will evaluate to true if for at-least-one of the "orgs.office" records the function result in a value satisfying the condition (aka. "result < 200"). + "geoDistance": func(argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), args ...fexpr.Token) (*ResolverResult, error) { + if len(args) != 4 { + return nil, fmt.Errorf("[geoDistance] expected 4 arguments, got %d", len(args)) + } + + resolvedArgs := make([]*ResolverResult, 4) + for i, arg := range args { + if arg.Type != fexpr.TokenIdentifier && arg.Type != fexpr.TokenNumber { + return nil, fmt.Errorf("[geoDistance] argument %d must be an identifier or number", i) + } + resolved, err := argTokenResolverFunc(arg) + if err != nil { + return nil, fmt.Errorf("[geoDistance] failed to resolve argument %d: %w", i, err) + } + resolvedArgs[i] = resolved + } + + lonA := resolvedArgs[0].Identifier + latA := resolvedArgs[1].Identifier + lonB := resolvedArgs[2].Identifier + latB := resolvedArgs[3].Identifier + + return &ResolverResult{ + NullFallback: NullFallbackDisabled, + // the clamping is to prevent floating point rounding errors for values like + // "1.0002" that could occur for example when comparing identical points + // (see the NULL note for arccosine in https://sqlite.org/lang_mathfunc.html#overview) + Identifier: `(6371 * acos(min(1, max(-1, ` + + `cos(radians(` + latA + `)) * cos(radians(` + latB + `)) * ` + + `cos(radians(` + lonB + `) - radians(` + lonA + `)) + ` + + `sin(radians(` + latA + `)) * sin(radians(` + latB + `))` + + `))))`, + Params: mergeParams(resolvedArgs[0].Params, resolvedArgs[1].Params, resolvedArgs[2].Params, resolvedArgs[3].Params), + }, nil + }, + + // strftime(format, [timeValue, modifier1, modifier2, ...]) returns + // a date string formatted according to the specified format argument. + // + // It is similar to the builtin SQLite strftime 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. + // + // The function accepts 1, 2 or 3+ arguments. + // + // (1) The first (format) argument must be always a formatting string + // with valid substitutions as listed in https://sqlite.org/lang_datefunc.html. + // + // (2) The second (time-value) argument is optional and must be either a date string, number or collection field identifier + // that matches one of the formats listed in https://sqlite.org/lang_datefunc.html#time_values. + // + // (3+) The remaining (modifiers) optional arguments are expected to be + // string literals matching the listed modifiers in https://sqlite.org/lang_datefunc.html#modifiers. + // + // A multi-match constraint will be also applied in case the time-value + // is an identifier as a result of a multi-value relation field. + "strftime": func(argTokenResolverFunc func(fexpr.Token) (*ResolverResult, error), args ...fexpr.Token) (*ResolverResult, error) { + totalArgs := len(args) + + if totalArgs < 1 { + return nil, fmt.Errorf("[strftime] expected at least 1 arguments, got %d", len(args)) + } + + // limit the number of arguments to prevent abuse + if totalArgs > 10 { + return nil, fmt.Errorf("[strftime] too many arguments (max allowed 10, got %d)", totalArgs) + } + + // format arg + // ----------------------------------------------------------- + if args[0].Type != fexpr.TokenText { + return nil, errors.New("[strftime] expects the first argument to be a format string") + } + + formatArgResult, err := argTokenResolverFunc(args[0]) + if err != nil { + return nil, fmt.Errorf("[strftime] failed to resolve format argument: %w", err) + } + + // no further arguments + if totalArgs == 1 { + formatArgResult.NullFallback = NullFallbackEnforced + formatArgResult.Identifier = "strftime(" + formatArgResult.Identifier + ")" + return formatArgResult, nil + } + + // time-value arg + // ----------------------------------------------------------- + allowedTimeValueTokens := []fexpr.TokenType{fexpr.TokenText, fexpr.TokenIdentifier, fexpr.TokenNumber} + if !slices.Contains(allowedTimeValueTokens, args[1].Type) { + return nil, errors.New("[strftime] expects the second argument to be of a valid time-value type") + } + + timeValueArgResult, err := argTokenResolverFunc(args[1]) + if err != nil { + return nil, fmt.Errorf("[strftime] failed to resolve time-value argument: %w", err) + } + + // modifiers args + // ----------------------------------------------------------- + resolvedModifierArgs := make([]*ResolverResult, totalArgs-2) + for i, arg := range args[2:] { + if arg.Type != fexpr.TokenText { + return nil, fmt.Errorf("[strftime] invalid modifier argument %d - can be only string", i) + } + + resolved, err := argTokenResolverFunc(arg) + if err != nil { + return nil, fmt.Errorf("[strftime] failed to resolve modifier argument %d: %w", i, err) + } + + resolvedModifierArgs[i] = resolved + } + + // generating new ResolverResult + // ----------------------------------------------------------- + result := &ResolverResult{ + NullFallback: NullFallbackEnforced, + Params: dbx.Params{}, + } + + identifiers := make([]string, 0, totalArgs) + + identifiers = append(identifiers, formatArgResult.Identifier) + if err = concatUniqueParams(result.Params, formatArgResult.Params); err != nil { + return nil, err + } + + identifiers = append(identifiers, timeValueArgResult.Identifier) + if err = concatUniqueParams(result.Params, timeValueArgResult.Params); err != nil { + return nil, err + } + + for _, m := range resolvedModifierArgs { + identifiers = append(identifiers, m.Identifier) + err = concatUniqueParams(result.Params, m.Params) + if err != nil { + return nil, err + } + } + + result.Identifier = "strftime(" + strings.Join(identifiers, ",") + ")" + + if timeValueArgResult.MultiMatchSubQuery != nil { + // replace the regular time-value identifier with the multi-match one + identifiers[1] = timeValueArgResult.MultiMatchSubQuery.ValueIdentifier + result.MultiMatchSubQuery = timeValueArgResult.MultiMatchSubQuery + result.MultiMatchSubQuery.ValueIdentifier = "strftime(" + strings.Join(identifiers, ",") + ")" + + err = concatUniqueParams(result.MultiMatchSubQuery.Params, result.Params) + if err != nil { + return nil, err + } + } + + return result, nil + }, +} + +func concatUniqueParams(destParams, newParams dbx.Params) error { + for k, v := range newParams { + found, ok := destParams[k] + if ok && v != found { + return fmt.Errorf("conflicting param key %s", k) + } + + destParams[k] = v + } + + return nil +} diff --git a/tools/search/token_functions_test.go b/tools/search/token_functions_test.go new file mode 100644 index 0000000000..7221145e59 --- /dev/null +++ b/tools/search/token_functions_test.go @@ -0,0 +1,656 @@ +package search + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/ganigeorgiev/fexpr" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestTokenFunctionsGeoDistance(t *testing.T) { + t.Parallel() + + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + fn, ok := TokenFunctions["geoDistance"] + if !ok { + t.Error("Expected geoDistance token function to be registered.") + } + + baseTokenResolver := func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + } + + scenarios := []struct { + name string + args []fexpr.Token + resolver func(t fexpr.Token) (*ResolverResult, error) + result *ResolverResult + expectErr bool + }{ + { + "no args", + nil, + baseTokenResolver, + nil, + true, + }, + { + "< 4 args", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "> 4 args", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + {Literal: "5", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "unsupported function argument", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenFunction}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "unsupported text argument", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "4 valid arguments but with resolver error", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + func(t fexpr.Token) (*ResolverResult, error) { + return nil, errors.New("test") + }, + nil, + true, + }, + { + "4 valid arguments", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenNumber}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "3", Type: fexpr.TokenNumber}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackDisabled, + Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`, + Params: map[string]any{ + "lonA": 1, + "latA": 2, + "lonB": 3, + "latB": 4, + }, + }, + false, + }, + { + "mixed arguments", + []fexpr.Token{ + {Literal: "null", Type: fexpr.TokenIdentifier}, + {Literal: "2", Type: fexpr.TokenNumber}, + {Literal: "false", Type: fexpr.TokenIdentifier}, + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackDisabled, + Identifier: `(6371 * acos(min(1, max(-1, cos(radians({:latA})) * cos(radians({:latB})) * cos(radians({:lonB}) - radians({:lonA})) + sin(radians({:latA})) * sin(radians({:latB}))))))`, + Params: map[string]any{ + "lonA": "null", + "latA": 2, + "lonB": false, + "latB": 4, + }, + }, + false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := fn(s.resolver, s.args...) + + hasErr := err != nil + if hasErr != s.expectErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) + } + + testCompareResults(t, s.result, result) + }) + } +} + +func TestTokenFunctionsGeoDistanceExec(t *testing.T) { + t.Parallel() + + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + fn, ok := TokenFunctions["geoDistance"] + if !ok { + t.Error("Expected geoDistance token function to be registered.") + } + + scenarios := []struct { + name string + coords []string // [lonA, latA, lonB, latB] + expected string + }{ + { + "distinct points", + []string{ + "23.23033854945808", + "42.713146090563384", + "23.44920680886216", + "42.7078484153991", + }, + "17.89", + }, + { + "identical points (rounding check with 8 lat)", + []string{"0", "8", "0", "8"}, // 8 is an example latitude where rounding happens to result in > 1.0 + "0.00", + }, + { + "identical points (rounding check with 45 lat)", + []string{"0", "45", "0", "45"}, // 45 is an example latitude where rounding happens to result in > 1.0 + "0.00", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := fn( + func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + }, + fexpr.Token{Literal: s.coords[0], Type: fexpr.TokenNumber}, + fexpr.Token{Literal: s.coords[1], Type: fexpr.TokenNumber}, + fexpr.Token{Literal: s.coords[2], Type: fexpr.TokenNumber}, + fexpr.Token{Literal: s.coords[3], Type: fexpr.TokenNumber}, + ) + if err != nil { + t.Fatal(err) + } + + column := []float64{} + err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column) + if err != nil { + t.Fatal(err) + } + + if len(column) != 1 { + t.Fatalf("Expected exactly 1 column value as result, got %v", column) + } + + distance := fmt.Sprintf("%.2f", column[0]) + if distance != s.expected { + t.Fatalf("Expected distance value %s, got %s", s.expected, distance) + } + }) + } +} + +func TestTokenFunctionsStrftime(t *testing.T) { + t.Parallel() + + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + fn, ok := TokenFunctions["strftime"] + if !ok { + t.Error("Expected strftime token function to be registered.") + } + + baseTokenResolver := func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + } + + scenarios := []struct { + name string + args []fexpr.Token + resolver func(t fexpr.Token) (*ResolverResult, error) + result *ResolverResult + expectErr bool + }{ + { + "no args", + nil, + baseTokenResolver, + nil, + true, + }, + + // format arg + // ----------------------------------------------------------- + { + "(format arg) invalid token type function", + []fexpr.Token{ + {Literal: "abc", Type: fexpr.TokenFunction}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) invalid token type ws", + []fexpr.Token{ + {Literal: "abc", Type: fexpr.TokenWS}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) invalid token type number", + []fexpr.Token{ + {Literal: "abc", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) invalid token type identifier", + []fexpr.Token{ + {Literal: "abc", Type: fexpr.TokenIdentifier}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) valid token type text", + []fexpr.Token{ + {Literal: "abc", Type: fexpr.TokenText}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format})`, + Params: map[string]any{"format": "abc"}, + }, + false, + }, + + // format + time-value args + // ----------------------------------------------------------- + { + "(format arg) invalid token type function", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenFunction}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) invalid token type ws", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenWS}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(format arg) valid token type number", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format},{:time})`, + Params: map[string]any{"format": "1", "time": "2"}, + }, + false, + }, + { + "(format arg) valid token type identifier", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenIdentifier}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format},{:time})`, + Params: map[string]any{"format": "1", "time": "2"}, + }, + false, + }, + { + "(format arg) valid token type text", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenText}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format},{:time})`, + Params: map[string]any{"format": "1", "time": "2"}, + }, + false, + }, + + // format + time-value + modifier args + // ----------------------------------------------------------- + { + "(modifiers arg) invalid token type function", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, // valid format + {Literal: "2", Type: fexpr.TokenText}, // valid time-value + {Literal: "3", Type: fexpr.TokenText}, // valid modifier + {Literal: "4", Type: fexpr.TokenFunction}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(modifiers arg) invalid token type ws", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, // valid format + {Literal: "2", Type: fexpr.TokenText}, // valid time-value + {Literal: "3", Type: fexpr.TokenText}, // valid modifier + {Literal: "4", Type: fexpr.TokenWS}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(modifiers arg) valid token type number", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, // valid format + {Literal: "2", Type: fexpr.TokenText}, // valid time-value + {Literal: "3", Type: fexpr.TokenText}, // valid modifier + {Literal: "4", Type: fexpr.TokenNumber}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(modifiers arg) valid token type identifier", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, // valid format + {Literal: "2", Type: fexpr.TokenText}, // valid time-value + {Literal: "3", Type: fexpr.TokenText}, // valid modifier + {Literal: "4", Type: fexpr.TokenIdentifier}, + }, + baseTokenResolver, + nil, + true, + }, + { + "(modifiers arg) valid token type text", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, // valid format + {Literal: "2", Type: fexpr.TokenText}, // valid time-value + {Literal: "3", Type: fexpr.TokenText}, // valid modifier + {Literal: "4", Type: fexpr.TokenText}, // valid modifier + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format},{:time},{:m1},{:m2})`, + Params: map[string]any{"format": "1", "time": "2", "m1": "3", "m2": "4"}, + }, + false, + }, + + // ----------------------------------------------------------- + + { + "= 10 args limit", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenText}, + {Literal: "3", Type: fexpr.TokenText}, + {Literal: "4", Type: fexpr.TokenText}, + {Literal: "5", Type: fexpr.TokenText}, + {Literal: "6", Type: fexpr.TokenText}, + {Literal: "7", Type: fexpr.TokenText}, + {Literal: "8", Type: fexpr.TokenText}, + {Literal: "9", Type: fexpr.TokenText}, + {Literal: "10", Type: fexpr.TokenText}, + }, + baseTokenResolver, + &ResolverResult{ + NullFallback: NullFallbackEnforced, + Identifier: `strftime({:format},{:time},{:m1},{:m2},{:m3},{:m4},{:m5},{:m6},{:m7},{:m8})`, + Params: map[string]any{ + "format": "1", + "time": "2", + "m1": "3", + "m2": "4", + "m3": "5", + "m4": "6", + "m5": "7", + "m6": "8", + "m7": "9", + "m8": "10", + }, + }, + false, + }, + { + "> 10 args limit", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenText}, + {Literal: "3", Type: fexpr.TokenText}, + {Literal: "4", Type: fexpr.TokenText}, + {Literal: "5", Type: fexpr.TokenText}, + {Literal: "6", Type: fexpr.TokenText}, + {Literal: "7", Type: fexpr.TokenText}, + {Literal: "8", Type: fexpr.TokenText}, + {Literal: "9", Type: fexpr.TokenText}, + {Literal: "10", Type: fexpr.TokenText}, + {Literal: "11", Type: fexpr.TokenText}, + }, + baseTokenResolver, + nil, + true, + }, + { + "valid arguments but with resolver error", + []fexpr.Token{ + {Literal: "1", Type: fexpr.TokenText}, + {Literal: "2", Type: fexpr.TokenText}, + {Literal: "3", Type: fexpr.TokenText}, + }, + func(t fexpr.Token) (*ResolverResult, error) { + return nil, errors.New("test") + }, + nil, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := fn(s.resolver, s.args...) + + hasErr := err != nil + if hasErr != s.expectErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) + } + + testCompareResults(t, s.result, result) + }) + } +} + +func TestTokenFunctionsStrftimeExec(t *testing.T) { + t.Parallel() + + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + fn, ok := TokenFunctions["strftime"] + if !ok { + t.Error("Expected strftime token function to be registered.") + } + + result, err := fn( + func(t fexpr.Token) (*ResolverResult, error) { + placeholder := "t" + security.PseudorandomString(5) + return &ResolverResult{Identifier: "{:" + placeholder + "}", Params: map[string]any{placeholder: t.Literal}}, nil + }, + fexpr.Token{Literal: "%Y-%m", Type: fexpr.TokenText}, + fexpr.Token{Literal: "2026-01-02 01:02:03.456Z", Type: fexpr.TokenText}, + fexpr.Token{Literal: "+1 years", Type: fexpr.TokenText}, + fexpr.Token{Literal: "+5 months", Type: fexpr.TokenText}, + ) + if err != nil { + t.Fatal(err) + } + + column := []string{} + err = testDB.NewQuery("select " + result.Identifier).Bind(result.Params).Column(&column) + if err != nil { + t.Fatal(err) + } + + if len(column) != 1 { + t.Fatalf("Expected exactly 1 column value as result, got %v", column) + } + + expected := "2027-06" + if column[0] != expected { + t.Fatalf("Expected date value %s, got %s", expected, column[0]) + } +} + +// ------------------------------------------------------------------- + +func testCompareResults(t *testing.T, a, b *ResolverResult) { + testDB, err := createTestDB() + if err != nil { + t.Fatal(err) + } + defer testDB.Close() + + aIsNil := a == nil + bIsNil := b == nil + if aIsNil != bIsNil { + t.Fatalf("Expected aIsNil and bIsNil to be the same, got %v vs %v", aIsNil, bIsNil) + } + + if aIsNil && bIsNil { + return + } + + aHasAfterBuild := a.AfterBuild == nil + bHasAfterBuild := b.AfterBuild == nil + if aHasAfterBuild != bHasAfterBuild { + t.Fatalf("Expected aHasAfterBuild and bHasAfterBuild to be the same, got %v vs %v", aHasAfterBuild, bHasAfterBuild) + } + + var aAfterBuild string + if a.AfterBuild != nil { + aAfterBuild = a.AfterBuild(dbx.NewExp("test")).Build(testDB.DB, a.Params) + } + var bAfterBuild string + if b.AfterBuild != nil { + bAfterBuild = b.AfterBuild(dbx.NewExp("test")).Build(testDB.DB, a.Params) + } + if aAfterBuild != bAfterBuild { + t.Fatalf("Expected bAfterBuild and bAfterBuild to be the same, got\n%s\nvs\n%s", aAfterBuild, bAfterBuild) + } + + var aMultiMatchSubQuery string + if a.MultiMatchSubQuery != nil { + aMultiMatchSubQuery = a.MultiMatchSubQuery.Build(testDB.DB, a.Params) + } + var bMultiMatchSubQuery string + if b.MultiMatchSubQuery != nil { + bMultiMatchSubQuery = b.MultiMatchSubQuery.Build(testDB.DB, b.Params) + } + if aMultiMatchSubQuery != bMultiMatchSubQuery { + t.Fatalf("Expected bMultiMatchSubQuery and bMultiMatchSubQuery to be the same, got\n%s\nvs\n%s", aMultiMatchSubQuery, bMultiMatchSubQuery) + } + + if a.NullFallback != b.NullFallback { + t.Fatalf("Expected NullFallback to match, got %v vs %v", a.NullFallback, b.NullFallback) + } + + if len(a.Params) != len(b.Params) { + t.Fatalf("Expected equal number of params, got %v vs %v", len(a.Params), len(b.Params)) + } + + // loose placeholders replacement + var aResolved = a.Identifier + for k, v := range a.Params { + aResolved = strings.ReplaceAll(aResolved, "{:"+k+"}", fmt.Sprintf("%v", v)) + } + var bResolved = b.Identifier + for k, v := range b.Params { + bResolved = strings.ReplaceAll(bResolved, "{:"+k+"}", fmt.Sprintf("%v", v)) + } + if aResolved != bResolved { + t.Fatalf("Expected resolved identifiers to match, got\n%s\nvs\n%s", aResolved, bResolved) + } +} diff --git a/tools/security/encrypt.go b/tools/security/encrypt.go index e0883b7096..7e25e9ed3c 100644 --- a/tools/security/encrypt.go +++ b/tools/security/encrypt.go @@ -8,7 +8,9 @@ import ( "io" ) -// Encrypt encrypts data with key (must be valid 32 char aes key). +// Encrypt encrypts "data" with the specified "key" (must be valid 32 char AES key). +// +// This method uses AES-256-GCM block cypher mode. func Encrypt(data []byte, key string) (string, error) { block, err := aes.NewCipher([]byte(key)) if err != nil { @@ -34,7 +36,9 @@ func Encrypt(data []byte, key string) (string, error) { return result, nil } -// Decrypt decrypts encrypted text with key (must be valid 32 chars aes key). +// Decrypt decrypts encrypted text with key (must be valid 32 chars AES key). +// +// This method uses AES-256-GCM block cypher mode. func Decrypt(cipherText string, key string) ([]byte, error) { block, err := aes.NewCipher([]byte(key)) if err != nil { diff --git a/tools/security/encrypt_test.go b/tools/security/encrypt_test.go index ed26e23aaf..19316721c2 100644 --- a/tools/security/encrypt_test.go +++ b/tools/security/encrypt_test.go @@ -1,6 +1,7 @@ package security_test import ( + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/security" @@ -17,30 +18,30 @@ func TestEncrypt(t *testing.T) { {"123", "abcdabcdabcdabcdabcdabcdabcdabcd", false}, } - for i, scenario := range scenarios { - result, err := security.Encrypt([]byte(scenario.data), scenario.key) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.data), func(t *testing.T) { + result, err := security.Encrypt([]byte(s.data), s.key) - if scenario.expectError && err == nil { - t.Errorf("(%d) Expected error got nil", i) - } - if !scenario.expectError && err != nil { - t.Errorf("(%d) Expected nil got error %v", i, err) - } + hasErr := err != nil - if scenario.expectError && result != "" { - t.Errorf("(%d) Expected empty string, got %q", i, result) - } - if !scenario.expectError && result == "" { - t.Errorf("(%d) Expected non empty encrypted result string", i) - } + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + if result != "" { + t.Fatalf("Expected empty Encrypt result on error, got %q", result) + } - // try to decrypt - if result != "" { - decrypted, _ := security.Decrypt(result, scenario.key) - if string(decrypted) != scenario.data { - t.Errorf("(%d) Expected decrypted value to match with the data input, got %q", i, decrypted) + return } - } + + // try to decrypt + decrypted, err := security.Decrypt(result, s.key) + if err != nil || string(decrypted) != s.data { + t.Fatalf("Expected decrypted value to match with the data input, got %q (%v)", decrypted, err) + } + }) } } @@ -57,19 +58,23 @@ func TestDecrypt(t *testing.T) { {"8kcEqilvv+YKYcfnSr0aSC54gmnQCsB02SaB8ATlnA==", "abcdabcdabcdabcdabcdabcdabcdabcd", false, "123"}, } - for i, scenario := range scenarios { - result, err := security.Decrypt(scenario.cipher, scenario.key) + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result, err := security.Decrypt(s.cipher, s.key) - if scenario.expectError && err == nil { - t.Errorf("(%d) Expected error got nil", i) - } - if !scenario.expectError && err != nil { - t.Errorf("(%d) Expected nil got error %v", i, err) - } + hasErr := err != nil - resultStr := string(result) - if resultStr != scenario.expectedData { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expectedData, resultStr) - } + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + if str := string(result); str != s.expectedData { + t.Fatalf("Expected %q, got %q", s.expectedData, str) + } + }) } } diff --git a/tools/security/jwt.go b/tools/security/jwt.go index b26814f33c..e72adf6999 100644 --- a/tools/security/jwt.go +++ b/tools/security/jwt.go @@ -4,10 +4,10 @@ import ( "errors" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) -// ParseUnverifiedJWT parses JWT token and returns its claims +// ParseUnverifiedJWT parses JWT and returns its claims // but DOES NOT verify the signature. // // It verifies only the exp, iat and nbf claims. @@ -18,13 +18,13 @@ func ParseUnverifiedJWT(token string) (jwt.MapClaims, error) { _, _, err := parser.ParseUnverified(token, claims) if err == nil { - err = claims.Valid() + err = jwt.NewValidator(jwt.WithIssuedAt()).Validate(claims) } return claims, err } -// ParseJWT verifies and parses JWT token and returns its claims. +// ParseJWT verifies and parses JWT and returns its claims. func ParseJWT(token string, verificationKey string) (jwt.MapClaims, error) { parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"})) @@ -39,15 +39,15 @@ func ParseJWT(token string, verificationKey string) (jwt.MapClaims, error) { return claims, nil } - return nil, errors.New("Unable to parse token.") + return nil, errors.New("unable to parse token") } -// NewJWT generates and returns new HS256 signed JWT token. -func NewJWT(payload jwt.MapClaims, signingKey string, secondsDuration int64) (string, error) { - seconds := time.Duration(secondsDuration) * time.Second - +// NewJWT generates and returns new HS256 signed JWT. +func NewJWT(payload jwt.MapClaims, signingKey string, duration time.Duration) (string, error) { claims := jwt.MapClaims{ - "exp": time.Now().Add(seconds).Unix(), + // @todo consider with the refactoring to either remove the + // duration argument or make it always take precedence? + "exp": time.Now().Add(duration).Unix(), } for k, v := range payload { @@ -56,11 +56,3 @@ func NewJWT(payload jwt.MapClaims, signingKey string, secondsDuration int64) (st return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey)) } - -// Deprecated: -// Consider replacing with NewJWT(). -// -// NewToken is a legacy alias for NewJWT that generates a HS256 signed JWT token. -func NewToken(payload jwt.MapClaims, signingKey string, secondsDuration int64) (string, error) { - return NewJWT(payload, signingKey, secondsDuration) -} diff --git a/tools/security/jwt_test.go b/tools/security/jwt_test.go index c979a4f9a9..afb636d267 100644 --- a/tools/security/jwt_test.go +++ b/tools/security/jwt_test.go @@ -2,13 +2,14 @@ package security_test import ( "testing" + "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" "github.com/pocketbase/pocketbase/tools/security" ) func TestParseUnverifiedJWT(t *testing.T) { - // invalid formatted JWT token + // invalid formatted JWT result1, err1 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9") if err1 == nil { t.Error("Expected error got nil") @@ -17,8 +18,8 @@ func TestParseUnverifiedJWT(t *testing.T) { t.Error("Expected no parsed claims, got", result1) } - // properly formatted JWT token with INVALID claims - // {"name": "test", "exp": 1516239022} + // properly formatted JWT with INVALID claims + // {"name": "test", "exp":1516239022} result2, err2 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU") if err2 == nil { t.Error("Expected error got nil") @@ -27,74 +28,77 @@ func TestParseUnverifiedJWT(t *testing.T) { t.Errorf("Expected to have 2 claims, got %v", result2) } - // properly formatted JWT token with VALID claims + // properly formatted JWT with VALID claims (missing exp) // {"name": "test"} result3, err3 := security.ParseUnverifiedJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.ml0QsTms3K9wMygTu41ZhKlTyjmW9zHQtoS8FUsCCjU") if err3 != nil { t.Error("Expected nil, got", err3) } if len(result3) != 1 || result3["name"] != "test" { - t.Errorf("Expected to have 2 claims, got %v", result3) + t.Errorf("Expected to have 1 claim, got %v", result3) + } + + // properly formatted JWT with VALID claims (valid exp) + // {"name": "test", "exp": 2524604461} + result4, err4 := security.ParseUnverifiedJWT("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ") + if err4 != nil { + t.Error("Expected nil, got", err4) + } + if len(result4) != 2 || result4["name"] != "test" { + t.Errorf("Expected to have 2 claims, got %v", result4) } } func TestParseJWT(t *testing.T) { scenarios := []struct { + name string token string secret string expectError bool expectClaims jwt.MapClaims }{ - // invalid formatted JWT token { + "invalid formatted JWT", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9", "test", true, nil, }, - // properly formatted JWT token with INVALID claims and INVALID secret - // {"name": "test", "exp": 1516239022} { + "properly formatted JWT with INVALID claims and INVALID secret", + // {"name": "test", "exp": 1516239022} "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU", "invalid", true, nil, }, - // properly formatted JWT token with INVALID claims and VALID secret - // {"name": "test", "exp": 1516239022} { + "properly formatted JWT with INVALID claims and VALID secret", + // {"name": "test", "exp": 1516239022} "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTUxNjIzOTAyMn0.xYHirwESfSEW3Cq2BL47CEASvD_p_ps3QCA54XtNktU", "test", true, nil, }, - // properly formatted JWT token with VALID claims and INVALID secret - // {"name": "test", "exp": 1898636137} { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTg5ODYzNjEzN30.gqRkHjpK5s1PxxBn9qPaWEWxTbpc1PPSD-an83TsXRY", + "properly formatted JWT with VALID claims and INVALID secret", + // {"name": "test", "exp": 2524604461} + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ", "invalid", true, nil, }, - // properly formatted EXPIRED JWT token with VALID secret - // {"name": "test", "exp": 1652097610} { - "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6OTU3ODczMzc0fQ.0oUUKUnsQHs4nZO1pnxQHahKtcHspHu4_AplN2sGC4A", - "test", - true, - nil, - }, - // properly formatted JWT token with VALID claims and VALID secret - // {"name": "test", "exp": 1898636137} - { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MTg5ODYzNjEzN30.gqRkHjpK5s1PxxBn9qPaWEWxTbpc1PPSD-an83TsXRY", + "properly formatted JWT with VALID claims and VALID secret", + // {"name": "test", "exp": 2524604461} + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdCIsImV4cCI6MjUyNDYwNDQ2MX0.VIEO73GP5QRQOSfHgQhaqeuYqcx59vL3xlxmFP-fytQ", "test", false, - jwt.MapClaims{"name": "test", "exp": 1898636137.0}, + jwt.MapClaims{"name": "test", "exp": 2524604461.0}, }, - // properly formatted JWT token with VALID claims (without exp) and VALID secret - // {"name": "test"} { + "properly formatted JWT with VALID claims (without exp) and VALID secret", + // {"name": "test"} "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.ml0QsTms3K9wMygTu41ZhKlTyjmW9zHQtoS8FUsCCjU", "test", false, @@ -102,78 +106,80 @@ func TestParseJWT(t *testing.T) { }, } - for i, scenario := range scenarios { - result, err := security.ParseJWT(scenario.token, scenario.secret) - if scenario.expectError && err == nil { - t.Errorf("(%d) Expected error got nil", i) - } - if !scenario.expectError && err != nil { - t.Errorf("(%d) Expected nil got error %v", i, err) - } - if len(result) != len(scenario.expectClaims) { - t.Errorf("(%d) Expected %v got %v", i, scenario.expectClaims, result) - } - for k, v := range scenario.expectClaims { - v2, ok := result[k] - if !ok { - t.Errorf("(%d) Missing expected claim %q", i, k) + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result, err := security.ParseJWT(s.token, s.secret) + + hasErr := err != nil + + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) } - if v != v2 { - t.Errorf("(%d) Expected %v for %q claim, got %v", i, v, k, v2) + + if len(result) != len(s.expectClaims) { + t.Fatalf("Expected %v claims got %v", s.expectClaims, result) } - } + + for k, v := range s.expectClaims { + v2, ok := result[k] + if !ok { + t.Fatalf("Missing expected claim %q", k) + } + if v != v2 { + t.Fatalf("Expected %v for %q claim, got %v", v, k, v2) + } + } + }) } } func TestNewJWT(t *testing.T) { scenarios := []struct { + name string claims jwt.MapClaims key string - duration int64 + duration time.Duration expectError bool }{ - // empty, zero duration - {jwt.MapClaims{}, "", 0, true}, - // empty, 10 seconds duration - {jwt.MapClaims{}, "", 10, false}, - // non-empty, 10 seconds duration - {jwt.MapClaims{"name": "test"}, "test", 10, false}, + {"empty, zero duration", jwt.MapClaims{}, "", 0, true}, + {"empty, 10 seconds duration", jwt.MapClaims{}, "", 10 * time.Second, false}, + {"non-empty, 10 seconds duration", jwt.MapClaims{"name": "test"}, "test", 10 * time.Second, false}, } - for i, scenario := range scenarios { - token, tokenErr := security.NewJWT(scenario.claims, scenario.key, scenario.duration) - if tokenErr != nil { - t.Errorf("(%d) Expected NewJWT to succeed, got error %v", i, tokenErr) - continue - } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + token, tokenErr := security.NewJWT(s.claims, s.key, s.duration) + if tokenErr != nil { + t.Fatalf("Expected NewJWT to succeed, got error %v", tokenErr) + } - claims, parseErr := security.ParseJWT(token, scenario.key) + claims, parseErr := security.ParseJWT(token, s.key) - hasParseErr := parseErr != nil - if hasParseErr != scenario.expectError { - t.Errorf("(%d) Expected hasParseErr to be %v, got %v (%v)", i, scenario.expectError, hasParseErr, parseErr) - continue - } + hasParseErr := parseErr != nil + if hasParseErr != s.expectError { + t.Fatalf("Expected hasParseErr to be %v, got %v (%v)", s.expectError, hasParseErr, parseErr) + } - if scenario.expectError { - continue - } + if s.expectError { + return + } - if _, ok := claims["exp"]; !ok { - t.Errorf("(%d) Missing required claim exp, got %v", i, claims) - } + if _, ok := claims["exp"]; !ok { + t.Fatalf("Missing required claim exp, got %v", claims) + } - // clear exp claim to match with the scenario ones - delete(claims, "exp") + // clear exp claim to match with the scenario ones + delete(claims, "exp") - if len(claims) != len(scenario.claims) { - t.Errorf("(%d) Expected %v claims, got %v", i, scenario.claims, claims) - } + if len(claims) != len(s.claims) { + t.Fatalf("Expected %v claims, got %v", s.claims, claims) + } - for j, k := range claims { - if claims[j] != scenario.claims[j] { - t.Errorf("(%d) Expected %v for %q claim, got %v", i, claims[j], k, scenario.claims[j]) + for k, v := range claims { + if v != s.claims[k] { + t.Fatalf("Expected %v for %q claim, got %v", s.claims[k], k, v) + } } - } + }) } } diff --git a/tools/security/random.go b/tools/security/random.go index 10663d467f..b931c84170 100644 --- a/tools/security/random.go +++ b/tools/security/random.go @@ -3,16 +3,11 @@ package security import ( cryptoRand "crypto/rand" "math/big" - mathRand "math/rand" - "time" + mathRand "math/rand/v2" ) const defaultRandomAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" -func init() { - mathRand.Seed(time.Now().UnixNano()) -} - // 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. @@ -57,7 +52,7 @@ func PseudorandomStringWithAlphabet(length int, alphabet string) string { max := len(alphabet) for i := range b { - b[i] = alphabet[mathRand.Intn(max)] + b[i] = alphabet[mathRand.IntN(max)] } return string(b) diff --git a/tools/security/random_by_regex.go b/tools/security/random_by_regex.go new file mode 100644 index 0000000000..52845cd0b1 --- /dev/null +++ b/tools/security/random_by_regex.go @@ -0,0 +1,179 @@ +package security + +import ( + cryptoRand "crypto/rand" + "errors" + "fmt" + "math/big" + "regexp/syntax" + "strings" +) + +const defaultMaxRepeat = 6 + +var anyCharNotNLPairs = []rune{'A', 'Z', 'a', 'z', '0', '9'} + +// RandomStringByRegex generates a random string matching the regex pattern. +// If optFlags is not set, fallbacks to [syntax.Perl]. +// +// 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. +func RandomStringByRegex(pattern string, optFlags ...syntax.Flags) (string, error) { + var flags syntax.Flags + if len(optFlags) == 0 { + flags = syntax.Perl + } else { + for _, f := range optFlags { + flags |= f + } + } + + r, err := syntax.Parse(pattern, flags) + if err != nil { + return "", err + } + + var sb = new(strings.Builder) + + err = writeRandomStringByRegex(r, sb) + if err != nil { + return "", err + } + + return sb.String(), nil +} + +func writeRandomStringByRegex(r *syntax.Regexp, sb *strings.Builder) error { + // https://pkg.go.dev/regexp/syntax#Op + switch r.Op { + case syntax.OpCharClass: + c, err := randomRuneFromPairs(r.Rune) + if err != nil { + return err + } + _, err = sb.WriteRune(c) + return err + case syntax.OpAnyChar, syntax.OpAnyCharNotNL: + c, err := randomRuneFromPairs(anyCharNotNLPairs) + if err != nil { + return err + } + _, err = sb.WriteRune(c) + return err + case syntax.OpAlternate: + idx, err := randomNumber(len(r.Sub)) + if err != nil { + return err + } + return writeRandomStringByRegex(r.Sub[idx], sb) + case syntax.OpConcat: + var err error + for _, sub := range r.Sub { + err = writeRandomStringByRegex(sub, sb) + if err != nil { + break + } + } + return err + case syntax.OpRepeat: + return repeatRandomStringByRegex(r.Sub[0], sb, r.Min, r.Max) + case syntax.OpQuest: + return repeatRandomStringByRegex(r.Sub[0], sb, 0, 1) + case syntax.OpPlus: + return repeatRandomStringByRegex(r.Sub[0], sb, 1, -1) + case syntax.OpStar: + return repeatRandomStringByRegex(r.Sub[0], sb, 0, -1) + case syntax.OpCapture: + return writeRandomStringByRegex(r.Sub[0], sb) + case syntax.OpLiteral: + _, err := sb.WriteString(string(r.Rune)) + return err + default: + return fmt.Errorf("unsupported pattern operator %d", r.Op) + } +} + +func repeatRandomStringByRegex(r *syntax.Regexp, sb *strings.Builder, min int, max int) error { + if max < 0 { + max = defaultMaxRepeat + } + + if max < min { + max = min + } + + n := min + if max != min { + randRange, err := randomNumber(max - min) + if err != nil { + return err + } + n += randRange + } + + var err error + for i := 0; i < n; i++ { + err = writeRandomStringByRegex(r, sb) + if err != nil { + return err + } + } + + return nil +} + +func randomRuneFromPairs(pairs []rune) (rune, error) { + if len(pairs)%2 != 0 { + return 0, fmt.Errorf("invalid pairs slice: odd number of elements") + } + + // Pre-calculate the cumulative size of all ranges to make the selection process more efficient. + cumulativeSizes := make([]int, len(pairs)/2) + totalRunes := 0 + for i := 0; i < len(pairs); i += 2 { + start, end := pairs[i], pairs[i+1] + if start > end { + return 0, fmt.Errorf("invalid range: start '%c' > end '%c'", start, end) + } + totalRunes += int(end - start + 1) + cumulativeSizes[i/2] = totalRunes + } + + if totalRunes == 0 { + return 0, errors.New("no runes to choose from") + } + + // Select a random number in the range of total runes. + runeNumber, err := randomNumber(totalRunes) + if err != nil { + return 0, fmt.Errorf("failed to generate random number: %w", err) + } + + // Find which range the selected number falls into using the pre-calculated cumulative sizes. + for i, size := range cumulativeSizes { + if runeNumber < size { + startRune := pairs[i*2] + previousSize := 0 + if i > 0 { + previousSize = cumulativeSizes[i-1] + } + return startRune + rune(runeNumber-previousSize), nil + } + } + + // This part should be unreachable if the logic is correct. + // It indicates a bug in this function or in randomNumber. + panic("unreachable: failed to find a rune") +} + +func randomNumber(maxSoft int) (int, error) { + randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(int64(maxSoft))) + + return int(randRange.Int64()), err +} diff --git a/tools/security/random_by_regex_test.go b/tools/security/random_by_regex_test.go new file mode 100644 index 0000000000..208e85533c --- /dev/null +++ b/tools/security/random_by_regex_test.go @@ -0,0 +1,75 @@ +package security_test + +import ( + "fmt" + "regexp" + "regexp/syntax" + "slices" + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestRandomStringByRegex(t *testing.T) { + generated := []string{} + + scenarios := []struct { + pattern string + flags []syntax.Flags + expectError bool + }{ + {``, nil, true}, + {`test`, nil, false}, + {`\d+`, []syntax.Flags{syntax.POSIX}, true}, + {`\d+`, nil, false}, + {`\d*`, nil, false}, + {`\d{1,20}`, nil, false}, + {`\d{5}`, nil, false}, + {`\d{0,}-abc`, nil, false}, + {`[a-zA-Z_]*`, nil, false}, + {`[^a-zA-Z]{5,30}`, nil, false}, + {`\w+_abc`, nil, false}, + {`[2-9]{10}-\w+`, nil, false}, + {`(a|b|c)`, nil, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%q", i, s.pattern), func(t *testing.T) { + var run func(int) + run = func(attempt int) { + str, err := security.RandomStringByRegex(s.pattern, s.flags...) + + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + r, err := regexp.Compile(s.pattern) + if err != nil { + t.Fatal(err) + } + if !r.Match([]byte(str)) { + t.Fatalf("Expected %q to match pattern %v", str, s.pattern) + } + + if slices.Contains(generated, str) { + if attempt > 3 { + t.Fatalf("The generated string %q already exists in\n%v", str, generated) + } + + // rerun + run(attempt + 1) + return + } + + generated = append(generated, str) + } + + run(1) + }) + } +} diff --git a/tools/security/random_test.go b/tools/security/random_test.go index 5560f9d606..d0f8eceaa1 100644 --- a/tools/security/random_test.go +++ b/tools/security/random_test.go @@ -1,7 +1,9 @@ package security_test import ( + "fmt" "regexp" + "slices" "testing" "github.com/pocketbase/pocketbase/tools/security" @@ -36,54 +38,74 @@ func testRandomStringWithAlphabet(t *testing.T, randomFunc func(n int, alphabet } for i, s := range scenarios { - generated := make([]string, 0, 1000) - length := 10 - - for j := 0; j < 1000; j++ { - result := randomFunc(length, s.alphabet) - - if len(result) != length { - t.Fatalf("(%d:%d) Expected the length of the string to be %d, got %d", i, j, length, len(result)) - } - - reg := regexp.MustCompile(s.expectPattern) - if match := reg.MatchString(result); !match { - t.Fatalf("(%d:%d) The generated string should have only %s characters, got %q", i, j, s.expectPattern, result) - } - - for _, str := range generated { - if str == result { - t.Fatalf("(%d:%d) Repeating random string - found %q in %q", i, j, result, generated) + t.Run(fmt.Sprintf("%d_%q", i, s.alphabet), func(t *testing.T) { + generated := make([]string, 0, 500) + length := 10 + + for j := 0; j < 500; j++ { + var run func(int) + run = func(attempt int) { + result := randomFunc(length, s.alphabet) + + if len(result) != length { + t.Fatalf("(%d) Expected the length of the string to be %d, got %d", j, length, len(result)) + } + + reg := regexp.MustCompile(s.expectPattern) + if match := reg.MatchString(result); !match { + t.Fatalf("(%d) The generated string should have only %s characters, got %q", j, s.expectPattern, result) + } + + if slices.Contains(generated, result) { + if attempt > 3 { + t.Fatalf("(%d) Repeating random string - found %q in %q", j, result, generated) + } + + // rerun + run(attempt + 1) + return + } + + generated = append(generated, result) } - } - generated = append(generated, result) - } + run(1) + } + }) } } func testRandomString(t *testing.T, randomFunc func(n int) string) { - generated := make([]string, 0, 1000) + generated := make([]string, 0, 500) reg := regexp.MustCompile(`[a-zA-Z0-9]+`) length := 10 - for i := 0; i < 1000; i++ { - result := randomFunc(length) + for i := 0; i < 500; i++ { + var run func(int) + run = func(attempt int) { + result := randomFunc(length) - if len(result) != length { - t.Fatalf("(%d) Expected the length of the string to be %d, got %d", i, length, len(result)) - } + if len(result) != length { + t.Fatalf("(%d) Expected the length of the string to be %d, got %d", i, length, len(result)) + } - if match := reg.MatchString(result); !match { - t.Fatalf("(%d) The generated string should have only [a-zA-Z0-9]+ characters, got %q", i, result) - } + if match := reg.MatchString(result); !match { + t.Fatalf("(%d) The generated string should have only [a-zA-Z0-9]+ characters, got %q", i, result) + } + + if slices.Contains(generated, result) { + if attempt > 3 { + t.Fatalf("(%d) Repeating random string - found %q in \n%v", i, result, generated) + } - for _, str := range generated { - if str == result { - t.Fatalf("(%d) Repeating random string - found %q in \n%v", i, result, generated) + // rerun + run(attempt + 1) + return } + + generated = append(generated, result) } - generated = append(generated, result) + run(1) } } diff --git a/tools/store/store.go b/tools/store/store.go index b3121e622c..1905bc0810 100644 --- a/tools/store/store.go +++ b/tools/store/store.go @@ -1,16 +1,23 @@ package store -import "sync" +import ( + "encoding/json/v2" + "sync" +) + +// @todo remove after https://github.com/golang/go/issues/20135 +const ShrinkThreshold = 200 // the number is arbitrary chosen // Store defines a concurrent safe in memory key-value data store. -type Store[T any] struct { - mux sync.RWMutex - data map[string]T +type Store[K comparable, T any] struct { + data map[K]T + mu sync.RWMutex + deleted int64 } // New creates a new Store[T] instance with a shallow copy of the provided data (if any). -func New[T any](data map[string]T) *Store[T] { - s := &Store[T]{} +func New[K comparable, T any](data map[K]T) *Store[K, T] { + s := &Store[K, T]{} s.Reset(data) @@ -19,49 +26,62 @@ func New[T any](data map[string]T) *Store[T] { // Reset clears the store and replaces the store data with a // shallow copy of the provided newData. -func (s *Store[T]) Reset(newData map[string]T) { - s.mux.Lock() - defer s.mux.Unlock() - - var clone = make(map[string]T, len(newData)) - - for k, v := range newData { - clone[k] = v +func (s *Store[K, T]) Reset(newData map[K]T) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(newData) > 0 { + s.data = make(map[K]T, len(newData)) + for k, v := range newData { + s.data[k] = v + } + } else { + s.data = make(map[K]T) } - s.data = clone + s.deleted = 0 } // Length returns the current number of elements in the store. -func (s *Store[T]) Length() int { - s.mux.RLock() - defer s.mux.RUnlock() +func (s *Store[K, T]) Length() int { + s.mu.RLock() + defer s.mu.RUnlock() return len(s.data) } // RemoveAll removes all the existing store entries. -func (s *Store[T]) RemoveAll() { - s.mux.Lock() - defer s.mux.Unlock() - - s.data = make(map[string]T) +func (s *Store[K, T]) RemoveAll() { + s.Reset(nil) } // Remove removes a single entry from the store. // // Remove does nothing if key doesn't exist in the store. -func (s *Store[T]) Remove(key string) { - s.mux.Lock() - defer s.mux.Unlock() +func (s *Store[K, T]) Remove(key K) { + s.mu.Lock() + defer s.mu.Unlock() delete(s.data, key) + s.deleted++ + + // reassign to a new map so that the old one can be gc-ed because it doesn't shrink + // + // @todo remove after https://github.com/golang/go/issues/20135 + if s.deleted >= ShrinkThreshold { + newData := make(map[K]T, len(s.data)) + for k, v := range s.data { + newData[k] = v + } + s.data = newData + s.deleted = 0 + } } // Has checks if element with the specified key exist or not. -func (s *Store[T]) Has(key string) bool { - s.mux.RLock() - defer s.mux.RUnlock() +func (s *Store[K, T]) Has(key K) bool { + s.mu.RLock() + defer s.mu.RUnlock() _, ok := s.data[key] @@ -71,19 +91,29 @@ func (s *Store[T]) Has(key string) bool { // Get returns a single element value from the store. // // If key is not set, the zero T value is returned. -func (s *Store[T]) Get(key string) T { - s.mux.RLock() - defer s.mux.RUnlock() +func (s *Store[K, T]) Get(key K) T { + s.mu.RLock() + defer s.mu.RUnlock() return s.data[key] } +// GetOk is similar to Get but returns also a boolean indicating whether the key exists or not. +func (s *Store[K, T]) GetOk(key K) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + v, ok := s.data[key] + + return v, ok +} + // GetAll returns a shallow copy of the current store data. -func (s *Store[T]) GetAll() map[string]T { - s.mux.RLock() - defer s.mux.RUnlock() +func (s *Store[K, T]) GetAll() map[K]T { + s.mu.RLock() + defer s.mu.RUnlock() - var clone = make(map[string]T, len(s.data)) + var clone = make(map[K]T, len(s.data)) for k, v := range s.data { clone[k] = v @@ -92,30 +122,108 @@ func (s *Store[T]) GetAll() map[string]T { return clone } -// Set sets (or overwrite if already exist) a new value for key. -func (s *Store[T]) Set(key string, value T) { - s.mux.Lock() - defer s.mux.Unlock() +// Keys returns a slice with all of the store keys. +func (s *Store[K, T]) Keys() []K { + s.mu.RLock() + defer s.mu.RUnlock() + + var keys = make([]K, 0, len(s.data)) + + for k := range s.data { + keys = append(keys, k) + } + + return keys +} + +// Values returns a slice with all of the store values. +func (s *Store[K, T]) Values() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + var values = make([]T, 0, len(s.data)) + + for _, v := range s.data { + values = append(values, v) + } + + return values +} + +// Set sets (or overwrite if already exists) a new value for key. +func (s *Store[K, T]) Set(key K, value T) { + s.mu.Lock() + defer s.mu.Unlock() if s.data == nil { - s.data = make(map[string]T) + s.data = make(map[K]T) } s.data[key] = value } +// SetFunc sets (or overwrite if already exists) a new value resolved +// from the function callback for the provided key. +// +// The function callback receives as argument the old store element value (if exists). +// If there is no old store element, the argument will be the T zero value. +// +// Example: +// +// s := store.New[string, int](nil) +// s.SetFunc("count", func(old int) int { +// return old + 1 +// }) +func (s *Store[K, T]) SetFunc(key K, fn func(old T) T) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + s.data[key] = fn(s.data[key]) +} + +// GetOrSet retrieves a single existing value for the provided key +// or stores a new one if it doesn't exist. +func (s *Store[K, T]) GetOrSet(key K, setFunc func() T) T { + // lock only reads to minimize locks contention + s.mu.RLock() + v, ok := s.data[key] + s.mu.RUnlock() + if ok { + return v + } + + // lock again for write + s.mu.Lock() + defer s.mu.Unlock() + + // check again in case it was set between the 2 locks + v, ok = s.data[key] + if !ok { + v = setFunc() + if s.data == nil { + s.data = make(map[K]T) + } + s.data[key] = v + } + + return v +} + // SetIfLessThanLimit sets (or overwrite if already exist) a new value for key. // // This method is similar to Set() but **it will skip adding new elements** // to the store if the store length has reached the specified limit. // false is returned if maxAllowedElements limit is reached. -func (s *Store[T]) SetIfLessThanLimit(key string, value T, maxAllowedElements int) bool { - s.mux.Lock() - defer s.mux.Unlock() +func (s *Store[K, T]) SetIfLessThanLimit(key K, value T, maxAllowedElements int) bool { + s.mu.Lock() + defer s.mu.Unlock() - // init map if not already if s.data == nil { - s.data = make(map[string]T) + s.data = make(map[K]T) } // check for existing item @@ -131,3 +239,33 @@ func (s *Store[T]) SetIfLessThanLimit(key string, value T, maxAllowedElements in return true } + +// UnmarshalJSON implements [json.Unmarshaler] and imports the +// provided JSON data into the store. +// +// The store entries that match with the ones from the data will be overwritten with the new value. +func (s *Store[K, T]) UnmarshalJSON(data []byte) error { + raw := map[K]T{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.data == nil { + s.data = make(map[K]T) + } + + for k, v := range raw { + s.data[k] = v + } + + return nil +} + +// MarshalJSON implements [json.Marshaler] and export the current +// store data into valid JSON. +func (s *Store[K, T]) MarshalJSON() ([]byte, error) { + return json.Marshal(s.GetAll()) +} diff --git a/tools/store/store_test.go b/tools/store/store_test.go index 55ebaa657d..8a1e2d95b8 100644 --- a/tools/store/store_test.go +++ b/tools/store/store_test.go @@ -2,7 +2,9 @@ package store_test import ( "bytes" - "encoding/json" + "encoding/json/v2" + "slices" + "strconv" "testing" "github.com/pocketbase/pocketbase/tools/store" @@ -10,7 +12,7 @@ import ( func TestNew(t *testing.T) { data := map[string]int{"test1": 1, "test2": 2} - originalRawData, err := json.Marshal(data) + originalRawData, err := json.Marshal(data, json.Deterministic(true)) if err != nil { t.Fatal(err) } @@ -20,7 +22,7 @@ func TestNew(t *testing.T) { s.Remove("test1") // remove 1 item // check if data was shallow copied - rawData, _ := json.Marshal(data) + rawData, _ := json.Marshal(data, json.Deterministic(true)) if !bytes.Equal(originalRawData, rawData) { t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData) } @@ -42,7 +44,7 @@ func TestReset(t *testing.T) { s := store.New(map[string]int{"test1": 1}) data := map[string]int{"test2": 2} - originalRawData, err := json.Marshal(data) + originalRawData, err := json.Marshal(data, json.Deterministic(true)) if err != nil { t.Fatal(err) } @@ -51,7 +53,7 @@ func TestReset(t *testing.T) { s.Set("test3", 3) // check if data was shallow copied - rawData, _ := json.Marshal(data) + rawData, _ := json.Marshal(data, json.Deterministic(true)) if !bytes.Equal(originalRawData, rawData) { t.Fatalf("Expected data \n%s, \ngot \n%s", originalRawData, rawData) } @@ -137,11 +139,41 @@ func TestGet(t *testing.T) { {"missing", 0}, // should auto fallback to the zero value } - for i, scenario := range scenarios { - val := s.Get(scenario.key) - if val != scenario.expect { - t.Errorf("(%d) Expected %v, got %v", i, scenario.expect, val) - } + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + val := s.Get(scenario.key) + if val != scenario.expect { + t.Fatalf("Expected %v, got %v", scenario.expect, val) + } + }) + } +} + +func TestGetOk(t *testing.T) { + s := store.New(map[string]int{"test1": 0, "test2": 1}) + + scenarios := []struct { + key string + expectValue int + expectOk bool + }{ + {"test1", 0, true}, + {"test2", 1, true}, + {"missing", 0, false}, // should auto fallback to the zero value + } + + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + val, ok := s.GetOk(scenario.key) + + if ok != scenario.expectOk { + t.Fatalf("Expected ok %v, got %v", scenario.expectOk, ok) + } + + if val != scenario.expectValue { + t.Fatalf("Expected %v, got %v", scenario.expectValue, val) + } + }) } } @@ -173,8 +205,50 @@ func TestGetAll(t *testing.T) { } } +func TestKeys(t *testing.T) { + data := map[string]int{ + "a": 1, + "b": 2, + } + + keys := store.New(data).Keys() + + expected := []string{"a", "b"} + + if len(keys) != len(expected) { + t.Fatalf("Expected %d keys, got %d", len(expected), len(keys)) + } + + for _, k := range expected { + if !slices.Contains(keys, k) { + t.Fatalf("Missing key %s in\n%v", k, keys) + } + } +} + +func TestValues(t *testing.T) { + data := map[string]int{ + "a": 1, + "b": 2, + } + + values := store.New(data).Values() + + expected := []int{1, 2} + + if len(values) != len(expected) { + t.Fatalf("Expected %d values, got %d", len(expected), len(values)) + } + + for _, v := range expected { + if !slices.Contains(values, v) { + t.Fatalf("Missing value %v in\n%v", v, values) + } + } +} + func TestSet(t *testing.T) { - s := store.Store[int]{} + s := store.Store[string, int]{} data := map[string]int{"test1": 0, "test2": 1, "test3": 3} @@ -196,8 +270,65 @@ func TestSet(t *testing.T) { } } +func TestSetFunc(t *testing.T) { + s := store.Store[string, int]{} + + // non existing value + s.SetFunc("test", func(old int) int { + if old != 0 { + t.Fatalf("Expected old value %d, got %d", 0, old) + } + return old + 2 + }) + if v := s.Get("test"); v != 2 { + t.Fatalf("Expected the stored value to be %d, got %d", 2, v) + } + + // increment existing value + s.SetFunc("test", func(old int) int { + if old != 2 { + t.Fatalf("Expected old value %d, got %d", 2, old) + } + return old + 1 + }) + if v := s.Get("test"); v != 3 { + t.Fatalf("Expected the stored value to be %d, got %d", 3, v) + } +} + +func TestGetOrSet(t *testing.T) { + s := store.New(map[string]int{ + "test1": 0, + "test2": 1, + "test3": 3, + }) + + scenarios := []struct { + key string + value int + expected int + }{ + {"test2", 20, 1}, + {"test3", 2, 3}, + {"test_new", 20, 20}, + {"test_new", 50, 20}, // should return the previously inserted value + } + + for _, scenario := range scenarios { + t.Run(scenario.key, func(t *testing.T) { + result := s.GetOrSet(scenario.key, func() int { + return scenario.value + }) + + if result != scenario.expected { + t.Fatalf("Expected %v, got %v", scenario.expected, result) + } + }) + } +} + func TestSetIfLessThanLimit(t *testing.T) { - s := store.Store[int]{} + s := store.Store[string, int]{} limit := 2 @@ -230,3 +361,77 @@ func TestSetIfLessThanLimit(t *testing.T) { } } } + +func TestUnmarshalJSON(t *testing.T) { + s := store.Store[string, string]{} + s.Set("b", "old") // should be overwritten + s.Set("c", "test3") // ensures that the old values are not removed + + raw := []byte(`{"a":"test1", "b":"test2"}`) + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatal(err) + } + + if v := s.Get("a"); v != "test1" { + t.Fatalf("Expected store.a to be %q, got %q", "test1", v) + } + + if v := s.Get("b"); v != "test2" { + t.Fatalf("Expected store.b to be %q, got %q", "test2", v) + } + + if v := s.Get("c"); v != "test3" { + t.Fatalf("Expected store.c to be %q, got %q", "test3", v) + } +} + +func TestMarshalJSON(t *testing.T) { + s := &store.Store[string, string]{} + s.Set("a", "test1") + s.Set("b", "test2") + + expected := []byte(`{"a":"test1", "b":"test2"}`) + + result, err := json.Marshal(s, json.Deterministic(true)) + if err != nil { + t.Fatal(err) + } + + if bytes.Equal(result, expected) { + t.Fatalf("Expected\n%s\ngot\n%s", expected, result) + } +} + +func TestShrink(t *testing.T) { + s := &store.Store[string, int]{} + + total := 1000 + + for i := 0; i < total; i++ { + s.Set(strconv.Itoa(i), i) + } + + if s.Length() != total { + t.Fatalf("Expected %d items, got %d", total, s.Length()) + } + + // trigger map "shrink" + for i := 0; i < store.ShrinkThreshold; i++ { + s.Remove(strconv.Itoa(i)) + } + + // ensure that after the deletion, the new map was copied properly + if s.Length() != total-store.ShrinkThreshold { + t.Fatalf("Expected %d items, got %d", total-store.ShrinkThreshold, s.Length()) + } + + for k := range s.GetAll() { + kInt, err := strconv.Atoi(k) + if err != nil { + t.Fatalf("failed to convert %s into int: %v", k, err) + } + if kInt < store.ShrinkThreshold { + t.Fatalf("Key %q should have been deleted", k) + } + } +} diff --git a/tools/subscriptions/broker.go b/tools/subscriptions/broker.go index efa864bc5f..82cf30d109 100644 --- a/tools/subscriptions/broker.go +++ b/tools/subscriptions/broker.go @@ -2,47 +2,46 @@ package subscriptions import ( "fmt" - "sync" + + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/store" ) // Broker defines a struct for managing subscriptions clients. type Broker struct { - mux sync.RWMutex - clients map[string]Client + store *store.Store[string, Client] } // NewBroker initializes and returns a new Broker instance. func NewBroker() *Broker { return &Broker{ - clients: make(map[string]Client), + store: store.New[string, Client](nil), } } // Clients returns a shallow copy of all registered clients indexed // with their connection id. func (b *Broker) Clients() map[string]Client { - b.mux.RLock() - defer b.mux.RUnlock() - - copy := make(map[string]Client, len(b.clients)) + return b.store.GetAll() +} - for id, c := range b.clients { - copy[id] = c - } +// ChunkedClients splits the current clients into a chunked slice. +func (b *Broker) ChunkedClients(chunkSize int) [][]Client { + return list.ToChunks(b.store.Values(), chunkSize) +} - return copy +// TotalClients returns the total number of registered clients. +func (b *Broker) TotalClients() int { + return b.store.Length() } // ClientById finds a registered client by its id. // // Returns non-nil error when client with clientId is not registered. func (b *Broker) ClientById(clientId string) (Client, error) { - b.mux.RLock() - defer b.mux.RUnlock() - - client, ok := b.clients[clientId] + client, ok := b.store.GetOk(clientId) if !ok { - return nil, fmt.Errorf("No client associated with connection ID %q", clientId) + return nil, fmt.Errorf("no client associated with connection ID %q", clientId) } return client, nil @@ -50,21 +49,17 @@ func (b *Broker) ClientById(clientId string) (Client, error) { // Register adds a new client to the broker instance. func (b *Broker) Register(client Client) { - b.mux.Lock() - defer b.mux.Unlock() - - b.clients[client.Id()] = client + b.store.Set(client.Id(), client) } -// Unregister removes a single client by its id. +// Unregister removes a single client by its id and marks it as discarded. // // If client with clientId doesn't exist, this method does nothing. func (b *Broker) Unregister(clientId string) { - b.mux.Lock() - defer b.mux.Unlock() - - if client, ok := b.clients[clientId]; ok { - client.Discard() - delete(b.clients, clientId) + client := b.store.Get(clientId) + if client == nil { + return } + client.Discard() + b.store.Remove(clientId) } diff --git a/tools/subscriptions/broker_test.go b/tools/subscriptions/broker_test.go index d01d290fe7..74955d6021 100644 --- a/tools/subscriptions/broker_test.go +++ b/tools/subscriptions/broker_test.go @@ -36,6 +36,47 @@ func TestClients(t *testing.T) { } } +func TestChunkedClients(t *testing.T) { + b := subscriptions.NewBroker() + + chunks := b.ChunkedClients(2) + if total := len(chunks); total != 0 { + t.Fatalf("Expected %d chunks, got %d", 0, total) + } + + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + + chunks = b.ChunkedClients(2) + if total := len(chunks); total != 2 { + t.Fatalf("Expected %d chunks, got %d", 2, total) + } + + if total := len(chunks[0]); total != 2 { + t.Fatalf("Expected the first chunk to have 2 clients, got %d", total) + } + + if total := len(chunks[1]); total != 1 { + t.Fatalf("Expected the second chunk to have 1 client, got %d", total) + } +} + +func TestTotalClients(t *testing.T) { + b := subscriptions.NewBroker() + + if total := b.TotalClients(); total != 0 { + t.Fatalf("Expected no clients, got %d", total) + } + + b.Register(subscriptions.NewDefaultClient()) + b.Register(subscriptions.NewDefaultClient()) + + if total := b.TotalClients(); total != 2 { + t.Fatalf("Expected %d clients, got %d", 2, total) + } +} + func TestClientById(t *testing.T) { b := subscriptions.NewBroker() diff --git a/tools/subscriptions/client.go b/tools/subscriptions/client.go index 85b40827af..3bff80180b 100644 --- a/tools/subscriptions/client.go +++ b/tools/subscriptions/client.go @@ -1,15 +1,23 @@ package subscriptions import ( + "encoding/json/v2" + "net/url" + "strings" "sync" + "github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/security" + "github.com/spf13/cast" ) -// Message defines a client's channel data. -type Message struct { - Name string - Data []byte +const optionsParam = "options" + +// SubscriptionOptions defines the request options (query params, headers, etc.) +// for a single subscription topic. +type SubscriptionOptions struct { + Query map[string]string `json:"query"` + Headers map[string]string `json:"headers"` } // Client is an interface for a generic subscription client. @@ -18,12 +26,24 @@ type Client interface { Id() string // Channel returns the client's communication channel. + // + // NB! The channel shouldn't be used after calling Discard(). Channel() chan Message - // Subscriptions returns all subscriptions to which the client has subscribed to. - Subscriptions() map[string]struct{} + // Subscriptions returns a shallow copy of the client subscriptions matching the prefixes. + // If no prefix is specified, returns all subscriptions. + Subscriptions(prefixes ...string) map[string]SubscriptionOptions // Subscribe subscribes the client to the provided subscriptions list. + // + // Each subscription can also have "options" (json serialized SubscriptionOptions) as query parameter. + // + // Example: + // + // Subscribe( + // "subscriptionA", + // `subscriptionB?options={"query":{"a":1},"headers":{"x_token":"abc"}}`, + // ) Subscribe(subs ...string) // Unsubscribe unsubscribes the client from the provided subscriptions list. @@ -41,8 +61,8 @@ type Client interface { // Get retrieves the key value from the client's context. Get(key string) any - // Discard marks the client as "discarded", meaning that it - // shouldn't be used anymore for sending new messages. + // Discard marks the client as "discarded" (and closes its channel), + // meaning that it shouldn't be used anymore for sending new messages. // // It is safe to call Discard() multiple times. Discard() @@ -60,12 +80,12 @@ var _ Client = (*DefaultClient)(nil) // DefaultClient defines a generic subscription client. type DefaultClient struct { - mux sync.RWMutex - isDiscarded bool - id string store map[string]any + subscriptions map[string]SubscriptionOptions channel chan Message - subscriptions map[string]struct{} + id string + mu sync.RWMutex + isDiscarded bool } // NewDefaultClient creates and returns a new DefaultClient instance. @@ -74,47 +94,104 @@ func NewDefaultClient() *DefaultClient { id: security.RandomString(40), store: map[string]any{}, channel: make(chan Message), - subscriptions: make(map[string]struct{}), + subscriptions: map[string]SubscriptionOptions{}, } } // Id implements the [Client.Id] interface method. func (c *DefaultClient) Id() string { - c.mux.RLock() - defer c.mux.RUnlock() + c.mu.RLock() + defer c.mu.RUnlock() return c.id } // Channel implements the [Client.Channel] interface method. func (c *DefaultClient) Channel() chan Message { - c.mux.RLock() - defer c.mux.RUnlock() + c.mu.RLock() + defer c.mu.RUnlock() return c.channel } // Subscriptions implements the [Client.Subscriptions] interface method. -func (c *DefaultClient) Subscriptions() map[string]struct{} { - c.mux.RLock() - defer c.mux.RUnlock() +// +// It returns a shallow copy of the client subscriptions matching the prefixes. +// If no prefix is specified, returns all subscriptions. +func (c *DefaultClient) Subscriptions(prefixes ...string) map[string]SubscriptionOptions { + c.mu.RLock() + defer c.mu.RUnlock() + + // no prefix -> return copy of all subscriptions + if len(prefixes) == 0 { + result := make(map[string]SubscriptionOptions, len(c.subscriptions)) + + for s, options := range c.subscriptions { + result[s] = options + } + + return result + } + + result := make(map[string]SubscriptionOptions) + + for _, prefix := range prefixes { + for s, options := range c.subscriptions { + // "?" ensures that the options query start character is always there + // so that it can be used as an end separator when looking only for the main subscription topic + if strings.HasPrefix(s+"?", prefix) { + result[s] = options + } + } + } - return c.subscriptions + return result } // Subscribe implements the [Client.Subscribe] interface method. // // Empty subscriptions (aka. "") are ignored. func (c *DefaultClient) Subscribe(subs ...string) { - c.mux.Lock() - defer c.mux.Unlock() + c.mu.Lock() + defer c.mu.Unlock() for _, s := range subs { if s == "" { continue // skip empty } - c.subscriptions[s] = struct{}{} + // extract subscription options (if any) + rawOptions := struct { + // note: any instead of string to minimize the breaking changes with earlier versions + Query map[string]any `json:"query"` + Headers map[string]any `json:"headers"` + }{} + u, err := url.Parse(s) + if err == nil { + raw := u.Query().Get(optionsParam) + if raw != "" { + json.Unmarshal([]byte(raw), &rawOptions) + } + } + + options := SubscriptionOptions{ + Query: make(map[string]string, len(rawOptions.Query)), + Headers: make(map[string]string, len(rawOptions.Headers)), + } + + // normalize query + // (currently only single string values are supported for consistency with the default routes handling) + for k, v := range rawOptions.Query { + options.Query[k] = cast.ToString(v) + } + + // normalize headers name and values, eg. "X-Token" is converted to "x_token" + // (currently only single string values are supported for consistency with the default routes handling) + for k, v := range rawOptions.Headers { + options.Headers[inflector.Snakecase(k)] = cast.ToString(v) + } + + c.subscriptions[s] = options } } @@ -122,8 +199,8 @@ func (c *DefaultClient) Subscribe(subs ...string) { // // If subs is not set, this method removes all registered client's subscriptions. func (c *DefaultClient) Unsubscribe(subs ...string) { - c.mux.Lock() - defer c.mux.Unlock() + c.mu.Lock() + defer c.mu.Unlock() if len(subs) > 0 { for _, s := range subs { @@ -139,8 +216,8 @@ func (c *DefaultClient) Unsubscribe(subs ...string) { // HasSubscription implements the [Client.HasSubscription] interface method. func (c *DefaultClient) HasSubscription(sub string) bool { - c.mux.RLock() - defer c.mux.RUnlock() + c.mu.RLock() + defer c.mu.RUnlock() _, ok := c.subscriptions[sub] @@ -149,40 +226,46 @@ func (c *DefaultClient) HasSubscription(sub string) bool { // Get implements the [Client.Get] interface method. func (c *DefaultClient) Get(key string) any { - c.mux.RLock() - defer c.mux.RUnlock() + c.mu.RLock() + defer c.mu.RUnlock() return c.store[key] } // Set implements the [Client.Set] interface method. func (c *DefaultClient) Set(key string, value any) { - c.mux.Lock() - defer c.mux.Unlock() + c.mu.Lock() + defer c.mu.Unlock() c.store[key] = value } // Unset implements the [Client.Unset] interface method. func (c *DefaultClient) Unset(key string) { - c.mux.Lock() - defer c.mux.Unlock() + c.mu.Lock() + defer c.mu.Unlock() delete(c.store, key) } // Discard implements the [Client.Discard] interface method. func (c *DefaultClient) Discard() { - c.mux.Lock() - defer c.mux.Unlock() + c.mu.Lock() + defer c.mu.Unlock() + + if c.isDiscarded { + return + } + + close(c.channel) c.isDiscarded = true } // IsDiscarded implements the [Client.IsDiscarded] interface method. func (c *DefaultClient) IsDiscarded() bool { - c.mux.RLock() - defer c.mux.RUnlock() + c.mu.RLock() + defer c.mu.RUnlock() return c.isDiscarded } @@ -193,5 +276,10 @@ func (c *DefaultClient) Send(m Message) { return } - c.Channel() <- m + // "gracefully" handle panics since channel close is not blocking and could cause races + defer func() { + recover() + }() + + c.channel <- m } diff --git a/tools/subscriptions/client_test.go b/tools/subscriptions/client_test.go index 43d075b2aa..eec6b81751 100644 --- a/tools/subscriptions/client_test.go +++ b/tools/subscriptions/client_test.go @@ -1,6 +1,9 @@ package subscriptions_test import ( + "encoding/json/v2" + "strings" + "sync" "testing" "time" @@ -51,7 +54,7 @@ func TestChannel(t *testing.T) { c := subscriptions.NewDefaultClient() if c.Channel() == nil { - t.Errorf("Expected channel to be initialized, got") + t.Fatalf("Expected channel to be initialized, got") } } @@ -59,13 +62,35 @@ func TestSubscriptions(t *testing.T) { c := subscriptions.NewDefaultClient() if len(c.Subscriptions()) != 0 { - t.Errorf("Expected subscriptions to be empty") + t.Fatalf("Expected subscriptions to be empty") } - c.Subscribe("sub1", "sub2", "sub3") + c.Subscribe("sub1", "sub11", "sub2") - if len(c.Subscriptions()) != 3 { - t.Errorf("Expected 3 subscriptions, got %v", c.Subscriptions()) + scenarios := []struct { + prefixes []string + expected []string + }{ + {nil, []string{"sub1", "sub11", "sub2"}}, + {[]string{"missing"}, nil}, + {[]string{"sub1"}, []string{"sub1", "sub11"}}, + {[]string{"sub2"}, []string{"sub2"}}, // with extra query start char + } + + for _, s := range scenarios { + t.Run(strings.Join(s.prefixes, ","), func(t *testing.T) { + subs := c.Subscriptions(s.prefixes...) + + if len(subs) != len(s.expected) { + t.Fatalf("Expected %d subscriptions, got %d", len(s.expected), len(subs)) + } + + for _, s := range s.expected { + if _, ok := subs[s]; !ok { + t.Fatalf("Missing subscription %q in \n%v", s, subs) + } + } + }) } } @@ -78,7 +103,7 @@ func TestSubscribe(t *testing.T) { c.Subscribe(subs...) // empty string should be skipped if len(c.Subscriptions()) != 3 { - t.Errorf("Expected 3 subscriptions, got %v", c.Subscriptions()) + t.Fatalf("Expected 3 subscriptions, got %v", c.Subscriptions()) } for i, s := range expected { @@ -88,6 +113,44 @@ func TestSubscribe(t *testing.T) { } } +func TestSubscribeOptions(t *testing.T) { + c := subscriptions.NewDefaultClient() + + sub1 := "test1" + sub2 := `test2?options={"query":{"name":123},"headers":{"X-Token":456}}` + + c.Subscribe(sub1, sub2) + + subs := c.Subscriptions() + + scenarios := []struct { + name string + expectedOptions string + }{ + {sub1, `{"query":{},"headers":{}}`}, + {sub2, `{"query":{"name":"123"},"headers":{"x_token":"456"}}`}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + options, ok := subs[s.name] + if !ok { + t.Fatalf("Missing subscription \n%q \nin \n%v", s.name, subs) + } + + rawBytes, err := json.Marshal(options) + if err != nil { + t.Fatal(err) + } + rawStr := string(rawBytes) + + if rawStr != s.expectedOptions { + t.Fatalf("Expected options\n%v\ngot\n%v", s.expectedOptions, rawStr) + } + }) + } +} + func TestUnsubscribe(t *testing.T) { c := subscriptions.NewDefaultClient() @@ -96,12 +159,12 @@ func TestUnsubscribe(t *testing.T) { c.Unsubscribe("sub1") if c.HasSubscription("sub1") { - t.Error("Expected sub1 to be removed") + t.Fatalf("Expected sub1 to be removed") } c.Unsubscribe( /* all */ ) if len(c.Subscriptions()) != 0 { - t.Errorf("Expected all subscriptions to be removed, got %v", c.Subscriptions()) + t.Fatalf("Expected all subscriptions to be removed, got %v", c.Subscriptions()) } } @@ -146,12 +209,16 @@ func TestDiscard(t *testing.T) { } func TestSend(t *testing.T) { + var mu sync.Mutex + c := subscriptions.NewDefaultClient() received := []string{} go func() { for m := range c.Channel() { + mu.Lock() received = append(received, m.Name) + mu.Unlock() } }() @@ -164,6 +231,8 @@ func TestSend(t *testing.T) { expected := []string{"m1", "m2"} + mu.Lock() + defer mu.Unlock() if len(received) != len(expected) { t.Fatalf("Expected %d messages, got %d", len(expected), len(received)) } diff --git a/tools/subscriptions/message.go b/tools/subscriptions/message.go new file mode 100644 index 0000000000..4d786477ac --- /dev/null +++ b/tools/subscriptions/message.go @@ -0,0 +1,37 @@ +package subscriptions + +import ( + "io" +) + +// Message defines a client's channel data. +type Message struct { + Name string `json:"name"` + Data []byte `json:"data"` +} + +// WriteSSE writes the current message in a SSE format into the provided writer. +// +// For example, writing to a router.Event: +// +// m := Message{Name: "users/create", Data: []byte{...}} +// m.WriteSSE(e.Response, "yourEventId") +// e.Flush() +func (m *Message) WriteSSE(w io.Writer, eventId string) error { + parts := [][]byte{ + []byte("id:" + eventId + "\n"), + []byte("event:" + m.Name + "\n"), + []byte("data:"), + m.Data, + []byte("\n\n"), + } + + for _, part := range parts { + _, err := w.Write(part) + if err != nil { + return err + } + } + + return nil +} diff --git a/tools/subscriptions/message_test.go b/tools/subscriptions/message_test.go new file mode 100644 index 0000000000..6483c9b70d --- /dev/null +++ b/tools/subscriptions/message_test.go @@ -0,0 +1,25 @@ +package subscriptions_test + +import ( + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +func TestMessageWrite(t *testing.T) { + m := subscriptions.Message{ + Name: "test_name", + Data: []byte("test_data"), + } + + var sb strings.Builder + + m.WriteSSE(&sb, "test_id") + + expected := "id:test_id\nevent:test_name\ndata:test_data\n\n" + + if v := sb.String(); v != expected { + t.Fatalf("Expected writer content\n%q\ngot\n%q", expected, v) + } +} diff --git a/tools/template/registry.go b/tools/template/registry.go index 4cbebe4cb6..2440206967 100644 --- a/tools/template/registry.go +++ b/tools/template/registry.go @@ -6,36 +6,43 @@ // // Example: // -// registry := template.NewRegistry() +// 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"}) +// 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"}) +// html2, err := registry.LoadFiles( +// // reuse the already parsed and cached files set +// "layout.html", +// "content.html", +// ).Render(map[string]any{"name": "Jane"}) package template import ( "fmt" "html/template" "io/fs" + "path/filepath" "strings" "github.com/pocketbase/pocketbase/tools/store" ) -// NewRegistry creates and initializes a new blank templates registry. +// NewRegistry creates and initializes a new templates registry with +// some defaults (eg. global "raw" template function for unescaped HTML). // // Use the Registry.Load* methods to load templates into the registry. func NewRegistry() *Registry { return &Registry{ - cache: store.New[*Renderer](nil), + cache: store.New[string, *Renderer](nil), + funcs: template.FuncMap{ + "raw": func(str string) template.HTML { + return template.HTML(str) + }, + }, } } @@ -43,7 +50,32 @@ func NewRegistry() *Registry { // // Use the Registry.Load* methods to load templates into the registry. type Registry struct { - cache *store.Store[*Renderer] + cache *store.Store[string, *Renderer] + funcs template.FuncMap +} + +// AddFuncs registers new global template functions. +// +// The key of each map entry is the function name that will be used in the templates. +// If a function with the map entry name already exists it will be replaced with the new one. +// +// The value of each map entry is a function that must have either a +// single return value, or two return values of which the second has type error. +// +// Example: +// +// r.AddFuncs(map[string]any{ +// "toUpper": func(str string) string { +// return strings.ToUppser(str) +// }, +// ... +// }) +func (r *Registry) AddFuncs(funcs map[string]any) *Registry { + for name, f := range funcs { + r.funcs[name] = f + } + + return r } // LoadFiles caches (if not already) the specified filenames set as a @@ -57,7 +89,7 @@ func (r *Registry) LoadFiles(filenames ...string) *Renderer { if found == nil { // parse and cache - tpl, err := template.ParseFiles(filenames...) + tpl, err := template.New(filepath.Base(filenames[0])).Funcs(r.funcs).ParseFiles(filenames...) found = &Renderer{template: tpl, parseError: err} r.cache.Set(key, found) } @@ -72,7 +104,7 @@ func (r *Registry) LoadString(text string) *Renderer { if found == nil { // parse and cache (using the text as key) - tpl, err := template.New("").Parse(text) + tpl, err := template.New("").Funcs(r.funcs).Parse(text) found = &Renderer{template: tpl, parseError: err} r.cache.Set(text, found) } @@ -80,19 +112,27 @@ func (r *Registry) LoadString(text string) *Renderer { return found } -// LoadString caches (if not already) the specified fs and globPatterns +// LoadFS 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). -func (r *Registry) LoadFS(fs fs.FS, globPatterns ...string) *Renderer { - key := fmt.Sprintf("%v%v", fs, globPatterns) +func (r *Registry) LoadFS(fsys fs.FS, globPatterns ...string) *Renderer { + key := fmt.Sprintf("%v%v", fsys, globPatterns) found := r.cache.Get(key) if found == nil { - // parse and cache - tpl, err := template.ParseFS(fs, globPatterns...) + // find the first file to use as template name (it is required when specifying Funcs) + var firstFilename string + if len(globPatterns) > 0 { + list, _ := fs.Glob(fsys, globPatterns[0]) + if len(list) > 0 { + firstFilename = filepath.Base(list[0]) + } + } + + tpl, err := template.New(firstFilename).Funcs(r.funcs).ParseFS(fsys, globPatterns...) found = &Renderer{template: tpl, parseError: err} r.cache.Set(key, found) } diff --git a/tools/template/registry_test.go b/tools/template/registry_test.go index 4a2147a93d..c037fe3255 100644 --- a/tools/template/registry_test.go +++ b/tools/template/registry_test.go @@ -8,6 +8,18 @@ import ( "testing" ) +func checkRegistryFuncs(t *testing.T, r *Registry, expectedFuncs ...string) { + if v := len(r.funcs); v != len(expectedFuncs) { + t.Fatalf("Expected total %d funcs, got %d", len(expectedFuncs), v) + } + + for _, name := range expectedFuncs { + if _, ok := r.funcs[name]; !ok { + t.Fatalf("Missing %q func", name) + } + } +} + func TestNewRegistry(t *testing.T) { r := NewRegistry() @@ -18,6 +30,28 @@ func TestNewRegistry(t *testing.T) { if v := r.cache.Length(); v != 0 { t.Fatalf("Expected cache store length to be 0, got %d", v) } + + checkRegistryFuncs(t, r, "raw") +} + +func TestRegistryAddFuncs(t *testing.T) { + r := NewRegistry() + + r.AddFuncs(map[string]any{ + "test": func(a string) string { return a + "-TEST" }, + }) + + checkRegistryFuncs(t, r, "raw", "test") + + result, err := r.LoadString(`{{.|test}}`).Render("example") + if err != nil { + t.Fatalf("Unexpected Render() error, got %v", err) + } + + expected := "example-TEST" + if result != expected { + t.Fatalf("Expected Render() result %q, got %q", expected, result) + } } func TestRegistryLoadFiles(t *testing.T) { @@ -48,10 +82,10 @@ func TestRegistryLoadFiles(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content"}}`), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content" .}}`), 0644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:123{{end}}`), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:{{.|raw}}{{end}}`), 0644); err != nil { t.Fatal(err) } defer os.RemoveAll(dir) @@ -74,12 +108,12 @@ func TestRegistryLoadFiles(t *testing.T) { t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) } - result, err := renderer.Render(nil) + result, err := renderer.Render("

123

") if err != nil { t.Fatalf("Unexpected Render() error, got %v", err) } - expected := "Base:Content:123" + expected := "Base:Content:

123

" if result != expected { t.Fatalf("Expected Render() result %q, got %q", expected, result) } @@ -110,7 +144,7 @@ func TestRegistryLoadString(t *testing.T) { }) t.Run("valid template string", func(t *testing.T) { - txt := `test {{.}}` + txt := `test {{.|raw}}` r.LoadString(txt) @@ -128,12 +162,12 @@ func TestRegistryLoadString(t *testing.T) { t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) } - result, err := renderer.Render(123) + result, err := renderer.Render("

123

") if err != nil { t.Fatalf("Unexpected Render() error, got %v", err) } - expected := "test 123" + expected := "test

123

" if result != expected { t.Fatalf("Expected Render() result %q, got %q", expected, result) } @@ -173,10 +207,10 @@ func TestRegistryLoadFS(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content"}}`), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "base.html"), []byte(`Base:{{template "content" .}}`), 0644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:123{{end}}`), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "content.html"), []byte(`{{define "content"}}Content:{{.|raw}}{{end}}`), 0644); err != nil { t.Fatal(err) } defer os.RemoveAll(dir) @@ -203,12 +237,12 @@ func TestRegistryLoadFS(t *testing.T) { t.Fatalf("Expected renderer parseError to be nil, got %v", renderer.parseError) } - result, err := renderer.Render(nil) + result, err := renderer.Render("

123

") if err != nil { t.Fatalf("Unexpected Render() error, got %v", err) } - expected := "Base:Content:123" + expected := "Base:Content:

123

" if result != expected { t.Fatalf("Expected Render() result %q, got %q", expected, result) } diff --git a/tools/tokenizer/tokenizer.go b/tools/tokenizer/tokenizer.go index 54a67d8662..934d369394 100644 --- a/tools/tokenizer/tokenizer.go +++ b/tools/tokenizer/tokenizer.go @@ -21,6 +21,8 @@ const eof = rune(0) // DefaultSeparators is a list with the default token separator characters. var DefaultSeparators = []rune{','} +var whitespaceChars = []rune{'\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0} + // NewFromString creates new Tokenizer from the provided string. func NewFromString(str string) *Tokenizer { return New(strings.NewReader(str)) @@ -33,11 +35,11 @@ func NewFromBytes(b []byte) *Tokenizer { // New creates new Tokenizer from the provided reader with DefaultSeparators. func New(r io.Reader) *Tokenizer { - return &Tokenizer{ - r: bufio.NewReader(r), - separators: DefaultSeparators, - keepSeparator: false, - } + t := &Tokenizer{r: bufio.NewReader(r)} + + t.Separators(DefaultSeparators...) + + return t } // Tokenizer defines a struct that parses a reader into tokens while @@ -45,54 +47,67 @@ func New(r io.Reader) *Tokenizer { type Tokenizer struct { r *bufio.Reader - separators []rune - keepSeparator bool + trimCutset string + separators []rune + keepSeparator bool + keepEmptyTokens bool + ignoreParenthesis bool } // Separators defines the provided separatos of the current Tokenizer. -func (s *Tokenizer) Separators(separators ...rune) { - s.separators = separators +func (t *Tokenizer) Separators(separators ...rune) { + t.separators = separators + + t.rebuildTrimCutset() } // KeepSeparator defines whether to keep the separator rune as part // of the token (default to false). -func (s *Tokenizer) KeepSeparator(state bool) { - s.keepSeparator = state +func (t *Tokenizer) KeepSeparator(state bool) { + t.keepSeparator = state +} + +// KeepEmptyTokens defines whether to keep empty tokens on Scan() (default to false). +func (t *Tokenizer) KeepEmptyTokens(state bool) { + t.keepEmptyTokens = state +} + +// IgnoreParenthesis defines whether to ignore the parenthesis boundaries +// and to treat the '(' and ')' as regular characters. +func (t *Tokenizer) IgnoreParenthesis(state bool) { + t.ignoreParenthesis = state } -// Scan reads and returns the next available token from the Tokenizer's buffer (trimmed). +// Scan reads and returns the next available token from the Tokenizer's buffer (trimmed!). +// +// Empty tokens are skipped if t.keepEmptyTokens is not set (which is the default). // // Returns [io.EOF] error when there are no more tokens to scan. -func (s *Tokenizer) Scan() (string, error) { - ch := s.read() - +func (t *Tokenizer) Scan() (string, error) { + ch := t.read() if ch == eof { return "", io.EOF } + t.unread() - if isWhitespaceRune(ch) { - s.readWhiteSpaces() - } else { - s.unread() - } - - token, err := s.readToken() + token, err := t.readToken() if err != nil { return "", err } - // read all remaining whitespaces - s.readWhiteSpaces() + if !t.keepEmptyTokens && token == "" { + return t.Scan() + } return token, err } // ScanAll reads the entire Tokenizer's buffer and return all found tokens. -func (s *Tokenizer) ScanAll() ([]string, error) { +func (t *Tokenizer) ScanAll() ([]string, error) { tokens := []string{} for { - token, err := s.Scan() + token, err := t.Scan() if err != nil { if err == io.EOF { break @@ -108,35 +123,36 @@ func (s *Tokenizer) ScanAll() ([]string, error) { } // readToken reads a single token from the buffer and returns it. -func (s *Tokenizer) readToken() (string, error) { +func (t *Tokenizer) readToken() (string, error) { var buf bytes.Buffer var parenthesis int var quoteCh rune var prevCh rune for { - ch := s.read() + ch := t.read() if ch == eof { break } - if !isEscapeRune(prevCh) { - if ch == '(' && quoteCh == eof { - parenthesis++ - } else if ch == ')' && parenthesis > 0 && quoteCh == eof { - parenthesis-- - } else if isQuoteRune(ch) { - if quoteCh == ch { - quoteCh = eof // reached closing quote - } else if quoteCh == eof { + if !t.isEscapeRune(prevCh) { + if !t.ignoreParenthesis && ch == '(' && quoteCh == eof { + parenthesis++ // opening parenthesis + } else if !t.ignoreParenthesis && ch == ')' && parenthesis > 0 && quoteCh == eof { + parenthesis-- // closing parenthesis + } else if t.isQuoteRune(ch) { + switch quoteCh { + case ch: + quoteCh = eof // closing quote + case eof: quoteCh = ch // opening quote } } } - if s.isSeperatorRune(ch) && parenthesis == 0 && quoteCh == eof { - if s.keepSeparator { + if t.isSeperatorRune(ch) && parenthesis == 0 && quoteCh == eof { + if t.keepSeparator { buf.WriteRune(ch) } break @@ -150,29 +166,13 @@ func (s *Tokenizer) readToken() (string, error) { return "", fmt.Errorf("unbalanced parenthesis or quoted expression: %q", buf.String()) } - return buf.String(), nil -} - -// readWhiteSpaces consumes all contiguous whitespace runes. -func (s *Tokenizer) readWhiteSpaces() { - for { - ch := s.read() - - if ch == eof { - break - } - - if !s.isSeperatorRune(ch) { - s.unread() - break - } - } + return strings.Trim(buf.String(), t.trimCutset), nil } // read reads the next rune from the buffered reader. // Returns the `rune(0)` if an error or `io.EOF` occurs. -func (s *Tokenizer) read() rune { - ch, _, err := s.r.ReadRune() +func (t *Tokenizer) read() rune { + ch, _, err := t.r.ReadRune() if err != nil { return eof } @@ -181,13 +181,27 @@ func (s *Tokenizer) read() rune { } // unread places the previously read rune back on the reader. -func (s *Tokenizer) unread() error { - return s.r.UnreadRune() +func (t *Tokenizer) unread() error { + return t.r.UnreadRune() +} + +// rebuildTrimCutset rebuilds the tokenizer trimCutset based on its separator runes. +func (t *Tokenizer) rebuildTrimCutset() { + var cutset strings.Builder + + for _, w := range whitespaceChars { + if t.isSeperatorRune(w) { + continue + } + cutset.WriteRune(w) + } + + t.trimCutset = cutset.String() } // isSeperatorRune checks if a rune is a token part separator. -func (s *Tokenizer) isSeperatorRune(ch rune) bool { - for _, r := range s.separators { +func (t *Tokenizer) isSeperatorRune(ch rune) bool { + for _, r := range t.separators { if ch == r { return true } @@ -196,17 +210,12 @@ func (s *Tokenizer) isSeperatorRune(ch rune) bool { return false } -// isWhitespaceRune checks if a rune is a space, tab, or newline. -func isWhitespaceRune(ch rune) bool { - return ch == ' ' || ch == '\t' || ch == '\n' -} - // isQuoteRune checks if a rune is a quote. -func isQuoteRune(ch rune) bool { +func (t *Tokenizer) isQuoteRune(ch rune) bool { return ch == '\'' || ch == '"' || ch == '`' } // isEscapeRune checks if a rune is an escape character. -func isEscapeRune(ch rune) bool { +func (t *Tokenizer) isEscapeRune(ch rune) bool { return ch == '\\' } diff --git a/tools/tokenizer/tokenizer_test.go b/tools/tokenizer/tokenizer_test.go index d728ba8995..c9aab59652 100644 --- a/tools/tokenizer/tokenizer_test.go +++ b/tools/tokenizer/tokenizer_test.go @@ -28,32 +28,38 @@ func TestFactories(t *testing.T) { } for _, s := range scenarios { - content, _ := s.tk.r.ReadString(0) + t.Run(s.name, func(t *testing.T) { + content, _ := s.tk.r.ReadString(0) - if content != expectedContent { - t.Fatalf("[%s] Expected reader with content %q, got %q", s.name, expectedContent, content) - } + if content != expectedContent { + t.Fatalf("Expected reader with content %q, got %q", expectedContent, content) + } - if s.tk.keepSeparator != false { - t.Fatalf("[%s] Expected false, got true", s.name) - } + if s.tk.keepSeparator != false { + t.Fatal("Expected keepSeparator false, got true") + } - if len(s.tk.separators) != len(DefaultSeparators) { - t.Fatalf("[%s] Expected \n%v, \ngot \n%v", s.name, DefaultSeparators, s.tk.separators) - } + if s.tk.ignoreParenthesis != false { + t.Fatal("Expected ignoreParenthesis false, got true") + } - for _, r := range s.tk.separators { - exists := false - for _, def := range s.tk.separators { - if r == def { - exists = true - break - } + if len(s.tk.separators) != len(DefaultSeparators) { + t.Fatalf("Expected \n%v, \ngot \n%v", DefaultSeparators, s.tk.separators) } - if !exists { - t.Fatalf("[%s] Unexpected sepator %s", s.name, string(r)) + + for _, r := range s.tk.separators { + exists := false + for _, def := range s.tk.separators { + if r == def { + exists = true + break + } + } + if !exists { + t.Fatalf("Unexpected sepator %s", string(r)) + } } - } + }) } } @@ -85,54 +91,65 @@ func TestScan(t *testing.T) { func TestScanAll(t *testing.T) { scenarios := []struct { - name string - content string - separators []rune - keepSeparator bool - expectError bool - expectTokens []string + name string + content string + separators []rune + keepSeparator bool + keepEmptyTokens bool + ignoreParenthesis bool + expectError bool + expectTokens []string }{ { - "empty string", - "", - DefaultSeparators, - false, - false, - nil, + name: "empty string", + content: "", + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: nil, }, { - "unbalanced parenthesis", - `(a,b() c`, - DefaultSeparators, - false, - true, - []string{}, + name: "unbalanced parenthesis", + content: `(a,b() c`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: true, + expectTokens: []string{}, }, { - "unmatching quotes", - `'asd"`, - DefaultSeparators, - false, - true, - []string{}, + name: "unmatching quotes", + content: `'asd"`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: true, + expectTokens: []string{}, }, { - "no separators", - `a, b, c, d, e 123, "abc"`, - nil, - false, - false, - []string{ - `a, b, c, d, e 123, "abc"`, - }, + name: "no separators", + content: `a, b, c, d, e 123, "abc"`, + separators: nil, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{`a, b, c, d, e 123, "abc"`}, }, { - "default separators", - `a, b, c, d e, "a,b, c ", (123, 456)`, - DefaultSeparators, - false, - false, - []string{ + name: "default separators", + content: `a, b , c , d e , "a,b, c " , ,, , (123, 456) + `, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ "a", "b", "c", @@ -142,92 +159,145 @@ func TestScanAll(t *testing.T) { }, }, { - "default separators (with preserve)", - `a, b, c, d e, "a,b, c ", (123, 456)`, - DefaultSeparators, - true, - false, - []string{ + name: "keep separators", + content: `a, b, c, d e, "a,b, c ", (123, 456)`, + separators: []rune{',', ' '}, // the space should be removed from the cutset + keepSeparator: true, + keepEmptyTokens: true, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ "a,", + " ", "b,", + " ", "c,", - "d e,", + " ", + "d ", + " ", + "e,", + " ", `"a,b, c ",`, `(123, 456)`, }, }, { - "custom separators", - ` a , 123.456, b, c d, ( - test (a,b,c) " 123 " - ),"(abc d", "abc) d", "(abc) d \" " 'abc "'`, - []rune{',', ' ', '\t', '\n'}, - false, - false, - []string{ + name: "custom separators", + content: `a | b c d &(e + f) & "g & h" & & &`, + separators: []rune{'|', '&'}, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a", + "b c d", + "(e + f)", + `"g & h"`, + }, + }, + { + name: "ignoring parenthesis", + content: `a, b, (c,d)`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: false, + ignoreParenthesis: true, + expectError: false, + expectTokens: []string{ "a", - "123.456", "b", - "c", - "d", - "(\n\t\t\t\ttest (a,b,c) \" 123 \"\n\t\t\t)", - `"(abc d"`, - `"abc) d"`, - `"(abc) d \" "`, - `'abc "'`, + "(c", + "d)", }, }, { - "custom separators (with preserve)", - ` a , 123.456, b, c d, ( - test (a,b,c) " 123 " - ),"(abc d", "abc) d", "(abc) d \" " 'abc "'`, - []rune{',', ' ', '\t', '\n'}, - true, - false, - []string{ - "a ", - "123.456,", - "b,", - "c ", - "d,", - "(\n\t\t\t\ttest (a,b,c) \" 123 \"\n\t\t\t),", - `"(abc d",`, - `"abc) d",`, - `"(abc) d \" " `, - `'abc "'`, + name: "keep empty tokens", + content: `a, b, (c, d), ,, , e, , f`, + separators: DefaultSeparators, + keepSeparator: false, + keepEmptyTokens: true, + ignoreParenthesis: false, + expectError: false, + expectTokens: []string{ + "a", + "b", + "(c, d)", + "", + "", + "", + "e", + "", + "f", }, }, } for _, s := range scenarios { - tk := NewFromString(s.content) + t.Run(s.name, func(t *testing.T) { + tk := NewFromString(s.content) - tk.Separators(s.separators...) - tk.KeepSeparator(s.keepSeparator) + tk.Separators(s.separators...) + tk.KeepSeparator(s.keepSeparator) + tk.KeepEmptyTokens(s.keepEmptyTokens) + tk.IgnoreParenthesis(s.ignoreParenthesis) - tokens, err := tk.ScanAll() + tokens, err := tk.ScanAll() - hasErr := err != nil - if hasErr != s.expectError { - t.Fatalf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err) - } + hasErr := err != nil + if hasErr != s.expectError { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } - if len(tokens) != len(s.expectTokens) { - t.Fatalf("[%s] Expected \n%v (%d), \ngot \n%v (%d)", s.name, s.expectTokens, len(s.expectTokens), tokens, len(tokens)) - } + if len(tokens) != len(s.expectTokens) { + t.Fatalf("Expected \n%v (%d), \ngot \n%v (%d)", s.expectTokens, len(s.expectTokens), tokens, len(tokens)) + } - for _, tok := range tokens { - exists := false - for _, def := range s.expectTokens { - if tok == def { - exists = true - break + for _, tok := range tokens { + exists := false + for _, def := range s.expectTokens { + if tok == def { + exists = true + break + } + } + if !exists { + t.Fatalf("Unexpected token %q", tok) } } - if !exists { - t.Fatalf("[%s] Unexpected token %s", s.name, tok) + }) + } +} + +func TestTrimCutset(t *testing.T) { + scenarios := []struct { + name string + separators []rune + expectedCutset string + }{ + { + "default factory separators", + nil, + "\t\n\v\f\r \u0085\u00a0", + }, + { + "custom separators", + []rune{'\t', ' ', '\r', ','}, + "\n\v\f\u0085\u00a0", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + tk := NewFromString("") + + if len(s.separators) > 0 { + tk.Separators(s.separators...) } - } + + if tk.trimCutset != s.expectedCutset { + t.Fatalf("Expected cutset %q, got %q", s.expectedCutset, tk.trimCutset) + } + }) } } diff --git a/tools/types/datetime.go b/tools/types/datetime.go index 70bf3f348f..b302e99433 100644 --- a/tools/types/datetime.go +++ b/tools/types/datetime.go @@ -2,7 +2,7 @@ package types import ( "database/sql/driver" - "encoding/json" + "encoding/json/v2" "time" "github.com/spf13/cast" @@ -35,6 +35,59 @@ func (d DateTime) Time() time.Time { return d.t } +// Add returns a new DateTime based on the current DateTime + the specified duration. +func (d DateTime) Add(duration time.Duration) DateTime { + d.t = d.t.Add(duration) + return d +} + +// Sub returns a [time.Duration] by subtracting the specified DateTime from the current one. +// +// If the result exceeds the maximum (or minimum) value that can be stored in a [time.Duration], +// the maximum (or minimum) duration will be returned. +func (d DateTime) Sub(u DateTime) time.Duration { + return d.Time().Sub(u.Time()) +} + +// AddDate returns a new DateTime based on the current one + duration. +// +// It follows the same rules as [time.AddDate]. +func (d DateTime) AddDate(years, months, days int) DateTime { + d.t = d.t.AddDate(years, months, days) + return d +} + +// After reports whether the current DateTime instance is after u. +func (d DateTime) After(u DateTime) bool { + return d.Time().After(u.Time()) +} + +// Before reports whether the current DateTime instance is before u. +func (d DateTime) Before(u DateTime) bool { + return d.Time().Before(u.Time()) +} + +// Compare compares the current DateTime instance with u. +// If the current instance is before u, it returns -1. +// If the current instance is after u, it returns +1. +// If they're the same, it returns 0. +func (d DateTime) Compare(u DateTime) int { + return d.Time().Compare(u.Time()) +} + +// Equal reports whether the current DateTime and u represent the same time instant. +// Two DateTime can be equal even if they are in different locations. +// For example, 6:00 +0200 and 4:00 UTC are Equal. +func (d DateTime) Equal(u DateTime) bool { + return d.Time().Equal(u.Time()) +} + +// Unix returns the current DateTime as a Unix time, aka. +// the number of seconds elapsed since January 1, 1970 UTC. +func (d DateTime) Unix() int64 { + return d.Time().Unix() +} + // IsZero checks whether the current DateTime instance has zero time value. func (d DateTime) IsZero() bool { return d.Time().IsZero() @@ -45,10 +98,11 @@ func (d DateTime) IsZero() bool { // // The zero value is serialized to an empty string. func (d DateTime) String() string { - if d.IsZero() { + t := d.Time() + if t.IsZero() { return "" } - return d.Time().UTC().Format(DefaultDateLayout) + return t.UTC().Format(DefaultDateLayout) } // MarshalJSON implements the [json.Marshaler] interface. @@ -74,12 +128,10 @@ func (d DateTime) Value() (driver.Value, error) { // into the current DateTime instance. func (d *DateTime) Scan(value any) error { switch v := value.(type) { - case DateTime: - d.t = v.Time() case time.Time: d.t = v - case int, int64, int32, uint, uint64, uint32: - d.t = cast.ToTime(v) + case DateTime: + d.t = v.Time() case string: if v == "" { d.t = time.Time{} @@ -91,6 +143,8 @@ func (d *DateTime) Scan(value any) error { } d.t = t } + case int, int64, int32, uint, uint64, uint32: + d.t = cast.ToTime(v) default: str := cast.ToString(v) if str == "" { diff --git a/tools/types/datetime_test.go b/tools/types/datetime_test.go index 60cbe5f821..0d6e38b179 100644 --- a/tools/types/datetime_test.go +++ b/tools/types/datetime_test.go @@ -1,6 +1,7 @@ package types_test import ( + "fmt" "strings" "testing" "time" @@ -41,15 +42,16 @@ func TestParseDateTime(t *testing.T) { } for i, s := range scenarios { - dt, err := types.ParseDateTime(s.value) - if err != nil { - t.Errorf("(%d) Failed to parse %v: %v", i, s.value, err) - continue - } - - if dt.String() != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, dt.String()) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + dt, err := types.ParseDateTime(s.value) + if err != nil { + t.Fatalf("Failed to parse %v: %v", s.value, err) + } + + if dt.String() != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) } } @@ -69,7 +71,210 @@ func TestDateTimeTime(t *testing.T) { result := dt.Time() if !expected.Equal(result) { - t.Errorf("Expected time %v, got %v", expected, result) + t.Fatalf("Expected time %v, got %v", expected, result) + } +} + +func TestDateTimeAdd(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + + d2 := d1.Add(1 * time.Hour) + + if d1.String() != "2024-01-01 10:00:00.123Z" { + t.Fatalf("Expected d1 to remain unchanged, got %s", d1.String()) + } + + expected := "2024-01-01 11:00:00.123Z" + if d2.String() != expected { + t.Fatalf("Expected d2 %s, got %s", expected, d2.String()) + } +} + +func TestDateTimeSub(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-01 10:30:00.123Z") + + result := d2.Sub(d1) + + if result.Minutes() != 30 { + t.Fatalf("Expected %v minutes diff, got %v", 30, result.Minutes()) + } +} + +func TestDateTimeAddDate(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + + d2 := d1.AddDate(1, 2, 3) + + if d1.String() != "2024-01-01 10:00:00.123Z" { + t.Fatalf("Expected d1 to remain unchanged, got %s", d1.String()) + } + + expected := "2025-03-04 10:00:00.123Z" + if d2.String() != expected { + t.Fatalf("Expected d2 %s, got %s", expected, d2.String()) + } +} + +func TestDateTimeAfter(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + // d1 + {d1, d1, false}, + {d1, d2, false}, + {d1, d3, false}, + // d2 + {d2, d1, true}, + {d2, d2, false}, + {d2, d3, false}, + // d3 + {d3, d1, true}, + {d3, d2, true}, + {d3, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("after_%d", i), func(t *testing.T) { + if v := s.a.After(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeBefore(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + // d1 + {d1, d1, false}, + {d1, d2, true}, + {d1, d3, true}, + // d2 + {d2, d1, false}, + {d2, d2, false}, + {d2, d3, true}, + // d3 + {d3, d1, false}, + {d3, d2, false}, + {d3, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("before_%d", i), func(t *testing.T) { + if v := s.a.Before(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeCompare(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-02 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-03 10:00:00.123Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect int + }{ + // d1 + {d1, d1, 0}, + {d1, d2, -1}, + {d1, d3, -1}, + // d2 + {d2, d1, 1}, + {d2, d2, 0}, + {d2, d3, -1}, + // d3 + {d3, d1, 1}, + {d3, d2, 1}, + {d3, d3, 0}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("compare_%d", i), func(t *testing.T) { + if v := s.a.Compare(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeEqual(t *testing.T) { + t.Parallel() + + d1, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d2, _ := types.ParseDateTime("2024-01-01 10:00:00.123Z") + d3, _ := types.ParseDateTime("2024-01-01 10:00:00.124Z") + + scenarios := []struct { + a types.DateTime + b types.DateTime + expect bool + }{ + {d1, d1, true}, + {d1, d2, true}, + {d1, d3, false}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("equal_%d", i), func(t *testing.T) { + if v := s.a.Equal(s.b); v != s.expect { + t.Fatalf("Expected %v, got %v", s.expect, v) + } + }) + } +} + +func TestDateTimeUnix(t *testing.T) { + scenarios := []struct { + date string + expected int64 + }{ + {"", -62135596800}, + {"2022-01-01 11:23:45.678Z", 1641036225}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt, err := types.ParseDateTime(s.date) + if err != nil { + t.Fatal(err) + } + + v := dt.Unix() + + if v != s.expected { + t.Fatalf("Expected %d, got %d", s.expected, v) + } + }) } } @@ -108,19 +313,21 @@ func TestDateTimeMarshalJSON(t *testing.T) { } for i, s := range scenarios { - dt, err := types.ParseDateTime(s.date) - if err != nil { - t.Errorf("(%d) %v", i, err) - } - - result, err := dt.MarshalJSON() - if err != nil { - t.Errorf("(%d) %v", i, err) - } - - if string(result) != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, string(result)) - } + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt, err := types.ParseDateTime(s.date) + if err != nil { + t.Fatal(err) + } + + result, err := dt.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, string(result)) + } + }) } } @@ -137,12 +344,14 @@ func TestDateTimeUnmarshalJSON(t *testing.T) { } for i, s := range scenarios { - dt := types.DateTime{} - dt.UnmarshalJSON([]byte(s.date)) - - if dt.String() != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, dt.String()) - } + t.Run(fmt.Sprintf("%d_%s", i, s.date), func(t *testing.T) { + dt := types.DateTime{} + dt.UnmarshalJSON([]byte(s.date)) + + if dt.String() != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) } } @@ -159,16 +368,18 @@ func TestDateTimeValue(t *testing.T) { } for i, s := range scenarios { - dt, _ := types.ParseDateTime(s.value) - result, err := dt.Value() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - - if result != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%s", i, s.value), func(t *testing.T) { + dt, _ := types.ParseDateTime(s.value) + + result, err := dt.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } @@ -190,16 +401,17 @@ func TestDateTimeScan(t *testing.T) { } for i, s := range scenarios { - dt := types.DateTime{} - - err := dt.Scan(s.value) - if err != nil { - t.Errorf("(%d) Failed to parse %v: %v", i, s.value, err) - continue - } - - if !strings.Contains(dt.String(), s.expected) { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, dt.String()) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + dt := types.DateTime{} + + err := dt.Scan(s.value) + if err != nil { + t.Fatalf("Failed to parse %v: %v", s.value, err) + } + + if !strings.Contains(dt.String(), s.expected) { + t.Fatalf("Expected %q, got %q", s.expected, dt.String()) + } + }) } } diff --git a/tools/types/geo_point.go b/tools/types/geo_point.go new file mode 100644 index 0000000000..bbe3a7b93c --- /dev/null +++ b/tools/types/geo_point.go @@ -0,0 +1,84 @@ +package types + +import ( + "database/sql/driver" + "encoding/json/v2" + "fmt" +) + +// GeoPoint defines a struct for storing geo coordinates as serialized json object +// (e.g. {lon:0,lat:0}). +// +// Note: using object notation and not a plain array to avoid the confusion +// as there doesn't seem to be a fixed standard for the coordinates order. +type GeoPoint struct { + Lon float64 `form:"lon" json:"lon"` + Lat float64 `form:"lat" json:"lat"` +} + +// String returns the string representation of the current GeoPoint instance. +func (p GeoPoint) String() string { + raw, _ := json.Marshal(p) + return string(raw) +} + +// AsMap implements [core.mapExtractor] and returns a value suitable +// to be used in an API rule expression. +func (p GeoPoint) AsMap() map[string]any { + return map[string]any{ + "lon": p.Lon, + "lat": p.Lat, + } +} + +// Value implements the [driver.Valuer] interface. +func (p GeoPoint) Value() (driver.Value, error) { + data, err := json.Marshal(p) + return string(data), err +} + +// Scan implements [sql.Scanner] interface to scan the provided value +// into the current GeoPoint instance. +// +// The value argument could be nil (no-op), another GeoPoint instance, +// map or serialized json object with lat-lon props. +func (p *GeoPoint) Scan(value any) error { + var err error + + switch v := value.(type) { + case nil: + // no cast needed + case *GeoPoint: + p.Lon = v.Lon + p.Lat = v.Lat + case GeoPoint: + p.Lon = v.Lon + p.Lat = v.Lat + case JSONRaw: + if len(v) != 0 { + err = json.Unmarshal(v, p) + } + case []byte: + if len(v) != 0 { + err = json.Unmarshal(v, p) + } + case string: + if len(v) != 0 { + err = json.Unmarshal([]byte(v), p) + } + default: + var raw []byte + raw, err = json.Marshal(v) + if err != nil { + err = fmt.Errorf("unable to marshalize value for scanning: %w", err) + } else { + err = json.Unmarshal(raw, p) + } + } + + if err != nil { + return fmt.Errorf("[GeoPoint] unable to scan value %v: %w", value, err) + } + + return nil +} diff --git a/tools/types/geo_point_test.go b/tools/types/geo_point_test.go new file mode 100644 index 0000000000..fa7c5d5a00 --- /dev/null +++ b/tools/types/geo_point_test.go @@ -0,0 +1,116 @@ +package types_test + +import ( + "fmt" + "testing" + + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestGeoPointAsMap(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + point types.GeoPoint + expected map[string]any + }{ + {"zero", types.GeoPoint{}, map[string]any{"lon": 0.0, "lat": 0.0}}, + {"non-zero", types.GeoPoint{Lon: -10, Lat: 20.123}, map[string]any{"lon": -10.0, "lat": 20.123}}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := s.point.AsMap() + + if len(result) != len(s.expected) { + t.Fatalf("Expected %d keys, got %d: %v", len(s.expected), len(result), result) + } + + for k, v := range s.expected { + found, ok := result[k] + if !ok { + t.Fatalf("Missing expected %q key: %v", k, result) + } + + if found != v { + t.Fatalf("Expected %q key value %v, got %v", k, v, found) + } + } + }) + } +} + +func TestGeoPointStringAndValue(t *testing.T) { + t.Parallel() + + scenarios := []struct { + name string + point types.GeoPoint + expected string + }{ + {"zero", types.GeoPoint{}, `{"lon":0,"lat":0}`}, + {"non-zero", types.GeoPoint{Lon: -10, Lat: 20.123}, `{"lon":-10,"lat":20.123}`}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + str := s.point.String() + + val, err := s.point.Value() + if err != nil { + t.Fatal(err) + } + + if str != val { + t.Fatalf("Expected String and Value to return the same value") + } + + if str != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str) + } + }) + } +} + +func TestGeoPointScan(t *testing.T) { + t.Parallel() + + scenarios := []struct { + value any + expectErr bool + expectStr string + }{ + {nil, false, `{"lon":1,"lat":2}`}, + {"", false, `{"lon":1,"lat":2}`}, + {types.JSONRaw{}, false, `{"lon":1,"lat":2}`}, + {[]byte{}, false, `{"lon":1,"lat":2}`}, + {`{}`, false, `{"lon":1,"lat":2}`}, + {`[]`, true, `{"lon":1,"lat":2}`}, + {0, true, `{"lon":1,"lat":2}`}, + {`{"lon":"1.23","lat":"4.56"}`, true, `{"lon":1,"lat":2}`}, + {`{"lon":1.23,"lat":4.56}`, false, `{"lon":1.23,"lat":4.56}`}, + {[]byte(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`}, + {types.JSONRaw(`{"lon":1.23,"lat":4.56}`), false, `{"lon":1.23,"lat":4.56}`}, + {types.GeoPoint{}, false, `{"lon":0,"lat":0}`}, + {types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`}, + {&types.GeoPoint{Lon: 1.23, Lat: 4.56}, false, `{"lon":1.23,"lat":4.56}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + point := types.GeoPoint{Lon: 1, Lat: 2} + + err := point.Scan(s.value) + + hasErr := err != nil + if hasErr != s.expectErr { + t.Errorf("Expected hasErr %v, got %v (%v)", s.expectErr, hasErr, err) + } + + if str := point.String(); str != s.expectStr { + t.Errorf("Expected\n%s\ngot\n%s", s.expectStr, str) + } + }) + } +} diff --git a/tools/types/json_array.go b/tools/types/json_array.go index 3a8f8b38d6..07ee6e9d15 100644 --- a/tools/types/json_array.go +++ b/tools/types/json_array.go @@ -2,36 +2,43 @@ package types import ( "database/sql/driver" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" ) -// JsonArray defines a slice that is safe for json and db read/write. -type JsonArray[T any] []T +// JSONArray defines a slice that is safe for json and db read/write. +type JSONArray[T any] []T // internal alias to prevent recursion during marshalization. -type jsonArrayAlias[T any] JsonArray[T] +type jsonArrayAlias[T any] JSONArray[T] // MarshalJSON implements the [json.Marshaler] interface. -func (m JsonArray[T]) MarshalJSON() ([]byte, error) { - // initialize an empty map to ensure that `[]` is returned as json - if m == nil { - m = JsonArray[T]{} - } +func (m JSONArray[T]) MarshalJSON() ([]byte, error) { + // note: forces the Deterministic and AllowInvalidUTF8 options to + // ensure consistent output in mixed json v1 and v2 configurations + return json.Marshal( + jsonArrayAlias[T](m), + json.Deterministic(true), + jsontext.AllowInvalidUTF8(true), + ) +} - return json.Marshal(jsonArrayAlias[T](m)) +// String returns the string representation of the current json array. +func (m JSONArray[T]) String() string { + v, _ := m.MarshalJSON() + return string(v) } // Value implements the [driver.Valuer] interface. -func (m JsonArray[T]) Value() (driver.Value, error) { - data, err := json.Marshal(m) - +func (m JSONArray[T]) Value() (driver.Value, error) { + data, err := m.MarshalJSON() return string(data), err } // Scan implements [sql.Scanner] interface to scan the provided value -// into the current JsonArray[T] instance. -func (m *JsonArray[T]) Scan(value any) error { +// into the current JSONArray[T] instance. +func (m *JSONArray[T]) Scan(value any) error { var data []byte switch v := value.(type) { case nil: @@ -41,12 +48,19 @@ func (m *JsonArray[T]) Scan(value any) error { case string: data = []byte(v) default: - return fmt.Errorf("Failed to unmarshal JsonArray value: %q.", value) + return fmt.Errorf("failed to unmarshal JSONArray value: %q", value) } if len(data) == 0 { data = []byte("[]") } - return json.Unmarshal(data, m) + err := json.Unmarshal(data, m) + if err != nil { + // reset because jsonv2 performs streaming decoding and mutates the dst even on error + *m = JSONArray[T]{} + return err + } + + return nil } diff --git a/tools/types/json_array_test.go b/tools/types/json_array_test.go index 9ab4e5a445..d0a2be9c64 100644 --- a/tools/types/json_array_test.go +++ b/tools/types/json_array_test.go @@ -2,65 +2,93 @@ package types_test import ( "database/sql/driver" - "encoding/json" + "encoding/json/v2" + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/types" ) -func TestJsonArrayMarshalJSON(t *testing.T) { +func TestJSONArrayMarshalJSON(t *testing.T) { scenarios := []struct { json json.Marshaler expected string }{ - {new(types.JsonArray[any]), "[]"}, - {types.JsonArray[any]{}, `[]`}, - {types.JsonArray[int]{1, 2, 3}, `[1,2,3]`}, - {types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, - {types.JsonArray[any]{1, "test"}, `[1,"test"]`}, + {new(types.JSONArray[any]), "[]"}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test\xc3" /* invalid utf8 char to test mangling */}, `[1,"test�"]`}, } for i, s := range scenarios { - result, err := s.json.MarshalJSON() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - if string(result) != s.expected { - t.Errorf("(%d) Expected %s, got %s", i, s.expected, string(result)) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %s, got %s", s.expected, result) + } + }) + } +} + +func TestJSONArrayString(t *testing.T) { + scenarios := []struct { + json fmt.Stringer + expected string + }{ + {new(types.JSONArray[any]), "[]"}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test"}, `[1,"test"]`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result := s.json.String() + + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) } } -func TestJsonArrayValue(t *testing.T) { +func TestJSONArrayValue(t *testing.T) { scenarios := []struct { json driver.Valuer expected driver.Value }{ - {new(types.JsonArray[any]), `[]`}, - {types.JsonArray[any]{}, `[]`}, - {types.JsonArray[int]{1, 2, 3}, `[1,2,3]`}, - {types.JsonArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, - {types.JsonArray[any]{1, "test"}, `[1,"test"]`}, + {new(types.JSONArray[any]), `[]`}, + {types.JSONArray[any]{}, `[]`}, + {types.JSONArray[int]{1, 2, 3}, `[1,2,3]`}, + {types.JSONArray[string]{"test1", "test2", "test3"}, `["test1","test2","test3"]`}, + {types.JSONArray[any]{1, "test"}, `[1,"test"]`}, } for i, s := range scenarios { - result, err := s.json.Value() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - if result != s.expected { - t.Errorf("(%d) Expected %s, got %v", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) } } -func TestJsonArrayScan(t *testing.T) { +func TestJSONArrayScan(t *testing.T) { scenarios := []struct { value any expectError bool - expectJson string + expectJSON string }{ {``, false, `[]`}, {[]byte{}, false, `[]`}, @@ -78,7 +106,7 @@ func TestJsonArrayScan(t *testing.T) { } for i, s := range scenarios { - arr := types.JsonArray[any]{} + arr := types.JSONArray[any]{} scanErr := arr.Scan(s.value) hasErr := scanErr != nil @@ -87,10 +115,10 @@ func TestJsonArrayScan(t *testing.T) { continue } - result, _ := arr.MarshalJSON() + result, _ := json.Marshal(arr, json.Deterministic(true)) - if string(result) != s.expectJson { - t.Errorf("(%d) Expected %s, got %v", i, s.expectJson, string(result)) + if string(result) != s.expectJSON { + t.Errorf("(%d) Expected %s, got %v", i, s.expectJSON, string(result)) } } } diff --git a/tools/types/json_map.go b/tools/types/json_map.go index bb2b14ec80..bca05635d0 100644 --- a/tools/types/json_map.go +++ b/tools/types/json_map.go @@ -2,51 +2,58 @@ package types import ( "database/sql/driver" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" ) -// JsonMap defines a map that is safe for json and db read/write. -type JsonMap map[string]any +// JSONMap defines a map that is safe for json and db read/write. +type JSONMap[T any] map[string]T -// MarshalJSON implements the [json.Marshaler] interface. -func (m JsonMap) MarshalJSON() ([]byte, error) { - type alias JsonMap // prevent recursion - - // initialize an empty map to ensure that `{}` is returned as json - if m == nil { - m = JsonMap{} - } - - return json.Marshal(alias(m)) -} - -// Get retrieves a single value from the current JsonMap. +// Get retrieves a single value from the current JSONMap[T]. // // This helper was added primarily to assist the goja integration since custom map types // don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). -func (m JsonMap) Get(key string) any { +func (m JSONMap[T]) Get(key string) T { return m[key] } -// Set sets a single value in the current JsonMap. +// Set sets a single value in the current JSONMap[T]. // // This helper was added primarily to assist the goja integration since custom map types // don't have direct access to the map keys (https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods). -func (m JsonMap) Set(key string, value any) { +func (m JSONMap[T]) Set(key string, value T) { m[key] = value } -// Value implements the [driver.Valuer] interface. -func (m JsonMap) Value() (driver.Value, error) { - data, err := json.Marshal(m) +// MarshalJSON implements the [json.Marshaler] interface. +func (m JSONMap[T]) MarshalJSON() ([]byte, error) { + type alias JSONMap[T] // prevent recursion + // note: forces the Deterministic and AllowInvalidUTF8 options to + // ensure consistent output in mixed json v1 and v2 configurations + return json.Marshal( + alias(m), + json.Deterministic(true), + jsontext.AllowInvalidUTF8(true), + ) +} + +// String returns the string representation of the current json map. +func (m JSONMap[T]) String() string { + v, _ := m.MarshalJSON() + return string(v) +} + +// Value implements the [driver.Valuer] interface. +func (m JSONMap[T]) Value() (driver.Value, error) { + data, err := m.MarshalJSON() return string(data), err } // Scan implements [sql.Scanner] interface to scan the provided value -// into the current `JsonMap` instance. -func (m *JsonMap) Scan(value any) error { +// into the current JSONMap[T] instance. +func (m *JSONMap[T]) Scan(value any) error { var data []byte switch v := value.(type) { case nil: @@ -56,12 +63,19 @@ func (m *JsonMap) Scan(value any) error { case string: data = []byte(v) default: - return fmt.Errorf("Failed to unmarshal JsonMap value: %q.", value) + return fmt.Errorf("failed to unmarshal JSONMap[T] value: %q", value) } if len(data) == 0 { data = []byte("{}") } - return json.Unmarshal(data, m) + err := json.Unmarshal(data, m) + if err != nil { + // reset because jsonv2 performs streaming decoding and mutates the dst even on error + *m = JSONMap[T]{} + return err + } + + return nil } diff --git a/tools/types/json_map_test.go b/tools/types/json_map_test.go index 1c396df615..1102dfe688 100644 --- a/tools/types/json_map_test.go +++ b/tools/types/json_map_test.go @@ -2,54 +2,85 @@ package types_test import ( "database/sql/driver" + "encoding/json/v2" + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/types" ) -func TestJsonMapMarshalJSON(t *testing.T) { +func TestJSONMapMarshalJSON(t *testing.T) { scenarios := []struct { - json types.JsonMap + json types.JSONMap[any] expected string }{ {nil, "{}"}, - {types.JsonMap{}, `{}`}, - {types.JsonMap{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, - {types.JsonMap{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + { + types.JSONMap[any]{"test1": 123, "test2": "lorem\xc3" /* invalid utf8 char to test mangling */}, + `{"test1":123,"test2":"lorem�"}`, + }, } for i, s := range scenarios { - result, err := s.json.MarshalJSON() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - if string(result) != s.expected { - t.Errorf("(%d) Expected %s, got %s", i, s.expected, string(result)) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) } } -func TestJsonMapGet(t *testing.T) { +func TestJSONMapMarshalString(t *testing.T) { scenarios := []struct { - json types.JsonMap + json types.JSONMap[any] + expected string + }{ + {nil, "{}"}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + } + + for i, s := range scenarios { + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result := s.json.String() + + if result != s.expected { + t.Fatalf("Expected\n%s\ngot\n%s", s.expected, result) + } + }) + } +} + +func TestJSONMapGet(t *testing.T) { + scenarios := []struct { + json types.JSONMap[any] key string expected any }{ {nil, "test", nil}, - {types.JsonMap{"test": 123}, "test", 123}, - {types.JsonMap{"test": 123}, "missing", nil}, + {types.JSONMap[any]{"test": 123}, "test", 123}, + {types.JSONMap[any]{"test": 123}, "missing", nil}, } for i, s := range scenarios { - result := s.json.Get(s.key) - if result != s.expected { - t.Errorf("(%d) Expected %s, got %v", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + result := s.json.Get(s.key) + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) } } -func TestJsonMapSet(t *testing.T) { +func TestJSONMapSet(t *testing.T) { scenarios := []struct { key string value any @@ -60,44 +91,48 @@ func TestJsonMapSet(t *testing.T) { } for i, s := range scenarios { - j := types.JsonMap{} + t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) { + j := types.JSONMap[any]{} - j.Set(s.key, s.value) + j.Set(s.key, s.value) - if v := j[s.key]; v != s.value { - t.Errorf("(%d) Expected %s, got %v", i, s.value, v) - } + if v := j[s.key]; v != s.value { + t.Fatalf("Expected %s, got %#v", s.value, v) + } + }) } } -func TestJsonMapValue(t *testing.T) { +func TestJSONMapValue(t *testing.T) { scenarios := []struct { - json types.JsonMap + json types.JSONMap[any] expected driver.Value }{ {nil, `{}`}, - {types.JsonMap{}, `{}`}, - {types.JsonMap{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, - {types.JsonMap{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, + {types.JSONMap[any]{}, `{}`}, + {types.JSONMap[any]{"test1": 123, "test2": "lorem"}, `{"test1":123,"test2":"lorem"}`}, + {types.JSONMap[any]{"test": []int{1, 2, 3}}, `{"test":[1,2,3]}`}, } for i, s := range scenarios { - result, err := s.json.Value() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - if result != s.expected { - t.Errorf("(%d) Expected %s, got %v", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.expected), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %#v", s.expected, result) + } + }) } } -func TestJsonArrayMapScan(t *testing.T) { +func TestJSONArrayMapScan(t *testing.T) { scenarios := []struct { value any expectError bool - expectJson string + expectJSON string }{ {``, false, `{}`}, {nil, false, `{}`}, @@ -114,19 +149,20 @@ func TestJsonArrayMapScan(t *testing.T) { } for i, s := range scenarios { - arr := types.JsonMap{} - scanErr := arr.Scan(s.value) + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + arr := types.JSONMap[any]{} + scanErr := arr.Scan(s.value) - hasErr := scanErr != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, scanErr) - continue - } + hasErr := scanErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, scanErr) + } - result, _ := arr.MarshalJSON() + result, _ := json.Marshal(arr, json.Deterministic(true)) - if string(result) != s.expectJson { - t.Errorf("(%d) Expected %s, got %v", i, s.expectJson, string(result)) - } + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %s", s.expectJSON, result) + } + }) } } diff --git a/tools/types/json_raw.go b/tools/types/json_raw.go index 7f5defe54e..cb0ba093af 100644 --- a/tools/types/json_raw.go +++ b/tools/types/json_raw.go @@ -2,28 +2,32 @@ package types import ( "database/sql/driver" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" ) -// JsonRaw defines a json value type that is safe for db read/write. -type JsonRaw []byte +// @todo consider deprecating in favour of jsontext.Value -// ParseJsonRaw creates a new JsonRaw instance from the provided value -// (could be JsonRaw, int, float, string, []byte, etc.). -func ParseJsonRaw(value any) (JsonRaw, error) { - result := JsonRaw{} +// JSONRaw defines a json value type that is safe for db read/write. +type JSONRaw []byte + +// ParseJSONRaw creates a new JSONRaw instance from the provided value +// (could be JSONRaw, int, float, string, []byte, etc.). +func ParseJSONRaw(value any) (JSONRaw, error) { + result := JSONRaw{} err := result.Scan(value) return result, err } -// String returns the current JsonRaw instance as a json encoded string. -func (j JsonRaw) String() string { - return string(j) +// String returns the current JSONRaw instance as a json encoded string. +func (j JSONRaw) String() string { + raw, _ := j.MarshalJSON() + return string(raw) } // MarshalJSON implements the [json.Marshaler] interface. -func (j JsonRaw) MarshalJSON() ([]byte, error) { +func (j JSONRaw) MarshalJSON() ([]byte, error) { if len(j) == 0 { return []byte("null"), nil } @@ -32,9 +36,9 @@ func (j JsonRaw) MarshalJSON() ([]byte, error) { } // UnmarshalJSON implements the [json.Unmarshaler] interface. -func (j *JsonRaw) UnmarshalJSON(b []byte) error { +func (j *JSONRaw) UnmarshalJSON(b []byte) error { if j == nil { - return errors.New("JsonRaw: UnmarshalJSON on nil pointer") + return errors.New("JSONRaw: UnmarshalJSON on nil pointer") } *j = append((*j)[0:0], b...) @@ -43,7 +47,7 @@ func (j *JsonRaw) UnmarshalJSON(b []byte) error { } // Value implements the [driver.Valuer] interface. -func (j JsonRaw) Value() (driver.Value, error) { +func (j JSONRaw) Value() (driver.Value, error) { if len(j) == 0 { return nil, nil } @@ -52,8 +56,8 @@ func (j JsonRaw) Value() (driver.Value, error) { } // Scan implements [sql.Scanner] interface to scan the provided value -// into the current JsonRaw instance. -func (j *JsonRaw) Scan(value interface{}) error { +// into the current JSONRaw instance. +func (j *JSONRaw) Scan(value any) error { var data []byte switch v := value.(type) { @@ -67,12 +71,12 @@ func (j *JsonRaw) Scan(value interface{}) error { if v != "" { data = []byte(v) } - case JsonRaw: + case JSONRaw: if len(v) != 0 { data = []byte(v) } default: - bytes, err := json.Marshal(v) + bytes, err := json.Marshal(v, json.Deterministic(true), jsontext.AllowInvalidUTF8(true)) if err != nil { return err } diff --git a/tools/types/json_raw_test.go b/tools/types/json_raw_test.go index 6683b3ff18..384d39fe9d 100644 --- a/tools/types/json_raw_test.go +++ b/tools/types/json_raw_test.go @@ -2,21 +2,22 @@ package types_test import ( "database/sql/driver" + "fmt" "testing" "github.com/pocketbase/pocketbase/tools/types" ) -func TestParseJsonRaw(t *testing.T) { +func TestParseJSONRaw(t *testing.T) { scenarios := []struct { value any expectError bool - expectJson string + expectJSON string }{ {nil, false, `null`}, {``, false, `null`}, {[]byte{}, false, `null`}, - {types.JsonRaw{}, false, `null`}, + {types.JSONRaw{}, false, `null`}, {`{}`, false, `{}`}, {`[]`, false, `[]`}, {123, false, `123`}, @@ -30,70 +31,75 @@ func TestParseJsonRaw(t *testing.T) { } for i, s := range scenarios { - raw, parseErr := types.ParseJsonRaw(s.value) - hasErr := parseErr != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, parseErr) - continue - } - - result, _ := raw.MarshalJSON() - - if string(result) != s.expectJson { - t.Errorf("(%d) Expected %s, got %v", i, s.expectJson, string(result)) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + raw, parseErr := types.ParseJSONRaw(s.value) + + hasErr := parseErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, parseErr) + } + + result, _ := raw.MarshalJSON() + + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %s", s.expectJSON, string(result)) + } + }) } } -func TestJsonRawString(t *testing.T) { +func TestJSONRawString(t *testing.T) { scenarios := []struct { - json types.JsonRaw + json types.JSONRaw expected string }{ - {nil, ``}, - {types.JsonRaw{}, ``}, - {types.JsonRaw([]byte(`123`)), `123`}, - {types.JsonRaw(`{"demo":123}`), `{"demo":123}`}, + {nil, `null`}, + {types.JSONRaw{}, `null`}, + {types.JSONRaw([]byte(`123`)), `123`}, + {types.JSONRaw(`{"demo":123}`), `{"demo":123}`}, } for i, s := range scenarios { - result := s.json.String() - if result != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result := s.json.String() + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) } } -func TestJsonRawMarshalJSON(t *testing.T) { +func TestJSONRawMarshalJSON(t *testing.T) { scenarios := []struct { - json types.JsonRaw + json types.JSONRaw expected string }{ {nil, `null`}, - {types.JsonRaw{}, `null`}, - {types.JsonRaw([]byte(`123`)), `123`}, - {types.JsonRaw(`{"demo":123}`), `{"demo":123}`}, + {types.JSONRaw{}, `null`}, + {types.JSONRaw([]byte(`123`)), `123`}, + {types.JSONRaw(`{"demo":123}`), `{"demo":123}`}, } for i, s := range scenarios { - result, err := s.json.MarshalJSON() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - - if string(result) != s.expected { - t.Errorf("(%d) Expected %q, got %q", i, s.expected, string(result)) - } + t.Run(fmt.Sprintf("%d_%s", i, s.expected), func(t *testing.T) { + result, err := s.json.MarshalJSON() + if err != nil { + t.Fatal(err) + } + + if string(result) != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, string(result)) + } + }) } } -func TestJsonRawUnmarshalJSON(t *testing.T) { +func TestJSONRawUnmarshalJSON(t *testing.T) { scenarios := []struct { json []byte expectString string }{ - {nil, ""}, + {nil, `null`}, {[]byte{0, 1, 2}, "\x00\x01\x02"}, {[]byte("123"), "123"}, {[]byte("test"), "test"}, @@ -101,53 +107,57 @@ func TestJsonRawUnmarshalJSON(t *testing.T) { } for i, s := range scenarios { - raw := types.JsonRaw{} - err := raw.UnmarshalJSON(s.json) - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - - if raw.String() != s.expectString { - t.Errorf("(%d) Expected %q, got %q", i, s.expectString, raw.String()) - } + t.Run(fmt.Sprintf("%d_%s", i, s.expectString), func(t *testing.T) { + raw := types.JSONRaw{} + + err := raw.UnmarshalJSON(s.json) + if err != nil { + t.Fatal(err) + } + + if raw.String() != s.expectString { + t.Fatalf("Expected %q, got %q", s.expectString, raw.String()) + } + }) } } -func TestJsonRawValue(t *testing.T) { +func TestJSONRawValue(t *testing.T) { scenarios := []struct { - json types.JsonRaw + json types.JSONRaw expected driver.Value }{ {nil, nil}, - {types.JsonRaw{}, nil}, - {types.JsonRaw(``), nil}, - {types.JsonRaw(`test`), `test`}, + {types.JSONRaw{}, nil}, + {types.JSONRaw(``), nil}, + {types.JSONRaw(`test`), `test`}, } for i, s := range scenarios { - result, err := s.json.Value() - if err != nil { - t.Errorf("(%d) %v", i, err) - continue - } - if result != s.expected { - t.Errorf("(%d) Expected %s, got %v", i, s.expected, result) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.json), func(t *testing.T) { + result, err := s.json.Value() + if err != nil { + t.Fatal(err) + } + + if result != s.expected { + t.Fatalf("Expected %s, got %v", s.expected, result) + } + }) } } -func TestJsonRawScan(t *testing.T) { +func TestJSONRawScan(t *testing.T) { scenarios := []struct { value any expectError bool - expectJson string + expectJSON string }{ {nil, false, `null`}, {``, false, `null`}, {[]byte{}, false, `null`}, - {types.JsonRaw{}, false, `null`}, - {types.JsonRaw(`test`), false, `test`}, + {types.JSONRaw{}, false, `null`}, + {types.JSONRaw(`test`), false, `test`}, {`{}`, false, `{}`}, {`[]`, false, `[]`}, {123, false, `123`}, @@ -157,22 +167,27 @@ func TestJsonRawScan(t *testing.T) { {`{"test":1}`, false, `{"test":1}`}, {[]byte(`[1,2,3]`), false, `[1,2,3]`}, {[]int{1, 2, 3}, false, `[1,2,3]`}, - {map[string]int{"test": 1}, false, `{"test":1}`}, + { + map[string]string{"test": "a\xc3" /* invalid utf8 char to test mangling */}, + false, + `{"test":"a�"}`, + }, } for i, s := range scenarios { - raw := types.JsonRaw{} - scanErr := raw.Scan(s.value) - hasErr := scanErr != nil - if hasErr != s.expectError { - t.Errorf("(%d) Expected %v, got %v (%v)", i, s.expectError, hasErr, scanErr) - continue - } - - result, _ := raw.MarshalJSON() - - if string(result) != s.expectJson { - t.Errorf("(%d) Expected %s, got %v", i, s.expectJson, string(result)) - } + t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) { + raw := types.JSONRaw{} + scanErr := raw.Scan(s.value) + hasErr := scanErr != nil + if hasErr != s.expectError { + t.Fatalf("Expected %v, got %v (%v)", s.expectError, hasErr, scanErr) + } + + result, _ := raw.MarshalJSON() + + if string(result) != s.expectJSON { + t.Fatalf("Expected %s, got %v", s.expectJSON, string(result)) + } + }) } } diff --git a/ui/.env b/ui/.env index b08c37271f..692c7ad106 100644 --- a/ui/.env +++ b/ui/.env @@ -1,12 +1,15 @@ # all environments should start with 'PB_' prefix -PB_BACKEND_URL = "../" -PB_INSTALLER_PARAM = "installer" -PB_OAUTH2_EXAMPLE = "https://pocketbase.io/docs/authentication/#oauth2-integration" -PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters/" -PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling/" -PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling/#protected-files" -PB_DOCS_URL = "https://pocketbase.io/docs/" -PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk" -PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk" -PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases" -PB_VERSION = "v0.18.5" +PB_BACKEND_URL = "../" +PB_MFA_DOCS = "https://pocketbase.io/docs/authentication#multi-factor-authentication" +PB_OAUTH2_DOCS = "https://pocketbase.io/docs/authentication#authenticate-with-oauth2" +PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/api-rules-and-filters" +PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling" +PB_PROTECTED_FILE_DOCS = "https://pocketbase.io/docs/files-handling#protected-files" +PB_REALTIME_DOCS = "https://pocketbase.io/docs/api-realtime/" +PB_FIELDS_DOCS = "https://pocketbase.io/docs/collections/#fields" +PB_SUPERUSER_IPS_RESET_DOCS = "https://pocketbase.io/docs/going-to-production/#limit-superusers-to-specific-ipssubnets" +PB_DOCS_URL = "https://pocketbase.io/docs" +PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk" +PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk" +PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases" +PB_VERSION = "v0.40.5" diff --git a/ui/.gitignore b/ui/.gitignore index 631b5f8662..e6814f9a67 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,7 +1,22 @@ -/node_modules/ -/.vscode/ -.DS_Store +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +*.local +*.todo -# exclude local env files -.env.local -.env.*.local +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/README.md b/ui/README.md index f783614648..21155ed0d0 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,20 +1,23 @@ -PocketBase Admin dashboard UI +PocketBase Superuser dashboard UI ====================================================================== -This is the PocketBase Admin dashboard UI (built with Svelte and Vite). +This is the PocketBase Superuser dashboard UI (built with Shablon and Vite). -Although it could be used independently, it is mainly intended to be embedded -as part of a PocketBase app executable (hence the `embed.go` file). +Although it could be used independently, it is intended to be embedded and extended +as part of the PocketBase app executable (hence the `dist` directory and `embed.go` file). + +> [!WARNING] +> The UI kit and extension APIs remains deliberately undocumented for the time being until a stable PocketBase release is published ([#7612](https://github.com/pocketbase/pocketbase/discussions/7612)). ## Development -Download the repo and run the appropriate console commands: +Download the repository and run the appropriate console commands: ```sh # install dependencies npm install -# start a dev server with hot reload at localhost:3000 +# start a dev server with hot reload at localhost:5173 npm run dev # or generate production ready bundle in dist/ directory diff --git a/ui/dist/assets/AuthMethodsDocs-f6af1af9.js b/ui/dist/assets/AuthMethodsDocs-f6af1af9.js deleted file mode 100644 index 8c9a994587..0000000000 --- a/ui/dist/assets/AuthMethodsDocs-f6af1af9.js +++ /dev/null @@ -1,64 +0,0 @@ -import{S as Se,i as ye,s as Te,O as G,e as c,w,b as k,c as se,f as p,g as d,h as a,m as ae,x as U,P as ve,Q as je,k as Ae,R as Be,n as Oe,t as W,a as V,o as u,d as ne,C as Fe,p as Qe,r as L,u as Ne,N as He}from"./index-bd739ba3.js";import{S as Ke}from"./SdkTabs-cf570492.js";import{F as qe}from"./FieldsQueryParam-85406354.js";function Ce(n,l,o){const s=n.slice();return s[5]=l[o],s}function Pe(n,l,o){const s=n.slice();return s[5]=l[o],s}function $e(n,l){let o,s=l[5].code+"",_,f,i,h;function m(){return l[4](l[5])}return{key:n,first:null,c(){o=c("button"),_=w(s),f=k(),p(o,"class","tab-item"),L(o,"active",l[1]===l[5].code),this.first=o},m(v,C){d(v,o,C),a(o,_),a(o,f),i||(h=Ne(o,"click",m),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&U(_,s),C&6&&L(o,"active",l[1]===l[5].code)},d(v){v&&u(o),i=!1,h()}}}function Me(n,l){let o,s,_,f;return s=new He({props:{content:l[5].body}}),{key:n,first:null,c(){o=c("div"),se(s.$$.fragment),_=k(),p(o,"class","tab-item"),L(o,"active",l[1]===l[5].code),this.first=o},m(i,h){d(i,o,h),ae(s,o,null),a(o,_),f=!0},p(i,h){l=i;const m={};h&4&&(m.content=l[5].body),s.$set(m),(!f||h&6)&&L(o,"active",l[1]===l[5].code)},i(i){f||(W(s.$$.fragment,i),f=!0)},o(i){V(s.$$.fragment,i),f=!1},d(i){i&&u(o),ne(s)}}}function ze(n){var be,ke;let l,o,s=n[0].name+"",_,f,i,h,m,v,C,H=n[0].name+"",E,ie,I,P,J,j,Y,$,K,ce,q,A,re,R,z=n[0].name+"",X,de,Z,B,x,M,ee,ue,te,T,le,O,oe,S,F,g=[],he=new Map,me,Q,b=[],fe=new Map,y;P=new Ke({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${n[3]}'); - - ... - - const result = await pb.collection('${(be=n[0])==null?void 0:be.name}').listAuthMethods(); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${n[3]}'); - - ... - - final result = await pb.collection('${(ke=n[0])==null?void 0:ke.name}').listAuthMethods(); - `}}),T=new qe({});let D=G(n[2]);const pe=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description',ue=k(),te=c("tbody"),se(T.$$.fragment),le=k(),O=c("div"),O.textContent="Responses",oe=k(),S=c("div"),F=c("div");for(let e=0;eo(1,f=m.code);return n.$$set=m=>{"collection"in m&&o(0,_=m.collection)},o(3,s=Fe.getApiExampleUrl(Qe.baseUrl)),o(2,i=[{code:200,body:` - { - "usernamePassword": true, - "emailPassword": true, - "authProviders": [ - { - "name": "github", - "state": "3Yd8jNkK_6PJG6hPWwBjLqKwse6Ejd", - "codeVerifier": "KxFDWz1B3fxscCDJ_9gHQhLuh__ie7", - "codeChallenge": "NM1oVexB6Q6QH8uPtOUfK7tq4pmu4Jz6lNDIwoxHZNE=", - "codeChallengeMethod": "S256", - "authUrl": "https://github.com/login/oauth/authorize?client_id=demo&code_challenge=NM1oVexB6Q6QH8uPtOUfK7tq4pmu4Jz6lNDIwoxHZNE%3D&code_challenge_method=S256&response_type=code&scope=user&state=3Yd8jNkK_6PJG6hPWwBjLqKwse6Ejd&redirect_uri=" - }, - { - "name": "gitlab", - "state": "NeQSbtO5cShr_mk5__3CUukiMnymeb", - "codeVerifier": "ahTFHOgua8mkvPAlIBGwCUJbWKR_xi", - "codeChallenge": "O-GATkTj4eXDCnfonsqGLCd6njvTixlpCMvy5kjgOOg=", - "codeChallengeMethod": "S256", - "authUrl": "https://gitlab.com/oauth/authorize?client_id=demo&code_challenge=O-GATkTj4eXDCnfonsqGLCd6njvTixlpCMvy5kjgOOg%3D&code_challenge_method=S256&response_type=code&scope=read_user&state=NeQSbtO5cShr_mk5__3CUukiMnymeb&redirect_uri=" - }, - { - "name": "google", - "state": "zB3ZPifV1TW2GMuvuFkamSXfSNkHPQ", - "codeVerifier": "t3CmO5VObGzdXqieakvR_fpjiW0zdO", - "codeChallenge": "KChwoQPKYlz2anAdqtgsSTdIo8hdwtc1fh2wHMwW2Yk=", - "codeChallengeMethod": "S256", - "authUrl": "https://accounts.google.com/o/oauth2/auth?client_id=demo&code_challenge=KChwoQPKYlz2anAdqtgsSTdIo8hdwtc1fh2wHMwW2Yk%3D&code_challenge_method=S256&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&state=zB3ZPifV1TW2GMuvuFkamSXfSNkHPQ&redirect_uri=" - } - ] - } - `}]),[_,f,i,s,h]}class Ve extends Se{constructor(l){super(),ye(this,l,De,ze,Te,{collection:0})}}export{Ve as default}; diff --git a/ui/dist/assets/AuthRefreshDocs-ae629beb.js b/ui/dist/assets/AuthRefreshDocs-ae629beb.js deleted file mode 100644 index 1b6420e28b..0000000000 --- a/ui/dist/assets/AuthRefreshDocs-ae629beb.js +++ /dev/null @@ -1,79 +0,0 @@ -import{S as je,i as xe,s as Je,N as Ue,O as J,e as s,w as k,b as p,c as K,f as b,g as d,h as o,m as I,x as de,P as Ee,Q as Ke,k as Ie,R as We,n as Ge,t as N,a as V,o as u,d as W,C as Le,p as Xe,r as G,u as Ye}from"./index-bd739ba3.js";import{S as Ze}from"./SdkTabs-cf570492.js";import{F as et}from"./FieldsQueryParam-85406354.js";function Ne(r,l,a){const n=r.slice();return n[5]=l[a],n}function Ve(r,l,a){const n=r.slice();return n[5]=l[a],n}function ze(r,l){let a,n=l[5].code+"",m,_,i,h;function g(){return l[4](l[5])}return{key:r,first:null,c(){a=s("button"),m=k(n),_=p(),b(a,"class","tab-item"),G(a,"active",l[1]===l[5].code),this.first=a},m(v,w){d(v,a,w),o(a,m),o(a,_),i||(h=Ye(a,"click",g),i=!0)},p(v,w){l=v,w&4&&n!==(n=l[5].code+"")&&de(m,n),w&6&&G(a,"active",l[1]===l[5].code)},d(v){v&&u(a),i=!1,h()}}}function Qe(r,l){let a,n,m,_;return n=new Ue({props:{content:l[5].body}}),{key:r,first:null,c(){a=s("div"),K(n.$$.fragment),m=p(),b(a,"class","tab-item"),G(a,"active",l[1]===l[5].code),this.first=a},m(i,h){d(i,a,h),I(n,a,null),o(a,m),_=!0},p(i,h){l=i;const g={};h&4&&(g.content=l[5].body),n.$set(g),(!_||h&6)&&G(a,"active",l[1]===l[5].code)},i(i){_||(N(n.$$.fragment,i),_=!0)},o(i){V(n.$$.fragment,i),_=!1},d(i){i&&u(a),W(n)}}}function tt(r){var De,Fe;let l,a,n=r[0].name+"",m,_,i,h,g,v,w,M,X,S,z,ue,Q,q,pe,Y,U=r[0].name+"",Z,he,fe,j,ee,D,te,T,oe,be,F,C,le,me,ae,_e,f,ke,R,ge,ve,$e,se,ye,ne,Se,we,Te,re,Ce,Pe,A,ie,O,ce,P,H,y=[],Re=new Map,Ae,E,$=[],Be=new Map,B;v=new Ze({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${r[3]}'); - - ... - - const authData = await pb.collection('${(De=r[0])==null?void 0:De.name}').authRefresh(); - - // after the above you can also access the refreshed auth data from the authStore - console.log(pb.authStore.isValid); - console.log(pb.authStore.token); - console.log(pb.authStore.model.id); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${r[3]}'); - - ... - - final authData = await pb.collection('${(Fe=r[0])==null?void 0:Fe.name}').authRefresh(); - - // after the above you can also access the refreshed auth data from the authStore - print(pb.authStore.isValid); - print(pb.authStore.token); - print(pb.authStore.model.id); - `}}),R=new Ue({props:{content:"?expand=relField1,relField2.subRelField"}}),A=new et({});let x=J(r[2]);const Me=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eReturns a new auth response (token and record data) for an - already authenticated record.

This method is usually called by users on page/screen reload to ensure that the previously stored - data in pb.authStore is still valid and up-to-date.

`,g=p(),K(v.$$.fragment),w=p(),M=s("h6"),M.textContent="API details",X=p(),S=s("div"),z=s("strong"),z.textContent="POST",ue=p(),Q=s("div"),q=s("p"),pe=k("/api/collections/"),Y=s("strong"),Z=k(U),he=k("/auth-refresh"),fe=p(),j=s("p"),j.innerHTML="Requires record Authorization:TOKEN header",ee=p(),D=s("div"),D.textContent="Query parameters",te=p(),T=s("table"),oe=s("thead"),oe.innerHTML='Param Type Description',be=p(),F=s("tbody"),C=s("tr"),le=s("td"),le.textContent="expand",me=p(),ae=s("td"),ae.innerHTML='String',_e=p(),f=s("td"),ke=k(`Auto expand record relations. Ex.: - `),K(R.$$.fragment),ge=k(` - Supports up to 6-levels depth nested relations expansion. `),ve=s("br"),$e=k(` - The expanded relations will be appended to the record under the - `),se=s("code"),se.textContent="expand",ye=k(" property (eg. "),ne=s("code"),ne.textContent='"expand": {"relField1": {...}, ...}',Se=k(`). - `),we=s("br"),Te=k(` - Only the relations to which the request user has permissions to `),re=s("strong"),re.textContent="view",Ce=k(" will be expanded."),Pe=p(),K(A.$$.fragment),ie=p(),O=s("div"),O.textContent="Responses",ce=p(),P=s("div"),H=s("div");for(let e=0;ea(1,_=g.code);return r.$$set=g=>{"collection"in g&&a(0,m=g.collection)},r.$$.update=()=>{r.$$.dirty&1&&a(2,i=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:Le.dummyCollectionRecord(m)},null,2)},{code:401,body:` - { - "code": 401, - "message": "The request requires valid record authorization token to be set.", - "data": {} - } - `},{code:403,body:` - { - "code": 403, - "message": "The authorized record model is not allowed to perform this action.", - "data": {} - } - `},{code:404,body:` - { - "code": 404, - "message": "Missing auth record context.", - "data": {} - } - `}])},a(3,n=Le.getApiExampleUrl(Xe.baseUrl)),[m,_,i,n,h]}class nt extends je{constructor(l){super(),xe(this,l,ot,tt,Je,{collection:0})}}export{nt as default}; diff --git a/ui/dist/assets/AuthWithOAuth2Docs-7fd7151d.js b/ui/dist/assets/AuthWithOAuth2Docs-7fd7151d.js deleted file mode 100644 index b172d27c1a..0000000000 --- a/ui/dist/assets/AuthWithOAuth2Docs-7fd7151d.js +++ /dev/null @@ -1,117 +0,0 @@ -import{S as Le,i as Ee,s as Je,N as Ve,O as z,e as s,w as k,b as h,c as I,f as p,g as r,h as a,m as K,x as pe,P as We,Q as Ne,k as Qe,R as ze,n as Ie,t as V,a as L,o as c,d as G,C as Ue,p as Ke,r as X,u as Ge}from"./index-bd739ba3.js";import{S as Xe}from"./SdkTabs-cf570492.js";import{F as Ye}from"./FieldsQueryParam-85406354.js";function Be(i,l,o){const n=i.slice();return n[5]=l[o],n}function Fe(i,l,o){const n=i.slice();return n[5]=l[o],n}function He(i,l){let o,n=l[5].code+"",f,g,d,b;function _(){return l[4](l[5])}return{key:i,first:null,c(){o=s("button"),f=k(n),g=h(),p(o,"class","tab-item"),X(o,"active",l[1]===l[5].code),this.first=o},m(v,A){r(v,o,A),a(o,f),a(o,g),d||(b=Ge(o,"click",_),d=!0)},p(v,A){l=v,A&4&&n!==(n=l[5].code+"")&&pe(f,n),A&6&&X(o,"active",l[1]===l[5].code)},d(v){v&&c(o),d=!1,b()}}}function je(i,l){let o,n,f,g;return n=new Ve({props:{content:l[5].body}}),{key:i,first:null,c(){o=s("div"),I(n.$$.fragment),f=h(),p(o,"class","tab-item"),X(o,"active",l[1]===l[5].code),this.first=o},m(d,b){r(d,o,b),K(n,o,null),a(o,f),g=!0},p(d,b){l=d;const _={};b&4&&(_.content=l[5].body),n.$set(_),(!g||b&6)&&X(o,"active",l[1]===l[5].code)},i(d){g||(V(n.$$.fragment,d),g=!0)},o(d){L(n.$$.fragment,d),g=!1},d(d){d&&c(o),G(n)}}}function Ze(i){let l,o,n=i[0].name+"",f,g,d,b,_,v,A,P,Y,S,E,be,J,R,me,Z,N=i[0].name+"",ee,fe,te,M,ae,x,le,W,oe,y,se,ge,U,$,ne,ke,ie,_e,m,ve,C,we,Oe,Ae,re,Se,ce,ye,$e,Te,de,Ce,qe,q,ue,B,he,T,F,O=[],De=new Map,Pe,H,w=[],Re=new Map,D;v=new Xe({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${i[3]}'); - - ... - - // OAuth2 authentication with a single realtime call. - // - // Make sure to register ${i[3]}/api/oauth2-redirect as redirect url. - const authData = await pb.collection('users').authWithOAuth2({ provider: 'google' }); - - // OR authenticate with manual OAuth2 code exchange - // const authData = await pb.collection('users').authWithOAuth2Code(...); - - // after the above you can also access the auth data from the authStore - console.log(pb.authStore.isValid); - console.log(pb.authStore.token); - console.log(pb.authStore.model.id); - - // "logout" the last authenticated model - pb.authStore.clear(); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - import 'package:url_launcher/url_launcher.dart'; - - final pb = PocketBase('${i[3]}'); - - ... - - // OAuth2 authentication with a single realtime call. - // - // Make sure to register ${i[3]}/api/oauth2-redirect as redirect url. - final authData = await pb.collection('users').authWithOAuth2('google', (url) async { - await launchUrl(url); - }); - - // OR authenticate with manual OAuth2 code exchange - // final authData = await pb.collection('users').authWithOAuth2Code(...); - - // after the above you can also access the auth data from the authStore - print(pb.authStore.isValid); - print(pb.authStore.token); - print(pb.authStore.model.id); - - // "logout" the last authenticated model - pb.authStore.clear(); - `}}),C=new Ve({props:{content:"?expand=relField1,relField2.subRelField"}}),q=new Ye({});let Q=z(i[2]);const Me=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eAuthenticate with an OAuth2 provider and returns a new auth token and record data.

For more details please check the - OAuth2 integration documentation - .

`,_=h(),I(v.$$.fragment),A=h(),P=s("h6"),P.textContent="API details",Y=h(),S=s("div"),E=s("strong"),E.textContent="POST",be=h(),J=s("div"),R=s("p"),me=k("/api/collections/"),Z=s("strong"),ee=k(N),fe=k("/auth-with-oauth2"),te=h(),M=s("div"),M.textContent="Body Parameters",ae=h(),x=s("table"),x.innerHTML=`Param Type Description
Required provider
String The name of the OAuth2 client provider (eg. "google").
Required code
String The authorization code returned from the initial request.
Required codeVerifier
String The code verifier sent with the initial request as part of the code_challenge.
Required redirectUrl
String The redirect url sent with the initial request.
Optional createData
Object

Optional data that will be used when creating the auth record on OAuth2 sign-up.

The created auth record must comply with the same requirements and validations in the - regular create action. -
The data can only be in json, aka. multipart/form-data and files - upload currently are not supported during OAuth2 sign-ups.

`,le=h(),W=s("div"),W.textContent="Query parameters",oe=h(),y=s("table"),se=s("thead"),se.innerHTML='Param Type Description',ge=h(),U=s("tbody"),$=s("tr"),ne=s("td"),ne.textContent="expand",ke=h(),ie=s("td"),ie.innerHTML='String',_e=h(),m=s("td"),ve=k(`Auto expand record relations. Ex.: - `),I(C.$$.fragment),we=k(` - Supports up to 6-levels depth nested relations expansion. `),Oe=s("br"),Ae=k(` - The expanded relations will be appended to the record under the - `),re=s("code"),re.textContent="expand",Se=k(" property (eg. "),ce=s("code"),ce.textContent='"expand": {"relField1": {...}, ...}',ye=k(`). - `),$e=s("br"),Te=k(` - Only the relations to which the request user has permissions to `),de=s("strong"),de.textContent="view",Ce=k(" will be expanded."),qe=h(),I(q.$$.fragment),ue=h(),B=s("div"),B.textContent="Responses",he=h(),T=s("div"),F=s("div");for(let e=0;eo(1,g=_.code);return i.$$set=_=>{"collection"in _&&o(0,f=_.collection)},i.$$.update=()=>{i.$$.dirty&1&&o(2,d=[{code:200,body:JSON.stringify({token:"JWT_AUTH_TOKEN",record:Ue.dummyCollectionRecord(f),meta:{id:"abc123",name:"John Doe",username:"john.doe",email:"test@example.com",avatarUrl:"https://example.com/avatar.png",accessToken:"...",refreshToken:"...",rawUser:{}}},null,2)},{code:400,body:` - { - "code": 400, - "message": "An error occurred while submitting the form.", - "data": { - "provider": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}])},o(3,n=Ue.getApiExampleUrl(Ke.baseUrl)),[f,g,d,n,b]}class ot extends Le{constructor(l){super(),Ee(this,l,et,Ze,Je,{collection:0})}}export{ot as default}; diff --git a/ui/dist/assets/AuthWithPasswordDocs-114fc628.js b/ui/dist/assets/AuthWithPasswordDocs-114fc628.js deleted file mode 100644 index c96f5faf43..0000000000 --- a/ui/dist/assets/AuthWithPasswordDocs-114fc628.js +++ /dev/null @@ -1,98 +0,0 @@ -import{S as we,i as ye,s as $e,N as ve,O as ot,e as n,w as p,b as d,c as nt,f as m,g as r,h as e,m as st,x as Dt,P as pe,Q as Pe,k as Re,R as Ce,n as Oe,t as Z,a as x,o as c,d as it,C as fe,p as Ae,r as rt,u as Te}from"./index-bd739ba3.js";import{S as Ue}from"./SdkTabs-cf570492.js";import{F as Me}from"./FieldsQueryParam-85406354.js";function he(s,l,a){const i=s.slice();return i[8]=l[a],i}function be(s,l,a){const i=s.slice();return i[8]=l[a],i}function De(s){let l;return{c(){l=p("email")},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function Ee(s){let l;return{c(){l=p("username")},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function We(s){let l;return{c(){l=p("username/email")},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function me(s){let l;return{c(){l=n("strong"),l.textContent="username"},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function _e(s){let l;return{c(){l=p("or")},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function ke(s){let l;return{c(){l=n("strong"),l.textContent="email"},m(a,i){r(a,l,i)},d(a){a&&c(l)}}}function ge(s,l){let a,i=l[8].code+"",g,b,f,u;function _(){return l[7](l[8])}return{key:s,first:null,c(){a=n("button"),g=p(i),b=d(),m(a,"class","tab-item"),rt(a,"active",l[3]===l[8].code),this.first=a},m(R,C){r(R,a,C),e(a,g),e(a,b),f||(u=Te(a,"click",_),f=!0)},p(R,C){l=R,C&16&&i!==(i=l[8].code+"")&&Dt(g,i),C&24&&rt(a,"active",l[3]===l[8].code)},d(R){R&&c(a),f=!1,u()}}}function Se(s,l){let a,i,g,b;return i=new ve({props:{content:l[8].body}}),{key:s,first:null,c(){a=n("div"),nt(i.$$.fragment),g=d(),m(a,"class","tab-item"),rt(a,"active",l[3]===l[8].code),this.first=a},m(f,u){r(f,a,u),st(i,a,null),e(a,g),b=!0},p(f,u){l=f;const _={};u&16&&(_.content=l[8].body),i.$set(_),(!b||u&24)&&rt(a,"active",l[3]===l[8].code)},i(f){b||(Z(i.$$.fragment,f),b=!0)},o(f){x(i.$$.fragment,f),b=!1},d(f){f&&c(a),it(i)}}}function Le(s){var re,ce;let l,a,i=s[0].name+"",g,b,f,u,_,R,C,O,B,Et,ct,T,dt,N,ut,U,tt,Wt,et,I,Lt,pt,lt=s[0].name+"",ft,Bt,ht,V,bt,M,mt,qt,Q,D,_t,Ft,kt,Ht,$,Yt,gt,St,vt,Nt,wt,yt,j,$t,E,Pt,It,J,W,Rt,Vt,Ct,Qt,k,jt,q,Jt,Kt,zt,Ot,Gt,At,Xt,Zt,xt,Tt,te,ee,F,Ut,K,Mt,L,z,A=[],le=new Map,ae,G,S=[],oe=new Map,H;function ne(t,o){if(t[1]&&t[2])return We;if(t[1])return Ee;if(t[2])return De}let Y=ne(s),P=Y&&Y(s);T=new Ue({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${s[6]}'); - - ... - - const authData = await pb.collection('${(re=s[0])==null?void 0:re.name}').authWithPassword( - '${s[5]}', - 'YOUR_PASSWORD', - ); - - // after the above you can also access the auth data from the authStore - console.log(pb.authStore.isValid); - console.log(pb.authStore.token); - console.log(pb.authStore.model.id); - - // "logout" the last authenticated account - pb.authStore.clear(); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${s[6]}'); - - ... - - final authData = await pb.collection('${(ce=s[0])==null?void 0:ce.name}').authWithPassword( - '${s[5]}', - 'YOUR_PASSWORD', - ); - - // after the above you can also access the auth data from the authStore - print(pb.authStore.isValid); - print(pb.authStore.token); - print(pb.authStore.model.id); - - // "logout" the last authenticated account - pb.authStore.clear(); - `}});let v=s[1]&&me(),w=s[1]&&s[2]&&_e(),y=s[2]&&ke();q=new ve({props:{content:"?expand=relField1,relField2.subRelField"}}),F=new Me({});let at=ot(s[4]);const se=t=>t[8].code;for(let t=0;tt[8].code;for(let t=0;tParam Type Description',qt=d(),Q=n("tbody"),D=n("tr"),_t=n("td"),_t.innerHTML='
Required identity
',Ft=d(),kt=n("td"),kt.innerHTML='String',Ht=d(),$=n("td"),Yt=p(`The - `),v&&v.c(),gt=d(),w&&w.c(),St=d(),y&&y.c(),vt=p(` - of the record to authenticate.`),Nt=d(),wt=n("tr"),wt.innerHTML='
Required password
String The auth record password.',yt=d(),j=n("div"),j.textContent="Query parameters",$t=d(),E=n("table"),Pt=n("thead"),Pt.innerHTML='Param Type Description',It=d(),J=n("tbody"),W=n("tr"),Rt=n("td"),Rt.textContent="expand",Vt=d(),Ct=n("td"),Ct.innerHTML='String',Qt=d(),k=n("td"),jt=p(`Auto expand record relations. Ex.: - `),nt(q.$$.fragment),Jt=p(` - Supports up to 6-levels depth nested relations expansion. `),Kt=n("br"),zt=p(` - The expanded relations will be appended to the record under the - `),Ot=n("code"),Ot.textContent="expand",Gt=p(" property (eg. "),At=n("code"),At.textContent='"expand": {"relField1": {...}, ...}',Xt=p(`). - `),Zt=n("br"),xt=p(` - Only the relations to which the request user has permissions to `),Tt=n("strong"),Tt.textContent="view",te=p(" will be expanded."),ee=d(),nt(F.$$.fragment),Ut=d(),K=n("div"),K.textContent="Responses",Mt=d(),L=n("div"),z=n("div");for(let t=0;ta(3,_=O.code);return s.$$set=O=>{"collection"in O&&a(0,u=O.collection)},s.$$.update=()=>{var O,B;s.$$.dirty&1&&a(2,g=(O=u==null?void 0:u.options)==null?void 0:O.allowEmailAuth),s.$$.dirty&1&&a(1,b=(B=u==null?void 0:u.options)==null?void 0:B.allowUsernameAuth),s.$$.dirty&6&&a(5,f=b&&g?"YOUR_USERNAME_OR_EMAIL":b?"YOUR_USERNAME":"YOUR_EMAIL"),s.$$.dirty&1&&a(4,R=[{code:200,body:JSON.stringify({token:"JWT_TOKEN",record:fe.dummyCollectionRecord(u)},null,2)},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "identity": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}])},a(6,i=fe.getApiExampleUrl(Ae.baseUrl)),[u,b,g,_,R,f,i,C]}class Ye extends we{constructor(l){super(),ye(this,l,Be,Le,$e,{collection:0})}}export{Ye as default}; diff --git a/ui/dist/assets/CodeEditor-88461eea.js b/ui/dist/assets/CodeEditor-88461eea.js deleted file mode 100644 index 934678c789..0000000000 --- a/ui/dist/assets/CodeEditor-88461eea.js +++ /dev/null @@ -1,14 +0,0 @@ -import{S as ft,i as gt,s as mt,e as Pt,f as Zt,U as K,g as bt,y as ze,o as Xt,J as xt,K as Yt,L as yt,I as kt,C as wt,M as vt}from"./index-bd739ba3.js";import{P as Tt,N as _t,u as Wt,D as Ut,v as we,T as ee,I as ve,w as D,x as o,y as Ct,L as J,z as L,A as C,B as F,F as Te,G as M,H as j,J as bO,K as XO,M as xO,E as U,O as YO,Q as P,R as Rt,U as qt,V as yO,W as Vt,X as jt,a as z,h as zt,b as Gt,c as Et,d as At,e as It,s as Nt,t as Bt,f as Dt,g as Jt,r as Lt,i as Ft,k as Mt,j as Kt,l as Ht,m as ea,n as Oa,o as ta,p as aa,q as Ge,C as H}from"./index-808c8630.js";class re{constructor(e,a,t,r,s,i,l,n,Q,d=0,c){this.p=e,this.stack=a,this.state=t,this.reducePos=r,this.pos=s,this.score=i,this.buffer=l,this.bufferBase=n,this.curContext=Q,this.lookAhead=d,this.parent=c}toString(){return`[${this.stack.filter((e,a)=>a%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(e,a,t=0){let r=e.parser.context;return new re(e,[],a,t,t,0,[],0,r?new Ee(r,r.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(e,a){this.stack.push(this.state,a,this.bufferBase+this.buffer.length),this.state=e}reduce(e){var a;let t=e>>19,r=e&65535,{parser:s}=this.p,i=s.dynamicPrecedence(r);if(i&&(this.score+=i),t==0){this.pushState(s.getGoto(this.state,r,!0),this.reducePos),r=2e3&&!(!((a=this.p.parser.nodeSet.types[r])===null||a===void 0)&&a.isAnonymous)&&(n==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=Q):this.p.lastBigReductionSizel;)this.stack.pop();this.reduceContext(r,n)}storeNode(e,a,t,r=4,s=!1){if(e==0&&(!this.stack.length||this.stack[this.stack.length-1]0&&i.buffer[l-4]==0&&i.buffer[l-1]>-1){if(a==t)return;if(i.buffer[l-2]>=a){i.buffer[l-2]=t;return}}}if(!s||this.pos==t)this.buffer.push(e,a,t,r);else{let i=this.buffer.length;if(i>0&&this.buffer[i-4]!=0)for(;i>0&&this.buffer[i-2]>t;)this.buffer[i]=this.buffer[i-4],this.buffer[i+1]=this.buffer[i-3],this.buffer[i+2]=this.buffer[i-2],this.buffer[i+3]=this.buffer[i-1],i-=4,r>4&&(r-=4);this.buffer[i]=e,this.buffer[i+1]=a,this.buffer[i+2]=t,this.buffer[i+3]=r}}shift(e,a,t){let r=this.pos;if(e&131072)this.pushState(e&65535,this.pos);else if(e&262144)this.pos=t,this.shiftContext(a,r),a<=this.p.parser.maxNode&&this.buffer.push(a,r,t,4);else{let s=e,{parser:i}=this.p;(t>this.pos||a<=i.maxNode)&&(this.pos=t,i.stateFlag(s,1)||(this.reducePos=t)),this.pushState(s,r),this.shiftContext(a,r),a<=i.maxNode&&this.buffer.push(a,r,t,4)}}apply(e,a,t){e&65536?this.reduce(e):this.shift(e,a,t)}useNode(e,a){let t=this.p.reused.length-1;(t<0||this.p.reused[t]!=e)&&(this.p.reused.push(e),t++);let r=this.pos;this.reducePos=this.pos=r+e.length,this.pushState(a,r),this.buffer.push(t,r,this.reducePos,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,e,this,this.p.stream.reset(this.pos-e.length)))}split(){let e=this,a=e.buffer.length;for(;a>0&&e.buffer[a-2]>e.reducePos;)a-=4;let t=e.buffer.slice(a),r=e.bufferBase+a;for(;e&&r==e.bufferBase;)e=e.parent;return new re(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,t,r,this.curContext,this.lookAhead,e)}recoverByDelete(e,a){let t=e<=this.p.parser.maxNode;t&&this.storeNode(e,this.pos,a,4),this.storeNode(0,this.pos,a,t?8:4),this.pos=this.reducePos=a,this.score-=190}canShift(e){for(let a=new ra(this);;){let t=this.p.parser.stateSlot(a.state,4)||this.p.parser.hasAction(a.state,e);if(t==0)return!1;if(!(t&65536))return!0;a.reduce(t)}}recoverByInsert(e){if(this.stack.length>=300)return[];let a=this.p.parser.nextStates(this.state);if(a.length>8||this.stack.length>=120){let r=[];for(let s=0,i;sn&1&&l==i)||r.push(a[s],i)}a=r}let t=[];for(let r=0;r>19,r=a&65535,s=this.stack.length-t*3;if(s<0||e.getGoto(this.stack[s],r,!1)<0){let i=this.findForcedReduction();if(i==null)return!1;a=i}this.storeNode(0,this.pos,this.pos,4,!0),this.score-=100}return this.reducePos=this.pos,this.reduce(a),!0}findForcedReduction(){let{parser:e}=this.p,a=[],t=(r,s)=>{if(!a.includes(r))return a.push(r),e.allActions(r,i=>{if(!(i&393216))if(i&65536){let l=(i>>19)-s;if(l>1){let n=i&65535,Q=this.stack.length-l*3;if(Q>=0&&e.getGoto(this.stack[Q],n,!1)>=0)return l<<19|65536|n}}else{let l=t(i,s+1);if(l!=null)return l}})};return t(this.state,0)}forceAll(){for(;!this.p.parser.stateFlag(this.state,2);)if(!this.forceReduce()){this.storeNode(0,this.pos,this.pos,4,!0);break}return this}get deadEnd(){if(this.stack.length!=3)return!1;let{parser:e}=this.p;return e.data[e.stateSlot(this.state,1)]==65535&&!e.stateSlot(this.state,4)}restart(){this.state=this.stack[0],this.stack.length=0}sameState(e){if(this.state!=e.state||this.stack.length!=e.stack.length)return!1;for(let a=0;athis.lookAhead&&(this.emitLookAhead(),this.lookAhead=e)}close(){this.curContext&&this.curContext.tracker.strict&&this.emitContext(),this.lookAhead>0&&this.emitLookAhead()}}class Ee{constructor(e,a){this.tracker=e,this.context=a,this.hash=e.strict?e.hash(a):0}}class ra{constructor(e){this.start=e,this.state=e.state,this.stack=e.stack,this.base=this.stack.length}reduce(e){let a=e&65535,t=e>>19;t==0?(this.stack==this.start.stack&&(this.stack=this.stack.slice()),this.stack.push(this.state,0,0),this.base+=3):this.base-=(t-1)*3;let r=this.start.p.parser.getGoto(this.stack[this.base-3],a,!0);this.state=r}}class ie{constructor(e,a,t){this.stack=e,this.pos=a,this.index=t,this.buffer=e.buffer,this.index==0&&this.maybeNext()}static create(e,a=e.bufferBase+e.buffer.length){return new ie(e,a,a-e.bufferBase)}maybeNext(){let e=this.stack.parent;e!=null&&(this.index=this.stack.bufferBase-e.bufferBase,this.stack=e,this.buffer=e.buffer)}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}next(){this.index-=4,this.pos-=4,this.index==0&&this.maybeNext()}fork(){return new ie(this.stack,this.pos,this.index)}}function I(O,e=Uint16Array){if(typeof O!="string")return O;let a=null;for(let t=0,r=0;t=92&&i--,i>=34&&i--;let n=i-32;if(n>=46&&(n-=46,l=!0),s+=n,l)break;s*=46}a?a[r++]=s:a=new e(s)}return a}class Oe{constructor(){this.start=-1,this.value=-1,this.end=-1,this.extended=-1,this.lookAhead=0,this.mask=0,this.context=0}}const Ae=new Oe;class ia{constructor(e,a){this.input=e,this.ranges=a,this.chunk="",this.chunkOff=0,this.chunk2="",this.chunk2Pos=0,this.next=-1,this.token=Ae,this.rangeIndex=0,this.pos=this.chunkPos=a[0].from,this.range=a[0],this.end=a[a.length-1].to,this.readNext()}resolveOffset(e,a){let t=this.range,r=this.rangeIndex,s=this.pos+e;for(;st.to:s>=t.to;){if(r==this.ranges.length-1)return null;let i=this.ranges[++r];s+=i.from-t.to,t=i}return s}clipPos(e){if(e>=this.range.from&&ee)return Math.max(e,a.from);return this.end}peek(e){let a=this.chunkOff+e,t,r;if(a>=0&&a=this.chunk2Pos&&tl.to&&(this.chunk2=this.chunk2.slice(0,l.to-t)),r=this.chunk2.charCodeAt(0)}}return t>=this.token.lookAhead&&(this.token.lookAhead=t+1),r}acceptToken(e,a=0){let t=a?this.resolveOffset(a,-1):this.pos;if(t==null||t=this.chunk2Pos&&this.posthis.range.to?e.slice(0,this.range.to-this.pos):e,this.chunkPos=this.pos,this.chunkOff=0}}readNext(){return this.chunkOff>=this.chunk.length&&(this.getChunk(),this.chunkOff==this.chunk.length)?this.next=-1:this.next=this.chunk.charCodeAt(this.chunkOff)}advance(e=1){for(this.chunkOff+=e;this.pos+e>=this.range.to;){if(this.rangeIndex==this.ranges.length-1)return this.setDone();e-=this.range.to-this.pos,this.range=this.ranges[++this.rangeIndex],this.pos=this.range.from}return this.pos+=e,this.pos>=this.token.lookAhead&&(this.token.lookAhead=this.pos+1),this.readNext()}setDone(){return this.pos=this.chunkPos=this.end,this.range=this.ranges[this.rangeIndex=this.ranges.length-1],this.chunk="",this.next=-1}reset(e,a){if(a?(this.token=a,a.start=e,a.lookAhead=e+1,a.value=a.extended=-1):this.token=Ae,this.pos!=e){if(this.pos=e,e==this.end)return this.setDone(),this;for(;e=this.range.to;)this.range=this.ranges[++this.rangeIndex];e>=this.chunkPos&&e=this.chunkPos&&a<=this.chunkPos+this.chunk.length)return this.chunk.slice(e-this.chunkPos,a-this.chunkPos);if(e>=this.chunk2Pos&&a<=this.chunk2Pos+this.chunk2.length)return this.chunk2.slice(e-this.chunk2Pos,a-this.chunk2Pos);if(e>=this.range.from&&a<=this.range.to)return this.input.read(e,a);let t="";for(let r of this.ranges){if(r.from>=a)break;r.to>e&&(t+=this.input.read(Math.max(r.from,e),Math.min(r.to,a)))}return t}}class R{constructor(e,a){this.data=e,this.id=a}token(e,a){let{parser:t}=a.p;kO(this.data,e,a,this.id,t.data,t.tokenPrecTable)}}R.prototype.contextual=R.prototype.fallback=R.prototype.extend=!1;class se{constructor(e,a,t){this.precTable=a,this.elseToken=t,this.data=typeof e=="string"?I(e):e}token(e,a){let t=e.pos,r=0;for(;;){let s=e.next<0,i=e.resolveOffset(1,1);if(kO(this.data,e,a,0,this.data,this.precTable),e.token.value>-1)break;if(this.elseToken==null)return;if(s||r++,i==null)break;e.reset(i,e.token)}r&&(e.reset(t,e.token),e.acceptToken(this.elseToken,r))}}se.prototype.contextual=R.prototype.fallback=R.prototype.extend=!1;class x{constructor(e,a={}){this.token=e,this.contextual=!!a.contextual,this.fallback=!!a.fallback,this.extend=!!a.extend}}function kO(O,e,a,t,r,s){let i=0,l=1<0){let S=O[p];if(n.allows(S)&&(e.token.value==-1||e.token.value==S||sa(S,e.token.value,r,s))){e.acceptToken(S);break}}let d=e.next,c=0,u=O[i+2];if(e.next<0&&u>c&&O[Q+u*3-3]==65535&&O[Q+u*3-3]==65535){i=O[Q+u*3-1];continue e}for(;c>1,S=Q+p+(p<<1),f=O[S],g=O[S+1]||65536;if(d=g)c=p+1;else{i=O[S+2],e.advance();continue e}}break}}function Ie(O,e,a){for(let t=e,r;(r=O[t])!=65535;t++)if(r==a)return t-e;return-1}function sa(O,e,a,t){let r=Ie(a,t,e);return r<0||Ie(a,t,O)e)&&!t.type.isError)return a<0?Math.max(0,Math.min(t.to-1,e-25)):Math.min(O.length,Math.max(t.from+1,e+25));if(a<0?t.prevSibling():t.nextSibling())break;if(!t.parent())return a<0?0:O.length}}class la{constructor(e,a){this.fragments=e,this.nodeSet=a,this.i=0,this.fragment=null,this.safeFrom=-1,this.safeTo=-1,this.trees=[],this.start=[],this.index=[],this.nextFragment()}nextFragment(){let e=this.fragment=this.i==this.fragments.length?null:this.fragments[this.i++];if(e){for(this.safeFrom=e.openStart?Ne(e.tree,e.from+e.offset,1)-e.offset:e.from,this.safeTo=e.openEnd?Ne(e.tree,e.to+e.offset,-1)-e.offset:e.to;this.trees.length;)this.trees.pop(),this.start.pop(),this.index.pop();this.trees.push(e.tree),this.start.push(-e.offset),this.index.push(0),this.nextStart=this.safeFrom}else this.nextStart=1e9}nodeAt(e){if(ee)return this.nextStart=i,null;if(s instanceof ee){if(i==e){if(i=Math.max(this.safeFrom,e)&&(this.trees.push(s),this.start.push(i),this.index.push(0))}else this.index[a]++,this.nextStart=i+s.length}}}class oa{constructor(e,a){this.stream=a,this.tokens=[],this.mainToken=null,this.actions=[],this.tokens=e.tokenizers.map(t=>new Oe)}getActions(e){let a=0,t=null,{parser:r}=e.p,{tokenizers:s}=r,i=r.stateSlot(e.state,3),l=e.curContext?e.curContext.hash:0,n=0;for(let Q=0;Qc.end+25&&(n=Math.max(c.lookAhead,n)),c.value!=0)){let u=a;if(c.extended>-1&&(a=this.addActions(e,c.extended,c.end,a)),a=this.addActions(e,c.value,c.end,a),!d.extend&&(t=c,a>u))break}}for(;this.actions.length>a;)this.actions.pop();return n&&e.setLookAhead(n),!t&&e.pos==this.stream.end&&(t=new Oe,t.value=e.p.parser.eofTerm,t.start=t.end=e.pos,a=this.addActions(e,t.value,t.end,a)),this.mainToken=t,this.actions}getMainToken(e){if(this.mainToken)return this.mainToken;let a=new Oe,{pos:t,p:r}=e;return a.start=t,a.end=Math.min(t+1,r.stream.end),a.value=t==r.stream.end?r.parser.eofTerm:0,a}updateCachedToken(e,a,t){let r=this.stream.clipPos(t.pos);if(a.token(this.stream.reset(r,e),t),e.value>-1){let{parser:s}=t.p;for(let i=0;i=0&&t.p.parser.dialect.allows(l>>1)){l&1?e.extended=l>>1:e.value=l>>1;break}}}else e.value=0,e.end=this.stream.clipPos(r+1)}putAction(e,a,t,r){for(let s=0;se.bufferLength*4?new la(t,e.nodeSet):null}get parsedPos(){return this.minStackPos}advance(){let e=this.stacks,a=this.minStackPos,t=this.stacks=[],r,s;if(this.bigReductionCount>300&&e.length==1){let[i]=e;for(;i.forceReduce()&&i.stack.length&&i.stack[i.stack.length-2]>=this.lastBigReductionStart;);this.bigReductionCount=this.lastBigReductionSize=0}for(let i=0;ia)t.push(l);else{if(this.advanceStack(l,t,e))continue;{r||(r=[],s=[]),r.push(l);let n=this.tokens.getMainToken(l);s.push(n.value,n.end)}}break}}if(!t.length){let i=r&&Qa(r);if(i)return Z&&console.log("Finish with "+this.stackID(i)),this.stackToTree(i);if(this.parser.strict)throw Z&&r&&console.log("Stuck with token "+(this.tokens.mainToken?this.parser.getName(this.tokens.mainToken.value):"none")),new SyntaxError("No parse at "+a);this.recovering||(this.recovering=5)}if(this.recovering&&r){let i=this.stoppedAt!=null&&r[0].pos>this.stoppedAt?r[0]:this.runRecovery(r,s,t);if(i)return Z&&console.log("Force-finish "+this.stackID(i)),this.stackToTree(i.forceAll())}if(this.recovering){let i=this.recovering==1?1:this.recovering*3;if(t.length>i)for(t.sort((l,n)=>n.score-l.score);t.length>i;)t.pop();t.some(l=>l.reducePos>a)&&this.recovering--}else if(t.length>1){e:for(let i=0;i500&&Q.buffer.length>500)if((l.score-Q.score||l.buffer.length-Q.buffer.length)>0)t.splice(n--,1);else{t.splice(i--,1);continue e}}}t.length>12&&t.splice(12,t.length-12)}this.minStackPos=t[0].pos;for(let i=1;i ":"";if(this.stoppedAt!=null&&r>this.stoppedAt)return e.forceReduce()?e:null;if(this.fragments){let Q=e.curContext&&e.curContext.tracker.strict,d=Q?e.curContext.hash:0;for(let c=this.fragments.nodeAt(r);c;){let u=this.parser.nodeSet.types[c.type.id]==c.type?s.getGoto(e.state,c.type.id):-1;if(u>-1&&c.length&&(!Q||(c.prop(we.contextHash)||0)==d))return e.useNode(c,u),Z&&console.log(i+this.stackID(e)+` (via reuse of ${s.getName(c.type.id)})`),!0;if(!(c instanceof ee)||c.children.length==0||c.positions[0]>0)break;let p=c.children[0];if(p instanceof ee&&c.positions[0]==0)c=p;else break}}let l=s.stateSlot(e.state,4);if(l>0)return e.reduce(l),Z&&console.log(i+this.stackID(e)+` (via always-reduce ${s.getName(l&65535)})`),!0;if(e.stack.length>=15e3)for(;e.stack.length>9e3&&e.forceReduce(););let n=this.tokens.getActions(e);for(let Q=0;Qr?a.push(S):t.push(S)}return!1}advanceFully(e,a){let t=e.pos;for(;;){if(!this.advanceStack(e,null,null))return!1;if(e.pos>t)return Be(e,a),!0}}runRecovery(e,a,t){let r=null,s=!1;for(let i=0;i ":"";if(l.deadEnd&&(s||(s=!0,l.restart(),Z&&console.log(d+this.stackID(l)+" (restarted)"),this.advanceFully(l,t))))continue;let c=l.split(),u=d;for(let p=0;c.forceReduce()&&p<10&&(Z&&console.log(u+this.stackID(c)+" (via force-reduce)"),!this.advanceFully(c,t));p++)Z&&(u=this.stackID(c)+" -> ");for(let p of l.recoverByInsert(n))Z&&console.log(d+this.stackID(p)+" (via recover-insert)"),this.advanceFully(p,t);this.stream.end>l.pos?(Q==l.pos&&(Q++,n=0),l.recoverByDelete(n,Q),Z&&console.log(d+this.stackID(l)+` (via recover-delete ${this.parser.getName(n)})`),Be(l,t)):(!r||r.scoreO;class wO{constructor(e){this.start=e.start,this.shift=e.shift||he,this.reduce=e.reduce||he,this.reuse=e.reuse||he,this.hash=e.hash||(()=>0),this.strict=e.strict!==!1}}class T extends Tt{constructor(e){if(super(),this.wrappers=[],e.version!=14)throw new RangeError(`Parser version (${e.version}) doesn't match runtime version (14)`);let a=e.nodeNames.split(" ");this.minRepeatTerm=a.length;for(let l=0;le.topRules[l][1]),r=[];for(let l=0;l=0)s(d,n,l[Q++]);else{let c=l[Q+-d];for(let u=-d;u>0;u--)s(l[Q++],n,c);Q++}}}this.nodeSet=new _t(a.map((l,n)=>Wt.define({name:n>=this.minRepeatTerm?void 0:l,id:n,props:r[n],top:t.indexOf(n)>-1,error:n==0,skipped:e.skippedNodes&&e.skippedNodes.indexOf(n)>-1}))),e.propSources&&(this.nodeSet=this.nodeSet.extend(...e.propSources)),this.strict=!1,this.bufferLength=Ut;let i=I(e.tokenData);this.context=e.context,this.specializerSpecs=e.specialized||[],this.specialized=new Uint16Array(this.specializerSpecs.length);for(let l=0;ltypeof l=="number"?new R(i,l):l),this.topRules=e.topRules,this.dialects=e.dialects||{},this.dynamicPrecedences=e.dynamicPrecedences||null,this.tokenPrecTable=e.tokenPrec,this.termNames=e.termNames||null,this.maxNode=this.nodeSet.types.length-1,this.dialect=this.parseDialect(),this.top=this.topRules[Object.keys(this.topRules)[0]]}createParse(e,a,t){let r=new na(this,e,a,t);for(let s of this.wrappers)r=s(r,e,a,t);return r}getGoto(e,a,t=!1){let r=this.goto;if(a>=r[0])return-1;for(let s=r[a+1];;){let i=r[s++],l=i&1,n=r[s++];if(l&&t)return n;for(let Q=s+(i>>1);s0}validAction(e,a){return!!this.allActions(e,t=>t==a?!0:null)}allActions(e,a){let t=this.stateSlot(e,4),r=t?a(t):void 0;for(let s=this.stateSlot(e,1);r==null;s+=3){if(this.data[s]==65535)if(this.data[s+1]==1)s=k(this.data,s+2);else break;r=a(k(this.data,s+1))}return r}nextStates(e){let a=[];for(let t=this.stateSlot(e,1);;t+=3){if(this.data[t]==65535)if(this.data[t+1]==1)t=k(this.data,t+2);else break;if(!(this.data[t+2]&1)){let r=this.data[t+1];a.some((s,i)=>i&1&&s==r)||a.push(this.data[t],r)}}return a}configure(e){let a=Object.assign(Object.create(T.prototype),this);if(e.props&&(a.nodeSet=this.nodeSet.extend(...e.props)),e.top){let t=this.topRules[e.top];if(!t)throw new RangeError(`Invalid top rule name ${e.top}`);a.top=t}return e.tokenizers&&(a.tokenizers=this.tokenizers.map(t=>{let r=e.tokenizers.find(s=>s.from==t);return r?r.to:t})),e.specializers&&(a.specializers=this.specializers.slice(),a.specializerSpecs=this.specializerSpecs.map((t,r)=>{let s=e.specializers.find(l=>l.from==t.external);if(!s)return t;let i=Object.assign(Object.assign({},t),{external:s.to});return a.specializers[r]=De(i),i})),e.contextTracker&&(a.context=e.contextTracker),e.dialect&&(a.dialect=this.parseDialect(e.dialect)),e.strict!=null&&(a.strict=e.strict),e.wrap&&(a.wrappers=a.wrappers.concat(e.wrap)),e.bufferLength!=null&&(a.bufferLength=e.bufferLength),a}hasWrappers(){return this.wrappers.length>0}getName(e){return this.termNames?this.termNames[e]:String(e<=this.maxNode&&this.nodeSet.types[e].name||e)}get eofTerm(){return this.maxNode+1}get topNode(){return this.nodeSet.types[this.top[1]]}dynamicPrecedence(e){let a=this.dynamicPrecedences;return a==null?0:a[e]||0}parseDialect(e){let a=Object.keys(this.dialects),t=a.map(()=>!1);if(e)for(let s of e.split(" ")){let i=a.indexOf(s);i>=0&&(t[i]=!0)}let r=null;for(let s=0;st)&&a.p.parser.stateFlag(a.state,2)&&(!e||e.scoreO.external(a,t)<<1|e}return O.get}const pa=54,da=1,ha=55,ua=2,Sa=56,$a=3,Je=4,fa=5,le=6,vO=7,TO=8,_O=9,WO=10,ga=11,ma=12,Pa=13,ue=57,Za=14,Le=58,UO=20,ba=22,CO=23,Xa=24,Xe=26,RO=27,xa=28,Ya=31,ya=34,ka=36,wa=37,va=0,Ta=1,_a={area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},Wa={dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},Fe={dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}};function Ua(O){return O==45||O==46||O==58||O>=65&&O<=90||O==95||O>=97&&O<=122||O>=161}function qO(O){return O==9||O==10||O==13||O==32}let Me=null,Ke=null,He=0;function xe(O,e){let a=O.pos+e;if(He==a&&Ke==O)return Me;let t=O.peek(e);for(;qO(t);)t=O.peek(++e);let r="";for(;Ua(t);)r+=String.fromCharCode(t),t=O.peek(++e);return Ke=O,He=a,Me=r?r.toLowerCase():t==Ca||t==Ra?void 0:null}const VO=60,oe=62,_e=47,Ca=63,Ra=33,qa=45;function eO(O,e){this.name=O,this.parent=e,this.hash=e?e.hash:0;for(let a=0;a-1?new eO(xe(t,1)||"",O):O},reduce(O,e){return e==UO&&O?O.parent:O},reuse(O,e,a,t){let r=e.type.id;return r==le||r==ka?new eO(xe(t,1)||"",O):O},hash(O){return O?O.hash:0},strict:!1}),za=new x((O,e)=>{if(O.next!=VO){O.next<0&&e.context&&O.acceptToken(ue);return}O.advance();let a=O.next==_e;a&&O.advance();let t=xe(O,0);if(t===void 0)return;if(!t)return O.acceptToken(a?Za:le);let r=e.context?e.context.name:null;if(a){if(t==r)return O.acceptToken(ga);if(r&&Wa[r])return O.acceptToken(ue,-2);if(e.dialectEnabled(va))return O.acceptToken(ma);for(let s=e.context;s;s=s.parent)if(s.name==t)return;O.acceptToken(Pa)}else{if(t=="script")return O.acceptToken(vO);if(t=="style")return O.acceptToken(TO);if(t=="textarea")return O.acceptToken(_O);if(_a.hasOwnProperty(t))return O.acceptToken(WO);r&&Fe[r]&&Fe[r][t]?O.acceptToken(ue,-1):O.acceptToken(le)}},{contextual:!0}),Ga=new x(O=>{for(let e=0,a=0;;a++){if(O.next<0){a&&O.acceptToken(Le);break}if(O.next==qa)e++;else if(O.next==oe&&e>=2){a>3&&O.acceptToken(Le,-2);break}else e=0;O.advance()}});function Ea(O){for(;O;O=O.parent)if(O.name=="svg"||O.name=="math")return!0;return!1}const Aa=new x((O,e)=>{if(O.next==_e&&O.peek(1)==oe){let a=e.dialectEnabled(Ta)||Ea(e.context);O.acceptToken(a?fa:Je,2)}else O.next==oe&&O.acceptToken(Je,1)});function We(O,e,a){let t=2+O.length;return new x(r=>{for(let s=0,i=0,l=0;;l++){if(r.next<0){l&&r.acceptToken(e);break}if(s==0&&r.next==VO||s==1&&r.next==_e||s>=2&&si?r.acceptToken(e,-i):r.acceptToken(a,-(i-2));break}else if((r.next==10||r.next==13)&&l){r.acceptToken(e,1);break}else s=i=0;r.advance()}})}const Ia=We("script",pa,da),Na=We("style",ha,ua),Ba=We("textarea",Sa,$a),Da=D({"Text RawText":o.content,"StartTag StartCloseTag SelfClosingEndTag EndTag":o.angleBracket,TagName:o.tagName,"MismatchedCloseTag/TagName":[o.tagName,o.invalid],AttributeName:o.attributeName,"AttributeValue UnquotedAttributeValue":o.attributeValue,Is:o.definitionOperator,"EntityReference CharacterReference":o.character,Comment:o.blockComment,ProcessingInst:o.processingInstruction,DoctypeDecl:o.documentMeta}),Ja=T.deserialize({version:14,states:",xOVO!rOOO!WQ#tO'#CqO!]Q#tO'#CzO!bQ#tO'#C}O!gQ#tO'#DQO!lQ#tO'#DSO!qOaO'#CpO!|ObO'#CpO#XOdO'#CpO$eO!rO'#CpOOO`'#Cp'#CpO$lO$fO'#DTO$tQ#tO'#DVO$yQ#tO'#DWOOO`'#Dk'#DkOOO`'#DY'#DYQVO!rOOO%OQ&rO,59]O%WQ&rO,59fO%`Q&rO,59iO%hQ&rO,59lO%sQ&rO,59nOOOa'#D^'#D^O%{OaO'#CxO&WOaO,59[OOOb'#D_'#D_O&`ObO'#C{O&kObO,59[OOOd'#D`'#D`O&sOdO'#DOO'OOdO,59[OOO`'#Da'#DaO'WO!rO,59[O'_Q#tO'#DROOO`,59[,59[OOOp'#Db'#DbO'dO$fO,59oOOO`,59o,59oO'lQ#|O,59qO'qQ#|O,59rOOO`-E7W-E7WO'vQ&rO'#CsOOQW'#DZ'#DZO(UQ&rO1G.wOOOa1G.w1G.wO(^Q&rO1G/QOOOb1G/Q1G/QO(fQ&rO1G/TOOOd1G/T1G/TO(nQ&rO1G/WOOO`1G/W1G/WOOO`1G/Y1G/YO(yQ&rO1G/YOOOa-E7[-E7[O)RQ#tO'#CyOOO`1G.v1G.vOOOb-E7]-E7]O)WQ#tO'#C|OOOd-E7^-E7^O)]Q#tO'#DPOOO`-E7_-E7_O)bQ#|O,59mOOOp-E7`-E7`OOO`1G/Z1G/ZOOO`1G/]1G/]OOO`1G/^1G/^O)gQ,UO,59_OOQW-E7X-E7XOOOa7+$c7+$cOOOb7+$l7+$lOOOd7+$o7+$oOOO`7+$r7+$rOOO`7+$t7+$tO)rQ#|O,59eO)wQ#|O,59hO)|Q#|O,59kOOO`1G/X1G/XO*RO7[O'#CvO*dOMhO'#CvOOQW1G.y1G.yOOO`1G/P1G/POOO`1G/S1G/SOOO`1G/V1G/VOOOO'#D['#D[O*uO7[O,59bOOQW,59b,59bOOOO'#D]'#D]O+WOMhO,59bOOOO-E7Y-E7YOOQW1G.|1G.|OOOO-E7Z-E7Z",stateData:"+s~O!^OS~OUSOVPOWQOXROYTO[]O][O^^O`^Oa^Ob^Oc^Ox^O{_O!dZO~OfaO~OfbO~OfcO~OfdO~OfeO~O!WfOPlP!ZlP~O!XiOQoP!ZoP~O!YlORrP!ZrP~OUSOVPOWQOXROYTOZqO[]O][O^^O`^Oa^Ob^Oc^Ox^O!dZO~O!ZrO~P#dO![sO!euO~OfvO~OfwO~OS|OhyO~OS!OOhyO~OS!QOhyO~OS!SOT!TOhyO~OS!TOhyO~O!WfOPlX!ZlX~OP!WO!Z!XO~O!XiOQoX!ZoX~OQ!ZO!Z!XO~O!YlORrX!ZrX~OR!]O!Z!XO~O!Z!XO~P#dOf!_O~O![sO!e!aO~OS!bO~OS!cO~Oi!dOSgXhgXTgX~OS!fOhyO~OS!gOhyO~OS!hOhyO~OS!iOT!jOhyO~OS!jOhyO~Of!kO~Of!lO~Of!mO~OS!nO~Ok!qO!`!oO!b!pO~OS!rO~OS!sO~OS!tO~Oa!uOb!uOc!uO!`!wO!a!uO~Oa!xOb!xOc!xO!b!wO!c!xO~Oa!uOb!uOc!uO!`!{O!a!uO~Oa!xOb!xOc!xO!b!{O!c!xO~OT~bac!dx{!d~",goto:"%p!`PPPPPPPPPPPPPPPPPPPP!a!gP!mPP!yP!|#P#S#Y#]#`#f#i#l#r#x!aP!a!aP$O$U$l$r$x%O%U%[%bPPPPPPPP%hX^OX`pXUOX`pezabcde{}!P!R!UR!q!dRhUR!XhXVOX`pRkVR!XkXWOX`pRnWR!XnXXOX`pQrXR!XpXYOX`pQ`ORx`Q{aQ}bQ!PcQ!RdQ!UeZ!e{}!P!R!UQ!v!oR!z!vQ!y!pR!|!yQgUR!VgQjVR!YjQmWR![mQpXR!^pQtZR!`tS_O`ToXp",nodeNames:"⚠ StartCloseTag StartCloseTag StartCloseTag EndTag SelfClosingEndTag StartTag StartTag StartTag StartTag StartTag StartCloseTag StartCloseTag StartCloseTag IncompleteCloseTag Document Text EntityReference CharacterReference InvalidEntity Element OpenTag TagName Attribute AttributeName Is AttributeValue UnquotedAttributeValue ScriptText CloseTag OpenTag StyleText CloseTag OpenTag TextareaText CloseTag OpenTag CloseTag SelfClosingTag Comment ProcessingInst MismatchedCloseTag CloseTag DoctypeDecl",maxTerm:67,context:ja,nodeProps:[["closedBy",-10,1,2,3,7,8,9,10,11,12,13,"EndTag",6,"EndTag SelfClosingEndTag",-4,21,30,33,36,"CloseTag"],["openedBy",4,"StartTag StartCloseTag",5,"StartTag",-4,29,32,35,37,"OpenTag"],["group",-9,14,17,18,19,20,39,40,41,42,"Entity",16,"Entity TextContent",-3,28,31,34,"TextContent Entity"]],propSources:[Da],skippedNodes:[0],repeatNodeCount:9,tokenData:"!]tw8twx7Sx!P8t!P!Q5u!Q!]8t!]!^/^!^!a7S!a#S8t#S#T;{#T#s8t#s$f5u$f;'S8t;'S;=`>V<%l?Ah8t?Ah?BY5u?BY?Mn8t?MnO5u!Z5zbkWOX5uXZ7SZ[5u[^7S^p5uqr5urs7Sst+Ptw5uwx7Sx!]5u!]!^7w!^!a7S!a#S5u#S#T7S#T;'S5u;'S;=`8n<%lO5u!R7VVOp7Sqs7St!]7S!]!^7l!^;'S7S;'S;=`7q<%lO7S!R7qOa!R!R7tP;=`<%l7S!Z8OYkWa!ROX+PZ[+P^p+Pqr+Psw+Px!^+P!a#S+P#T;'S+P;'S;=`+t<%lO+P!Z8qP;=`<%l5u!_8{ihSkWOX5uXZ7SZ[5u[^7S^p5uqr8trs7Sst/^tw8twx7Sx!P8t!P!Q5u!Q!]8t!]!^:j!^!a7S!a#S8t#S#T;{#T#s8t#s$f5u$f;'S8t;'S;=`>V<%l?Ah8t?Ah?BY5u?BY?Mn8t?MnO5u!_:sbhSkWa!ROX+PZ[+P^p+Pqr/^sw/^x!P/^!P!Q+P!Q!^/^!a#S/^#S#T0m#T#s/^#s$f+P$f;'S/^;'S;=`1e<%l?Ah/^?Ah?BY+P?BY?Mn/^?MnO+P!VP<%l?Ah;{?Ah?BY7S?BY?Mn;{?MnO7S!V=dXhSa!Rqr0msw0mx!P0m!Q!^0m!a#s0m$f;'S0m;'S;=`1_<%l?Ah0m?BY?Mn0m!V>SP;=`<%l;{!_>YP;=`<%l8t!_>dhhSkWOX@OXZAYZ[@O[^AY^p@OqrBwrsAYswBwwxAYx!PBw!P!Q@O!Q!]Bw!]!^/^!^!aAY!a#SBw#S#TE{#T#sBw#s$f@O$f;'SBw;'S;=`HS<%l?AhBw?Ah?BY@O?BY?MnBw?MnO@O!Z@TakWOX@OXZAYZ[@O[^AY^p@Oqr@OrsAYsw@OwxAYx!]@O!]!^Az!^!aAY!a#S@O#S#TAY#T;'S@O;'S;=`Bq<%lO@O!RA]UOpAYq!]AY!]!^Ao!^;'SAY;'S;=`At<%lOAY!RAtOb!R!RAwP;=`<%lAY!ZBRYkWb!ROX+PZ[+P^p+Pqr+Psw+Px!^+P!a#S+P#T;'S+P;'S;=`+t<%lO+P!ZBtP;=`<%l@O!_COhhSkWOX@OXZAYZ[@O[^AY^p@OqrBwrsAYswBwwxAYx!PBw!P!Q@O!Q!]Bw!]!^Dj!^!aAY!a#SBw#S#TE{#T#sBw#s$f@O$f;'SBw;'S;=`HS<%l?AhBw?Ah?BY@O?BY?MnBw?MnO@O!_DsbhSkWb!ROX+PZ[+P^p+Pqr/^sw/^x!P/^!P!Q+P!Q!^/^!a#S/^#S#T0m#T#s/^#s$f+P$f;'S/^;'S;=`1e<%l?Ah/^?Ah?BY+P?BY?Mn/^?MnO+P!VFQbhSOpAYqrE{rsAYswE{wxAYx!PE{!P!QAY!Q!]E{!]!^GY!^!aAY!a#sE{#s$fAY$f;'SE{;'S;=`G|<%l?AhE{?Ah?BYAY?BY?MnE{?MnOAY!VGaXhSb!Rqr0msw0mx!P0m!Q!^0m!a#s0m$f;'S0m;'S;=`1_<%l?Ah0m?BY?Mn0m!VHPP;=`<%lE{!_HVP;=`<%lBw!ZHcW!bx`P!a`Or(trs'ksv(tw!^(t!^!_)e!_;'S(t;'S;=`*P<%lO(t!aIYlhS`PkW!a`!cpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx}-_}!OKQ!O!P-_!P!Q$q!Q!^-_!^!_*V!_!a&X!a#S-_#S#T1k#T#s-_#s$f$q$f;'S-_;'S;=`3X<%l?Ah-_?Ah?BY$q?BY?Mn-_?MnO$q!aK_khS`PkW!a`!cpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx!P-_!P!Q$q!Q!^-_!^!_*V!_!`&X!`!aMS!a#S-_#S#T1k#T#s-_#s$f$q$f;'S-_;'S;=`3X<%l?Ah-_?Ah?BY$q?BY?Mn-_?MnO$q!TM_X`P!a`!cp!eQOr&Xrs&}sv&Xwx(tx!^&X!^!_*V!_;'S&X;'S;=`*y<%lO&X!aNZ!ZhSfQ`PkW!a`!cpOX$qXZ&XZ[$q[^&X^p$qpq&Xqr-_rs&}sv-_vw/^wx(tx}-_}!OMz!O!PMz!P!Q$q!Q![Mz![!]Mz!]!^-_!^!_*V!_!a&X!a!c-_!c!}Mz!}#R-_#R#SMz#S#T1k#T#oMz#o#s-_#s$f$q$f$}-_$}%OMz%O%W-_%W%oMz%o%p-_%p&aMz&a&b-_&b1pMz1p4UMz4U4dMz4d4e-_4e$ISMz$IS$I`-_$I`$IbMz$Ib$Je-_$Je$JgMz$Jg$Kh-_$Kh%#tMz%#t&/x-_&/x&EtMz&Et&FV-_&FV;'SMz;'S;:j!#|;:j;=`3X<%l?&r-_?&r?AhMz?Ah?BY$q?BY?MnMz?MnO$q!a!$PP;=`<%lMz!R!$ZY!a`!cpOq*Vqr!$yrs(Vsv*Vwx)ex!a*V!a!b!4t!b;'S*V;'S;=`*s<%lO*V!R!%Q]!a`!cpOr*Vrs(Vsv*Vwx)ex}*V}!O!%y!O!f*V!f!g!']!g#W*V#W#X!0`#X;'S*V;'S;=`*s<%lO*V!R!&QX!a`!cpOr*Vrs(Vsv*Vwx)ex}*V}!O!&m!O;'S*V;'S;=`*s<%lO*V!R!&vV!a`!cp!dPOr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!'dX!a`!cpOr*Vrs(Vsv*Vwx)ex!q*V!q!r!(P!r;'S*V;'S;=`*s<%lO*V!R!(WX!a`!cpOr*Vrs(Vsv*Vwx)ex!e*V!e!f!(s!f;'S*V;'S;=`*s<%lO*V!R!(zX!a`!cpOr*Vrs(Vsv*Vwx)ex!v*V!v!w!)g!w;'S*V;'S;=`*s<%lO*V!R!)nX!a`!cpOr*Vrs(Vsv*Vwx)ex!{*V!{!|!*Z!|;'S*V;'S;=`*s<%lO*V!R!*bX!a`!cpOr*Vrs(Vsv*Vwx)ex!r*V!r!s!*}!s;'S*V;'S;=`*s<%lO*V!R!+UX!a`!cpOr*Vrs(Vsv*Vwx)ex!g*V!g!h!+q!h;'S*V;'S;=`*s<%lO*V!R!+xY!a`!cpOr!+qrs!,hsv!+qvw!-Swx!.[x!`!+q!`!a!/j!a;'S!+q;'S;=`!0Y<%lO!+qq!,mV!cpOv!,hvx!-Sx!`!,h!`!a!-q!a;'S!,h;'S;=`!.U<%lO!,hP!-VTO!`!-S!`!a!-f!a;'S!-S;'S;=`!-k<%lO!-SP!-kO{PP!-nP;=`<%l!-Sq!-xS!cp{POv(Vx;'S(V;'S;=`(h<%lO(Vq!.XP;=`<%l!,ha!.aX!a`Or!.[rs!-Ssv!.[vw!-Sw!`!.[!`!a!.|!a;'S!.[;'S;=`!/d<%lO!.[a!/TT!a`{POr)esv)ew;'S)e;'S;=`)y<%lO)ea!/gP;=`<%l!.[!R!/sV!a`!cp{POr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!0]P;=`<%l!+q!R!0gX!a`!cpOr*Vrs(Vsv*Vwx)ex#c*V#c#d!1S#d;'S*V;'S;=`*s<%lO*V!R!1ZX!a`!cpOr*Vrs(Vsv*Vwx)ex#V*V#V#W!1v#W;'S*V;'S;=`*s<%lO*V!R!1}X!a`!cpOr*Vrs(Vsv*Vwx)ex#h*V#h#i!2j#i;'S*V;'S;=`*s<%lO*V!R!2qX!a`!cpOr*Vrs(Vsv*Vwx)ex#m*V#m#n!3^#n;'S*V;'S;=`*s<%lO*V!R!3eX!a`!cpOr*Vrs(Vsv*Vwx)ex#d*V#d#e!4Q#e;'S*V;'S;=`*s<%lO*V!R!4XX!a`!cpOr*Vrs(Vsv*Vwx)ex#X*V#X#Y!+q#Y;'S*V;'S;=`*s<%lO*V!R!4{Y!a`!cpOr!4trs!5ksv!4tvw!6Vwx!8]x!a!4t!a!b!:]!b;'S!4t;'S;=`!;r<%lO!4tq!5pV!cpOv!5kvx!6Vx!a!5k!a!b!7W!b;'S!5k;'S;=`!8V<%lO!5kP!6YTO!a!6V!a!b!6i!b;'S!6V;'S;=`!7Q<%lO!6VP!6lTO!`!6V!`!a!6{!a;'S!6V;'S;=`!7Q<%lO!6VP!7QOxPP!7TP;=`<%l!6Vq!7]V!cpOv!5kvx!6Vx!`!5k!`!a!7r!a;'S!5k;'S;=`!8V<%lO!5kq!7yS!cpxPOv(Vx;'S(V;'S;=`(h<%lO(Vq!8YP;=`<%l!5ka!8bX!a`Or!8]rs!6Vsv!8]vw!6Vw!a!8]!a!b!8}!b;'S!8];'S;=`!:V<%lO!8]a!9SX!a`Or!8]rs!6Vsv!8]vw!6Vw!`!8]!`!a!9o!a;'S!8];'S;=`!:V<%lO!8]a!9vT!a`xPOr)esv)ew;'S)e;'S;=`)y<%lO)ea!:YP;=`<%l!8]!R!:dY!a`!cpOr!4trs!5ksv!4tvw!6Vwx!8]x!`!4t!`!a!;S!a;'S!4t;'S;=`!;r<%lO!4t!R!;]V!a`!cpxPOr*Vrs(Vsv*Vwx)ex;'S*V;'S;=`*s<%lO*V!R!;uP;=`<%l!4t!V!{let Q=l.type.id;if(Q==xa)return Se(l,n,a);if(Q==Ya)return Se(l,n,t);if(Q==ya)return Se(l,n,r);if(Q==UO&&s.length){let d=l.node,c=d.firstChild,u=c&&OO(c,n),p;if(u){for(let S of s)if(S.tag==u&&(!S.attrs||S.attrs(p||(p=jO(d,n))))){let f=d.lastChild;return{parser:S.parser,overlay:[{from:c.to,to:f.type.id==wa?f.from:d.to}]}}}}if(i&&Q==CO){let d=l.node,c;if(c=d.firstChild){let u=i[n.read(c.from,c.to)];if(u)for(let p of u){if(p.tagName&&p.tagName!=OO(d.parent,n))continue;let S=d.lastChild;if(S.type.id==Xe){let f=S.from+1,g=S.lastChild,X=S.to-(g&&g.isError?0:1);if(X>f)return{parser:p.parser,overlay:[{from:f,to:X}]}}else if(S.type.id==RO)return{parser:p.parser,overlay:[{from:S.from,to:S.to}]}}}}return null})}const La=96,tO=1,Fa=97,Ma=98,aO=2,GO=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],Ka=58,Ha=40,EO=95,er=91,te=45,Or=46,tr=35,ar=37;function ne(O){return O>=65&&O<=90||O>=97&&O<=122||O>=161}function rr(O){return O>=48&&O<=57}const ir=new x((O,e)=>{for(let a=!1,t=0,r=0;;r++){let{next:s}=O;if(ne(s)||s==te||s==EO||a&&rr(s))!a&&(s!=te||r>0)&&(a=!0),t===r&&s==te&&t++,O.advance();else{a&&O.acceptToken(s==Ha?Fa:t==2&&e.canShift(aO)?aO:Ma);break}}}),sr=new x(O=>{if(GO.includes(O.peek(-1))){let{next:e}=O;(ne(e)||e==EO||e==tr||e==Or||e==er||e==Ka||e==te)&&O.acceptToken(La)}}),lr=new x(O=>{if(!GO.includes(O.peek(-1))){let{next:e}=O;if(e==ar&&(O.advance(),O.acceptToken(tO)),ne(e)){do O.advance();while(ne(O.next));O.acceptToken(tO)}}}),or=D({"AtKeyword import charset namespace keyframes media supports":o.definitionKeyword,"from to selector":o.keyword,NamespaceName:o.namespace,KeyframeName:o.labelName,KeyframeRangeName:o.operatorKeyword,TagName:o.tagName,ClassName:o.className,PseudoClassName:o.constant(o.className),IdName:o.labelName,"FeatureName PropertyName":o.propertyName,AttributeName:o.attributeName,NumberLiteral:o.number,KeywordQuery:o.keyword,UnaryQueryOp:o.operatorKeyword,"CallTag ValueName":o.atom,VariableName:o.variableName,Callee:o.operatorKeyword,Unit:o.unit,"UniversalSelector NestingSelector":o.definitionOperator,MatchOp:o.compareOperator,"ChildOp SiblingOp, LogicOp":o.logicOperator,BinOp:o.arithmeticOperator,Important:o.modifier,Comment:o.blockComment,ColorLiteral:o.color,"ParenthesizedContent StringLiteral":o.string,":":o.punctuation,"PseudoOp #":o.derefOperator,"; ,":o.separator,"( )":o.paren,"[ ]":o.squareBracket,"{ }":o.brace}),nr={__proto__:null,lang:32,"nth-child":32,"nth-last-child":32,"nth-of-type":32,"nth-last-of-type":32,dir:32,"host-context":32,url:60,"url-prefix":60,domain:60,regexp:60,selector:134},cr={__proto__:null,"@import":114,"@media":138,"@charset":142,"@namespace":146,"@keyframes":152,"@supports":164},Qr={__proto__:null,not:128,only:128},pr=T.deserialize({version:14,states:"9bQYQ[OOO#_Q[OOP#fOWOOOOQP'#Cd'#CdOOQP'#Cc'#CcO#kQ[O'#CfO$_QXO'#CaO$fQ[O'#ChO$qQ[O'#DPO$vQ[O'#DTOOQP'#Ej'#EjO${QdO'#DeO%gQ[O'#DrO${QdO'#DtO%xQ[O'#DvO&TQ[O'#DyO&]Q[O'#EPO&kQ[O'#EROOQS'#Ei'#EiOOQS'#EU'#EUQYQ[OOO&rQXO'#CdO'gQWO'#DaO'lQWO'#EpO'wQ[O'#EpQOQWOOP(RO#tO'#C_POOO)C@X)C@XOOQP'#Cg'#CgOOQP,59Q,59QO#kQ[O,59QO(^Q[O'#EXO(xQWO,58{O)QQ[O,59SO$qQ[O,59kO$vQ[O,59oO(^Q[O,59sO(^Q[O,59uO(^Q[O,59vO)]Q[O'#D`OOQS,58{,58{OOQP'#Ck'#CkOOQO'#C}'#C}OOQP,59S,59SO)dQWO,59SO)iQWO,59SOOQP'#DR'#DROOQP,59k,59kOOQO'#DV'#DVO)nQ`O,59oOOQS'#Cp'#CpO${QdO'#CqO)vQvO'#CsO+TQtO,5:POOQO'#Cx'#CxO)iQWO'#CwO+iQWO'#CyOOQS'#Em'#EmOOQO'#Dh'#DhO+nQ[O'#DoO+|QWO'#EqO&]Q[O'#DmO,[QWO'#DpOOQO'#Er'#ErO({QWO,5:^O,aQpO,5:`OOQS'#Dx'#DxO,iQWO,5:bO,nQ[O,5:bOOQO'#D{'#D{O,vQWO,5:eO,{QWO,5:kO-TQWO,5:mOOQS-E8S-E8SO${QdO,59{O-]Q[O'#EZO-jQWO,5;[O-jQWO,5;[POOO'#ET'#ETP-uO#tO,58yPOOO,58y,58yOOQP1G.l1G.lO.lQXO,5:sOOQO-E8V-E8VOOQS1G.g1G.gOOQP1G.n1G.nO)dQWO1G.nO)iQWO1G.nOOQP1G/V1G/VO.yQ`O1G/ZO/dQXO1G/_O/zQXO1G/aO0bQXO1G/bO0xQWO,59zO0}Q[O'#DOO1UQdO'#CoOOQP1G/Z1G/ZO${QdO1G/ZO1]QpO,59]OOQS,59_,59_O${QdO,59aO1eQWO1G/kOOQS,59c,59cO1jQ!bO,59eO1rQWO'#DhO1}QWO,5:TO2SQWO,5:ZO&]Q[O,5:VO&]Q[O'#E[O2[QWO,5;]O2gQWO,5:XO(^Q[O,5:[OOQS1G/x1G/xOOQS1G/z1G/zOOQS1G/|1G/|O2xQWO1G/|O2}QdO'#D|OOQS1G0P1G0POOQS1G0V1G0VOOQS1G0X1G0XO3YQtO1G/gOOQO,5:u,5:uO3pQ[O,5:uOOQO-E8X-E8XO3}QWO1G0vPOOO-E8R-E8RPOOO1G.e1G.eOOQP7+$Y7+$YOOQP7+$u7+$uO${QdO7+$uOOQS1G/f1G/fO4YQXO'#EoO4aQWO,59jO4fQtO'#EVO5ZQdO'#ElO5eQWO,59ZO5jQpO7+$uOOQS1G.w1G.wOOQS1G.{1G.{OOQS7+%V7+%VO5rQWO1G/PO${QdO1G/oOOQO1G/u1G/uOOQO1G/q1G/qO5wQWO,5:vOOQO-E8Y-E8YO6VQXO1G/vOOQS7+%h7+%hO6^QYO'#CsOOQO'#EO'#EOO6iQ`O'#D}OOQO'#D}'#D}O6tQWO'#E]O6|QdO,5:hOOQS,5:h,5:hO7XQtO'#EYO${QdO'#EYO8VQdO7+%ROOQO7+%R7+%ROOQO1G0a1G0aO8jQpO<T![;'S%^;'S;=`%o<%lO%^l;TUo`Oy%^z!Q%^!Q![;g![;'S%^;'S;=`%o<%lO%^l;nYo`#b[Oy%^z!Q%^!Q![;g![!g%^!g!h<^!h#X%^#X#Y<^#Y;'S%^;'S;=`%o<%lO%^l[[o`#b[Oy%^z!O%^!O!P;g!P!Q%^!Q![>T![!g%^!g!h<^!h#X%^#X#Y<^#Y;'S%^;'S;=`%o<%lO%^n?VSp^Oy%^z;'S%^;'S;=`%o<%lO%^l?hWjWOy%^z!O%^!O!P;O!P!Q%^!Q![>T![;'S%^;'S;=`%o<%lO%^n@VU#_QOy%^z!Q%^!Q![;g![;'S%^;'S;=`%o<%lO%^~@nTjWOy%^z{@}{;'S%^;'S;=`%o<%lO%^~AUSo`#X~Oy%^z;'S%^;'S;=`%o<%lO%^lAg[#b[Oy%^z!O%^!O!P;g!P!Q%^!Q![>T![!g%^!g!h<^!h#X%^#X#Y<^#Y;'S%^;'S;=`%o<%lO%^bBbU]QOy%^z![%^![!]Bt!];'S%^;'S;=`%o<%lO%^bB{S^Qo`Oy%^z;'S%^;'S;=`%o<%lO%^nC^S!W^Oy%^z;'S%^;'S;=`%o<%lO%^dCoSzSOy%^z;'S%^;'S;=`%o<%lO%^bDQU|QOy%^z!`%^!`!aDd!a;'S%^;'S;=`%o<%lO%^bDkS|Qo`Oy%^z;'S%^;'S;=`%o<%lO%^bDzWOy%^z!c%^!c!}Ed!}#T%^#T#oEd#o;'S%^;'S;=`%o<%lO%^bEk[!YQo`Oy%^z}%^}!OEd!O!Q%^!Q![Ed![!c%^!c!}Ed!}#T%^#T#oEd#o;'S%^;'S;=`%o<%lO%^bFfSxQOy%^z;'S%^;'S;=`%o<%lO%^lFwSv[Oy%^z;'S%^;'S;=`%o<%lO%^bGWUOy%^z#b%^#b#cGj#c;'S%^;'S;=`%o<%lO%^bGoUo`Oy%^z#W%^#W#XHR#X;'S%^;'S;=`%o<%lO%^bHYS!`Qo`Oy%^z;'S%^;'S;=`%o<%lO%^bHiUOy%^z#f%^#f#gHR#g;'S%^;'S;=`%o<%lO%^fIQS!RUOy%^z;'S%^;'S;=`%o<%lO%^nIcS!Q^Oy%^z;'S%^;'S;=`%o<%lO%^fItU!PQOy%^z!_%^!_!`6y!`;'S%^;'S;=`%o<%lO%^`JZP;=`<%l$}",tokenizers:[sr,lr,ir,1,2,3,4,new se("m~RRYZ[z{a~~g~aO#Z~~dP!P!Qg~lO#[~~",28,102)],topRules:{StyleSheet:[0,4],Styles:[1,84]},specialized:[{term:97,get:O=>nr[O]||-1},{term:56,get:O=>cr[O]||-1},{term:98,get:O=>Qr[O]||-1}],tokenPrec:1169});let $e=null;function fe(){if(!$e&&typeof document=="object"&&document.body){let{style:O}=document.body,e=[],a=new Set;for(let t in O)t!="cssText"&&t!="cssFloat"&&typeof O[t]=="string"&&(/[A-Z]/.test(t)&&(t=t.replace(/[A-Z]/g,r=>"-"+r.toLowerCase())),a.has(t)||(e.push(t),a.add(t)));$e=e.sort().map(t=>({type:"property",label:t}))}return $e||[]}const rO=["active","after","any-link","autofill","backdrop","before","checked","cue","default","defined","disabled","empty","enabled","file-selector-button","first","first-child","first-letter","first-line","first-of-type","focus","focus-visible","focus-within","fullscreen","has","host","host-context","hover","in-range","indeterminate","invalid","is","lang","last-child","last-of-type","left","link","marker","modal","not","nth-child","nth-last-child","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","part","placeholder","placeholder-shown","read-only","read-write","required","right","root","scope","selection","slotted","target","target-text","valid","visited","where"].map(O=>({type:"class",label:O})),iO=["above","absolute","activeborder","additive","activecaption","after-white-space","ahead","alias","all","all-scroll","alphabetic","alternate","always","antialiased","appworkspace","asterisks","attr","auto","auto-flow","avoid","avoid-column","avoid-page","avoid-region","axis-pan","background","backwards","baseline","below","bidi-override","blink","block","block-axis","bold","bolder","border","border-box","both","bottom","break","break-all","break-word","bullets","button","button-bevel","buttonface","buttonhighlight","buttonshadow","buttontext","calc","capitalize","caps-lock-indicator","caption","captiontext","caret","cell","center","checkbox","circle","cjk-decimal","clear","clip","close-quote","col-resize","collapse","color","color-burn","color-dodge","column","column-reverse","compact","condensed","contain","content","contents","content-box","context-menu","continuous","copy","counter","counters","cover","crop","cross","crosshair","currentcolor","cursive","cyclic","darken","dashed","decimal","decimal-leading-zero","default","default-button","dense","destination-atop","destination-in","destination-out","destination-over","difference","disc","discard","disclosure-closed","disclosure-open","document","dot-dash","dot-dot-dash","dotted","double","down","e-resize","ease","ease-in","ease-in-out","ease-out","element","ellipse","ellipsis","embed","end","ethiopic-abegede-gez","ethiopic-halehame-aa-er","ethiopic-halehame-gez","ew-resize","exclusion","expanded","extends","extra-condensed","extra-expanded","fantasy","fast","fill","fill-box","fixed","flat","flex","flex-end","flex-start","footnotes","forwards","from","geometricPrecision","graytext","grid","groove","hand","hard-light","help","hidden","hide","higher","highlight","highlighttext","horizontal","hsl","hsla","hue","icon","ignore","inactiveborder","inactivecaption","inactivecaptiontext","infinite","infobackground","infotext","inherit","initial","inline","inline-axis","inline-block","inline-flex","inline-grid","inline-table","inset","inside","intrinsic","invert","italic","justify","keep-all","landscape","large","larger","left","level","lighter","lighten","line-through","linear","linear-gradient","lines","list-item","listbox","listitem","local","logical","loud","lower","lower-hexadecimal","lower-latin","lower-norwegian","lowercase","ltr","luminosity","manipulation","match","matrix","matrix3d","medium","menu","menutext","message-box","middle","min-intrinsic","mix","monospace","move","multiple","multiple_mask_images","multiply","n-resize","narrower","ne-resize","nesw-resize","no-close-quote","no-drop","no-open-quote","no-repeat","none","normal","not-allowed","nowrap","ns-resize","numbers","numeric","nw-resize","nwse-resize","oblique","opacity","open-quote","optimizeLegibility","optimizeSpeed","outset","outside","outside-shape","overlay","overline","padding","padding-box","painted","page","paused","perspective","pinch-zoom","plus-darker","plus-lighter","pointer","polygon","portrait","pre","pre-line","pre-wrap","preserve-3d","progress","push-button","radial-gradient","radio","read-only","read-write","read-write-plaintext-only","rectangle","region","relative","repeat","repeating-linear-gradient","repeating-radial-gradient","repeat-x","repeat-y","reset","reverse","rgb","rgba","ridge","right","rotate","rotate3d","rotateX","rotateY","rotateZ","round","row","row-resize","row-reverse","rtl","run-in","running","s-resize","sans-serif","saturation","scale","scale3d","scaleX","scaleY","scaleZ","screen","scroll","scrollbar","scroll-position","se-resize","self-start","self-end","semi-condensed","semi-expanded","separate","serif","show","single","skew","skewX","skewY","skip-white-space","slide","slider-horizontal","slider-vertical","sliderthumb-horizontal","sliderthumb-vertical","slow","small","small-caps","small-caption","smaller","soft-light","solid","source-atop","source-in","source-out","source-over","space","space-around","space-between","space-evenly","spell-out","square","start","static","status-bar","stretch","stroke","stroke-box","sub","subpixel-antialiased","svg_masks","super","sw-resize","symbolic","symbols","system-ui","table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row","table-row-group","text","text-bottom","text-top","textarea","textfield","thick","thin","threeddarkshadow","threedface","threedhighlight","threedlightshadow","threedshadow","to","top","transform","translate","translate3d","translateX","translateY","translateZ","transparent","ultra-condensed","ultra-expanded","underline","unidirectional-pan","unset","up","upper-latin","uppercase","url","var","vertical","vertical-text","view-box","visible","visibleFill","visiblePainted","visibleStroke","visual","w-resize","wait","wave","wider","window","windowframe","windowtext","words","wrap","wrap-reverse","x-large","x-small","xor","xx-large","xx-small"].map(O=>({type:"keyword",label:O})).concat(["aliceblue","antiquewhite","aqua","aquamarine","azure","beige","bisque","black","blanchedalmond","blue","blueviolet","brown","burlywood","cadetblue","chartreuse","chocolate","coral","cornflowerblue","cornsilk","crimson","cyan","darkblue","darkcyan","darkgoldenrod","darkgray","darkgreen","darkkhaki","darkmagenta","darkolivegreen","darkorange","darkorchid","darkred","darksalmon","darkseagreen","darkslateblue","darkslategray","darkturquoise","darkviolet","deeppink","deepskyblue","dimgray","dodgerblue","firebrick","floralwhite","forestgreen","fuchsia","gainsboro","ghostwhite","gold","goldenrod","gray","grey","green","greenyellow","honeydew","hotpink","indianred","indigo","ivory","khaki","lavender","lavenderblush","lawngreen","lemonchiffon","lightblue","lightcoral","lightcyan","lightgoldenrodyellow","lightgray","lightgreen","lightpink","lightsalmon","lightseagreen","lightskyblue","lightslategray","lightsteelblue","lightyellow","lime","limegreen","linen","magenta","maroon","mediumaquamarine","mediumblue","mediumorchid","mediumpurple","mediumseagreen","mediumslateblue","mediumspringgreen","mediumturquoise","mediumvioletred","midnightblue","mintcream","mistyrose","moccasin","navajowhite","navy","oldlace","olive","olivedrab","orange","orangered","orchid","palegoldenrod","palegreen","paleturquoise","palevioletred","papayawhip","peachpuff","peru","pink","plum","powderblue","purple","rebeccapurple","red","rosybrown","royalblue","saddlebrown","salmon","sandybrown","seagreen","seashell","sienna","silver","skyblue","slateblue","slategray","snow","springgreen","steelblue","tan","teal","thistle","tomato","turquoise","violet","wheat","white","whitesmoke","yellow","yellowgreen"].map(O=>({type:"constant",label:O}))),dr=["a","abbr","address","article","aside","b","bdi","bdo","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","dd","del","details","dfn","dialog","div","dl","dt","em","figcaption","figure","footer","form","header","hgroup","h1","h2","h3","h4","h5","h6","hr","html","i","iframe","img","input","ins","kbd","label","legend","li","main","meter","nav","ol","output","p","pre","ruby","section","select","small","source","span","strong","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","tr","u","ul"].map(O=>({type:"type",label:O})),v=/^(\w[\w-]*|-\w[\w-]*|)$/,hr=/^-(-[\w-]*)?$/;function ur(O,e){var a;if((O.name=="("||O.type.isError)&&(O=O.parent||O),O.name!="ArgList")return!1;let t=(a=O.parent)===null||a===void 0?void 0:a.firstChild;return(t==null?void 0:t.name)!="Callee"?!1:e.sliceString(t.from,t.to)=="var"}const sO=new bO,Sr=["Declaration"];function $r(O){for(let e=O;;){if(e.type.isTop)return e;if(!(e=e.parent))return O}}function AO(O,e,a){if(e.to-e.from>4096){let t=sO.get(e);if(t)return t;let r=[],s=new Set,i=e.cursor(ve.IncludeAnonymous);if(i.firstChild())do for(let l of AO(O,i.node,a))s.has(l.label)||(s.add(l.label),r.push(l));while(i.nextSibling());return sO.set(e,r),r}else{let t=[],r=new Set;return e.cursor().iterate(s=>{var i;if(a(s)&&s.matchContext(Sr)&&((i=s.node.nextSibling)===null||i===void 0?void 0:i.name)==":"){let l=O.sliceString(s.from,s.to);r.has(l)||(r.add(l),t.push({label:l,type:"variable"}))}}),t}}const fr=O=>e=>{let{state:a,pos:t}=e,r=j(a).resolveInner(t,-1),s=r.type.isError&&r.from==r.to-1&&a.doc.sliceString(r.from,r.to)=="-";if(r.name=="PropertyName"||(s||r.name=="TagName")&&/^(Block|Styles)$/.test(r.resolve(r.to).name))return{from:r.from,options:fe(),validFor:v};if(r.name=="ValueName")return{from:r.from,options:iO,validFor:v};if(r.name=="PseudoClassName")return{from:r.from,options:rO,validFor:v};if(O(r)||(e.explicit||s)&&ur(r,a.doc))return{from:O(r)||s?r.from:t,options:AO(a.doc,$r(r),O),validFor:hr};if(r.name=="TagName"){for(let{parent:n}=r;n;n=n.parent)if(n.name=="Block")return{from:r.from,options:fe(),validFor:v};return{from:r.from,options:dr,validFor:v}}if(!e.explicit)return null;let i=r.resolve(t),l=i.childBefore(t);return l&&l.name==":"&&i.name=="PseudoClassSelector"?{from:t,options:rO,validFor:v}:l&&l.name==":"&&i.name=="Declaration"||i.name=="ArgList"?{from:t,options:iO,validFor:v}:i.name=="Block"||i.name=="Styles"?{from:t,options:fe(),validFor:v}:null},gr=fr(O=>O.name=="VariableName"),ce=J.define({name:"css",parser:pr.configure({props:[L.add({Declaration:C()}),F.add({"Block KeyframeList":Te})]}),languageData:{commentTokens:{block:{open:"/*",close:"*/"}},indentOnInput:/^\s*\}$/,wordChars:"-"}});function mr(){return new M(ce,ce.data.of({autocomplete:gr}))}const Pr=304,lO=1,Zr=2,br=305,Xr=307,xr=308,Yr=3,yr=4,kr=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],IO=125,wr=59,oO=47,vr=42,Tr=43,_r=45,Wr=new wO({start:!1,shift(O,e){return e==Yr||e==yr||e==Xr?O:e==xr},strict:!1}),Ur=new x((O,e)=>{let{next:a}=O;(a==IO||a==-1||e.context)&&O.acceptToken(br)},{contextual:!0,fallback:!0}),Cr=new x((O,e)=>{let{next:a}=O,t;kr.indexOf(a)>-1||a==oO&&((t=O.peek(1))==oO||t==vr)||a!=IO&&a!=wr&&a!=-1&&!e.context&&O.acceptToken(Pr)},{contextual:!0}),Rr=new x((O,e)=>{let{next:a}=O;if((a==Tr||a==_r)&&(O.advance(),a==O.next)){O.advance();let t=!e.context&&e.canShift(lO);O.acceptToken(t?lO:Zr)}},{contextual:!0}),qr=D({"get set async static":o.modifier,"for while do if else switch try catch finally return throw break continue default case":o.controlKeyword,"in of await yield void typeof delete instanceof":o.operatorKeyword,"let var const function class extends":o.definitionKeyword,"import export from":o.moduleKeyword,"with debugger as new":o.keyword,TemplateString:o.special(o.string),super:o.atom,BooleanLiteral:o.bool,this:o.self,null:o.null,Star:o.modifier,VariableName:o.variableName,"CallExpression/VariableName TaggedTemplateExpression/VariableName":o.function(o.variableName),VariableDefinition:o.definition(o.variableName),Label:o.labelName,PropertyName:o.propertyName,PrivatePropertyName:o.special(o.propertyName),"CallExpression/MemberExpression/PropertyName":o.function(o.propertyName),"FunctionDeclaration/VariableDefinition":o.function(o.definition(o.variableName)),"ClassDeclaration/VariableDefinition":o.definition(o.className),PropertyDefinition:o.definition(o.propertyName),PrivatePropertyDefinition:o.definition(o.special(o.propertyName)),UpdateOp:o.updateOperator,"LineComment Hashbang":o.lineComment,BlockComment:o.blockComment,Number:o.number,String:o.string,Escape:o.escape,ArithOp:o.arithmeticOperator,LogicOp:o.logicOperator,BitOp:o.bitwiseOperator,CompareOp:o.compareOperator,RegExp:o.regexp,Equals:o.definitionOperator,Arrow:o.function(o.punctuation),": Spread":o.punctuation,"( )":o.paren,"[ ]":o.squareBracket,"{ }":o.brace,"InterpolationStart InterpolationEnd":o.special(o.brace),".":o.derefOperator,", ;":o.separator,"@":o.meta,TypeName:o.typeName,TypeDefinition:o.definition(o.typeName),"type enum interface implements namespace module declare":o.definitionKeyword,"abstract global Privacy readonly override":o.modifier,"is keyof unique infer":o.operatorKeyword,JSXAttributeValue:o.attributeValue,JSXText:o.content,"JSXStartTag JSXStartCloseTag JSXSelfCloseEndTag JSXEndTag":o.angleBracket,"JSXIdentifier JSXNameSpacedName":o.tagName,"JSXAttribute/JSXIdentifier JSXAttribute/JSXNameSpacedName":o.attributeName,"JSXBuiltin/JSXIdentifier":o.standard(o.tagName)}),Vr={__proto__:null,export:16,as:21,from:29,default:32,async:37,function:38,extends:48,this:52,true:60,false:60,null:72,void:76,typeof:80,super:98,new:132,delete:148,yield:157,await:161,class:166,public:223,private:223,protected:223,readonly:225,instanceof:244,satisfies:247,in:248,const:250,import:282,keyof:337,unique:341,infer:347,is:383,abstract:403,implements:405,type:407,let:410,var:412,using:415,interface:421,enum:425,namespace:431,module:433,declare:437,global:441,for:460,of:469,while:472,with:476,do:480,if:484,else:486,switch:490,case:496,try:502,catch:506,finally:510,return:514,throw:518,break:522,continue:526,debugger:530},jr={__proto__:null,async:119,get:121,set:123,declare:183,public:185,private:185,protected:185,static:187,abstract:189,override:191,readonly:197,accessor:199,new:387},zr={__proto__:null,"<":139},Gr=T.deserialize({version:14,states:"$6zO%TQUOOO%[QUOOO'_QWOOP(lOSOOO*zQ(CjO'#CgO+ROpO'#ChO+aO!bO'#ChO+oO07`O'#D[O.QQUO'#DbO.bQUO'#DmO%[QUO'#DwO0fQUO'#EPOOQ(CY'#EX'#EXO1PQSO'#EUOOQO'#Ej'#EjOOQO'#Id'#IdO1XQSO'#GlO1dQSO'#EiO1iQSO'#EiO3kQ(CjO'#JeO6[Q(CjO'#JfO6xQSO'#FXO6}Q#tO'#FpOOQ(CY'#Fa'#FaO7YO&jO'#FaO7hQ,UO'#FwO9OQSO'#FvOOQ(CY'#Jf'#JfOOQ(CW'#Je'#JeO9TQSO'#GpOOQQ'#KQ'#KQO9`QSO'#IQO9eQ(C[O'#IROOQQ'#JR'#JROOQQ'#IV'#IVQ`QUOOO`QUOOO%[QUO'#DoO9mQUO'#D{O9tQUO'#D}O9ZQSO'#GlO9{Q,UO'#CmO:ZQSO'#EhO:fQSO'#EsO:kQ,UO'#F`O;YQSO'#GlOOQO'#KR'#KRO;_QSO'#KRO;mQSO'#GtO;mQSO'#GuO;mQSO'#GwO9ZQSO'#GzO]QSO'#HZO>eQSO'#HaO>eQSO'#HcO`QUO'#HeO>eQSO'#HgO>eQSO'#HjO>jQSO'#HpO>oQ(C]O'#HvO%[QUO'#HxO>zQ(C]O'#HzO?VQ(C]O'#H|O9eQ(C[O'#IOO?bQ(CjO'#CgO@dQWO'#DgQOQSOOO%[QUO'#D}O@zQSO'#EQO9{Q,UO'#EhOAVQSO'#EhOAbQ`O'#F`OOQQ'#Ce'#CeOOQ(CW'#Dl'#DlOOQ(CW'#Ji'#JiO%[QUO'#JiOOQO'#Jm'#JmOOQO'#Ia'#IaOBbQWO'#EaOOQ(CW'#E`'#E`OC^Q(C`O'#EaOChQWO'#ETOOQO'#Jl'#JlOC|QWO'#JmOEZQWO'#ETOChQWO'#EaPEhO?MpO'#C`POOO)CDp)CDpOOOO'#IW'#IWOEsOpO,59SOOQ(CY,59S,59SOOOO'#IX'#IXOFRO!bO,59SO%[QUO'#D^OOOO'#IZ'#IZOFaO07`O,59vOOQ(CY,59v,59vOFoQUO'#I[OGSQSO'#JgOIUQbO'#JgO+}QUO'#JgOI]QSO,59|OIsQSO'#EjOJQQSO'#JuOJ]QSO'#JtOJ]QSO'#JtOJeQSO,5;WOJjQSO'#JsOOQ(CY,5:X,5:XOJqQUO,5:XOLrQ(CjO,5:cOMcQSO,5:kOM|Q(C[O'#JrONTQSO'#JqO9TQSO'#JqONiQSO'#JqONqQSO,5;VONvQSO'#JqO!#OQbO'#JfOOQ(CY'#Cg'#CgO%[QUO'#EPO!#nQ`O,5:pOOQO'#Jn'#JnOOQO-ElOOQQ'#JZ'#JZOOQQ,5>m,5>mOOQQ-ExQ(CjO,5:iOOQO,5@m,5@mO!?iQ,UO,5=WO!?wQ(C[O'#J[O9OQSO'#J[O!@YQ(C[O,59XO!@eQWO,59XO!@mQ,UO,59XO9{Q,UO,59XO!@xQSO,5;TO!AQQSO'#HYO!AcQSO'#KVO%[QUO,5;xO!7cQWO,5;zO!AkQSO,5=sO!ApQSO,5=sO!AuQSO,5=sO9eQ(C[O,5=sO;mQSO,5=cOOQO'#Cs'#CsO!BTQWO,5=`O!B]Q,UO,5=aO!BhQSO,5=cO!BmQ`O,5=fO!BuQSO'#KRO>jQSO'#HPO9ZQSO'#HRO!BzQSO'#HRO9{Q,UO'#HTO!CPQSO'#HTOOQQ,5=i,5=iO!CUQSO'#HUO!C^QSO'#CmO!CcQSO,58}O!CmQSO,58}O!ErQUO,58}OOQQ,58},58}O!FSQ(C[O,58}O%[QUO,58}O!H_QUO'#H]OOQQ'#H^'#H^OOQQ'#H_'#H_O`QUO,5=uO!HuQSO,5=uO`QUO,5={O`QUO,5=}O!HzQSO,5>PO`QUO,5>RO!IPQSO,5>UO!IUQUO,5>[OOQQ,5>b,5>bO%[QUO,5>bO9eQ(C[O,5>dOOQQ,5>f,5>fO!M`QSO,5>fOOQQ,5>h,5>hO!M`QSO,5>hOOQQ,5>j,5>jO!MeQWO'#DYO%[QUO'#JiO!NSQWO'#JiO!NqQWO'#DhO# SQWO'#DhO##eQUO'#DhO##lQSO'#JhO##tQSO,5:RO##yQSO'#EnO#$XQSO'#JvO#$aQSO,5;XO#$fQWO'#DhO#$sQWO'#ESOOQ(CY,5:l,5:lO%[QUO,5:lO#$zQSO,5:lO>jQSO,5;SO!@eQWO,5;SO!@mQ,UO,5;SO9{Q,UO,5;SO#%SQSO,5@TO#%XQ!LQO,5:pOOQO-E<_-E<_O#&_Q(C`O,5:{OChQWO,5:oO#&iQWO,5:oOChQWO,5:{O!@YQ(C[O,5:oOOQ(CW'#Ed'#EdOOQO,5:{,5:{O%[QUO,5:{O#&vQ(C[O,5:{O#'RQ(C[O,5:{O!@eQWO,5:oOOQO,5;R,5;RO#'aQ(C[O,5:{POOO'#IU'#IUP#'uO?MpO,58zPOOO,58z,58zOOOO-EvO+}QUO,5>vOOQO,5>|,5>|O#(aQUO'#I[OOQO-ERQ(CjO1G0yO#?yQ(CjO1G0yO#ByQ$IUO'#CgO#DwQ$IUO1G1[O#EOQ$IUO'#JfO!,YQSO1G1bO#E`Q(CjO,5?SOOQ(CW-EeQSO1G3kO$.VQUO1G3mO$2ZQUO'#HlOOQQ1G3p1G3pO$2hQSO'#HrO>jQSO'#HtOOQQ1G3v1G3vO$2pQUO1G3vO9eQ(C[O1G3|OOQQ1G4O1G4OOOQ(CW'#GX'#GXO9eQ(C[O1G4QO9eQ(C[O1G4SO$6wQSO,5@TO!*SQUO,5;YO9TQSO,5;YO>jQSO,5:SO!*SQUO,5:SO!@eQWO,5:SO$6|Q$IUO,5:SOOQO,5;Y,5;YO$7WQWO'#I]O$7nQSO,5@SOOQ(CY1G/m1G/mO$7vQWO'#IcO$8QQSO,5@bOOQ(CW1G0s1G0sO# SQWO,5:SOOQO'#I`'#I`O$8YQWO,5:nOOQ(CY,5:n,5:nO#$}QSO1G0WOOQ(CY1G0W1G0WO%[QUO1G0WOOQ(CY1G0n1G0nO>jQSO1G0nO!@eQWO1G0nO!@mQ,UO1G0nOOQ(CW1G5o1G5oO!@YQ(C[O1G0ZOOQO1G0g1G0gO%[QUO1G0gO$8aQ(C[O1G0gO$8lQ(C[O1G0gO!@eQWO1G0ZOChQWO1G0ZO$8zQ(C[O1G0gOOQO1G0Z1G0ZO$9`Q(CjO1G0gPOOO-EvO$9|QSO1G5mO$:UQSO1G5zO$:^QbO1G5{O9TQSO,5>|O$:hQ(CjO1G5xO%[QUO1G5xO$:xQ(C[O1G5xO$;ZQSO1G5wO$;ZQSO1G5wO9TQSO1G5wO$;cQSO,5?PO9TQSO,5?POOQO,5?P,5?PO$;wQSO,5?PO$$XQSO,5?POOQO-ExQ(CjO,5WOOQQ,5>W,5>WO%[QUO'#HmO%(ZQSO'#HoOOQQ,5>^,5>^O9TQSO,5>^OOQQ,5>`,5>`OOQQ7+)b7+)bOOQQ7+)h7+)hOOQQ7+)l7+)lOOQQ7+)n7+)nO%(`QWO1G5oO%(tQ$IUO1G0tO%)OQSO1G0tOOQO1G/n1G/nO%)ZQ$IUO1G/nO>jQSO1G/nO!*SQUO'#DhOOQO,5>w,5>wOOQO-E},5>}OOQO-EjQSO7+&YO!@eQWO7+&YOOQO7+%u7+%uO$9`Q(CjO7+&ROOQO7+&R7+&RO%[QUO7+&RO%)eQ(C[O7+&RO!@YQ(C[O7+%uO!@eQWO7+%uO%)pQ(C[O7+&RO%*OQ(CjO7++dO%[QUO7++dO%*`QSO7++cO%*`QSO7++cOOQO1G4k1G4kO9TQSO1G4kO%*hQSO1G4kOOQO7+%z7+%zO#$}QSO<xOOQO-E<[-E<[O%2^QbO,5>yO%[QUO,5>yOOQO-E<]-E<]O%2hQSO1G5qOOQ(CY<XQ$IUO1G0yO%>`Q$IUO1G0yO%@WQ$IUO1G0yO%@kQ(CjO<XOOQQ,5>Z,5>ZO%NUQSO1G3xO9TQSO7+&`O!*SQUO7+&`OOQO7+%Y7+%YO%NZQ$IUO1G5{O>jQSO7+%YOOQ(CY<jQSO<jQSO7+)dO&5rQSO<{AN>{O%[QUOAN?XOOQO<SQSO7++uO%LgQSOANAyOOQQANAyANAyO!&^Q,UOANAyO&>[QSOANAyOOQQANA{ANA{O9eQ(C[OANA{O#MzQSOANA{OOQO'#HW'#HWOOQO7+*e7+*eOOQQG22uG22uOOQQANEPANEPOOQQANEQANEQOOQQANBTANBTO&>dQSOANBTOOQQ<iQSOLD,jO&>qQ$IUO7+'tO&@gQ$IUO7+'vO&B]Q,UOG26|OOQO<YOPZXYZXlZXzZX{ZX}ZX!fZX!gZX!iZX!mZX#YZX#edX#hZX#iZX#jZX#kZX#lZX#mZX#nZX#oZX#pZX#rZX#tZX#vZX#wZX#|ZX(TZX(dZX(kZX(lZX!WZX!XZX~O#zZX~P#@sOP$]OY:YOl9|Oz#yO{#zO}#{O!f:OO!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O#l9}O#m:OO#n:OO#o:XO#p:OO#r:PO#t:RO#v:TO#w:UO(TVO(d$ZO(k#|O(l#}O~O#z.iO~P#CQO#Y:ZO#|:ZO#z(YX!X(YX~P! UO_'[a!W'[a'm'[a'k'[a!h'[a!T'[ap'[a!Y'[a%b'[a!b'[a~P!7zOP#giY#gi_#gil#gi{#gi!W#gi!f#gi!g#gi!i#gi!m#gi#h#gi#i#gi#j#gi#k#gi#l#gi#m#gi#n#gi#o#gi#p#gi#r#gi#t#gi#v#gi#w#gi'm#gi(T#gi(d#gi'k#gi!T#gi!h#gip#gi!Y#gi%b#gi!b#gi~P#,gO_#{i!W#{i'm#{i'k#{i!T#{i!h#{ip#{i!Y#{i%b#{i!b#{i~P!7zO$X.nO$Z.nO~O$X.oO$Z.oO~O!b)_O#Y.pO!Y$_X$U$_X$X$_X$Z$_X$b$_X~O!V.qO~O!Y)bO$U.sO$X)aO$Z)aO$b.tO~O!W:VO!X(XX~P#CQO!X.uO~O!b)_O$b(mX~O$b.wO~Or)qO(U)rO(V.zO~O!T/OO~P!&^O!WdX!bdX!hdX!h$tX(ddX~P!/bO!h/UO~P#,gO!W/VO!b#uO(d'gO!h(qX~O!h/[O~O!V*SO'v%`O!h(qP~O#e/^O~O!T$tX!W$tX!b${X~P!/bO!W/_O!T(rX~P#,gO!b/aO~O!T/cO~Ol/gO!b#uO!i%^O(P%RO(d'gO~O'v/iO~O!b+YO~O_%gO!W/mO'm%gO~O!X/oO~P!3`O!^/pO!_/pO'w!lO(W!mO~O}/rO(W!mO~O#U/sO~O'v&QOe'aX!W'aX~O!W*lOe(Qa~Oe/xO~Oz/yO{/yO}/zOhwa(kwa(lwa!Wwa#Ywa~Oewa#zwa~P$ hOz)vO})wOh$ma(k$ma(l$ma!W$ma#Y$ma~Oe$ma#z$ma~P$!^Oz)vO})wOh$oa(k$oa(l$oa!W$oa#Y$oa~Oe$oa#z$oa~P$#PO#e/|O~Oe$}a!W$}a#Y$}a#z$}a~P!0kO!b#uO~O#e0PO~O!W*}O_(va'm(va~Oz#yO{#zO}#{O!g#wO!i#xO(TVOP!oiY!oil!oi!W!oi!f!oi!m!oi#h!oi#i!oi#j!oi#k!oi#l!oi#m!oi#n!oi#o!oi#p!oi#r!oi#t!oi#v!oi#w!oi(d!oi(k!oi(l!oi~O_!oi'm!oi'k!oi!T!oi!h!oip!oi!Y!oi%b!oi!b!oi~P$$nOh.UO!Y'VO%b.TO~Oj0ZO'v0YO~P!1]O!b+YO_(Oa!Y(Oa'm(Oa!W(Oa~O#e0aO~OYZX!WdX!XdX~O!W0bO!X(zX~O!X0dO~OY0eO~O'v+bO'xTO'{UO~O!Y%wO'v%`O^'iX!W'iX~O!W+gO^(ya~O!h0jO~P!7zOY0mO~O^0nO~O#Y0qO~Oh0tO!Y$|O~O(W(tO!X(wP~Oh0}O!Y0zO%b0|O(P%RO~OY1XO!W1VO!X(xX~O!X1YO~O^1[O_%gO'm%gO~O'v#mO'xTO'{UO~O#Y$eO#|$eOP(YXY(YXl(YXz(YX{(YX}(YX!W(YX!f(YX!i(YX!m(YX#h(YX#i(YX#j(YX#k(YX#l(YX#m(YX#n(YX#o(YX#r(YX#t(YX#v(YX#w(YX(T(YX(d(YX(k(YX(l(YX~O#p1_O&S1`O_(YX!g(YX~P$+dO#Y$eO#p1_O&S1`O~O_1bO~P%[O_1dO~O&]1gOP&ZiQ&ZiW&Zi_&Zib&Zic&Zij&Zil&Zim&Zin&Zit&Ziv&Zix&Zi}&Zi!R&Zi!S&Zi!Y&Zi!d&Zi!i&Zi!l&Zi!m&Zi!n&Zi!p&Zi!r&Zi!u&Zi!y&Zi#q&Zi$R&Zi$V&Zi%a&Zi%c&Zi%e&Zi%f&Zi%g&Zi%j&Zi%l&Zi%o&Zi%p&Zi%r&Zi&O&Zi&U&Zi&W&Zi&Y&Zi&[&Zi&_&Zi&e&Zi&k&Zi&m&Zi&o&Zi&q&Zi&s&Zi'k&Zi'v&Zi'x&Zi'{&Zi(T&Zi(c&Zi(p&Zi!X&Zi`&Zi&b&Zi~O`1mO!X1kO&b1lO~P`O!YXO!i1oO~O&i,jOP&diQ&diW&di_&dib&dic&dij&dil&dim&din&dit&div&dix&di}&di!R&di!S&di!Y&di!d&di!i&di!l&di!m&di!n&di!p&di!r&di!u&di!y&di#q&di$R&di$V&di%a&di%c&di%e&di%f&di%g&di%j&di%l&di%o&di%p&di%r&di&O&di&U&di&W&di&Y&di&[&di&_&di&e&di&k&di&m&di&o&di&q&di&s&di'k&di'v&di'x&di'{&di(T&di(c&di(p&di!X&di&]&di`&di&b&di~O!T1uO~O!W![a!X![a~P#CQOm!nO}!oO!V1{O(W!mO!W'PX!X'PX~P@OO!W,zO!X([a~O!W'VX!X'VX~P!7SO!W,}O!X(ja~O!X2SO~P'_O_%gO#Y2]O'm%gO~O_%gO!b#uO#Y2]O'm%gO~O_%gO!b#uO!m2aO#Y2]O'm%gO(d'gO~O_%gO'm%gO~P!7zO!W$aOp$la~O!T'Oi!W'Oi~P!7zO!W'{O!T(Zi~O!W(SO!T(hi~O!T(ii!W(ii~P!7zO!W(fi!h(fi_(fi'm(fi~P!7zO#Y2cO!W(fi!h(fi_(fi'm(fi~O!W(`O!h(ei~O}%aO!Y%bO!y]O#c2hO#d2gO'v%`O~O}%aO!Y%bO#d2gO'v%`O~Oh2oO!Y'VO%b2nO~Oh2oO!Y'VO%b2nO(P%RO~O#ewaPwaYwa_walwa!fwa!gwa!iwa!mwa#hwa#iwa#jwa#kwa#lwa#mwa#nwa#owa#pwa#rwa#twa#vwa#wwa'mwa(Twa(dwa!hwa!Twa'kwapwa!Ywa%bwa!bwa~P$ hO#e$maP$maY$ma_$mal$ma{$ma!f$ma!g$ma!i$ma!m$ma#h$ma#i$ma#j$ma#k$ma#l$ma#m$ma#n$ma#o$ma#p$ma#r$ma#t$ma#v$ma#w$ma'm$ma(T$ma(d$ma!h$ma!T$ma'k$map$ma!Y$ma%b$ma!b$ma~P$!^O#e$oaP$oaY$oa_$oal$oa{$oa!f$oa!g$oa!i$oa!m$oa#h$oa#i$oa#j$oa#k$oa#l$oa#m$oa#n$oa#o$oa#p$oa#r$oa#t$oa#v$oa#w$oa'm$oa(T$oa(d$oa!h$oa!T$oa'k$oap$oa!Y$oa%b$oa!b$oa~P$#PO#e$}aP$}aY$}a_$}al$}a{$}a!W$}a!f$}a!g$}a!i$}a!m$}a#h$}a#i$}a#j$}a#k$}a#l$}a#m$}a#n$}a#o$}a#p$}a#r$}a#t$}a#v$}a#w$}a'm$}a(T$}a(d$}a!h$}a!T$}a'k$}a#Y$}ap$}a!Y$}a%b$}a!b$}a~P#,gO_#]q!W#]q'm#]q'k#]q!T#]q!h#]qp#]q!Y#]q%b#]q!b#]q~P!7zOe'QX!W'QX~P!'vO!W._Oe(^a~O!V2wO!W'RX!h'RX~P%[O!W.bO!h(_a~O!W.bO!h(_a~P!7zO!T2zO~O#z!ka!X!ka~PJxO#z!ca!W!ca!X!ca~P#CQO#z!oa!X!oa~P!:eO#z!qa!X!qa~P!=OO!Y3^O$VfO$`3_O~O!X3cO~Op3dO~P#,gO_$iq!W$iq'm$iq'k$iq!T$iq!h$iqp$iq!Y$iq%b$iq!b$iq~P!7zO!T3eO~P#,gOz)vO})wO(l){Oh%Yi(k%Yi!W%Yi#Y%Yi~Oe%Yi#z%Yi~P$I|Oz)vO})wOh%[i(k%[i(l%[i!W%[i#Y%[i~Oe%[i#z%[i~P$JoO(d$ZO~P#,gO!V3hO'v%`O!W']X!h']X~O!W/VO!h(qa~O!W/VO!b#uO!h(qa~O!W/VO!b#uO(d'gO!h(qa~Oe$vi!W$vi#Y$vi#z$vi~P!0kO!V3pO'v*XO!T'_X!W'_X~P!1YO!W/_O!T(ra~O!W/_O!T(ra~P#,gO!b#uO#p3xO~Ol3{O!b#uO(d'gO~Oe(Ri!W(Ri~P!0kO#Y4OOe(Ri!W(Ri~P!0kO!h4RO~O_$jq!W$jq'm$jq'k$jq!T$jq!h$jqp$jq!Y$jq%b$jq!b$jq~P!7zO!T4VO~O!W4WO!Y(sX~P#,gO!g#wO~P4XO_$tX!Y$tX%VZX'm$tX!W$tX~P!/bO%V4YO_iXhiXziX}iX!YiX'miX(kiX(liX!WiX~O%V4YO~O%c4aO'v+bO'xTO'{UO!W'hX!X'hX~O!W0bO!X(za~OY4eO~O^4fO~O_%gO'm%gO~P#,gO!Y$|O~P#,gO!W4nO#Y4pO!X(wX~O!X4qO~Om!nO}4rO!]!xO!^!uO!_!uO!y9rO!}!pO#O!pO#P!pO#Q!pO#R!pO#U4wO#V!yO'w!lO'xTO'{UO(W!mO(c!sO~O!X4vO~P%$nOh4|O!Y0zO%b4{O~Oh4|O!Y0zO%b4{O(P%RO~O'v#mO!W'gX!X'gX~O!W1VO!X(xa~O'xTO'{UO(W5VO~O^5ZO~O#p5^O&S5_O~PMhO!h5`O~P%[O_5bO~O_5bO~P%[O`1mO!X5gO&b1lO~P`O!b5iO~O!b5kO!W(]i!X(]i!b(]i!i(]i(P(]i~O!W#bi!X#bi~P#CQO#Y5lO!W#bi!X#bi~O!W![i!X![i~P#CQO_%gO#Y5uO'm%gO~O_%gO!b#uO#Y5uO'm%gO~O!W(fq!h(fq_(fq'm(fq~P!7zO!W(`O!h(eq~O}%aO!Y%bO#d5|O'v%`O~O!Y'VO%b6PO~Oh6SO!Y'VO%b6PO~O#e%YiP%YiY%Yi_%Yil%Yi{%Yi!f%Yi!g%Yi!i%Yi!m%Yi#h%Yi#i%Yi#j%Yi#k%Yi#l%Yi#m%Yi#n%Yi#o%Yi#p%Yi#r%Yi#t%Yi#v%Yi#w%Yi'm%Yi(T%Yi(d%Yi!h%Yi!T%Yi'k%Yip%Yi!Y%Yi%b%Yi!b%Yi~P$I|O#e%[iP%[iY%[i_%[il%[i{%[i!f%[i!g%[i!i%[i!m%[i#h%[i#i%[i#j%[i#k%[i#l%[i#m%[i#n%[i#o%[i#p%[i#r%[i#t%[i#v%[i#w%[i'm%[i(T%[i(d%[i!h%[i!T%[i'k%[ip%[i!Y%[i%b%[i!b%[i~P$JoO#e$viP$viY$vi_$vil$vi{$vi!W$vi!f$vi!g$vi!i$vi!m$vi#h$vi#i$vi#j$vi#k$vi#l$vi#m$vi#n$vi#o$vi#p$vi#r$vi#t$vi#v$vi#w$vi'm$vi(T$vi(d$vi!h$vi!T$vi'k$vi#Y$vip$vi!Y$vi%b$vi!b$vi~P#,gOe'Qa!W'Qa~P!0kO!W'Ra!h'Ra~P!7zO!W.bO!h(_i~O#z#]i!W#]i!X#]i~P#CQOP$]Oz#yO{#zO}#{O!g#wO!i#xO!m$]O(TVOY#gil#gi!f#gi#i#gi#j#gi#k#gi#l#gi#m#gi#n#gi#o#gi#p#gi#r#gi#t#gi#v#gi#w#gi#z#gi(d#gi(k#gi(l#gi!W#gi!X#gi~O#h#gi~P%2}O#h9zO~P%2}OP$]Oz#yO{#zO}#{O!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O(TVOY#gi!f#gi#l#gi#m#gi#n#gi#o#gi#p#gi#r#gi#t#gi#v#gi#w#gi#z#gi(d#gi(k#gi(l#gi!W#gi!X#gi~Ol#gi~P%5YOl9|O~P%5YOP$]Ol9|Oz#yO{#zO}#{O!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O#l9}O(TVO#r#gi#t#gi#v#gi#w#gi#z#gi(d#gi(k#gi(l#gi!W#gi!X#gi~OY#gi!f#gi#m#gi#n#gi#o#gi#p#gi~P%7eOY:YO!f:OO#m:OO#n:OO#o:XO#p:OO~P%7eOP$]OY:YOl9|Oz#yO{#zO}#{O!f:OO!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O#l9}O#m:OO#n:OO#o:XO#p:OO#r:PO(TVO#t#gi#v#gi#w#gi#z#gi(d#gi(l#gi!W#gi!X#gi~O(k#gi~P%:PO(k#|O~P%:POP$]OY:YOl9|Oz#yO{#zO}#{O!f:OO!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O#l9}O#m:OO#n:OO#o:XO#p:OO#r:PO#t:RO(TVO(k#|O#v#gi#w#gi#z#gi(d#gi!W#gi!X#gi~O(l#gi~P%<[O(l#}O~P%<[OP$]OY:YOl9|Oz#yO{#zO}#{O!f:OO!g#wO!i#xO!m$]O#h9zO#i9{O#j9{O#k9{O#l9}O#m:OO#n:OO#o:XO#p:OO#r:PO#t:RO#v:TO(TVO(k#|O(l#}O~O#w#gi#z#gi(d#gi!W#gi!X#gi~P%>gO_#xy!W#xy'm#xy'k#xy!T#xy!h#xyp#xy!Y#xy%b#xy!b#xy~P!7zOh;mOz)vO})wO(k)yO(l){O~OP#giY#gil#gi{#gi!f#gi!g#gi!i#gi!m#gi#h#gi#i#gi#j#gi#k#gi#l#gi#m#gi#n#gi#o#gi#p#gi#r#gi#t#gi#v#gi#w#gi#z#gi(T#gi(d#gi!W#gi!X#gi~P%A_O!g#wOP(SXY(SXh(SXl(SXz(SX{(SX}(SX!f(SX!i(SX!m(SX#h(SX#i(SX#j(SX#k(SX#l(SX#m(SX#n(SX#o(SX#p(SX#r(SX#t(SX#v(SX#w(SX#z(SX(T(SX(d(SX(k(SX(l(SX!W(SX!X(SX~O#z#{i!W#{i!X#{i~P#CQO#z!oi!X!oi~P$$nO!X6`O~O!W'[a!X'[a~P#CQO!b#uO(d'gO!W']a!h']a~O!W/VO!h(qi~O!W/VO!b#uO!h(qi~Oe$vq!W$vq#Y$vq#z$vq~P!0kO!T'_a!W'_a~P#,gO!b6gO~O!W/_O!T(ri~P#,gO!W/_O!T(ri~O!T6kO~O!b#uO#p6pO~Ol6qO!b#uO(d'gO~O!T6sO~Oe$xq!W$xq#Y$xq#z$xq~P!0kO_$jy!W$jy'm$jy'k$jy!T$jy!h$jyp$jy!Y$jy%b$jy!b$jy~P!7zO!b5kO~O!W4WO!Y(sa~O_#]y!W#]y'm#]y'k#]y!T#]y!h#]yp#]y!Y#]y%b#]y!b#]y~P!7zOY6xO~O!W0bO!X(zi~O^7OO~O(W(tO!W'dX!X'dX~O!W4nO!X(wa~OjkO'v7VO~P.iO!X7YO~P%$nOm!nO}7ZO'xTO'{UO(W!mO(c!sO~O!Y0zO~O!Y0zO%b7]O~Oh7`O!Y0zO%b7]O~OY7eO!W'ga!X'ga~O!W1VO!X(xi~O!h7iO~O!h7jO~O!h7mO~O!h7mO~P%[O_7oO~O!b7pO~O!h7qO~O!W(ii!X(ii~P#CQO_%gO#Y7yO'm%gO~O!W(fy!h(fy_(fy'm(fy~P!7zO!W(`O!h(ey~O!Y'VO%b7|O~O#e$vqP$vqY$vq_$vql$vq{$vq!W$vq!f$vq!g$vq!i$vq!m$vq#h$vq#i$vq#j$vq#k$vq#l$vq#m$vq#n$vq#o$vq#p$vq#r$vq#t$vq#v$vq#w$vq'm$vq(T$vq(d$vq!h$vq!T$vq'k$vq#Y$vqp$vq!Y$vq%b$vq!b$vq~P#,gO#e$xqP$xqY$xq_$xql$xq{$xq!W$xq!f$xq!g$xq!i$xq!m$xq#h$xq#i$xq#j$xq#k$xq#l$xq#m$xq#n$xq#o$xq#p$xq#r$xq#t$xq#v$xq#w$xq'm$xq(T$xq(d$xq!h$xq!T$xq'k$xq#Y$xqp$xq!Y$xq%b$xq!b$xq~P#,gO!W'Ri!h'Ri~P!7zO#z#]q!W#]q!X#]q~P#CQOz/yO{/yO}/zOPwaYwahwalwa!fwa!gwa!iwa!mwa#hwa#iwa#jwa#kwa#lwa#mwa#nwa#owa#pwa#rwa#twa#vwa#wwa#zwa(Twa(dwa(kwa(lwa!Wwa!Xwa~Oz)vO})wOP$maY$mah$mal$ma{$ma!f$ma!g$ma!i$ma!m$ma#h$ma#i$ma#j$ma#k$ma#l$ma#m$ma#n$ma#o$ma#p$ma#r$ma#t$ma#v$ma#w$ma#z$ma(T$ma(d$ma(k$ma(l$ma!W$ma!X$ma~Oz)vO})wOP$oaY$oah$oal$oa{$oa!f$oa!g$oa!i$oa!m$oa#h$oa#i$oa#j$oa#k$oa#l$oa#m$oa#n$oa#o$oa#p$oa#r$oa#t$oa#v$oa#w$oa#z$oa(T$oa(d$oa(k$oa(l$oa!W$oa!X$oa~OP$}aY$}al$}a{$}a!f$}a!g$}a!i$}a!m$}a#h$}a#i$}a#j$}a#k$}a#l$}a#m$}a#n$}a#o$}a#p$}a#r$}a#t$}a#v$}a#w$}a#z$}a(T$}a(d$}a!W$}a!X$}a~P%A_O#z$iq!W$iq!X$iq~P#CQO#z$jq!W$jq!X$jq~P#CQO!X8WO~O#z8XO~P!0kO!b#uO!W']i!h']i~O!b#uO(d'gO!W']i!h']i~O!W/VO!h(qq~O!T'_i!W'_i~P#,gO!W/_O!T(rq~O!T8_O~P#,gO!T8_O~Oe(Ry!W(Ry~P!0kO!W'ba!Y'ba~P#,gO_%Uq!Y%Uq'm%Uq!W%Uq~P#,gOY8dO~O!W0bO!X(zq~O#Y8hO!W'da!X'da~O!W4nO!X(wi~P#CQOPZXYZXlZXzZX{ZX}ZX!TZX!WZX!fZX!gZX!iZX!mZX#YZX#edX#hZX#iZX#jZX#kZX#lZX#mZX#nZX#oZX#pZX#rZX#tZX#vZX#wZX#|ZX(TZX(dZX(kZX(lZX~O!b%SX#p%SX~P&2_O!Y0zO%b8lO~O'xTO'{UO(W8qO~O!W1VO!X(xq~O!h8tO~O!h8uO~O!h8vO~O!h8vO~P%[O#Y8yO!W#by!X#by~O!W#by!X#by~P#CQO!Y'VO%b9OO~O#z#xy!W#xy!X#xy~P#CQOP$viY$vil$vi{$vi!f$vi!g$vi!i$vi!m$vi#h$vi#i$vi#j$vi#k$vi#l$vi#m$vi#n$vi#o$vi#p$vi#r$vi#t$vi#v$vi#w$vi#z$vi(T$vi(d$vi!W$vi!X$vi~P%A_Oz)vO})wO(l){OP%YiY%Yih%Yil%Yi{%Yi!f%Yi!g%Yi!i%Yi!m%Yi#h%Yi#i%Yi#j%Yi#k%Yi#l%Yi#m%Yi#n%Yi#o%Yi#p%Yi#r%Yi#t%Yi#v%Yi#w%Yi#z%Yi(T%Yi(d%Yi(k%Yi!W%Yi!X%Yi~Oz)vO})wOP%[iY%[ih%[il%[i{%[i!f%[i!g%[i!i%[i!m%[i#h%[i#i%[i#j%[i#k%[i#l%[i#m%[i#n%[i#o%[i#p%[i#r%[i#t%[i#v%[i#w%[i#z%[i(T%[i(d%[i(k%[i(l%[i!W%[i!X%[i~O#z$jy!W$jy!X$jy~P#CQO#z#]y!W#]y!X#]y~P#CQO!b#uO!W']q!h']q~O!W/VO!h(qy~O!T'_q!W'_q~P#,gO!T9VO~P#,gO!W0bO!X(zy~O!W4nO!X(wq~O!Y0zO%b9^O~O!h9aO~O!Y'VO%b9fO~OP$vqY$vql$vq{$vq!f$vq!g$vq!i$vq!m$vq#h$vq#i$vq#j$vq#k$vq#l$vq#m$vq#n$vq#o$vq#p$vq#r$vq#t$vq#v$vq#w$vq#z$vq(T$vq(d$vq!W$vq!X$vq~P%A_OP$xqY$xql$xq{$xq!f$xq!g$xq!i$xq!m$xq#h$xq#i$xq#j$xq#k$xq#l$xq#m$xq#n$xq#o$xq#p$xq#r$xq#t$xq#v$xq#w$xq#z$xq(T$xq(d$xq!W$xq!X$xq~P%A_Oe%^!Z!W%^!Z#Y%^!Z#z%^!Z~P!0kO!W'dq!X'dq~P#CQO!W#b!Z!X#b!Z~P#CQO#e%^!ZP%^!ZY%^!Z_%^!Zl%^!Z{%^!Z!W%^!Z!f%^!Z!g%^!Z!i%^!Z!m%^!Z#h%^!Z#i%^!Z#j%^!Z#k%^!Z#l%^!Z#m%^!Z#n%^!Z#o%^!Z#p%^!Z#r%^!Z#t%^!Z#v%^!Z#w%^!Z'm%^!Z(T%^!Z(d%^!Z!h%^!Z!T%^!Z'k%^!Z#Y%^!Zp%^!Z!Y%^!Z%b%^!Z!b%^!Z~P#,gOP%^!ZY%^!Zl%^!Z{%^!Z!f%^!Z!g%^!Z!i%^!Z!m%^!Z#h%^!Z#i%^!Z#j%^!Z#k%^!Z#l%^!Z#m%^!Z#n%^!Z#o%^!Z#p%^!Z#r%^!Z#t%^!Z#v%^!Z#w%^!Z#z%^!Z(T%^!Z(d%^!Z!W%^!Z!X%^!Z~P%A_Op(XX~P1qO'w!lO~P!*SO!TdX!WdX#YdX~P&2_OPZXYZXlZXzZX{ZX}ZX!WZX!WdX!fZX!gZX!iZX!mZX#YZX#YdX#edX#hZX#iZX#jZX#kZX#lZX#mZX#nZX#oZX#pZX#rZX#tZX#vZX#wZX#|ZX(TZX(dZX(kZX(lZX~O!bdX!hZX!hdX(ddX~P&GuOP9qOQ9qOb;bOc!iOjkOl9qOmkOnkOtkOv9qOx9qO}WO!RkO!SkO!YXO!d9tO!iZO!l9qO!m9qO!n9qO!p9uO!r9xO!u!hO$R!kO$VfO'v)UO'xTO'{UO(TVO(c[O(p;`O~O!W:VO!X$la~Oj%SOl$tOm$sOn$sOt%TOv%UOx:]O}${O!Y$|O!d;gO!i$xO#d:cO$R%YO$n:_O$p:aO$s%ZO'v(lO'xTO'{UO(P%RO(T$uO~O#q)]O~P&LkO!XZX!XdX~P&GuO#e9yO~O!b#uO#e9yO~O#Y:ZO~O#p:OO~O#Y:eO!W(iX!X(iX~O#Y:ZO!W(gX!X(gX~O#e:fO~Oe:hO~P!0kO#e:mO~O#e:nO~O!b#uO#e:oO~O!b#uO#e:fO~O#z:pO~P#CQO#e:qO~O#e:rO~O#e:sO~O#e:tO~O#e:uO~O#e:vO~O#z:wO~P!0kO#z:xO~P!0kO$V~!g!}#O#Q#R#U#c#d#o(p$n$p$s%V%a%b%c%j%l%o%p%r%t~'qR$V(p#i!S'o'w#jm#h#klz'p(W'p'v$X$Z$X~",goto:"$'R)OPPPP)PPP)SP)eP*t.xPPPP5YPP5pP;l>sP?WP?WPPP?WP@xP?WP?WP?WP@|PPARPAlPFdPPPFhPPPPFhIiPPPIoJjPFhPLxPPPP! WFhPPPFhPFhP!#fFhP!&z!'|!(VP!(y!(}!(yPPPPP!,Y!'|PP!,v!-pP!0dFhFh!0i!3s!8Y!8Y!wP#@W#@_#@gPPPP#Du#Gl#NT#NW#NZ$ S$ V$ Y$ a$ iPP$ o$ s$!k$#j$#n$$SPP$$W$$^$$bP$$e$$i$$l$%b$%y$&b$&f$&i$&l$&r$&u$&y$&}R!{RoqOXst!Z#c%f&i&k&l&n,b,g1g1jY!uQ'V-S0z4uQ%lvQ%tyQ%{|Q&a!VS&}!e,zQ']!iS'c!r!xS*_$|*dQ+`%uQ+m%}Q,R&ZQ-Q'UQ-['^Q-d'dQ/p*fQ1U,SR:d9u%OdOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|,_,b,g-W-`-n-t.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2w4r4|5^5_5b5u7Z7`7o7yS#p]9r!r)W$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cQ*o%VQ+e%wQ,T&^Q,[&fQ.X:[Q0W+WQ0[+YQ0g+fQ1^,YQ2k.UQ4`0bQ5T1VQ6R2oQ6X:]Q6z4aR8P6S&|kOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;ct!nQ!r!u!x!y&}'U'V'c'd'e,z-Q-S-d0z4u4w$^$si#u#w$c$d$x${%W%X%])q)w)z)|)}*U*[*j*k+V+Y+q+t.T._/P/^/_/a/|0q0t0|2n3f3p3x4O4W4Y4{6P6g6p7]7|8X8l9O9^9f:X:Y:^:_:`:a:b:c:i:j:k:l:m:n:q:r:s:t:w:x;`;h;i;l;mQ&O|Q&{!eS'R%b,}Q+e%wQ/{*sQ0g+fQ0l+lQ1],XQ1^,YQ4`0bQ4i0nQ5W1XQ5X1[Q6z4aQ6}4fQ7h5ZQ8g7OR8r7ernOXst!V!Z#c%f&`&i&k&l&n,b,g1g1jR,V&b&v^OPXYstuvwz!Z!`!g!j!o#R#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'X'i'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;b;c[#[WZ#V#Y'O'y!S%cm#g#h#k%^%a(S(^(_(`*z*{*},^,t-r-x-y-z-|1o2g2h5k5|Q%oxQ%syS%x|%}Q&U!TQ'Y!hQ'[!iQ(g#rS*R$x*VS+_%t%uQ+c%wQ+|&XQ,Q&ZS-Z']'^Q.W(hQ/Z*SQ0`+`Q0f+fQ0h+gQ0k+kQ1P+}S1T,R,SQ2X-[Q3g/VQ4_0bQ4c0eQ4h0mQ5S1UQ6d3hQ6y4aQ6|4eQ8c6xR9X8dv$zi#w%W%X%])z)|*U*j*k._/^/|3f4O8X;`;h;i!S%qy!i!t%s%t%u&|'[']'^'b'l*^+_+`,w-Z-[-c/h0`2Q2X2`3zQ+X%oQ+r&RQ+u&SQ,P&ZQ.V(gQ1O+|U1S,Q,R,SQ2p.WQ4}1PS5R1T1UQ7d5S#O;d#u$c$d$x${)q)w)}*[+V+Y+q+t.T/P/_/a0q0t0|2n3p3x4W4Y4{6P6g6p7]7|8l9O9^9f:^:`:b:i:k:m:q:s:w;l;mg;e:X:Y:_:a:c:j:l:n:r:t:xW%Pi%R*l;`S&R!Q&`Q&S!RQ&T!SR+p&P$_%Oi#u#w$c$d$x${%W%X%])q)w)z)|)}*U*[*j*k+V+Y+q+t.T._/P/^/_/a/|0q0t0|2n3f3p3x4O4W4Y4{6P6g6p7]7|8X8l9O9^9f:X:Y:^:_:`:a:b:c:i:j:k:l:m:n:q:r:s:t:w:x;`;h;i;l;mT)r$u)sV*p%V:[:]U'R!e%b,}S(u#y#zQ+j%zS.P(c(dQ0u+vQ4P/yR7S4n&|kOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;c$i$`c#X#d%j%k%m'x(O(j(q(y(z({(|(})O)P)Q)R)S)T)V)Y)^)h+T+i,x-g-l-q-s.^.d.h.j.k.l.{/}1v1y2Z2b2v2{2|2}3O3P3Q3R3S3T3U3V3W3X3[3]3b4T4]5n5t5y6V6W6]6^7U7s7w8Q8U8V8{9Z9b9s;VT#SV#T&}kOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cQ'P!eR1|,zv!nQ!e!r!u!x!y&}'U'V'c'd'e,z-Q-S-d0z4u4wS*^$|*dS/h*_*fQ/q*gQ0w+xQ3z/pR3}/snqOXst!Z#c%f&i&k&l&n,b,g1g1jQ&p!^Q'm!wS(i#t9yQ+]%rQ+z&UQ+{&WQ-X'ZQ-f'fS.](n:fS0O*x:oQ0^+^Q0y+yQ1n,iQ1p,jQ1x,uQ2V-YQ2Y-^S4U0P:uQ4Z0_S4^0a:vQ5m1zQ5q2WQ5v2_Q6w4[Q7t5oQ7u5rQ7x5wR8x7q$d$_c#X#d%k%m'x(O(j(q(y(z({(|(})O)P)Q)R)S)T)V)Y)^)h+T+i,x-g-l-q-s.^.d.h.k.l.{/}1v1y2Z2b2v2{2|2}3O3P3Q3R3S3T3U3V3W3X3[3]3b4T4]5n5t5y6V6W6]6^7U7s7w8Q8U8V8{9Z9b9s;VS(f#o'`U*i$}(m3ZS+S%j.jQ2l0WQ6O2kQ8O6RR9P8P$d$^c#X#d%k%m'x(O(j(q(y(z({(|(})O)P)Q)R)S)T)V)Y)^)h+T+i,x-g-l-q-s.^.d.h.k.l.{/}1v1y2Z2b2v2{2|2}3O3P3Q3R3S3T3U3V3W3X3[3]3b4T4]5n5t5y6V6W6]6^7U7s7w8Q8U8V8{9Z9b9s;VS(e#o'`S(w#z$_S+R%j.jS.Q(d(fQ.m)XQ0T+SR2i.R&|kOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cS#p]9rQ&k!XQ&l!YQ&n![Q&o!]R1f,eQ'W!hQ+U%oQ-V'YS.S(g+XQ2T-UW2m.V.W0V0XQ5p2UU5}2j2l2pS7{6O6QS8}7}8OS9d8|9PQ9l9eR9o9mU!vQ'V-ST4s0z4u!Q_OXZ`st!V!Z#c#g%^%f&`&b&i&k&l&n(`,b,g-y1g1j]!pQ!r'V-S0z4uT#p]9r%Y{OPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&f&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|+W,_,b,g-W-`-n-t.U.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2o2w4r4|5^5_5b5u6S7Z7`7o7yS(u#y#zS.P(c(d!s:|$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cY!tQ'V-S0z4uQ'b!rS'l!u!xS'n!y4wS-c'c'dQ-e'eR2`-dQ'k!tS([#f1aS-b'b'nQ/Y*RQ/f*^Q2a-eQ3l/ZS3u/g/qQ6c3gS6n3{3}Q8Z6dR8b6qQ#vbQ'j!tS(Z#f1aS(]#l*wQ*y%_Q+Z%pQ+a%vU-a'b'k'nQ-u([Q/X*RQ/e*^Q/k*aQ0]+[Q1Q,OS2^-b-eQ2f-}S3k/Y/ZS3t/f/qQ3w/jQ3y/lQ5P1RQ5x2aQ6b3gQ6f3lS6j3u3}Q6o3|Q7b5QS8Y6c6dQ8^6kQ8`6nQ8o7cQ9T8ZQ9U8_Q9W8bQ9`8pQ9h9VQ;P:zQ;[;TR;];UV!vQ'V-S%YaOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&f&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|+W,_,b,g-W-`-n-t.U.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2o2w4r4|5^5_5b5u6S7Z7`7o7yS#vz!j!r:y$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cR;P;b%YbOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&f&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|+W,_,b,g-W-`-n-t.U.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2o2w4r4|5^5_5b5u6S7Z7`7o7yQ%_j!S%py!i!t%s%t%u&|'[']'^'b'l*^+_+`,w-Z-[-c/h0`2Q2X2`3zS%vz!jQ+[%qQ,O&ZW1R,P,Q,R,SU5Q1S1T1US7c5R5SQ8p7d!r:z$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cQ;T;aR;U;b$|eOPXYstuvw!Z!`!g!o#R#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&i&k&l&n&r&z'X'i'{(R(Y(n(r(v)u*x*|+W,_,b,g-W-`-n-t.U.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2o2w4r4|5^5_5b5u6S7Z7`7o7yY#aWZ#V#Y'y!S%cm#g#h#k%^%a(S(^(_(`*z*{*},^,t-r-x-y-z-|1o2g2h5k5|Q,]&f!p:{$[$m)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cR;O'OS'S!e%bR2O,}%OdOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|,_,b,g-W-`-n-t.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2w4r4|5^5_5b5u7Z7`7o7y!r)W$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cQ,[&fQ0W+WQ2k.UQ6R2oR8P6S!f$Uc#X%j'x(O(j(q)Q)R)S)T)Y)^+i-g-l-q-s.^.d.{/}2Z2b2v3X4T4]5t5y6V7w8{9s!T:Q)V)h,x.j1v1y2{3T3U3V3W3[3b5n6W6]6^7U7s8Q8U8V9Z9b;V!b$Wc#X%j'x(O(j(q)S)T)Y)^+i-g-l-q-s.^.d.{/}2Z2b2v3X4T4]5t5y6V7w8{9s!P:S)V)h,x.j1v1y2{3V3W3[3b5n6W6]6^7U7s8Q8U8V9Z9b;V!^$[c#X%j'x(O(j(q)Y)^+i-g-l-q-s.^.d.{/}2Z2b2v3X4T4]5t5y6V7w8{9sQ3f/Tz;c)V)h,x.j1v1y2{3[3b5n6W6]6^7U7s8Q8U8V9Z9b;VQ;h;jR;i;k&|kOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cS$nh$oR3_.p'TgOPWXYZhstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m$o%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.p.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cT$jf$pQ$hfS)a$k)eR)m$pT$if$pT)c$k)e'ThOPWXYZhstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$[$a$e$m$o%f%l%y&b&e&f&i&k&l&n&r&z'O'X'i'y'{(R(Y(n(r(v)j)u*x*|+W,_,b,g,s,v-W-`-n-t.U.b.i.p.q/z0P0a0}1_1`1b1d1g1j1l1{2]2c2o2w3^4p4r4|5^5_5b5l5u6S7Z7`7o7y8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;cT$nh$oQ$qhR)l$o%YjOPWXYZstuvw!Z!`!g!o#R#V#Y#c#n#t#x#{$O$P$Q$R$S$T$U$V$W$X$Y$a$e%f%l%y&b&e&f&i&k&l&n&r&z'X'i'y'{(R(Y(n(r(v)u*x*|+W,_,b,g-W-`-n-t.U.b.i/z0P0a0}1_1`1b1d1g1j1l2]2c2o2w4r4|5^5_5b5u6S7Z7`7o7y!s;a$[$m'O)j,s,v.q1{3^4p5l8h8y9q9t9u9x9y9z9{9|9}:O:P:Q:R:S:T:U:V:Z:d:e:f:h:o:p:u:v;c#clOPXZst!Z!`!o#R#c#n#{$m%f&b&e&f&i&k&l&n&r&z'X(v)j*|+W,_,b,g-W.U.q/z0}1_1`1b1d1g1j1l2o3^4r4|5^5_5b6S7Z7`7ov$}i#w%W%X%])z)|*U*j*k._/^/|3f4O8X;`;h;i#O(m#u$c$d$x${)q)w)}*[+V+Y+q+t.T/P/_/a0q0t0|2n3p3x4W4Y4{6P6g6p7]7|8l9O9^9f:^:`:b:i:k:m:q:s:w;l;mQ*t%ZQ.|)vg3Z:X:Y:_:a:c:j:l:n:r:t:xv$yi#w%W%X%])z)|*U*j*k._/^/|3f4O8X;`;h;iQ*W$zS*a$|*dQ*u%[Q/l*b#O;R#u$c$d$x${)q)w)}*[+V+Y+q+t.T/P/_/a0q0t0|2n3p3x4W4Y4{6P6g6p7]7|8l9O9^9f:^:`:b:i:k:m:q:s:w;l;mf;S:X:Y:_:a:c:j:l:n:r:t:xQ;W;dQ;X;eQ;Y;fR;Z;gv$}i#w%W%X%])z)|*U*j*k._/^/|3f4O8X;`;h;i#O(m#u$c$d$x${)q)w)}*[+V+Y+q+t.T/P/_/a0q0t0|2n3p3x4W4Y4{6P6g6p7]7|8l9O9^9f:^:`:b:i:k:m:q:s:w;l;mg3Z:X:Y:_:a:c:j:l:n:r:t:xnoOXst!Z#c%f&i&k&l&n,b,g1g1jQ*Z${Q,p&uQ,q&wR3o/_$^%Oi#u#w$c$d$x${%W%X%])q)w)z)|)}*U*[*j*k+V+Y+q+t.T._/P/^/_/a/|0q0t0|2n3f3p3x4O4W4Y4{6P6g6p7]7|8X8l9O9^9f:X:Y:^:_:`:a:b:c:i:j:k:l:m:n:q:r:s:t:w:x;`;h;i;l;mQ+s&SQ0s+uQ4l0rR7R4mT*c$|*dS*c$|*dT4t0z4uS/j*`4rT3|/r7ZQ+Z%pQ/k*aQ0]+[Q1Q,OQ5P1RQ7b5QQ8o7cR9`8pn)z$v(o*v/]/t/u2t3m4S6a6r9S;Q;^;_!Y:i(k)[*Q*Y.[.x.}/T/b0U0p0r2s3n3r4k4m6T6U6h6l6t6v8]8a9g;j;k]:j3Y6[8R9Q9R9pp)|$v(o*v/R/]/t/u2t3m4S6a6r9S;Q;^;_![:k(k)[*Q*Y.[.x.}/T/b0U0p0r2q2s3n3r4k4m6T6U6h6l6t6v8]8a9g;j;k_:l3Y6[8R8S9Q9R9prnOXst!V!Z#c%f&`&i&k&l&n,b,g1g1jQ&]!UR,_&frnOXst!V!Z#c%f&`&i&k&l&n,b,g1g1jR&]!UQ+w&TR0o+psnOXst!V!Z#c%f&`&i&k&l&n,b,g1g1jQ0{+|S4z1O1PU7[4x4y4}S8k7^7_S9[8j8mQ9i9]R9n9jQ&d!VR,W&`R5W1XS%x|%}R0h+gQ&i!WR,b&jR,h&oT1h,g1jR,l&pQ,k&pR1q,lQ'p!zR-h'pSsOtQ#cXT%is#cQ!}TR'r!}Q#QUR't#QQ)s$uR.y)sQ#TVR'v#TQ#WWU'|#W'}-oQ'}#XR-o(OQ,{'PR1},{Q.`(oR2u.`Q.c(qS2x.c2yR2y.dQ-S'VR2R-SY!rQ'V-S0z4uR'a!rS#^W%aU(T#^(U-pQ(U#_R-p(PQ-O'SR2P-Ot`OXst!V!Z#c%f&`&b&i&k&l&n,b,g1g1jS#gZ%^U#q`#g-yR-y(`Q(a#iQ-v(]W.O(a-v2d5zQ2d-wR5z2eQ)e$kR.r)eQ$ohR)k$oQ$bcU)Z$b-k:WQ-k9sR:W)hQ/W*RW3i/W3j6e8[U3j/X/Y/ZS6e3k3lR8[6f#o)x$v(k(o)[*Q*Y*q*r*v.Y.Z.[.x.}/R/S/T/]/b/t/u0U0p0r2q2r2s2t3Y3m3n3r4S4k4m6T6U6Y6Z6[6a6h6l6r6t6v8R8S8T8]8a9Q9R9S9g9p;Q;^;_;j;kQ/`*YU3q/`3s6iQ3s/bR6i3rQ*d$|R/n*dQ*m%QR/w*mQ4X0UR6u4XQ+O%dR0S+OQ4o0uS7T4o8iR8i7UQ+y&UR0x+yQ4u0zR7X4uQ1W,TS5U1W7fR7f5WQ0c+cW4b0c4d6{8eQ4d0fQ6{4cR8e6|Q+h%xR0i+hQ1j,gR5f1jYrOXst#cQ&m!ZQ+Q%fQ,a&iQ,c&kQ,d&lQ,f&nQ1e,bS1h,g1jR5e1gQ%hpQ&q!_Q&t!aQ&v!bQ&x!cQ'h!tQ+P%eQ+]%rQ+o&OQ,V&dQ,n&sW-_'b'j'k'nQ-f'fQ/m*cQ0^+^S1Z,W,ZQ1r,mQ1s,pQ1t,qQ2Y-^W2[-a-b-e-gQ4Z0_Q4g0lQ4j0pQ5O1QQ5Y1]Q5d1fU5s2Z2^2aQ5v2_Q6w4[Q7P4iQ7Q4kQ7W4tQ7a5PQ7g5XS7v5t5xQ7x5wQ8f6}Q8n7bQ8s7hQ8z7wQ9Y8gQ9_8oQ9c8{R9k9`Q%ryQ'Z!iQ'f!tU+^%s%t%uQ,u&|U-Y'[']'^S-^'b'lQ/d*^S0_+_+`Q1z,wS2W-Z-[Q2_-cQ3v/hQ4[0`Q5o2QQ5r2XQ5w2`R6m3zS$wi;`R*n%RU%Qi%R;`R/v*lQ$viS(k#u+YQ(o#wS)[$c$dQ*Q$xQ*Y${Q*q%WQ*r%XQ*v%]Q.Y:^Q.Z:`Q.[:bQ.x)qS.})w/PQ/R)zQ/S)|Q/T)}Q/]*UQ/b*[Q/t*jQ/u*kh0U+V.T0|2n4{6P7]7|8l9O9^9fQ0p+qQ0r+tQ2q:iQ2r:kQ2s:mQ2t._S3Y:X:YQ3m/^Q3n/_Q3r/aQ4S/|Q4k0qQ4m0tQ6T:qQ6U:sQ6Y:_Q6Z:aQ6[:cQ6a3fQ6h3pQ6l3xQ6r4OQ6t4WQ6v4YQ8R:nQ8S:jQ8T:lQ8]6gQ8a6pQ9Q:rQ9R:tQ9S8XQ9g:wQ9p:xQ;Q;`Q;^;hQ;_;iQ;j;lR;k;mnpOXst!Z#c%f&i&k&l&n,b,g1g1jQ!fPS#eZ#nQ&s!`U'_!o4r7ZQ'u#RQ(x#{Q)i$mS,Z&b&eQ,`&fQ,m&rQ,r&zQ-U'XQ.f(vQ.v)jQ0Q*|Q0X+WQ1c,_Q2U-WQ2l.UQ3a.qQ4Q/zQ4y0}Q5[1_Q5]1`Q5a1bQ5c1dQ5h1lQ6O2oQ6_3^Q7_4|Q7k5^Q7l5_Q7n5bQ8O6SQ8m7`R8w7o#WcOPXZst!Z!`!o#c#n#{%f&b&e&f&i&k&l&n&r&z'X(v*|+W,_,b,g-W.U/z0}1_1`1b1d1g1j1l2o4r4|5^5_5b6S7Z7`7oQ#XWQ#dYQ%juQ%kvS%mw!gS'x#V'{Q(O#YQ(j#tQ(q#xQ(y$OQ(z$PQ({$QQ(|$RQ(}$SQ)O$TQ)P$UQ)Q$VQ)R$WQ)S$XQ)T$YQ)V$[Q)Y$aQ)^$eW)h$m)j.q3^Q+T%lQ+i%yS,x'O1{Q-g'iS-l'y-nQ-q(RQ-s(YQ.^(nQ.d(rQ.h9qQ.j9tQ.k9uQ.l9xQ.{)uQ/}*xQ1v,sQ1y,vQ2Z-`Q2b-tQ2v.bQ2{9yQ2|9zQ2}9{Q3O9|Q3P9}Q3Q:OQ3R:PQ3S:QQ3T:RQ3U:SQ3V:TQ3W:UQ3X.iQ3[:ZQ3]:dQ3b:VQ4T0PQ4]0aQ5n:eQ5t2]Q5y2cQ6V2wQ6W:fQ6]:hQ6^:oQ7U4pQ7s5lQ7w5uQ8Q:pQ8U:uQ8V:vQ8{7yQ9Z8hQ9b8yQ9s#RR;V;cR#ZWR'Q!eY!tQ'V-S0z4uS&|!e,zQ'b!rS'l!u!xS'n!y4wS,w&}'US-c'c'dQ-e'eQ2Q-QR2`-dR(p#wR(s#xQ!fQT-R'V-S]!qQ!r'V-S0z4uQ#o]R'`9rT#jZ%^S#iZ%^S%dm,^U(]#g#h#kS-w(^(_Q-{(`Q0R*}Q2e-xU2f-y-z-|S5{2g2hR7z5|`#]W#V#Y%a'y(S*z-rr#fZm#g#h#k%^(^(_(`*}-x-y-z-|2g2h5|Q1a,^Q1w,tQ5j1oQ7r5kT:}'O*{T#`W%aS#_W%aS'z#V(SS(P#Y*zS,y'O*{T-m'y-rT'T!e%bQ$kfR)o$pT)d$k)eR3`.pT*T$x*VR*]${Q0V+VQ2j.TQ4x0|Q6Q2nQ7^4{Q7}6PQ8j7]Q8|7|Q9]8lQ9e9OQ9j9^R9m9fnqOXst!Z#c%f&i&k&l&n,b,g1g1jQ&c!VR,V&`tmOXst!U!V!Z#c%f&`&i&k&l&n,b,g1g1jR,^&fT%em,^R0v+vR,U&^Q%||R+n%}R+d%wT&g!W&jT&h!W&jT1i,g1j",nodeNames:"⚠ ArithOp ArithOp LineComment BlockComment Script Hashbang ExportDeclaration export Star as VariableName String Escape from ; default FunctionDeclaration async function VariableDefinition > TypeParamList TypeDefinition extends ThisType this LiteralType ArithOp Number BooleanLiteral TemplateType InterpolationEnd Interpolation InterpolationStart NullType null VoidType void TypeofType typeof MemberExpression . ?. PropertyName [ TemplateString Escape Interpolation super RegExp ] ArrayExpression Spread , } { ObjectExpression Property async get set PropertyDefinition Block : NewExpression new TypeArgList CompareOp < ) ( ArgList UnaryExpression delete LogicOp BitOp YieldExpression yield AwaitExpression await ParenthesizedExpression ClassExpression class ClassBody MethodDeclaration Decorator @ MemberExpression PrivatePropertyName CallExpression declare Privacy static abstract override PrivatePropertyDefinition PropertyDeclaration readonly accessor Optional TypeAnnotation Equals StaticBlock FunctionExpression ArrowFunction ParamList ParamList ArrayPattern ObjectPattern PatternProperty Privacy readonly Arrow MemberExpression BinaryExpression ArithOp ArithOp ArithOp ArithOp BitOp CompareOp instanceof satisfies in const CompareOp BitOp BitOp BitOp LogicOp LogicOp ConditionalExpression LogicOp LogicOp AssignmentExpression UpdateOp PostfixExpression CallExpression TaggedTemplateExpression DynamicImport import ImportMeta JSXElement JSXSelfCloseEndTag JSXStartTag JSXSelfClosingTag JSXIdentifier JSXBuiltin JSXIdentifier JSXNamespacedName JSXMemberExpression JSXSpreadAttribute JSXAttribute JSXAttributeValue JSXEscape JSXEndTag JSXOpenTag JSXFragmentTag JSXText JSXEscape JSXStartCloseTag JSXCloseTag PrefixCast ArrowFunction TypeParamList SequenceExpression KeyofType keyof UniqueType unique ImportType InferredType infer TypeName ParenthesizedType FunctionSignature ParamList NewSignature IndexedType TupleType Label ArrayType ReadonlyType ObjectType MethodType PropertyType IndexSignature PropertyDefinition CallSignature TypePredicate is NewSignature new UnionType LogicOp IntersectionType LogicOp ConditionalType ParameterizedType ClassDeclaration abstract implements type VariableDeclaration let var using TypeAliasDeclaration InterfaceDeclaration interface EnumDeclaration enum EnumBody NamespaceDeclaration namespace module AmbientDeclaration declare GlobalDeclaration global ClassDeclaration ClassBody AmbientFunctionDeclaration ExportGroup VariableName VariableName ImportDeclaration ImportGroup ForStatement for ForSpec ForInSpec ForOfSpec of WhileStatement while WithStatement with DoStatement do IfStatement if else SwitchStatement switch SwitchBody CaseLabel case DefaultLabel TryStatement try CatchClause catch FinallyClause finally ReturnStatement return ThrowStatement throw BreakStatement break ContinueStatement continue DebuggerStatement debugger LabeledStatement ExpressionStatement SingleExpression SingleClassItem",maxTerm:367,context:Wr,nodeProps:[["group",-26,7,15,17,63,200,204,208,209,211,214,217,227,229,235,237,239,241,244,250,256,258,260,262,264,266,267,"Statement",-32,11,12,26,29,30,36,46,49,50,52,57,65,73,77,79,81,82,104,105,114,115,132,135,137,138,139,140,142,143,163,164,166,"Expression",-23,25,27,31,35,37,39,167,169,171,172,174,175,176,178,179,180,182,183,184,194,196,198,199,"Type",-3,85,97,103,"ClassItem"],["openedBy",32,"InterpolationStart",51,"[",55,"{",70,"(",144,"JSXStartTag",156,"JSXStartTag JSXStartCloseTag"],["closedBy",34,"InterpolationEnd",45,"]",56,"}",71,")",145,"JSXSelfCloseEndTag JSXEndTag",161,"JSXEndTag"]],propSources:[qr],skippedNodes:[0,3,4,270],repeatNodeCount:33,tokenData:"$Fl(CSR!bOX%ZXY+gYZ-yZ[+g[]%Z]^.c^p%Zpq+gqr/mrs3cst:_tuEruvJSvwLkwx! Yxy!'iyz!(sz{!)}{|!,q|}!.O}!O!,q!O!P!/Y!P!Q!9j!Q!R#8g!R![#:v![!]#Gv!]!^#IS!^!_#J^!_!`#Nu!`!a$#a!a!b$(n!b!c$,m!c!}Er!}#O$-w#O#P$/R#P#Q$4j#Q#R$5t#R#SEr#S#T$7R#T#o$8]#o#p$s#r#s$@P#s$f%Z$f$g+g$g#BYEr#BY#BZ$AZ#BZ$ISEr$IS$I_$AZ$I_$I|Er$I|$I}$Df$I}$JO$Df$JO$JTEr$JT$JU$AZ$JU$KVEr$KV$KW$AZ$KW&FUEr&FU&FV$AZ&FV;'SEr;'S;=`I|<%l?HTEr?HT?HU$AZ?HUOEr(n%d_$e&j'yp'|!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z&j&hT$e&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c&j&zP;=`<%l&c'|'U]$e&j'|!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!b(SU'|!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}!b(iP;=`<%l'}'|(oP;=`<%l&}'[(y]$e&j'ypOY(rYZ&cZr(rrs&cs!^(r!^!_)r!_#O(r#O#P&c#P#o(r#o#p)r#p;'S(r;'S;=`*a<%lO(rp)wU'ypOY)rZr)rs#O)r#P;'S)r;'S;=`*Z<%lO)rp*^P;=`<%l)r'[*dP;=`<%l(r#S*nX'yp'|!bOY*gZr*grs'}sw*gwx)rx#O*g#P;'S*g;'S;=`+Z<%lO*g#S+^P;=`<%l*g(n+dP;=`<%l%Z(CS+rq$e&j'yp'|!b'o(;dOX%ZXY+gYZ&cZ[+g[p%Zpq+gqr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p$f%Z$f$g+g$g#BY%Z#BY#BZ+g#BZ$IS%Z$IS$I_+g$I_$JT%Z$JT$JU+g$JU$KV%Z$KV$KW+g$KW&FU%Z&FU&FV+g&FV;'S%Z;'S;=`+a<%l?HT%Z?HT?HU+g?HUO%Z(CS.ST'z#S$e&j'p(;dO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c(CS.n_$e&j'yp'|!b'p(;dOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z%#`/x`$e&j!m$Ip'yp'|!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`0z!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z%#S1V`#r$Id$e&j'yp'|!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_!`2X!`#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z%#S2d_#r$Id$e&j'yp'|!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!^%Z!^!_*g!_#O%Z#O#P&c#P#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z$2b3l_'x$(n$e&j'|!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k*r4r_$e&j'|!bOY4kYZ5qZr4krs7nsw4kwx5qx!^4k!^!_8p!_#O4k#O#P5q#P#o4k#o#p8p#p;'S4k;'S;=`:X<%lO4k)`5vX$e&jOr5qrs6cs!^5q!^!_6y!_#o5q#o#p6y#p;'S5q;'S;=`7h<%lO5q)`6jT$`#t$e&jO!^&c!_#o&c#p;'S&c;'S;=`&w<%lO&c#t6|TOr6yrs7]s;'S6y;'S;=`7b<%lO6y#t7bO$`#t#t7eP;=`<%l6y)`7kP;=`<%l5q*r7w]$`#t$e&j'|!bOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}%W8uZ'|!bOY8pYZ6yZr8prs9hsw8pwx6yx#O8p#O#P6y#P;'S8p;'S;=`:R<%lO8p%W9oU$`#t'|!bOY'}Zw'}x#O'}#P;'S'};'S;=`(f<%lO'}%W:UP;=`<%l8p*r:[P;=`<%l4k#%|:hh$e&j'yp'|!bOY%ZYZ&cZq%Zqr`#P#o`x!^=^!^!_?q!_#O=^#O#P>`#P#o=^#o#p?q#p;'S=^;'S;=`@h<%lO=^&n>gXUS$e&jOY>`YZ&cZ!^>`!^!_?S!_#o>`#o#p?S#p;'S>`;'S;=`?k<%lO>`S?XSUSOY?SZ;'S?S;'S;=`?e<%lO?SS?hP;=`<%l?S&n?nP;=`<%l>`!f?xWUS'|!bOY?qZw?qwx?Sx#O?q#O#P?S#P;'S?q;'S;=`@b<%lO?q!f@eP;=`<%l?q(Q@kP;=`<%l=^'`@w]US$e&j'ypOY@nYZ&cZr@nrs>`s!^@n!^!_Ap!_#O@n#O#P>`#P#o@n#o#pAp#p;'S@n;'S;=`Bg<%lO@ntAwWUS'ypOYApZrAprs?Ss#OAp#O#P?S#P;'SAp;'S;=`Ba<%lOAptBdP;=`<%lAp'`BjP;=`<%l@n#WBvYUS'yp'|!bOYBmZrBmrs?qswBmwxApx#OBm#O#P?S#P;'SBm;'S;=`Cf<%lOBm#WCiP;=`<%lBm(rCoP;=`<%lQ^$e&j!SSOY!=yYZ&cZ!P!=y!P!Q!>|!Q!^!=y!^!_!@Y!_!}!=y!}#O!Bw#O#P!Dj#P#o!=y#o#p!@Y#p;'S!=y;'S;=`!E[<%lO!=y&n!?Ta$e&j!SSO!^&c!_#Z&c#Z#[!>|#[#]&c#]#^!>|#^#a&c#a#b!>|#b#g&c#g#h!>|#h#i&c#i#j!>|#j#m&c#m#n!>|#n#o&c#p;'S&c;'S;=`&w<%lO&cS!@_X!SSOY!@YZ!P!@Y!P!Q!@z!Q!}!@Y!}#O!Ac#O#P!Bb#P;'S!@Y;'S;=`!Bq<%lO!@YS!APU!SS#Z#[!@z#]#^!@z#a#b!@z#g#h!@z#i#j!@z#m#n!@zS!AfVOY!AcZ#O!Ac#O#P!A{#P#Q!@Y#Q;'S!Ac;'S;=`!B[<%lO!AcS!BOSOY!AcZ;'S!Ac;'S;=`!B[<%lO!AcS!B_P;=`<%l!AcS!BeSOY!@YZ;'S!@Y;'S;=`!Bq<%lO!@YS!BtP;=`<%l!@Y&n!B|[$e&jOY!BwYZ&cZ!^!Bw!^!_!Ac!_#O!Bw#O#P!Cr#P#Q!=y#Q#o!Bw#o#p!Ac#p;'S!Bw;'S;=`!Dd<%lO!Bw&n!CwX$e&jOY!BwYZ&cZ!^!Bw!^!_!Ac!_#o!Bw#o#p!Ac#p;'S!Bw;'S;=`!Dd<%lO!Bw&n!DgP;=`<%l!Bw&n!DoX$e&jOY!=yYZ&cZ!^!=y!^!_!@Y!_#o!=y#o#p!@Y#p;'S!=y;'S;=`!E[<%lO!=y&n!E_P;=`<%l!=y(Q!Eki$e&j'|!b!SSOY&}YZ&cZw&}wx&cx!^&}!^!_'}!_#O&}#O#P&c#P#Z&}#Z#[!Eb#[#]&}#]#^!Eb#^#a&}#a#b!Eb#b#g&}#g#h!Eb#h#i&}#i#j!Eb#j#m&}#m#n!Eb#n#o&}#o#p'}#p;'S&};'S;=`(l<%lO&}!f!GaZ'|!b!SSOY!GYZw!GYwx!@Yx!P!GY!P!Q!HS!Q!}!GY!}#O!Ic#O#P!Bb#P;'S!GY;'S;=`!JZ<%lO!GY!f!HZb'|!b!SSOY'}Zw'}x#O'}#P#Z'}#Z#[!HS#[#]'}#]#^!HS#^#a'}#a#b!HS#b#g'}#g#h!HS#h#i'}#i#j!HS#j#m'}#m#n!HS#n;'S'};'S;=`(f<%lO'}!f!IhX'|!bOY!IcZw!Icwx!Acx#O!Ic#O#P!A{#P#Q!GY#Q;'S!Ic;'S;=`!JT<%lO!Ic!f!JWP;=`<%l!Ic!f!J^P;=`<%l!GY(Q!Jh^$e&j'|!bOY!JaYZ&cZw!Jawx!Bwx!^!Ja!^!_!Ic!_#O!Ja#O#P!Cr#P#Q!Q#V#X%Z#X#Y!4|#Y#b%Z#b#c#Zd$e&j'yp'|!bOY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#?i!R!S#?i!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#?i#S#o%Z#o#p*g#p;'S%Z;'S;=`+a<%lO%Z$/l#?tf$e&j'yp'|!bm$'|OY%ZYZ&cZr%Zrs&}sw%Zwx(rx!Q%Z!Q!R#?i!R!S#?i!S!^%Z!^!_*g!_#O%Z#O#P&c#P#R%Z#R#S#?i#S#b%Z#b#c#Vr[O]||-1},{term:330,get:O=>jr[O]||-1},{term:68,get:O=>zr[O]||-1}],tokenPrec:12827}),NO=[P("function ${name}(${params}) {\n ${}\n}",{label:"function",detail:"definition",type:"keyword"}),P("for (let ${index} = 0; ${index} < ${bound}; ${index}++) {\n ${}\n}",{label:"for",detail:"loop",type:"keyword"}),P("for (let ${name} of ${collection}) {\n ${}\n}",{label:"for",detail:"of loop",type:"keyword"}),P("do {\n ${}\n} while (${})",{label:"do",detail:"loop",type:"keyword"}),P("while (${}) {\n ${}\n}",{label:"while",detail:"loop",type:"keyword"}),P(`try { - \${} -} catch (\${error}) { - \${} -}`,{label:"try",detail:"/ catch block",type:"keyword"}),P("if (${}) {\n ${}\n}",{label:"if",detail:"block",type:"keyword"}),P(`if (\${}) { - \${} -} else { - \${} -}`,{label:"if",detail:"/ else block",type:"keyword"}),P(`class \${name} { - constructor(\${params}) { - \${} - } -}`,{label:"class",detail:"definition",type:"keyword"}),P('import {${names}} from "${module}"\n${}',{label:"import",detail:"named",type:"keyword"}),P('import ${name} from "${module}"\n${}',{label:"import",detail:"default",type:"keyword"})],Er=NO.concat([P("interface ${name} {\n ${}\n}",{label:"interface",detail:"definition",type:"keyword"}),P("type ${name} = ${type}",{label:"type",detail:"definition",type:"keyword"}),P("enum ${name} {\n ${}\n}",{label:"enum",detail:"definition",type:"keyword"})]),nO=new bO,BO=new Set(["Script","Block","FunctionExpression","FunctionDeclaration","ArrowFunction","MethodDeclaration","ForStatement"]);function G(O){return(e,a)=>{let t=e.node.getChild("VariableDefinition");return t&&a(t,O),!0}}const Ar=["FunctionDeclaration"],Ir={FunctionDeclaration:G("function"),ClassDeclaration:G("class"),ClassExpression:()=>!0,EnumDeclaration:G("constant"),TypeAliasDeclaration:G("type"),NamespaceDeclaration:G("namespace"),VariableDefinition(O,e){O.matchContext(Ar)||e(O,"variable")},TypeDefinition(O,e){e(O,"type")},__proto__:null};function DO(O,e){let a=nO.get(e);if(a)return a;let t=[],r=!0;function s(i,l){let n=O.sliceString(i.from,i.to);t.push({label:n,type:l})}return e.cursor(ve.IncludeAnonymous).iterate(i=>{if(r)r=!1;else if(i.name){let l=Ir[i.name];if(l&&l(i,s)||BO.has(i.name))return!1}else if(i.to-i.from>8192){for(let l of DO(O,i.node))t.push(l);return!1}}),nO.set(e,t),t}const cO=/^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/,JO=["TemplateString","String","RegExp","LineComment","BlockComment","VariableDefinition","TypeDefinition","Label","PropertyDefinition","PropertyName","PrivatePropertyDefinition","PrivatePropertyName",".","?."];function Nr(O){let e=j(O.state).resolveInner(O.pos,-1);if(JO.indexOf(e.name)>-1)return null;let a=e.name=="VariableName"||e.to-e.from<20&&cO.test(O.state.sliceDoc(e.from,e.to));if(!a&&!O.explicit)return null;let t=[];for(let r=e;r;r=r.parent)BO.has(r.name)&&(t=t.concat(DO(O.state.doc,r)));return{options:t,from:a?e.from:O.pos,validFor:cO}}const Y=J.define({name:"javascript",parser:Gr.configure({props:[L.add({IfStatement:C({except:/^\s*({|else\b)/}),TryStatement:C({except:/^\s*({|catch\b|finally\b)/}),LabeledStatement:Rt,SwitchBody:O=>{let e=O.textAfter,a=/^\s*\}/.test(e),t=/^\s*(case|default)\b/.test(e);return O.baseIndent+(a?0:t?1:2)*O.unit},Block:qt({closing:"}"}),ArrowFunction:O=>O.baseIndent+O.unit,"TemplateString BlockComment":()=>null,"Statement Property":C({except:/^{/}),JSXElement(O){let e=/^\s*<\//.test(O.textAfter);return O.lineIndent(O.node.from)+(e?0:O.unit)},JSXEscape(O){let e=/\s*\}/.test(O.textAfter);return O.lineIndent(O.node.from)+(e?0:O.unit)},"JSXOpenTag JSXSelfClosingTag"(O){return O.column(O.node.from)+O.unit}}),F.add({"Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType":Te,BlockComment(O){return{from:O.from+2,to:O.to-2}}})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"`"]},commentTokens:{line:"//",block:{open:"/*",close:"*/"}},indentOnInput:/^\s*(?:case |default:|\{|\}|<\/)$/,wordChars:"$"}}),LO={test:O=>/^JSX/.test(O.name),facet:Vt({commentTokens:{block:{open:"{/*",close:"*/}"}}})},FO=Y.configure({dialect:"ts"},"typescript"),MO=Y.configure({dialect:"jsx",props:[yO.add(O=>O.isTop?[LO]:void 0)]}),KO=Y.configure({dialect:"jsx ts",props:[yO.add(O=>O.isTop?[LO]:void 0)]},"typescript");let HO=O=>({label:O,type:"keyword"});const et="break case const continue default delete export extends false finally in instanceof let new return static super switch this throw true typeof var yield".split(" ").map(HO),Br=et.concat(["declare","implements","private","protected","public"].map(HO));function Ot(O={}){let e=O.jsx?O.typescript?KO:MO:O.typescript?FO:Y,a=O.typescript?Er.concat(Br):NO.concat(et);return new M(e,[Y.data.of({autocomplete:XO(JO,xO(a))}),Y.data.of({autocomplete:Nr}),O.jsx?Lr:[]])}function Dr(O){for(;;){if(O.name=="JSXOpenTag"||O.name=="JSXSelfClosingTag"||O.name=="JSXFragmentTag")return O;if(O.name=="JSXEscape"||!O.parent)return null;O=O.parent}}function QO(O,e,a=O.length){for(let t=e==null?void 0:e.firstChild;t;t=t.nextSibling)if(t.name=="JSXIdentifier"||t.name=="JSXBuiltin"||t.name=="JSXNamespacedName"||t.name=="JSXMemberExpression")return O.sliceString(t.from,Math.min(t.to,a));return""}const Jr=typeof navigator=="object"&&/Android\b/.test(navigator.userAgent),Lr=U.inputHandler.of((O,e,a,t,r)=>{if((Jr?O.composing:O.compositionStarted)||O.state.readOnly||e!=a||t!=">"&&t!="/"||!Y.isActiveAt(O.state,e,-1))return!1;let s=r(),{state:i}=s,l=i.changeByRange(n=>{var Q;let{head:d}=n,c=j(i).resolveInner(d-1,-1),u;if(c.name=="JSXStartTag"&&(c=c.parent),!(i.doc.sliceString(d-1,d)!=t||c.name=="JSXAttributeValue"&&c.to>d)){if(t==">"&&c.name=="JSXFragmentTag")return{range:n,changes:{from:d,insert:""}};if(t=="/"&&c.name=="JSXStartCloseTag"){let p=c.parent,S=p.parent;if(S&&p.from==d-2&&((u=QO(i.doc,S.firstChild,d))||((Q=S.firstChild)===null||Q===void 0?void 0:Q.name)=="JSXFragmentTag")){let f=`${u}>`;return{range:YO.cursor(d+f.length,-1),changes:{from:d,insert:f}}}}else if(t==">"){let p=Dr(c);if(p&&!/^\/?>|^<\//.test(i.doc.sliceString(d,d+2))&&(u=QO(i.doc,p,d)))return{range:n,changes:{from:d,insert:``}}}}return{range:n}});return l.changes.empty?!1:(O.dispatch([s,i.update(l,{userEvent:"input.complete",scrollIntoView:!0})]),!0)}),E=["_blank","_self","_top","_parent"],ge=["ascii","utf-8","utf-16","latin1","latin1"],me=["get","post","put","delete"],Pe=["application/x-www-form-urlencoded","multipart/form-data","text/plain"],b=["true","false"],h={},Fr={a:{attrs:{href:null,ping:null,type:null,media:null,target:E,hreflang:null}},abbr:h,address:h,area:{attrs:{alt:null,coords:null,href:null,target:null,ping:null,media:null,hreflang:null,type:null,shape:["default","rect","circle","poly"]}},article:h,aside:h,audio:{attrs:{src:null,mediagroup:null,crossorigin:["anonymous","use-credentials"],preload:["none","metadata","auto"],autoplay:["autoplay"],loop:["loop"],controls:["controls"]}},b:h,base:{attrs:{href:null,target:E}},bdi:h,bdo:h,blockquote:{attrs:{cite:null}},body:h,br:h,button:{attrs:{form:null,formaction:null,name:null,value:null,autofocus:["autofocus"],disabled:["autofocus"],formenctype:Pe,formmethod:me,formnovalidate:["novalidate"],formtarget:E,type:["submit","reset","button"]}},canvas:{attrs:{width:null,height:null}},caption:h,center:h,cite:h,code:h,col:{attrs:{span:null}},colgroup:{attrs:{span:null}},command:{attrs:{type:["command","checkbox","radio"],label:null,icon:null,radiogroup:null,command:null,title:null,disabled:["disabled"],checked:["checked"]}},data:{attrs:{value:null}},datagrid:{attrs:{disabled:["disabled"],multiple:["multiple"]}},datalist:{attrs:{data:null}},dd:h,del:{attrs:{cite:null,datetime:null}},details:{attrs:{open:["open"]}},dfn:h,div:h,dl:h,dt:h,em:h,embed:{attrs:{src:null,type:null,width:null,height:null}},eventsource:{attrs:{src:null}},fieldset:{attrs:{disabled:["disabled"],form:null,name:null}},figcaption:h,figure:h,footer:h,form:{attrs:{action:null,name:null,"accept-charset":ge,autocomplete:["on","off"],enctype:Pe,method:me,novalidate:["novalidate"],target:E}},h1:h,h2:h,h3:h,h4:h,h5:h,h6:h,head:{children:["title","base","link","style","meta","script","noscript","command"]},header:h,hgroup:h,hr:h,html:{attrs:{manifest:null}},i:h,iframe:{attrs:{src:null,srcdoc:null,name:null,width:null,height:null,sandbox:["allow-top-navigation","allow-same-origin","allow-forms","allow-scripts"],seamless:["seamless"]}},img:{attrs:{alt:null,src:null,ismap:null,usemap:null,width:null,height:null,crossorigin:["anonymous","use-credentials"]}},input:{attrs:{alt:null,dirname:null,form:null,formaction:null,height:null,list:null,max:null,maxlength:null,min:null,name:null,pattern:null,placeholder:null,size:null,src:null,step:null,value:null,width:null,accept:["audio/*","video/*","image/*"],autocomplete:["on","off"],autofocus:["autofocus"],checked:["checked"],disabled:["disabled"],formenctype:Pe,formmethod:me,formnovalidate:["novalidate"],formtarget:E,multiple:["multiple"],readonly:["readonly"],required:["required"],type:["hidden","text","search","tel","url","email","password","datetime","date","month","week","time","datetime-local","number","range","color","checkbox","radio","file","submit","image","reset","button"]}},ins:{attrs:{cite:null,datetime:null}},kbd:h,keygen:{attrs:{challenge:null,form:null,name:null,autofocus:["autofocus"],disabled:["disabled"],keytype:["RSA"]}},label:{attrs:{for:null,form:null}},legend:h,li:{attrs:{value:null}},link:{attrs:{href:null,type:null,hreflang:null,media:null,sizes:["all","16x16","16x16 32x32","16x16 32x32 64x64"]}},map:{attrs:{name:null}},mark:h,menu:{attrs:{label:null,type:["list","context","toolbar"]}},meta:{attrs:{content:null,charset:ge,name:["viewport","application-name","author","description","generator","keywords"],"http-equiv":["content-language","content-type","default-style","refresh"]}},meter:{attrs:{value:null,min:null,low:null,high:null,max:null,optimum:null}},nav:h,noscript:h,object:{attrs:{data:null,type:null,name:null,usemap:null,form:null,width:null,height:null,typemustmatch:["typemustmatch"]}},ol:{attrs:{reversed:["reversed"],start:null,type:["1","a","A","i","I"]},children:["li","script","template","ul","ol"]},optgroup:{attrs:{disabled:["disabled"],label:null}},option:{attrs:{disabled:["disabled"],label:null,selected:["selected"],value:null}},output:{attrs:{for:null,form:null,name:null}},p:h,param:{attrs:{name:null,value:null}},pre:h,progress:{attrs:{value:null,max:null}},q:{attrs:{cite:null}},rp:h,rt:h,ruby:h,samp:h,script:{attrs:{type:["text/javascript"],src:null,async:["async"],defer:["defer"],charset:ge}},section:h,select:{attrs:{form:null,name:null,size:null,autofocus:["autofocus"],disabled:["disabled"],multiple:["multiple"]}},slot:{attrs:{name:null}},small:h,source:{attrs:{src:null,type:null,media:null}},span:h,strong:h,style:{attrs:{type:["text/css"],media:null,scoped:null}},sub:h,summary:h,sup:h,table:h,tbody:h,td:{attrs:{colspan:null,rowspan:null,headers:null}},template:h,textarea:{attrs:{dirname:null,form:null,maxlength:null,name:null,placeholder:null,rows:null,cols:null,autofocus:["autofocus"],disabled:["disabled"],readonly:["readonly"],required:["required"],wrap:["soft","hard"]}},tfoot:h,th:{attrs:{colspan:null,rowspan:null,headers:null,scope:["row","col","rowgroup","colgroup"]}},thead:h,time:{attrs:{datetime:null}},title:h,tr:h,track:{attrs:{src:null,label:null,default:null,kind:["subtitles","captions","descriptions","chapters","metadata"],srclang:null}},ul:{children:["li","script","template","ul","ol"]},var:h,video:{attrs:{src:null,poster:null,width:null,height:null,crossorigin:["anonymous","use-credentials"],preload:["auto","metadata","none"],autoplay:["autoplay"],mediagroup:["movie"],muted:["muted"],controls:["controls"]}},wbr:h},tt={accesskey:null,class:null,contenteditable:b,contextmenu:null,dir:["ltr","rtl","auto"],draggable:["true","false","auto"],dropzone:["copy","move","link","string:","file:"],hidden:["hidden"],id:null,inert:["inert"],itemid:null,itemprop:null,itemref:null,itemscope:["itemscope"],itemtype:null,lang:["ar","bn","de","en-GB","en-US","es","fr","hi","id","ja","pa","pt","ru","tr","zh"],spellcheck:b,autocorrect:b,autocapitalize:b,style:null,tabindex:null,title:null,translate:["yes","no"],rel:["stylesheet","alternate","author","bookmark","help","license","next","nofollow","noreferrer","prefetch","prev","search","tag"],role:"alert application article banner button cell checkbox complementary contentinfo dialog document feed figure form grid gridcell heading img list listbox listitem main navigation region row rowgroup search switch tab table tabpanel textbox timer".split(" "),"aria-activedescendant":null,"aria-atomic":b,"aria-autocomplete":["inline","list","both","none"],"aria-busy":b,"aria-checked":["true","false","mixed","undefined"],"aria-controls":null,"aria-describedby":null,"aria-disabled":b,"aria-dropeffect":null,"aria-expanded":["true","false","undefined"],"aria-flowto":null,"aria-grabbed":["true","false","undefined"],"aria-haspopup":b,"aria-hidden":b,"aria-invalid":["true","false","grammar","spelling"],"aria-label":null,"aria-labelledby":null,"aria-level":null,"aria-live":["off","polite","assertive"],"aria-multiline":b,"aria-multiselectable":b,"aria-owns":null,"aria-posinset":null,"aria-pressed":["true","false","mixed","undefined"],"aria-readonly":b,"aria-relevant":null,"aria-required":b,"aria-selected":["true","false","undefined"],"aria-setsize":null,"aria-sort":["ascending","descending","none","other"],"aria-valuemax":null,"aria-valuemin":null,"aria-valuenow":null,"aria-valuetext":null},at="beforeunload copy cut dragstart dragover dragleave dragenter dragend drag paste focus blur change click load mousedown mouseenter mouseleave mouseup keydown keyup resize scroll unload".split(" ").map(O=>"on"+O);for(let O of at)tt[O]=null;class Qe{constructor(e,a){this.tags=Object.assign(Object.assign({},Fr),e),this.globalAttrs=Object.assign(Object.assign({},tt),a),this.allTags=Object.keys(this.tags),this.globalAttrNames=Object.keys(this.globalAttrs)}}Qe.default=new Qe;function q(O,e,a=O.length){if(!e)return"";let t=e.firstChild,r=t&&t.getChild("TagName");return r?O.sliceString(r.from,Math.min(r.to,a)):""}function V(O,e=!1){for(;O;O=O.parent)if(O.name=="Element")if(e)e=!1;else return O;return null}function rt(O,e,a){let t=a.tags[q(O,V(e))];return(t==null?void 0:t.children)||a.allTags}function Ue(O,e){let a=[];for(let t=V(e);t&&!t.type.isTop;t=V(t.parent)){let r=q(O,t);if(r&&t.lastChild.name=="CloseTag")break;r&&a.indexOf(r)<0&&(e.name=="EndTag"||e.from>=t.firstChild.to)&&a.push(r)}return a}const it=/^[:\-\.\w\u00b7-\uffff]*$/;function pO(O,e,a,t,r){let s=/\s*>/.test(O.sliceDoc(r,r+5))?"":">",i=V(a,!0);return{from:t,to:r,options:rt(O.doc,i,e).map(l=>({label:l,type:"type"})).concat(Ue(O.doc,a).map((l,n)=>({label:"/"+l,apply:"/"+l+s,type:"type",boost:99-n}))),validFor:/^\/?[:\-\.\w\u00b7-\uffff]*$/}}function dO(O,e,a,t){let r=/\s*>/.test(O.sliceDoc(t,t+5))?"":">";return{from:a,to:t,options:Ue(O.doc,e).map((s,i)=>({label:s,apply:s+r,type:"type",boost:99-i})),validFor:it}}function Mr(O,e,a,t){let r=[],s=0;for(let i of rt(O.doc,a,e))r.push({label:"<"+i,type:"type"});for(let i of Ue(O.doc,a))r.push({label:"",type:"type",boost:99-s++});return{from:t,to:t,options:r,validFor:/^<\/?[:\-\.\w\u00b7-\uffff]*$/}}function Kr(O,e,a,t,r){let s=V(a),i=s?e.tags[q(O.doc,s)]:null,l=i&&i.attrs?Object.keys(i.attrs):[],n=i&&i.globalAttrs===!1?l:l.length?l.concat(e.globalAttrNames):e.globalAttrNames;return{from:t,to:r,options:n.map(Q=>({label:Q,type:"property"})),validFor:it}}function Hr(O,e,a,t,r){var s;let i=(s=a.parent)===null||s===void 0?void 0:s.getChild("AttributeName"),l=[],n;if(i){let Q=O.sliceDoc(i.from,i.to),d=e.globalAttrs[Q];if(!d){let c=V(a),u=c?e.tags[q(O.doc,c)]:null;d=(u==null?void 0:u.attrs)&&u.attrs[Q]}if(d){let c=O.sliceDoc(t,r).toLowerCase(),u='"',p='"';/^['"]/.test(c)?(n=c[0]=='"'?/^[^"]*$/:/^[^']*$/,u="",p=O.sliceDoc(r,r+1)==c[0]?"":c[0],c=c.slice(1),t++):n=/^[^\s<>='"]*$/;for(let S of d)l.push({label:S,apply:u+S+p,type:"constant"})}}return{from:t,to:r,options:l,validFor:n}}function ei(O,e){let{state:a,pos:t}=e,r=j(a).resolveInner(t,-1),s=r.resolve(t);for(let i=t,l;s==r&&(l=r.childBefore(i));){let n=l.lastChild;if(!n||!n.type.isError||n.fromei(t,r)}const ti=Y.parser.configure({top:"SingleExpression"}),st=[{tag:"script",attrs:O=>O.type=="text/typescript"||O.lang=="ts",parser:FO.parser},{tag:"script",attrs:O=>O.type=="text/babel"||O.type=="text/jsx",parser:MO.parser},{tag:"script",attrs:O=>O.type=="text/typescript-jsx",parser:KO.parser},{tag:"script",attrs:O=>O.type=="importmap"||O.type=="speculationrules",parser:ti},{tag:"script",attrs(O){return!O.type||/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i.test(O.type)},parser:Y.parser},{tag:"style",attrs(O){return(!O.lang||O.lang=="css")&&(!O.type||/^(text\/)?(x-)?(stylesheet|css)$/i.test(O.type))},parser:ce.parser}],lt=[{name:"style",parser:ce.parser.configure({top:"Styles"})}].concat(at.map(O=>({name:O,parser:Y.parser}))),ot=J.define({name:"html",parser:Ja.configure({props:[L.add({Element(O){let e=/^(\s*)(<\/)?/.exec(O.textAfter);return O.node.to<=O.pos+e[0].length?O.continue():O.lineIndent(O.node.from)+(e[2]?0:O.unit)},"OpenTag CloseTag SelfClosingTag"(O){return O.column(O.node.from)+O.unit},Document(O){if(O.pos+/\s*/.exec(O.textAfter)[0].lengthO.getChild("TagName")})]}),languageData:{commentTokens:{block:{open:""}},indentOnInput:/^\s*<\/\w+\W$/,wordChars:"-._"}}),ae=ot.configure({wrap:zO(st,lt)});function ai(O={}){let e="",a;O.matchClosingTags===!1&&(e="noMatch"),O.selfClosingTags===!0&&(e=(e?e+" ":"")+"selfClosing"),(O.nestedLanguages&&O.nestedLanguages.length||O.nestedAttributes&&O.nestedAttributes.length)&&(a=zO((O.nestedLanguages||[]).concat(st),(O.nestedAttributes||[]).concat(lt)));let t=a?ot.configure({wrap:a,dialect:e}):e?ae.configure({dialect:e}):ae;return new M(t,[ae.data.of({autocomplete:Oi(O)}),O.autoCloseTags!==!1?ri:[],Ot().support,mr().support])}const hO=new Set("area base br col command embed frame hr img input keygen link meta param source track wbr menuitem".split(" ")),ri=U.inputHandler.of((O,e,a,t,r)=>{if(O.composing||O.state.readOnly||e!=a||t!=">"&&t!="/"||!ae.isActiveAt(O.state,e,-1))return!1;let s=r(),{state:i}=s,l=i.changeByRange(n=>{var Q,d,c;let u=i.doc.sliceString(n.from-1,n.to)==t,{head:p}=n,S=j(i).resolveInner(p-1,-1),f;if((S.name=="TagName"||S.name=="StartTag")&&(S=S.parent),u&&t==">"&&S.name=="OpenTag"){if(((d=(Q=S.parent)===null||Q===void 0?void 0:Q.lastChild)===null||d===void 0?void 0:d.name)!="CloseTag"&&(f=q(i.doc,S.parent,p))&&!hO.has(f)){let g=p+(i.doc.sliceString(p,p+1)===">"?1:0),X=``;return{range:n,changes:{from:p,to:g,insert:X}}}}else if(u&&t=="/"&&S.name=="IncompleteCloseTag"){let g=S.parent;if(S.from==p-2&&((c=g.lastChild)===null||c===void 0?void 0:c.name)!="CloseTag"&&(f=q(i.doc,g,p))&&!hO.has(f)){let X=p+(i.doc.sliceString(p,p+1)===">"?1:0),y=`${f}>`;return{range:YO.cursor(p+y.length,-1),changes:{from:p,to:X,insert:y}}}}return{range:n}});return l.changes.empty?!1:(O.dispatch([s,i.update(l,{userEvent:"input.complete",scrollIntoView:!0})]),!0)}),ii=D({String:o.string,Number:o.number,"True False":o.bool,PropertyName:o.propertyName,Null:o.null,",":o.separator,"[ ]":o.squareBracket,"{ }":o.brace}),si=T.deserialize({version:14,states:"$bOVQPOOOOQO'#Cb'#CbOnQPO'#CeOvQPO'#CjOOQO'#Cp'#CpQOQPOOOOQO'#Cg'#CgO}QPO'#CfO!SQPO'#CrOOQO,59P,59PO![QPO,59PO!aQPO'#CuOOQO,59U,59UO!iQPO,59UOVQPO,59QOqQPO'#CkO!nQPO,59^OOQO1G.k1G.kOVQPO'#ClO!vQPO,59aOOQO1G.p1G.pOOQO1G.l1G.lOOQO,59V,59VOOQO-E6i-E6iOOQO,59W,59WOOQO-E6j-E6j",stateData:"#O~OcOS~OQSORSOSSOTSOWQO]ROePO~OVXOeUO~O[[O~PVOg^O~Oh_OVfX~OVaO~OhbO[iX~O[dO~Oh_OVfa~OhbO[ia~O",goto:"!kjPPPPPPkPPkqwPPk{!RPPP!XP!ePP!hXSOR^bQWQRf_TVQ_Q`WRg`QcZRicQTOQZRQe^RhbRYQR]R",nodeNames:"⚠ JsonText True False Null Number String } { Object Property PropertyName ] [ Array",maxTerm:25,nodeProps:[["openedBy",7,"{",12,"["],["closedBy",8,"}",13,"]"]],propSources:[ii],skippedNodes:[0],repeatNodeCount:2,tokenData:"(|~RaXY!WYZ!W]^!Wpq!Wrs!]|}$u}!O$z!Q!R%T!R![&c![!]&t!}#O&y#P#Q'O#Y#Z'T#b#c'r#h#i(Z#o#p(r#q#r(w~!]Oc~~!`Wpq!]qr!]rs!xs#O!]#O#P!}#P;'S!];'S;=`$o<%lO!]~!}Oe~~#QXrs!]!P!Q!]#O#P!]#U#V!]#Y#Z!]#b#c!]#f#g!]#h#i!]#i#j#m~#pR!Q![#y!c!i#y#T#Z#y~#|R!Q![$V!c!i$V#T#Z$V~$YR!Q![$c!c!i$c#T#Z$c~$fR!Q![!]!c!i!]#T#Z!]~$rP;=`<%l!]~$zOh~~$}Q!Q!R%T!R![&c~%YRT~!O!P%c!g!h%w#X#Y%w~%fP!Q![%i~%nRT~!Q![%i!g!h%w#X#Y%w~%zR{|&T}!O&T!Q![&Z~&WP!Q![&Z~&`PT~!Q![&Z~&hST~!O!P%c!Q![&c!g!h%w#X#Y%w~&yOg~~'OO]~~'TO[~~'WP#T#U'Z~'^P#`#a'a~'dP#g#h'g~'jP#X#Y'm~'rOR~~'uP#i#j'x~'{P#`#a(O~(RP#`#a(U~(ZOS~~(^P#f#g(a~(dP#i#j(g~(jP#X#Y(m~(rOQ~~(wOW~~(|OV~",tokenizers:[0],topRules:{JsonText:[0,1]},tokenPrec:0}),li=J.define({name:"json",parser:si.configure({props:[L.add({Object:C({except:/^\s*\}/}),Array:C({except:/^\s*\]/})}),F.add({"Object Array":Te})]}),languageData:{closeBrackets:{brackets:["[","{",'"']},indentOnInput:/^\s*[\}\]]$/}});function oi(){return new M(li)}const ni=36,uO=1,ci=2,A=3,Ze=4,Qi=5,pi=6,di=7,hi=8,ui=9,Si=10,$i=11,fi=12,gi=13,mi=14,Pi=15,Zi=16,bi=17,SO=18,Xi=19,nt=20,ct=21,$O=22,xi=23,Yi=24;function Ye(O){return O>=65&&O<=90||O>=97&&O<=122||O>=48&&O<=57}function yi(O){return O>=48&&O<=57||O>=97&&O<=102||O>=65&&O<=70}function W(O,e,a){for(let t=!1;;){if(O.next<0)return;if(O.next==e&&!t){O.advance();return}t=a&&!t&&O.next==92,O.advance()}}function ki(O){for(;;){if(O.next<0||O.peek(1)<0)return;if(O.next==36&&O.peek(1)==36){O.advance(2);return}O.advance()}}function wi(O,e){let a="[{<(".indexOf(String.fromCharCode(e)),t=a<0?e:"]}>)".charCodeAt(a);for(;;){if(O.next<0)return;if(O.next==t&&O.peek(1)==39){O.advance(2);return}O.advance()}}function Qt(O,e){for(;!(O.next!=95&&!Ye(O.next));)e!=null&&(e+=String.fromCharCode(O.next)),O.advance();return e}function vi(O){if(O.next==39||O.next==34||O.next==96){let e=O.next;O.advance(),W(O,e,!1)}else Qt(O)}function fO(O,e){for(;O.next==48||O.next==49;)O.advance();e&&O.next==e&&O.advance()}function gO(O,e){for(;;){if(O.next==46){if(e)break;e=!0}else if(O.next<48||O.next>57)break;O.advance()}if(O.next==69||O.next==101)for(O.advance(),(O.next==43||O.next==45)&&O.advance();O.next>=48&&O.next<=57;)O.advance()}function mO(O){for(;!(O.next<0||O.next==10);)O.advance()}function _(O,e){for(let a=0;a!=&|~^/",specialVar:"?",identifierQuotes:'"',words:pt(_i,Ti)};function Wi(O,e,a,t){let r={};for(let s in ye)r[s]=(O.hasOwnProperty(s)?O:ye)[s];return e&&(r.words=pt(e,a||"",t)),r}function dt(O){return new x(e=>{var a;let{next:t}=e;if(e.advance(),_(t,be)){for(;_(e.next,be);)e.advance();e.acceptToken(ni)}else if(t==36&&e.next==36&&O.doubleDollarQuotedStrings)ki(e),e.acceptToken(A);else if(t==39||t==34&&O.doubleQuotedStrings)W(e,t,O.backslashEscapes),e.acceptToken(A);else if(t==35&&O.hashComments||t==47&&e.next==47&&O.slashComments)mO(e),e.acceptToken(uO);else if(t==45&&e.next==45&&(!O.spaceAfterDashes||e.peek(1)==32))mO(e),e.acceptToken(uO);else if(t==47&&e.next==42){e.advance();for(let r=1;;){let s=e.next;if(e.next<0)break;if(e.advance(),s==42&&e.next==47){if(r--,e.advance(),!r)break}else s==47&&e.next==42&&(r++,e.advance())}e.acceptToken(ci)}else if((t==101||t==69)&&e.next==39)e.advance(),W(e,39,!0);else if((t==110||t==78)&&e.next==39&&O.charSetCasts)e.advance(),W(e,39,O.backslashEscapes),e.acceptToken(A);else if(t==95&&O.charSetCasts)for(let r=0;;r++){if(e.next==39&&r>1){e.advance(),W(e,39,O.backslashEscapes),e.acceptToken(A);break}if(!Ye(e.next))break;e.advance()}else if(O.plsqlQuotingMechanism&&(t==113||t==81)&&e.next==39&&e.peek(1)>0&&!_(e.peek(1),be)){let r=e.peek(1);e.advance(2),wi(e,r),e.acceptToken(A)}else if(t==40)e.acceptToken(di);else if(t==41)e.acceptToken(hi);else if(t==123)e.acceptToken(ui);else if(t==125)e.acceptToken(Si);else if(t==91)e.acceptToken($i);else if(t==93)e.acceptToken(fi);else if(t==59)e.acceptToken(gi);else if(O.unquotedBitLiterals&&t==48&&e.next==98)e.advance(),fO(e),e.acceptToken($O);else if((t==98||t==66)&&(e.next==39||e.next==34)){const r=e.next;e.advance(),O.treatBitsAsBytes?(W(e,r,O.backslashEscapes),e.acceptToken(xi)):(fO(e,r),e.acceptToken($O))}else if(t==48&&(e.next==120||e.next==88)||(t==120||t==88)&&e.next==39){let r=e.next==39;for(e.advance();yi(e.next);)e.advance();r&&e.next==39&&e.advance(),e.acceptToken(Ze)}else if(t==46&&e.next>=48&&e.next<=57)gO(e,!0),e.acceptToken(Ze);else if(t==46)e.acceptToken(mi);else if(t>=48&&t<=57)gO(e,!1),e.acceptToken(Ze);else if(_(t,O.operatorChars)){for(;_(e.next,O.operatorChars);)e.advance();e.acceptToken(Pi)}else if(_(t,O.specialVar))e.next==t&&e.advance(),vi(e),e.acceptToken(bi);else if(_(t,O.identifierQuotes))W(e,t,!1),e.acceptToken(Xi);else if(t==58||t==44)e.acceptToken(Zi);else if(Ye(t)){let r=Qt(e,String.fromCharCode(t));e.acceptToken(e.next==46?SO:(a=O.words[r.toLowerCase()])!==null&&a!==void 0?a:SO)}})}const ht=dt(ye),Ui=T.deserialize({version:14,states:"%vQ]QQOOO#wQRO'#DSO$OQQO'#CwO%eQQO'#CxO%lQQO'#CyO%sQQO'#CzOOQQ'#DS'#DSOOQQ'#C}'#C}O'UQRO'#C{OOQQ'#Cv'#CvOOQQ'#C|'#C|Q]QQOOQOQQOOO'`QQO'#DOO(xQRO,59cO)PQQO,59cO)UQQO'#DSOOQQ,59d,59dO)cQQO,59dOOQQ,59e,59eO)jQQO,59eOOQQ,59f,59fO)qQQO,59fOOQQ-E6{-E6{OOQQ,59b,59bOOQQ-E6z-E6zOOQQ,59j,59jOOQQ-E6|-E6|O+VQRO1G.}O+^QQO,59cOOQQ1G/O1G/OOOQQ1G/P1G/POOQQ1G/Q1G/QP+kQQO'#C}O+rQQO1G.}O)PQQO,59cO,PQQO'#Cw",stateData:",[~OtOSPOSQOS~ORUOSUOTUOUUOVROXSOZTO]XO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O^]ORvXSvXTvXUvXVvXXvXZvX]vX_vX`vXavXbvXcvXdvXevXfvXgvXhvX~OsvX~P!jOa_Ob_Oc_O~ORUOSUOTUOUUOVROXSOZTO^tO_UO`UOa`Ob`Oc`OdUOeUOfUOgUOhUO~OWaO~P$ZOYcO~P$ZO[eO~P$ZORUOSUOTUOUUOVROXSOZTO^QO_UO`UOaPObPOcPOdUOeUOfUOgUOhUO~O]hOsoX~P%zOajObjOcjO~O^]ORkaSkaTkaUkaVkaXkaZka]ka_ka`kaakabkackadkaekafkagkahka~Oska~P'kO^]O~OWvXYvX[vX~P!jOWnO~P$ZOYoO~P$ZO[pO~P$ZO^]ORkiSkiTkiUkiVkiXkiZki]ki_ki`kiakibkickidkiekifkigkihki~Oski~P)xOWkaYka[ka~P'kO]hO~P$ZOWkiYki[ki~P)xOasObsOcsO~O",goto:"#hwPPPPPPPPPPPPPPPPPPPPPPPPPPx||||!Y!^!d!xPPP#[TYOZeUORSTWZbdfqT[OZQZORiZSWOZQbRQdSQfTZgWbdfqQ^PWk^lmrQl_Qm`RrseVORSTWZbdfq",nodeNames:"⚠ LineComment BlockComment String Number Bool Null ( ) { } [ ] ; . Operator Punctuation SpecialVar Identifier QuotedIdentifier Keyword Type Bits Bytes Builtin Script Statement CompositeIdentifier Parens Braces Brackets Statement",maxTerm:38,skippedNodes:[0,1,2],repeatNodeCount:3,tokenData:"RORO",tokenizers:[0,ht],topRules:{Script:[0,25]},tokenPrec:0});function ke(O){let e=O.cursor().moveTo(O.from,-1);for(;/Comment/.test(e.name);)e.moveTo(e.from,-1);return e.node}function N(O,e){let a=O.sliceString(e.from,e.to),t=/^([`'"])(.*)\1$/.exec(a);return t?t[2]:a}function pe(O){return O&&(O.name=="Identifier"||O.name=="QuotedIdentifier")}function Ci(O,e){if(e.name=="CompositeIdentifier"){let a=[];for(let t=e.firstChild;t;t=t.nextSibling)pe(t)&&a.push(N(O,t));return a}return[N(O,e)]}function PO(O,e){for(let a=[];;){if(!e||e.name!=".")return a;let t=ke(e);if(!pe(t))return a;a.unshift(N(O,t)),e=ke(t)}}function Ri(O,e){let a=j(O).resolveInner(e,-1),t=Vi(O.doc,a);return a.name=="Identifier"||a.name=="QuotedIdentifier"||a.name=="Keyword"?{from:a.from,quoted:a.name=="QuotedIdentifier"?O.doc.sliceString(a.from,a.from+1):null,parents:PO(O.doc,ke(a)),aliases:t}:a.name=="."?{from:e,quoted:null,parents:PO(O.doc,a),aliases:t}:{from:e,quoted:null,parents:[],empty:!0,aliases:t}}const qi=new Set("where group having order union intersect except all distinct limit offset fetch for".split(" "));function Vi(O,e){let a;for(let r=e;!a;r=r.parent){if(!r)return null;r.name=="Statement"&&(a=r)}let t=null;for(let r=a.firstChild,s=!1,i=null;r;r=r.nextSibling){let l=r.name=="Keyword"?O.sliceString(r.from,r.to).toLowerCase():null,n=null;if(!s)s=l=="from";else if(l=="as"&&i&&pe(r.nextSibling))n=N(O,r.nextSibling);else{if(l&&qi.has(l))break;i&&pe(r)&&(n=N(O,r))}n&&(t||(t=Object.create(null)),t[n]=Ci(O,i)),i=/Identifier$/.test(r.name)?r:null}return t}function ji(O,e){return O?e.map(a=>Object.assign(Object.assign({},a),{label:O+a.label+O,apply:void 0})):e}const zi=/^\w*$/,Gi=/^[`'"]?\w*[`'"]?$/;class Ce{constructor(){this.list=[],this.children=void 0}child(e,a){let t=this.children||(this.children=Object.create(null)),r=t[e];return r||(e&&this.list.push(ut(e,"type",a)),t[e]=new Ce)}addCompletions(e){for(let a of e){let t=this.list.findIndex(r=>r.label==a.label);t>-1?this.list[t]=a:this.list.push(a)}}}function ut(O,e,a){return/[^\w\xb5-\uffff]/.test(O)?{label:O,type:e,apply:a+O+a}:{label:O,type:e}}function Ei(O,e,a,t,r,s){var i;let l=new Ce,n=((i=s==null?void 0:s.spec.identifierQuotes)===null||i===void 0?void 0:i[0])||'"',Q=l.child(r||"",n);for(let d in O){let c=d.replace(/\\?\./g,p=>p=="."?"\0":p).split("\0"),u=c.length==1?Q:l;for(let p of c)u=u.child(p.replace(/\\\./g,"."),n);for(let p of O[d])p&&u.list.push(typeof p=="string"?ut(p,"property",n):p)}return e&&Q.addCompletions(e),a&&l.addCompletions(a),l.addCompletions(Q.list),t&&l.addCompletions(Q.child(t,n).list),d=>{let{parents:c,from:u,quoted:p,empty:S,aliases:f}=Ri(d.state,d.pos);if(S&&!d.explicit)return null;f&&c.length==1&&(c=f[c[0]]||c);let g=l;for(let w of c){for(;!g.children||!g.children[w];)if(g==l)g=Q;else if(g==Q&&t)g=g.child(t,n);else return null;g=g.child(w,n)}let X=p&&d.state.sliceDoc(d.pos,d.pos+1)==p,y=g.list;return g==l&&f&&(y=y.concat(Object.keys(f).map(w=>({label:w,type:"constant"})))),{from:u,to:X?d.pos+1:void 0,options:ji(p,y),validFor:p?Gi:zi}}}function Ai(O,e){let a=Object.keys(O).map(t=>({label:e?t.toUpperCase():t,type:O[t]==ct?"type":O[t]==nt?"keyword":"variable",boost:-1}));return XO(["QuotedIdentifier","SpecialVar","String","LineComment","BlockComment","."],xO(a))}let Ii=Ui.configure({props:[L.add({Statement:C()}),F.add({Statement(O){return{from:O.firstChild.to,to:O.to}},BlockComment(O){return{from:O.from+2,to:O.to-2}}}),D({Keyword:o.keyword,Type:o.typeName,Builtin:o.standard(o.name),Bits:o.number,Bytes:o.string,Bool:o.bool,Null:o.null,Number:o.number,String:o.string,Identifier:o.name,QuotedIdentifier:o.special(o.string),SpecialVar:o.special(o.name),LineComment:o.lineComment,BlockComment:o.blockComment,Operator:o.operator,"Semi Punctuation":o.punctuation,"( )":o.paren,"{ }":o.brace,"[ ]":o.squareBracket})]});class B{constructor(e,a,t){this.dialect=e,this.language=a,this.spec=t}get extension(){return this.language.extension}static define(e){let a=Wi(e,e.keywords,e.types,e.builtin),t=J.define({name:"sql",parser:Ii.configure({tokenizers:[{from:ht,to:dt(a)}]}),languageData:{commentTokens:{line:"--",block:{open:"/*",close:"*/"}},closeBrackets:{brackets:["(","[","{","'",'"',"`"]}}});return new B(a,t,e)}}function Ni(O,e=!1){return Ai(O.dialect.words,e)}function Bi(O,e=!1){return O.language.data.of({autocomplete:Ni(O,e)})}function Di(O){return O.schema?Ei(O.schema,O.tables,O.schemas,O.defaultTable,O.defaultSchema,O.dialect||Re):()=>null}function Ji(O){return O.schema?(O.dialect||Re).language.data.of({autocomplete:Di(O)}):[]}function ZO(O={}){let e=O.dialect||Re;return new M(e.language,[Ji(O),Bi(e,!!O.upperCaseKeywords)])}const Re=B.define({});function Li(O){let e;return{c(){e=Pt("div"),Zt(e,"class","code-editor"),K(e,"min-height",O[0]?O[0]+"px":null),K(e,"max-height",O[1]?O[1]+"px":"auto")},m(a,t){bt(a,e,t),O[11](e)},p(a,[t]){t&1&&K(e,"min-height",a[0]?a[0]+"px":null),t&2&&K(e,"max-height",a[1]?a[1]+"px":"auto")},i:ze,o:ze,d(a){a&&Xt(e),O[11](null)}}}function Fi(O,e,a){let t;xt(O,Yt,$=>a(12,t=$));const r=yt();let{id:s=""}=e,{value:i=""}=e,{minHeight:l=null}=e,{maxHeight:n=null}=e,{disabled:Q=!1}=e,{placeholder:d=""}=e,{language:c="javascript"}=e,{singleLine:u=!1}=e,p,S,f=new H,g=new H,X=new H,y=new H;function w(){p==null||p.focus()}function St(){S==null||S.dispatchEvent(new CustomEvent("change",{detail:{value:i},bubbles:!0})),r("change",i)}function qe(){if(!s)return;const $=document.querySelectorAll('[for="'+s+'"]');for(let m of $)m.removeEventListener("click",w)}function Ve(){if(!s)return;qe();const $=document.querySelectorAll('[for="'+s+'"]');for(let m of $)m.addEventListener("click",w)}function je(){switch(c){case"html":return ai();case"json":return oi();case"sql-create-index":return ZO({dialect:B.define({keywords:"create unique index if not exists on collate asc desc where like isnull notnull date time datetime unixepoch strftime lower upper substr case when then iif if else json_extract json_each json_tree json_array_length json_valid ",operatorChars:"*+-%<>!=&|/~",identifierQuotes:'`"',specialVar:"@:?$"}),upperCaseKeywords:!0});case"sql-select":let $={};for(let m of t)$[m.name]=wt.getAllCollectionIdentifiers(m);return ZO({dialect:B.define({keywords:"select distinct from where having group by order limit offset join left right inner with like not in match asc desc regexp isnull notnull glob count avg sum min max current random cast as int real text date time datetime unixepoch strftime coalesce lower upper substr case when then iif if else json_extract json_each json_tree json_array_length json_valid ",operatorChars:"*+-%<>!=&|/~",identifierQuotes:'`"',specialVar:"@:?$"}),schema:$,upperCaseKeywords:!0});default:return Ot()}}kt(()=>{const $={key:"Enter",run:m=>{u&&r("submit",i)}};return Ve(),a(10,p=new U({parent:S,state:z.create({doc:i,extensions:[zt(),Gt(),Et(),At(),It(),z.allowMultipleSelections.of(!0),Nt(Bt,{fallback:!0}),Dt(),Jt(),Lt(),Ft(),Mt.of([$,...Kt,...Ht,ea.find(m=>m.key==="Mod-d"),...Oa,...ta]),U.lineWrapping,aa({icons:!1}),f.of(je()),y.of(Ge(d)),g.of(U.editable.of(!0)),X.of(z.readOnly.of(!1)),z.transactionFilter.of(m=>u&&m.newDoc.lines>1?[]:m),U.updateListener.of(m=>{!m.docChanged||Q||(a(3,i=m.state.doc.toString()),St())})]})})),()=>{qe(),p==null||p.destroy()}});function $t($){vt[$?"unshift":"push"](()=>{S=$,a(2,S)})}return O.$$set=$=>{"id"in $&&a(4,s=$.id),"value"in $&&a(3,i=$.value),"minHeight"in $&&a(0,l=$.minHeight),"maxHeight"in $&&a(1,n=$.maxHeight),"disabled"in $&&a(5,Q=$.disabled),"placeholder"in $&&a(6,d=$.placeholder),"language"in $&&a(7,c=$.language),"singleLine"in $&&a(8,u=$.singleLine)},O.$$.update=()=>{O.$$.dirty&16&&s&&Ve(),O.$$.dirty&1152&&p&&c&&p.dispatch({effects:[f.reconfigure(je())]}),O.$$.dirty&1056&&p&&typeof Q<"u"&&p.dispatch({effects:[g.reconfigure(U.editable.of(!Q)),X.reconfigure(z.readOnly.of(Q))]}),O.$$.dirty&1032&&p&&i!=p.state.doc.toString()&&p.dispatch({changes:{from:0,to:p.state.doc.length,insert:i}}),O.$$.dirty&1088&&p&&typeof d<"u"&&p.dispatch({effects:[y.reconfigure(Ge(d))]})},[l,n,S,i,s,Q,d,c,u,w,p,$t]}class Hi extends ft{constructor(e){super(),gt(this,e,Fi,Li,mt,{id:4,value:3,minHeight:0,maxHeight:1,disabled:5,placeholder:6,language:7,singleLine:8,focus:9})}get focus(){return this.$$.ctx[9]}}export{Hi as default}; diff --git a/ui/dist/assets/ConfirmEmailChangeDocs-dd814dbe.js b/ui/dist/assets/ConfirmEmailChangeDocs-dd814dbe.js deleted file mode 100644 index 302288f05d..0000000000 --- a/ui/dist/assets/ConfirmEmailChangeDocs-dd814dbe.js +++ /dev/null @@ -1,56 +0,0 @@ -import{S as $e,i as Pe,s as Se,O as Y,e as r,w as v,b as k,c as ge,f as b,g as d,h as o,m as ve,x as j,P as ue,Q as we,k as Oe,R as Re,n as Te,t as x,a as ee,o as m,d as Ce,C as ye,p as Ee,r as H,u as Be,N as qe}from"./index-bd739ba3.js";import{S as Ae}from"./SdkTabs-cf570492.js";function be(n,l,s){const a=n.slice();return a[5]=l[s],a}function _e(n,l,s){const a=n.slice();return a[5]=l[s],a}function he(n,l){let s,a=l[5].code+"",_,u,i,p;function f(){return l[4](l[5])}return{key:n,first:null,c(){s=r("button"),_=v(a),u=k(),b(s,"class","tab-item"),H(s,"active",l[1]===l[5].code),this.first=s},m(C,$){d(C,s,$),o(s,_),o(s,u),i||(p=Be(s,"click",f),i=!0)},p(C,$){l=C,$&4&&a!==(a=l[5].code+"")&&j(_,a),$&6&&H(s,"active",l[1]===l[5].code)},d(C){C&&m(s),i=!1,p()}}}function ke(n,l){let s,a,_,u;return a=new qe({props:{content:l[5].body}}),{key:n,first:null,c(){s=r("div"),ge(a.$$.fragment),_=k(),b(s,"class","tab-item"),H(s,"active",l[1]===l[5].code),this.first=s},m(i,p){d(i,s,p),ve(a,s,null),o(s,_),u=!0},p(i,p){l=i;const f={};p&4&&(f.content=l[5].body),a.$set(f),(!u||p&6)&&H(s,"active",l[1]===l[5].code)},i(i){u||(x(a.$$.fragment,i),u=!0)},o(i){ee(a.$$.fragment,i),u=!1},d(i){i&&m(s),Ce(a)}}}function Ue(n){var de,me;let l,s,a=n[0].name+"",_,u,i,p,f,C,$,D=n[0].name+"",F,te,I,P,L,R,Q,S,N,le,K,T,se,z,M=n[0].name+"",G,ae,J,y,V,E,X,B,Z,w,q,g=[],ne=new Map,oe,A,h=[],ie=new Map,O;P=new Ae({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${n[3]}'); - - ... - - await pb.collection('${(de=n[0])==null?void 0:de.name}').confirmEmailChange( - 'TOKEN', - 'YOUR_PASSWORD', - ); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${n[3]}'); - - ... - - await pb.collection('${(me=n[0])==null?void 0:me.name}').confirmEmailChange( - 'TOKEN', - 'YOUR_PASSWORD', - ); - `}});let W=Y(n[2]);const ce=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description
Required token
String The token from the change email request email.
Required password
String The account password to confirm the email change.',X=k(),B=r("div"),B.textContent="Responses",Z=k(),w=r("div"),q=r("div");for(let e=0;es(1,u=f.code);return n.$$set=f=>{"collection"in f&&s(0,_=f.collection)},s(3,a=ye.getApiExampleUrl(Ee.baseUrl)),s(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "token": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}]),[_,u,i,a,p]}class Me extends $e{constructor(l){super(),Pe(this,l,De,Ue,Se,{collection:0})}}export{Me as default}; diff --git a/ui/dist/assets/ConfirmPasswordResetDocs-0b396acf.js b/ui/dist/assets/ConfirmPasswordResetDocs-0b396acf.js deleted file mode 100644 index c250c3b561..0000000000 --- a/ui/dist/assets/ConfirmPasswordResetDocs-0b396acf.js +++ /dev/null @@ -1,61 +0,0 @@ -import{S as Re,i as Oe,s as Ce,O as K,e as r,w,b as h,c as ge,f as b,g as d,h as n,m as Pe,x as U,P as _e,Q as Ne,k as We,R as $e,n as Ee,t as ee,a as te,o as p,d as Se,C as ye,p as Ae,r as j,u as Te,N as De}from"./index-bd739ba3.js";import{S as qe}from"./SdkTabs-cf570492.js";function ke(o,s,l){const a=o.slice();return a[5]=s[l],a}function he(o,s,l){const a=o.slice();return a[5]=s[l],a}function ve(o,s){let l,a=s[5].code+"",_,u,i,f;function m(){return s[4](s[5])}return{key:o,first:null,c(){l=r("button"),_=w(a),u=h(),b(l,"class","tab-item"),j(l,"active",s[1]===s[5].code),this.first=l},m(g,P){d(g,l,P),n(l,_),n(l,u),i||(f=Te(l,"click",m),i=!0)},p(g,P){s=g,P&4&&a!==(a=s[5].code+"")&&U(_,a),P&6&&j(l,"active",s[1]===s[5].code)},d(g){g&&p(l),i=!1,f()}}}function we(o,s){let l,a,_,u;return a=new De({props:{content:s[5].body}}),{key:o,first:null,c(){l=r("div"),ge(a.$$.fragment),_=h(),b(l,"class","tab-item"),j(l,"active",s[1]===s[5].code),this.first=l},m(i,f){d(i,l,f),Pe(a,l,null),n(l,_),u=!0},p(i,f){s=i;const m={};f&4&&(m.content=s[5].body),a.$set(m),(!u||f&6)&&j(l,"active",s[1]===s[5].code)},i(i){u||(ee(a.$$.fragment,i),u=!0)},o(i){te(a.$$.fragment,i),u=!1},d(i){i&&p(l),Se(a)}}}function Be(o){var fe,me;let s,l,a=o[0].name+"",_,u,i,f,m,g,P,q=o[0].name+"",H,se,le,L,Q,S,z,N,G,R,B,ae,M,W,ne,J,F=o[0].name+"",V,oe,X,$,Y,E,Z,y,x,O,A,v=[],ie=new Map,ce,T,k=[],re=new Map,C;S=new qe({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(fe=o[0])==null?void 0:fe.name}').confirmPasswordReset( - 'TOKEN', - 'NEW_PASSWORD', - 'NEW_PASSWORD_CONFIRM', - ); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(me=o[0])==null?void 0:me.name}').confirmPasswordReset( - 'TOKEN', - 'NEW_PASSWORD', - 'NEW_PASSWORD_CONFIRM', - ); - `}});let I=K(o[2]);const de=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description
Required token
String The token from the password reset request email.
Required password
String The new password to set.
Required passwordConfirm
String The new password confirmation.',Z=h(),y=r("div"),y.textContent="Responses",x=h(),O=r("div"),A=r("div");for(let e=0;el(1,u=m.code);return o.$$set=m=>{"collection"in m&&l(0,_=m.collection)},l(3,a=ye.getApiExampleUrl(Ae.baseUrl)),l(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "token": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}]),[_,u,i,a,f]}class Ke extends Re{constructor(s){super(),Oe(this,s,Me,Be,Ce,{collection:0})}}export{Ke as default}; diff --git a/ui/dist/assets/ConfirmVerificationDocs-8d19c105.js b/ui/dist/assets/ConfirmVerificationDocs-8d19c105.js deleted file mode 100644 index b5590ee462..0000000000 --- a/ui/dist/assets/ConfirmVerificationDocs-8d19c105.js +++ /dev/null @@ -1,56 +0,0 @@ -import{S as Se,i as Te,s as Be,O as D,e as r,w as g,b as k,c as ye,f as h,g as f,h as n,m as Ce,x as H,P as ke,Q as Re,k as qe,R as Oe,n as Ee,t as x,a as ee,o as d,d as Pe,C as Ne,p as Ve,r as F,u as Ke,N as Me}from"./index-bd739ba3.js";import{S as Ae}from"./SdkTabs-cf570492.js";function ve(o,l,s){const a=o.slice();return a[5]=l[s],a}function ge(o,l,s){const a=o.slice();return a[5]=l[s],a}function we(o,l){let s,a=l[5].code+"",b,m,i,p;function u(){return l[4](l[5])}return{key:o,first:null,c(){s=r("button"),b=g(a),m=k(),h(s,"class","tab-item"),F(s,"active",l[1]===l[5].code),this.first=s},m(w,$){f(w,s,$),n(s,b),n(s,m),i||(p=Ke(s,"click",u),i=!0)},p(w,$){l=w,$&4&&a!==(a=l[5].code+"")&&H(b,a),$&6&&F(s,"active",l[1]===l[5].code)},d(w){w&&d(s),i=!1,p()}}}function $e(o,l){let s,a,b,m;return a=new Me({props:{content:l[5].body}}),{key:o,first:null,c(){s=r("div"),ye(a.$$.fragment),b=k(),h(s,"class","tab-item"),F(s,"active",l[1]===l[5].code),this.first=s},m(i,p){f(i,s,p),Ce(a,s,null),n(s,b),m=!0},p(i,p){l=i;const u={};p&4&&(u.content=l[5].body),a.$set(u),(!m||p&6)&&F(s,"active",l[1]===l[5].code)},i(i){m||(x(a.$$.fragment,i),m=!0)},o(i){ee(a.$$.fragment,i),m=!1},d(i){i&&d(s),Pe(a)}}}function Ue(o){var fe,de,pe,ue;let l,s,a=o[0].name+"",b,m,i,p,u,w,$,K=o[0].name+"",I,te,L,y,Q,T,z,C,M,le,A,B,se,G,U=o[0].name+"",J,ae,W,R,X,q,Y,O,Z,P,E,v=[],oe=new Map,ne,N,_=[],ie=new Map,S;y=new Ae({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(fe=o[0])==null?void 0:fe.name}').confirmVerification('TOKEN'); - - // optionally refresh the previous authStore state with the latest record changes - await pb.collection('${(de=o[0])==null?void 0:de.name}').authRefresh(); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(pe=o[0])==null?void 0:pe.name}').confirmVerification('TOKEN'); - - // optionally refresh the previous authStore state with the latest record changes - await pb.collection('${(ue=o[0])==null?void 0:ue.name}').authRefresh(); - `}});let j=D(o[2]);const ce=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description
Required token
String The token from the verification request email.',Y=k(),O=r("div"),O.textContent="Responses",Z=k(),P=r("div"),E=r("div");for(let e=0;es(1,m=u.code);return o.$$set=u=>{"collection"in u&&s(0,b=u.collection)},s(3,a=Ne.getApiExampleUrl(Ve.baseUrl)),s(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "token": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}]),[b,m,i,a,p]}class Fe extends Se{constructor(l){super(),Te(this,l,je,Ue,Be,{collection:0})}}export{Fe as default}; diff --git a/ui/dist/assets/CreateApiDocs-29a7701b.js b/ui/dist/assets/CreateApiDocs-29a7701b.js deleted file mode 100644 index 3ee302c532..0000000000 --- a/ui/dist/assets/CreateApiDocs-29a7701b.js +++ /dev/null @@ -1,92 +0,0 @@ -import{S as qt,i as Ot,s as Mt,C as Q,O as ne,N as Tt,e as i,w as _,b as u,c as _e,f as v,g as r,h as n,m as he,x,P as Be,Q as ht,k as Ht,R as Lt,n as Pt,t as ue,a as fe,o as d,d as ke,p as Ft,r as ye,u as Rt,y as ae}from"./index-bd739ba3.js";import{S as At}from"./SdkTabs-cf570492.js";import{F as Bt}from"./FieldsQueryParam-85406354.js";function kt(o,e,t){const a=o.slice();return a[8]=e[t],a}function yt(o,e,t){const a=o.slice();return a[8]=e[t],a}function vt(o,e,t){const a=o.slice();return a[13]=e[t],a}function gt(o){let e;return{c(){e=i("p"),e.innerHTML="Requires admin Authorization:TOKEN header",v(e,"class","txt-hint txt-sm txt-right")},m(t,a){r(t,e,a)},d(t){t&&d(e)}}}function wt(o){let e,t,a,f,m,c,p,y,S,T,w,H,D,E,P,I,j,B,$,N,q,g,b;function O(h,C){var ee,K;return(K=(ee=h[0])==null?void 0:ee.options)!=null&&K.requireEmail?Dt:jt}let z=O(o),F=z(o);return{c(){e=i("tr"),e.innerHTML='Auth fields',t=u(),a=i("tr"),a.innerHTML=`
Optional username
String The username of the auth record. -
- If not set, it will be auto generated.`,f=u(),m=i("tr"),c=i("td"),p=i("div"),F.c(),y=u(),S=i("span"),S.textContent="email",T=u(),w=i("td"),w.innerHTML='String',H=u(),D=i("td"),D.textContent="Auth record email address.",E=u(),P=i("tr"),P.innerHTML='
Optional emailVisibility
Boolean Whether to show/hide the auth record email when fetching the record data.',I=u(),j=i("tr"),j.innerHTML='
Required password
String Auth record password.',B=u(),$=i("tr"),$.innerHTML='
Required passwordConfirm
String Auth record password confirmation.',N=u(),q=i("tr"),q.innerHTML=`
Optional verified
Boolean Indicates whether the auth record is verified or not. -
- This field can be set only by admins or auth records with "Manage" access.`,g=u(),b=i("tr"),b.innerHTML='Schema fields',v(p,"class","inline-flex")},m(h,C){r(h,e,C),r(h,t,C),r(h,a,C),r(h,f,C),r(h,m,C),n(m,c),n(c,p),F.m(p,null),n(p,y),n(p,S),n(m,T),n(m,w),n(m,H),n(m,D),r(h,E,C),r(h,P,C),r(h,I,C),r(h,j,C),r(h,B,C),r(h,$,C),r(h,N,C),r(h,q,C),r(h,g,C),r(h,b,C)},p(h,C){z!==(z=O(h))&&(F.d(1),F=z(h),F&&(F.c(),F.m(p,y)))},d(h){h&&(d(e),d(t),d(a),d(f),d(m),d(E),d(P),d(I),d(j),d(B),d($),d(N),d(q),d(g),d(b)),F.d()}}}function jt(o){let e;return{c(){e=i("span"),e.textContent="Optional",v(e,"class","label label-warning")},m(t,a){r(t,e,a)},d(t){t&&d(e)}}}function Dt(o){let e;return{c(){e=i("span"),e.textContent="Required",v(e,"class","label label-success")},m(t,a){r(t,e,a)},d(t){t&&d(e)}}}function Nt(o){let e;return{c(){e=i("span"),e.textContent="Optional",v(e,"class","label label-warning")},m(t,a){r(t,e,a)},d(t){t&&d(e)}}}function Vt(o){let e;return{c(){e=i("span"),e.textContent="Required",v(e,"class","label label-success")},m(t,a){r(t,e,a)},d(t){t&&d(e)}}}function Jt(o){var m;let e,t=((m=o[13].options)==null?void 0:m.maxSelect)===1?"id":"ids",a,f;return{c(){e=_("Relation record "),a=_(t),f=_(".")},m(c,p){r(c,e,p),r(c,a,p),r(c,f,p)},p(c,p){var y;p&1&&t!==(t=((y=c[13].options)==null?void 0:y.maxSelect)===1?"id":"ids")&&x(a,t)},d(c){c&&(d(e),d(a),d(f))}}}function Et(o){let e,t,a,f,m;return{c(){e=_("File object."),t=i("br"),a=_(` - Set to `),f=i("code"),f.textContent="null",m=_(" to delete already uploaded file(s).")},m(c,p){r(c,e,p),r(c,t,p),r(c,a,p),r(c,f,p),r(c,m,p)},p:ae,d(c){c&&(d(e),d(t),d(a),d(f),d(m))}}}function It(o){let e;return{c(){e=_("URL address.")},m(t,a){r(t,e,a)},p:ae,d(t){t&&d(e)}}}function Ut(o){let e;return{c(){e=_("Email address.")},m(t,a){r(t,e,a)},p:ae,d(t){t&&d(e)}}}function Qt(o){let e;return{c(){e=_("JSON array or object.")},m(t,a){r(t,e,a)},p:ae,d(t){t&&d(e)}}}function zt(o){let e;return{c(){e=_("Number value.")},m(t,a){r(t,e,a)},p:ae,d(t){t&&d(e)}}}function Kt(o){let e;return{c(){e=_("Plain text value.")},m(t,a){r(t,e,a)},p:ae,d(t){t&&d(e)}}}function $t(o,e){let t,a,f,m,c,p=e[13].name+"",y,S,T,w,H=Q.getFieldValueType(e[13])+"",D,E,P,I;function j(b,O){return b[13].required?Vt:Nt}let B=j(e),$=B(e);function N(b,O){if(b[13].type==="text")return Kt;if(b[13].type==="number")return zt;if(b[13].type==="json")return Qt;if(b[13].type==="email")return Ut;if(b[13].type==="url")return It;if(b[13].type==="file")return Et;if(b[13].type==="relation")return Jt}let q=N(e),g=q&&q(e);return{key:o,first:null,c(){t=i("tr"),a=i("td"),f=i("div"),$.c(),m=u(),c=i("span"),y=_(p),S=u(),T=i("td"),w=i("span"),D=_(H),E=u(),P=i("td"),g&&g.c(),I=u(),v(f,"class","inline-flex"),v(w,"class","label"),this.first=t},m(b,O){r(b,t,O),n(t,a),n(a,f),$.m(f,null),n(f,m),n(f,c),n(c,y),n(t,S),n(t,T),n(T,w),n(w,D),n(t,E),n(t,P),g&&g.m(P,null),n(t,I)},p(b,O){e=b,B!==(B=j(e))&&($.d(1),$=B(e),$&&($.c(),$.m(f,m))),O&1&&p!==(p=e[13].name+"")&&x(y,p),O&1&&H!==(H=Q.getFieldValueType(e[13])+"")&&x(D,H),q===(q=N(e))&&g?g.p(e,O):(g&&g.d(1),g=q&&q(e),g&&(g.c(),g.m(P,null)))},d(b){b&&d(t),$.d(),g&&g.d()}}}function Ct(o,e){let t,a=e[8].code+"",f,m,c,p;function y(){return e[7](e[8])}return{key:o,first:null,c(){t=i("button"),f=_(a),m=u(),v(t,"class","tab-item"),ye(t,"active",e[2]===e[8].code),this.first=t},m(S,T){r(S,t,T),n(t,f),n(t,m),c||(p=Rt(t,"click",y),c=!0)},p(S,T){e=S,T&8&&a!==(a=e[8].code+"")&&x(f,a),T&12&&ye(t,"active",e[2]===e[8].code)},d(S){S&&d(t),c=!1,p()}}}function St(o,e){let t,a,f,m;return a=new Tt({props:{content:e[8].body}}),{key:o,first:null,c(){t=i("div"),_e(a.$$.fragment),f=u(),v(t,"class","tab-item"),ye(t,"active",e[2]===e[8].code),this.first=t},m(c,p){r(c,t,p),he(a,t,null),n(t,f),m=!0},p(c,p){e=c;const y={};p&8&&(y.content=e[8].body),a.$set(y),(!m||p&12)&&ye(t,"active",e[2]===e[8].code)},i(c){m||(ue(a.$$.fragment,c),m=!0)},o(c){fe(a.$$.fragment,c),m=!1},d(c){c&&d(t),ke(a)}}}function Wt(o){var ot,rt,dt,ct,pt;let e,t,a=o[0].name+"",f,m,c,p,y,S,T,w=o[0].name+"",H,D,E,P,I,j,B,$,N,q,g,b,O,z,F,h,C,ee,K=o[0].name+"",ve,je,De,ge,se,we,W,$e,Ne,U,Ce,Ve,Se,V=[],Je=new Map,Te,ie,qe,Y,Oe,Ee,oe,G,Me,Ie,He,Ue,M,Qe,te,ze,Ke,We,Le,Ye,Pe,Ge,Xe,Ze,Fe,xe,et,le,Re,re,Ae,X,de,J=[],tt=new Map,lt,ce,R=[],nt=new Map,Z;$=new At({props:{js:` -import PocketBase from 'pocketbase'; - -const pb = new PocketBase('${o[5]}'); - -... - -// example create data -const data = ${JSON.stringify(Object.assign({},o[4],Q.dummyCollectionSchemaData(o[0])),null,4)}; - -const record = await pb.collection('${(ot=o[0])==null?void 0:ot.name}').create(data); -`+(o[1]?` -// (optional) send an email verification request -await pb.collection('${(rt=o[0])==null?void 0:rt.name}').requestVerification('test@example.com'); -`:""),dart:` -import 'package:pocketbase/pocketbase.dart'; - -final pb = PocketBase('${o[5]}'); - -... - -// example create body -final body = ${JSON.stringify(Object.assign({},o[4],Q.dummyCollectionSchemaData(o[0])),null,2)}; - -final record = await pb.collection('${(dt=o[0])==null?void 0:dt.name}').create(body: body); -`+(o[1]?` -// (optional) send an email verification request -await pb.collection('${(ct=o[0])==null?void 0:ct.name}').requestVerification('test@example.com'); -`:"")}});let A=o[6]&>(),L=o[1]&&wt(o),me=ne((pt=o[0])==null?void 0:pt.schema);const at=l=>l[13].name;for(let l=0;ll[8].code;for(let l=0;ll[8].code;for(let l=0;lapplication/json or - multipart/form-data.`,I=u(),j=i("p"),j.innerHTML=`File upload is supported only via multipart/form-data. -
- For more info and examples you could check the detailed - Files upload and handling docs - .`,B=u(),_e($.$$.fragment),N=u(),q=i("h6"),q.textContent="API details",g=u(),b=i("div"),O=i("strong"),O.textContent="POST",z=u(),F=i("div"),h=i("p"),C=_("/api/collections/"),ee=i("strong"),ve=_(K),je=_("/records"),De=u(),A&&A.c(),ge=u(),se=i("div"),se.textContent="Body Parameters",we=u(),W=i("table"),$e=i("thead"),$e.innerHTML='Param Type Description',Ne=u(),U=i("tbody"),Ce=i("tr"),Ce.innerHTML=`
Optional id
String 15 characters string to store as record ID. -
- If not set, it will be auto generated.`,Ve=u(),L&&L.c(),Se=u();for(let l=0;lParam Type Description',Ee=u(),oe=i("tbody"),G=i("tr"),Me=i("td"),Me.textContent="expand",Ie=u(),He=i("td"),He.innerHTML='String',Ue=u(),M=i("td"),Qe=_(`Auto expand relations when returning the created record. Ex.: - `),_e(te.$$.fragment),ze=_(` - Supports up to 6-levels depth nested relations expansion. `),Ke=i("br"),We=_(` - The expanded relations will be appended to the record under the - `),Le=i("code"),Le.textContent="expand",Ye=_(" property (eg. "),Pe=i("code"),Pe.textContent='"expand": {"relField1": {...}, ...}',Ge=_(`). - `),Xe=i("br"),Ze=_(` - Only the relations to which the request user has permissions to `),Fe=i("strong"),Fe.textContent="view",xe=_(" will be expanded."),et=u(),_e(le.$$.fragment),Re=u(),re=i("div"),re.textContent="Responses",Ae=u(),X=i("div"),de=i("div");for(let l=0;l${JSON.stringify(Object.assign({},l[4],Q.dummyCollectionSchemaData(l[0])),null,2)}; - -final record = await pb.collection('${(mt=l[0])==null?void 0:mt.name}').create(body: body); -`+(l[1]?` -// (optional) send an email verification request -await pb.collection('${(bt=l[0])==null?void 0:bt.name}').requestVerification('test@example.com'); -`:"")),$.$set(k),(!Z||s&1)&&K!==(K=l[0].name+"")&&x(ve,K),l[6]?A||(A=gt(),A.c(),A.m(b,null)):A&&(A.d(1),A=null),l[1]?L?L.p(l,s):(L=wt(l),L.c(),L.m(U,Se)):L&&(L.d(1),L=null),s&1&&(me=ne((_t=l[0])==null?void 0:_t.schema),V=Be(V,s,at,1,l,me,Je,U,ht,$t,null,vt)),s&12&&(be=ne(l[3]),J=Be(J,s,st,1,l,be,tt,de,ht,Ct,null,yt)),s&12&&(pe=ne(l[3]),Ht(),R=Be(R,s,it,1,l,pe,nt,ce,Lt,St,null,kt),Pt())},i(l){if(!Z){ue($.$$.fragment,l),ue(te.$$.fragment,l),ue(le.$$.fragment,l);for(let s=0;st(2,p=w.code);return o.$$set=w=>{"collection"in w&&t(0,c=w.collection)},o.$$.update=()=>{var w,H;o.$$.dirty&1&&t(1,a=c.type==="auth"),o.$$.dirty&1&&t(6,f=(c==null?void 0:c.createRule)===null),o.$$.dirty&1&&t(3,y=[{code:200,body:JSON.stringify(Q.dummyCollectionRecord(c),null,2)},{code:400,body:` - { - "code": 400, - "message": "Failed to create record.", - "data": { - "${(H=(w=c==null?void 0:c.schema)==null?void 0:w[0])==null?void 0:H.name}": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `},{code:403,body:` - { - "code": 403, - "message": "You are not allowed to perform this request.", - "data": {} - } - `}]),o.$$.dirty&2&&(a?t(4,S={username:"test_username",email:"test@example.com",emailVisibility:!0,password:"12345678",passwordConfirm:"12345678"}):t(4,S={}))},t(5,m=Q.getApiExampleUrl(Ft.baseUrl)),[c,a,p,y,S,m,f,T]}class xt extends qt{constructor(e){super(),Ot(this,e,Yt,Wt,Mt,{collection:0})}}export{xt as default}; diff --git a/ui/dist/assets/DeleteApiDocs-80395eef.js b/ui/dist/assets/DeleteApiDocs-80395eef.js deleted file mode 100644 index 6e4156b144..0000000000 --- a/ui/dist/assets/DeleteApiDocs-80395eef.js +++ /dev/null @@ -1,53 +0,0 @@ -import{S as Re,i as Pe,s as Ee,O as j,e as c,w as y,b as k,c as De,f as m,g as p,h as i,m as Ce,x as ee,P as he,Q as Oe,k as Te,R as Be,n as Ie,t as te,a as le,o as u,d as we,C as Ae,p as Me,r as N,u as Se,N as qe}from"./index-bd739ba3.js";import{S as He}from"./SdkTabs-cf570492.js";function ke(a,l,s){const o=a.slice();return o[6]=l[s],o}function ge(a,l,s){const o=a.slice();return o[6]=l[s],o}function ve(a){let l;return{c(){l=c("p"),l.innerHTML="Requires admin Authorization:TOKEN header",m(l,"class","txt-hint txt-sm txt-right")},m(s,o){p(s,l,o)},d(s){s&&u(l)}}}function ye(a,l){let s,o,h;function d(){return l[5](l[6])}return{key:a,first:null,c(){s=c("button"),s.textContent=`${l[6].code} `,m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(n,r){p(n,s,r),o||(h=Se(s,"click",d),o=!0)},p(n,r){l=n,r&20&&N(s,"active",l[2]===l[6].code)},d(n){n&&u(s),o=!1,h()}}}function $e(a,l){let s,o,h,d;return o=new qe({props:{content:l[6].body}}),{key:a,first:null,c(){s=c("div"),De(o.$$.fragment),h=k(),m(s,"class","tab-item"),N(s,"active",l[2]===l[6].code),this.first=s},m(n,r){p(n,s,r),Ce(o,s,null),i(s,h),d=!0},p(n,r){l=n,(!d||r&20)&&N(s,"active",l[2]===l[6].code)},i(n){d||(te(o.$$.fragment,n),d=!0)},o(n){le(o.$$.fragment,n),d=!1},d(n){n&&u(s),we(o)}}}function Le(a){var ue,me;let l,s,o=a[0].name+"",h,d,n,r,$,D,z,S=a[0].name+"",F,se,K,C,Q,E,G,g,q,ae,H,P,oe,J,L=a[0].name+"",V,ne,W,ie,X,O,Y,T,Z,B,x,w,I,v=[],ce=new Map,de,A,b=[],re=new Map,R;C=new He({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${a[3]}'); - - ... - - await pb.collection('${(ue=a[0])==null?void 0:ue.name}').delete('RECORD_ID'); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${a[3]}'); - - ... - - await pb.collection('${(me=a[0])==null?void 0:me.name}').delete('RECORD_ID'); - `}});let _=a[1]&&ve(),U=j(a[4]);const fe=e=>e[6].code;for(let e=0;ee[6].code;for(let e=0;eParam Type Description id String ID of the record to delete.',Z=k(),B=c("div"),B.textContent="Responses",x=k(),w=c("div"),I=c("div");for(let e=0;es(2,n=D.code);return a.$$set=D=>{"collection"in D&&s(0,d=D.collection)},a.$$.update=()=>{a.$$.dirty&1&&s(1,o=(d==null?void 0:d.deleteRule)===null),a.$$.dirty&3&&d!=null&&d.id&&(r.push({code:204,body:` - null - `}),r.push({code:400,body:` - { - "code": 400, - "message": "Failed to delete record. Make sure that the record is not part of a required relation reference.", - "data": {} - } - `}),o&&r.push({code:403,body:` - { - "code": 403, - "message": "Only admins can access this action.", - "data": {} - } - `}),r.push({code:404,body:` - { - "code": 404, - "message": "The requested resource wasn't found.", - "data": {} - } - `}))},s(3,h=Ae.getApiExampleUrl(Me.baseUrl)),[d,o,n,h,r,$]}class ze extends Re{constructor(l){super(),Pe(this,l,Ue,Le,Ee,{collection:0})}}export{ze as default}; diff --git a/ui/dist/assets/FieldsQueryParam-85406354.js b/ui/dist/assets/FieldsQueryParam-85406354.js deleted file mode 100644 index 3e61e20c30..0000000000 --- a/ui/dist/assets/FieldsQueryParam-85406354.js +++ /dev/null @@ -1,3 +0,0 @@ -import{S as d,i as n,s as i,e as l,g as o,y as s,o as p}from"./index-bd739ba3.js";function c(a){let e;return{c(){e=l("tr"),e.innerHTML=`fields String Comma separated string of the fields to return in the JSON response - (by default returns all fields). For example: -
?fields=id,expand.relField.id,expand.relField.created`},m(t,r){o(t,e,r)},p:s,i:s,o:s,d(t){t&&p(e)}}}class f extends d{constructor(e){super(),n(this,e,null,c,i,{})}}export{f as F}; diff --git a/ui/dist/assets/FilterAutocompleteInput-7a8d99d1.js b/ui/dist/assets/FilterAutocompleteInput-7a8d99d1.js deleted file mode 100644 index 5e06e1aa0d..0000000000 --- a/ui/dist/assets/FilterAutocompleteInput-7a8d99d1.js +++ /dev/null @@ -1 +0,0 @@ -import{S as se,i as ae,s as le,e as ce,f as ue,g as de,y as M,o as fe,J as he,K as ge,L as pe,I as ye,C as f,M as ke}from"./index-bd739ba3.js";import{E as K,a as C,h as xe,b as me,c as be,d as we,e as Ee,s as Se,f as Ke,g as Ce,r as qe,i as Re,k as Le,j as Ie,l as Ae,m as Be,n as Oe,o as _e,p as ve,q as Y,C as L,S as Me,t as De}from"./index-808c8630.js";function He(e){Z(e,"start");var r={},n=e.languageData||{},p=!1;for(var y in e)if(y!=n&&e.hasOwnProperty(y))for(var h=r[y]=[],s=e[y],i=0;i2&&s.token&&typeof s.token!="string"){n.pending=[];for(var a=2;a-1)return null;var y=n.indent.length-1,h=e[n.state];e:for(;;){for(var s=0;sn(21,p=t));const y=pe();let{id:h=""}=r,{value:s=""}=r,{disabled:i=!1}=r,{placeholder:l=""}=r,{baseCollection:a=null}=r,{singleLine:m=!1}=r,{extraAutocompleteKeys:I=[]}=r,{disableRequestKeys:w=!1}=r,{disableIndirectCollectionsKeys:E=!1}=r,d,b,A=i,D=new L,H=new L,F=new L,T=new L,q=[],U=[],W=[],N=[],R="",B="";function O(){d==null||d.focus()}let _=null;function j(){clearTimeout(_),_=setTimeout(()=>{q=$(p),N=ee(),U=w?[]:te(),W=E?[]:ne()},300)}function $(t){let o=t.slice();return a&&f.pushOrReplaceByKey(o,a,"id"),o}function J(){b==null||b.dispatchEvent(new CustomEvent("change",{detail:{value:s},bubbles:!0}))}function P(){if(!h)return;const t=document.querySelectorAll('[for="'+h+'"]');for(let o of t)o.removeEventListener("click",O)}function V(){if(!h)return;P();const t=document.querySelectorAll('[for="'+h+'"]');for(let o of t)o.addEventListener("click",O)}function S(t,o="",u=0){var x,z,Q;let g=q.find(k=>k.name==t||k.id==t);if(!g||u>=4)return[];let c=f.getAllCollectionIdentifiers(g,o);for(const k of g.schema){const v=o+k.name;if(k.type==="relation"&&((x=k.options)!=null&&x.collectionId)){const X=S(k.options.collectionId,v+".",u+1);X.length&&(c=c.concat(X))}k.type==="select"&&((z=k.options)==null?void 0:z.maxSelect)!=1&&c.push(v+":each"),((Q=k.options)==null?void 0:Q.maxSelect)!=1&&["select","file","relation"].includes(k.type)&&c.push(v+":length")}return c}function ee(){return S(a==null?void 0:a.name)}function te(){const t=[];t.push("@request.method"),t.push("@request.query."),t.push("@request.data."),t.push("@request.headers."),t.push("@request.auth.id"),t.push("@request.auth.collectionId"),t.push("@request.auth.collectionName"),t.push("@request.auth.verified"),t.push("@request.auth.username"),t.push("@request.auth.email"),t.push("@request.auth.emailVisibility"),t.push("@request.auth.created"),t.push("@request.auth.updated");const o=q.filter(g=>g.type==="auth");for(const g of o){const c=S(g.id,"@request.auth.");for(const x of c)f.pushUnique(t,x)}const u=["created","updated"];if(a!=null&&a.id){const g=S(a.name,"@request.data.");for(const c of g){t.push(c);const x=c.split(".");x.length===3&&x[2].indexOf(":")===-1&&!u.includes(x[2])&&t.push(c+":isset")}}return t}function ne(){const t=[];for(const o of q){const u="@collection."+o.name+".",g=S(o.name,u);for(const c of g)t.push(c)}return t}function re(t=!0,o=!0){let u=[].concat(I);return u=u.concat(N||[]),t&&(u=u.concat(U||[])),o&&(u=u.concat(W||[])),u.sort(function(g,c){return c.length-g.length}),u}function ie(t){let o=t.matchBefore(/[\'\"\@\w\.]*/);if(o&&o.from==o.to&&!t.explicit)return null;let u=[{label:"false"},{label:"true"},{label:"@now"},{label:"@second"},{label:"@minute"},{label:"@hour"},{label:"@year"},{label:"@day"},{label:"@month"},{label:"@weekday"},{label:"@todayStart"},{label:"@todayEnd"},{label:"@monthStart"},{label:"@monthEnd"},{label:"@yearStart"},{label:"@yearEnd"}];E||u.push({label:"@collection.*",apply:"@collection."});const g=re(!w,!w&&o.text.startsWith("@c"));for(const c of g)u.push({label:c.endsWith(".")?c+"*":c,apply:c});return{from:o.from,options:u}}function G(){return Me.define(He({start:[{regex:/true|false|null/,token:"atom"},{regex:/"(?:[^\\]|\\.)*?(?:"|$)/,token:"string"},{regex:/'(?:[^\\]|\\.)*?(?:'|$)/,token:"string"},{regex:/0x[a-f\d]+|[-+]?(?:\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/i,token:"number"},{regex:/\&\&|\|\||\=|\!\=|\~|\!\~|\>|\<|\>\=|\<\=/,token:"operator"},{regex:/[\{\[\(]/,indent:!0},{regex:/[\}\]\)]/,dedent:!0},{regex:/\w+[\w\.]*\w+/,token:"keyword"},{regex:f.escapeRegExp("@now"),token:"keyword"},{regex:f.escapeRegExp("@second"),token:"keyword"},{regex:f.escapeRegExp("@minute"),token:"keyword"},{regex:f.escapeRegExp("@hour"),token:"keyword"},{regex:f.escapeRegExp("@year"),token:"keyword"},{regex:f.escapeRegExp("@day"),token:"keyword"},{regex:f.escapeRegExp("@month"),token:"keyword"},{regex:f.escapeRegExp("@weekday"),token:"keyword"},{regex:f.escapeRegExp("@todayStart"),token:"keyword"},{regex:f.escapeRegExp("@todayEnd"),token:"keyword"},{regex:f.escapeRegExp("@monthStart"),token:"keyword"},{regex:f.escapeRegExp("@monthEnd"),token:"keyword"},{regex:f.escapeRegExp("@yearStart"),token:"keyword"},{regex:f.escapeRegExp("@yearEnd"),token:"keyword"},{regex:f.escapeRegExp("@request.method"),token:"keyword"}]}))}ye(()=>{const t={key:"Enter",run:o=>{m&&y("submit",s)}};return V(),n(11,d=new K({parent:b,state:C.create({doc:s,extensions:[xe(),me(),be(),we(),Ee(),C.allowMultipleSelections.of(!0),Se(De,{fallback:!0}),Ke(),Ce(),qe(),Re(),Le.of([t,...Ie,...Ae,Be.find(o=>o.key==="Mod-d"),...Oe,..._e]),K.lineWrapping,ve({override:[ie],icons:!1}),T.of(Y(l)),H.of(K.editable.of(!i)),F.of(C.readOnly.of(i)),D.of(G()),C.transactionFilter.of(o=>m&&o.newDoc.lines>1?[]:o),K.updateListener.of(o=>{!o.docChanged||i||(n(1,s=o.state.doc.toString()),J())})]})})),()=>{clearTimeout(_),P(),d==null||d.destroy()}});function oe(t){ke[t?"unshift":"push"](()=>{b=t,n(0,b)})}return e.$$set=t=>{"id"in t&&n(2,h=t.id),"value"in t&&n(1,s=t.value),"disabled"in t&&n(3,i=t.disabled),"placeholder"in t&&n(4,l=t.placeholder),"baseCollection"in t&&n(5,a=t.baseCollection),"singleLine"in t&&n(6,m=t.singleLine),"extraAutocompleteKeys"in t&&n(7,I=t.extraAutocompleteKeys),"disableRequestKeys"in t&&n(8,w=t.disableRequestKeys),"disableIndirectCollectionsKeys"in t&&n(9,E=t.disableIndirectCollectionsKeys)},e.$$.update=()=>{e.$$.dirty[0]&32&&n(13,R=Pe(a)),e.$$.dirty[0]&25352&&!i&&(B!=R||w!==-1||E!==-1)&&(n(14,B=R),j()),e.$$.dirty[0]&4&&h&&V(),e.$$.dirty[0]&2080&&d&&a!=null&&a.schema&&d.dispatch({effects:[D.reconfigure(G())]}),e.$$.dirty[0]&6152&&d&&A!=i&&(d.dispatch({effects:[H.reconfigure(K.editable.of(!i)),F.reconfigure(C.readOnly.of(i))]}),n(12,A=i),J()),e.$$.dirty[0]&2050&&d&&s!=d.state.doc.toString()&&d.dispatch({changes:{from:0,to:d.state.doc.length,insert:s}}),e.$$.dirty[0]&2064&&d&&typeof l<"u"&&d.dispatch({effects:[T.reconfigure(Y(l))]})},[b,s,h,i,l,a,m,I,w,E,O,d,A,R,B,oe]}class Qe extends se{constructor(r){super(),ae(this,r,Ve,Je,le,{id:2,value:1,disabled:3,placeholder:4,baseCollection:5,singleLine:6,extraAutocompleteKeys:7,disableRequestKeys:8,disableIndirectCollectionsKeys:9,focus:10},null,[-1,-1])}get focus(){return this.$$.ctx[10]}}export{Qe as default}; diff --git a/ui/dist/assets/ListApiDocs-68f52edd.css b/ui/dist/assets/ListApiDocs-68f52edd.css deleted file mode 100644 index b4b4197471..0000000000 --- a/ui/dist/assets/ListApiDocs-68f52edd.css +++ /dev/null @@ -1 +0,0 @@ -.filter-op.svelte-1w7s5nw{display:inline-block;vertical-align:top;margin-right:5px;width:30px;text-align:center;padding-left:0;padding-right:0} diff --git a/ui/dist/assets/ListApiDocs-bd0331ec.js b/ui/dist/assets/ListApiDocs-bd0331ec.js deleted file mode 100644 index 05ff437df3..0000000000 --- a/ui/dist/assets/ListApiDocs-bd0331ec.js +++ /dev/null @@ -1,141 +0,0 @@ -import{S as Ze,i as tl,s as el,e,b as s,E as sl,f as a,g as u,u as ll,y as Ue,o as m,w as _,h as t,N as Fe,O as te,c as ee,m as le,x as ke,P as je,Q as nl,k as ol,R as al,n as il,t as Bt,a as Gt,d as se,T as rl,C as ve,p as cl,r as Le}from"./index-bd739ba3.js";import{S as dl}from"./SdkTabs-cf570492.js";function pl(d){let n,o,i;return{c(){n=e("span"),n.textContent="Show details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-down-s-line")},m(f,h){u(f,n,h),u(f,o,h),u(f,i,h)},d(f){f&&(m(n),m(o),m(i))}}}function fl(d){let n,o,i;return{c(){n=e("span"),n.textContent="Hide details",o=s(),i=e("i"),a(n,"class","txt"),a(i,"class","ri-arrow-up-s-line")},m(f,h){u(f,n,h),u(f,o,h),u(f,i,h)},d(f){f&&(m(n),m(o),m(i))}}}function ze(d){let n,o,i,f,h,r,b,C,$,g,p,Z,Ct,Ut,E,jt,M,it,S,tt,ne,G,U,oe,rt,$t,et,kt,ae,ct,dt,lt,N,zt,yt,y,st,vt,Jt,Ft,j,nt,Lt,Kt,At,F,pt,Tt,ie,ft,re,D,Pt,ot,St,O,ut,ce,z,Ot,Qt,Rt,de,q,Vt,J,mt,pe,I,fe,B,ue,P,Et,K,ht,me,bt,he,w,Nt,at,qt,be,Ht,Wt,Q,gt,ge,Mt,_e,_t,xe,V,xt,we,wt,Xt,W,Yt,L,X,R,Dt,Ce,Y,v,It;return{c(){n=e("p"),n.innerHTML=`The syntax basically follows the format - OPERAND OPERATOR OPERAND, where:`,o=s(),i=e("ul"),f=e("li"),f.innerHTML=`OPERAND - could be any of the above field literal, string (single - or double quoted), number, null, true, false`,h=s(),r=e("li"),b=e("code"),b.textContent="OPERATOR",C=_(` - is one of: - `),$=e("br"),g=s(),p=e("ul"),Z=e("li"),Ct=e("code"),Ct.textContent="=",Ut=s(),E=e("span"),E.textContent="Equal",jt=s(),M=e("li"),it=e("code"),it.textContent="!=",S=s(),tt=e("span"),tt.textContent="NOT equal",ne=s(),G=e("li"),U=e("code"),U.textContent=">",oe=s(),rt=e("span"),rt.textContent="Greater than",$t=s(),et=e("li"),kt=e("code"),kt.textContent=">=",ae=s(),ct=e("span"),ct.textContent="Greater than or equal",dt=s(),lt=e("li"),N=e("code"),N.textContent="<",zt=s(),yt=e("span"),yt.textContent="Less than",y=s(),st=e("li"),vt=e("code"),vt.textContent="<=",Jt=s(),Ft=e("span"),Ft.textContent="Less than or equal",j=s(),nt=e("li"),Lt=e("code"),Lt.textContent="~",Kt=s(),At=e("span"),At.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for - wildcard match)`,F=s(),pt=e("li"),Tt=e("code"),Tt.textContent="!~",ie=s(),ft=e("span"),ft.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for - wildcard match)`,re=s(),D=e("li"),Pt=e("code"),Pt.textContent="?=",ot=s(),St=e("em"),St.textContent="Any/At least one of",O=s(),ut=e("span"),ut.textContent="Equal",ce=s(),z=e("li"),Ot=e("code"),Ot.textContent="?!=",Qt=s(),Rt=e("em"),Rt.textContent="Any/At least one of",de=s(),q=e("span"),q.textContent="NOT equal",Vt=s(),J=e("li"),mt=e("code"),mt.textContent="?>",pe=s(),I=e("em"),I.textContent="Any/At least one of",fe=s(),B=e("span"),B.textContent="Greater than",ue=s(),P=e("li"),Et=e("code"),Et.textContent="?>=",K=s(),ht=e("em"),ht.textContent="Any/At least one of",me=s(),bt=e("span"),bt.textContent="Greater than or equal",he=s(),w=e("li"),Nt=e("code"),Nt.textContent="?<",at=s(),qt=e("em"),qt.textContent="Any/At least one of",be=s(),Ht=e("span"),Ht.textContent="Less than",Wt=s(),Q=e("li"),gt=e("code"),gt.textContent="?<=",ge=s(),Mt=e("em"),Mt.textContent="Any/At least one of",_e=s(),_t=e("span"),_t.textContent="Less than or equal",xe=s(),V=e("li"),xt=e("code"),xt.textContent="?~",we=s(),wt=e("em"),wt.textContent="Any/At least one of",Xt=s(),W=e("span"),W.textContent=`Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for - wildcard match)`,Yt=s(),L=e("li"),X=e("code"),X.textContent="?!~",R=s(),Dt=e("em"),Dt.textContent="Any/At least one of",Ce=s(),Y=e("span"),Y.textContent=`NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for - wildcard match)`,v=s(),It=e("p"),It.innerHTML=`To group and combine several expressions you could use brackets - (...), && (AND) and || (OR) tokens.`,a(b,"class","txt-danger"),a(Ct,"class","filter-op svelte-1w7s5nw"),a(E,"class","txt"),a(it,"class","filter-op svelte-1w7s5nw"),a(tt,"class","txt"),a(U,"class","filter-op svelte-1w7s5nw"),a(rt,"class","txt"),a(kt,"class","filter-op svelte-1w7s5nw"),a(ct,"class","txt"),a(N,"class","filter-op svelte-1w7s5nw"),a(yt,"class","txt"),a(vt,"class","filter-op svelte-1w7s5nw"),a(Ft,"class","txt"),a(Lt,"class","filter-op svelte-1w7s5nw"),a(At,"class","txt"),a(Tt,"class","filter-op svelte-1w7s5nw"),a(ft,"class","txt"),a(Pt,"class","filter-op svelte-1w7s5nw"),a(St,"class","txt-hint"),a(ut,"class","txt"),a(Ot,"class","filter-op svelte-1w7s5nw"),a(Rt,"class","txt-hint"),a(q,"class","txt"),a(mt,"class","filter-op svelte-1w7s5nw"),a(I,"class","txt-hint"),a(B,"class","txt"),a(Et,"class","filter-op svelte-1w7s5nw"),a(ht,"class","txt-hint"),a(bt,"class","txt"),a(Nt,"class","filter-op svelte-1w7s5nw"),a(qt,"class","txt-hint"),a(Ht,"class","txt"),a(gt,"class","filter-op svelte-1w7s5nw"),a(Mt,"class","txt-hint"),a(_t,"class","txt"),a(xt,"class","filter-op svelte-1w7s5nw"),a(wt,"class","txt-hint"),a(W,"class","txt"),a(X,"class","filter-op svelte-1w7s5nw"),a(Dt,"class","txt-hint"),a(Y,"class","txt")},m(A,k){u(A,n,k),u(A,o,k),u(A,i,k),t(i,f),t(i,h),t(i,r),t(r,b),t(r,C),t(r,$),t(r,g),t(r,p),t(p,Z),t(Z,Ct),t(Z,Ut),t(Z,E),t(p,jt),t(p,M),t(M,it),t(M,S),t(M,tt),t(p,ne),t(p,G),t(G,U),t(G,oe),t(G,rt),t(p,$t),t(p,et),t(et,kt),t(et,ae),t(et,ct),t(p,dt),t(p,lt),t(lt,N),t(lt,zt),t(lt,yt),t(p,y),t(p,st),t(st,vt),t(st,Jt),t(st,Ft),t(p,j),t(p,nt),t(nt,Lt),t(nt,Kt),t(nt,At),t(p,F),t(p,pt),t(pt,Tt),t(pt,ie),t(pt,ft),t(p,re),t(p,D),t(D,Pt),t(D,ot),t(D,St),t(D,O),t(D,ut),t(p,ce),t(p,z),t(z,Ot),t(z,Qt),t(z,Rt),t(z,de),t(z,q),t(p,Vt),t(p,J),t(J,mt),t(J,pe),t(J,I),t(J,fe),t(J,B),t(p,ue),t(p,P),t(P,Et),t(P,K),t(P,ht),t(P,me),t(P,bt),t(p,he),t(p,w),t(w,Nt),t(w,at),t(w,qt),t(w,be),t(w,Ht),t(p,Wt),t(p,Q),t(Q,gt),t(Q,ge),t(Q,Mt),t(Q,_e),t(Q,_t),t(p,xe),t(p,V),t(V,xt),t(V,we),t(V,wt),t(V,Xt),t(V,W),t(p,Yt),t(p,L),t(L,X),t(L,R),t(L,Dt),t(L,Ce),t(L,Y),u(A,v,k),u(A,It,k)},d(A){A&&(m(n),m(o),m(i),m(v),m(It))}}}function ul(d){let n,o,i,f,h;function r(g,p){return g[0]?fl:pl}let b=r(d),C=b(d),$=d[0]&&ze();return{c(){n=e("button"),C.c(),o=s(),$&&$.c(),i=sl(),a(n,"class","btn btn-sm btn-secondary m-t-10")},m(g,p){u(g,n,p),C.m(n,null),u(g,o,p),$&&$.m(g,p),u(g,i,p),f||(h=ll(n,"click",d[1]),f=!0)},p(g,[p]){b!==(b=r(g))&&(C.d(1),C=b(g),C&&(C.c(),C.m(n,null))),g[0]?$||($=ze(),$.c(),$.m(i.parentNode,i)):$&&($.d(1),$=null)},i:Ue,o:Ue,d(g){g&&(m(n),m(o),m(i)),C.d(),$&&$.d(g),f=!1,h()}}}function ml(d,n,o){let i=!1;function f(){o(0,i=!i)}return[i,f]}class hl extends Ze{constructor(n){super(),tl(this,n,ml,ul,el,{})}}function Je(d,n,o){const i=d.slice();return i[7]=n[o],i}function Ke(d,n,o){const i=d.slice();return i[7]=n[o],i}function Qe(d,n,o){const i=d.slice();return i[12]=n[o],i[14]=o,i}function Ve(d){let n;return{c(){n=e("p"),n.innerHTML="Requires admin Authorization:TOKEN header",a(n,"class","txt-hint txt-sm txt-right")},m(o,i){u(o,n,i)},d(o){o&&m(n)}}}function We(d){let n,o=d[12]+"",i,f=d[14]= "2022-01-01 00:00:00" && someField1 != someField2', - }); - - // you can also fetch all records at once via getFullList - const records = await pb.collection('${(Se=d[0])==null?void 0:Se.name}').getFullList({ - sort: '-created', - }); - - // or fetch only the first record that matches the specified filter - const record = await pb.collection('${(Oe=d[0])==null?void 0:Oe.name}').getFirstListItem('someField="test"', { - expand: 'relField1,relField2.subRelField', - }); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${d[3]}'); - - ... - - // fetch a paginated records list - final resultList = await pb.collection('${(Re=d[0])==null?void 0:Re.name}').getList( - page: 1, - perPage: 50, - filter: 'created >= "2022-01-01 00:00:00" && someField1 != someField2', - ); - - // you can also fetch all records at once via getFullList - final records = await pb.collection('${(Ee=d[0])==null?void 0:Ee.name}').getFullList( - sort: '-created', - ); - - // or fetch only the first record that matches the specified filter - final record = await pb.collection('${(Ne=d[0])==null?void 0:Ne.name}').getFirstListItem( - 'someField="test"', - expand: 'relField1,relField2.subRelField', - ); - `}});let k=d[1]&&Ve();ot=new Fe({props:{content:` - // DESC by created and ASC by id - ?sort=-created,id - `}});let Zt=te(d[4]),T=[];for(let l=0;l'2022-01-01') - `}}),P=new hl({}),at=new Fe({props:{content:"?expand=relField1,relField2.subRelField"}});let ye=te(d[5]);const Ae=l=>l[7].code;for(let l=0;ll[7].code;for(let l=0;l<$e.length;l+=1){let c=Je(d,$e,l),x=Te(c);It.set(x,v[l]=Ye(x,c))}return{c(){n=e("h3"),o=_("List/Search ("),f=_(i),h=_(")"),r=s(),b=e("div"),C=e("p"),$=_("Fetch a paginated "),g=e("strong"),Z=_(p),Ct=_(" records list, supporting sorting and filtering."),Ut=s(),ee(E.$$.fragment),jt=s(),M=e("h6"),M.textContent="API details",it=s(),S=e("div"),tt=e("strong"),tt.textContent="GET",ne=s(),G=e("div"),U=e("p"),oe=_("/api/collections/"),rt=e("strong"),et=_($t),kt=_("/records"),ae=s(),k&&k.c(),ct=s(),dt=e("div"),dt.textContent="Query parameters",lt=s(),N=e("table"),zt=e("thead"),zt.innerHTML='Param Type Description',yt=s(),y=e("tbody"),st=e("tr"),st.innerHTML='page Number The page (aka. offset) of the paginated list (default to 1).',vt=s(),Jt=e("tr"),Jt.innerHTML='perPage Number Specify the max returned records per page (default to 30).',Ft=s(),j=e("tr"),nt=e("td"),nt.textContent="sort",Lt=s(),Kt=e("td"),Kt.innerHTML='String',At=s(),F=e("td"),pt=_("Specify the records order attribute(s). "),Tt=e("br"),ie=_(` - Add `),ft=e("code"),ft.textContent="-",re=_(" / "),D=e("code"),D.textContent="+",Pt=_(` (default) in front of the attribute for DESC / ASC order. - Ex.: - `),ee(ot.$$.fragment),St=s(),O=e("p"),ut=e("strong"),ut.textContent="Supported record sort fields:",ce=s(),z=e("br"),Ot=s(),Qt=e("code"),Qt.textContent="@random",Rt=_(`, - `);for(let l=0;lString
',pe=s(),I=e("td"),fe=_(`Filter the returned records. Ex.: - `),ee(B.$$.fragment),ue=s(),ee(P.$$.fragment),Et=s(),K=e("tr"),ht=e("td"),ht.textContent="expand",me=s(),bt=e("td"),bt.innerHTML='String',he=s(),w=e("td"),Nt=_(`Auto expand record relations. Ex.: - `),ee(at.$$.fragment),qt=_(` - Supports up to 6-levels depth nested relations expansion. `),be=e("br"),Ht=_(` - The expanded relations will be appended to each individual record under the - `),Wt=e("code"),Wt.textContent="expand",Q=_(" property (eg. "),gt=e("code"),gt.textContent='"expand": {"relField1": {...}, ...}',ge=_(`). - `),Mt=e("br"),_e=_(` - Only the relations to which the request user has permissions to `),_t=e("strong"),_t.textContent="view",xe=_(" will be expanded."),V=s(),xt=e("tr"),xt.innerHTML=`fields String Comma separated string of the fields to return in the JSON response - (by default returns all fields). -
- Use * to target all keys from the specific depth level. -
- For example: ?fields=*,expand.author.name`,we=s(),wt=e("tr"),wt.innerHTML=`skipTotal Boolean If it is set the total counts query will be skipped and the response fields - totalItems and totalPages will have -1 value. -
- This could drastically speed up the search queries when the total counters are not needed or cursor - based pagination is used. -
- For optimization purposes, it is set by default for the - getFirstListItem() - and - getFullList() SDKs methods.`,Xt=s(),W=e("div"),W.textContent="Responses",Yt=s(),L=e("div"),X=e("div");for(let l=0;l= "2022-01-01 00:00:00" && someField1 != someField2', - }); - - // you can also fetch all records at once via getFullList - const records = await pb.collection('${(He=l[0])==null?void 0:He.name}').getFullList({ - sort: '-created', - }); - - // or fetch only the first record that matches the specified filter - const record = await pb.collection('${(Me=l[0])==null?void 0:Me.name}').getFirstListItem('someField="test"', { - expand: 'relField1,relField2.subRelField', - }); - `),c&9&&(x.dart=` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${l[3]}'); - - ... - - // fetch a paginated records list - final resultList = await pb.collection('${(De=l[0])==null?void 0:De.name}').getList( - page: 1, - perPage: 50, - filter: 'created >= "2022-01-01 00:00:00" && someField1 != someField2', - ); - - // you can also fetch all records at once via getFullList - final records = await pb.collection('${(Ie=l[0])==null?void 0:Ie.name}').getFullList( - sort: '-created', - ); - - // or fetch only the first record that matches the specified filter - final record = await pb.collection('${(Be=l[0])==null?void 0:Be.name}').getFirstListItem( - 'someField="test"', - expand: 'relField1,relField2.subRelField', - ); - `),E.$set(x),(!A||c&1)&&$t!==($t=l[0].name+"")&&ke(et,$t),l[1]?k||(k=Ve(),k.c(),k.m(S,null)):k&&(k.d(1),k=null),c&16){Zt=te(l[4]);let H;for(H=0;Ho(2,b=g.code);return d.$$set=g=>{"collection"in g&&o(0,r=g.collection)},d.$$.update=()=>{d.$$.dirty&1&&o(4,i=ve.getAllCollectionIdentifiers(r)),d.$$.dirty&1&&o(1,f=(r==null?void 0:r.listRule)===null),d.$$.dirty&3&&r!=null&&r.id&&(C.push({code:200,body:JSON.stringify({page:1,perPage:30,totalPages:1,totalItems:2,items:[ve.dummyCollectionRecord(r),ve.dummyCollectionRecord(r)]},null,2)}),C.push({code:400,body:` - { - "code": 400, - "message": "Something went wrong while processing your request. Invalid filter.", - "data": {} - } - `}),f&&C.push({code:403,body:` - { - "code": 403, - "message": "Only admins can access this action.", - "data": {} - } - `}))},o(3,h=ve.getApiExampleUrl(cl.baseUrl)),[r,f,b,h,i,C,$]}class wl extends Ze{constructor(n){super(),tl(this,n,gl,bl,el,{collection:0})}}export{wl as default}; diff --git a/ui/dist/assets/ListExternalAuthsDocs-b7d22614.js b/ui/dist/assets/ListExternalAuthsDocs-b7d22614.js deleted file mode 100644 index e4775f6d54..0000000000 --- a/ui/dist/assets/ListExternalAuthsDocs-b7d22614.js +++ /dev/null @@ -1,88 +0,0 @@ -import{S as ze,i as Qe,s as Ue,O as F,e as i,w as v,b as m,c as pe,f as b,g as c,h as a,m as ue,x as N,P as Oe,Q as je,k as Fe,R as Ne,n as Ge,t as G,a as K,o as d,d as me,C as Ke,p as Je,r as J,u as Ve,N as Xe}from"./index-bd739ba3.js";import{S as Ye}from"./SdkTabs-cf570492.js";import{F as Ze}from"./FieldsQueryParam-85406354.js";function De(o,l,s){const n=o.slice();return n[5]=l[s],n}function He(o,l,s){const n=o.slice();return n[5]=l[s],n}function Re(o,l){let s,n=l[5].code+"",f,_,r,u;function h(){return l[4](l[5])}return{key:o,first:null,c(){s=i("button"),f=v(n),_=m(),b(s,"class","tab-item"),J(s,"active",l[1]===l[5].code),this.first=s},m(w,y){c(w,s,y),a(s,f),a(s,_),r||(u=Ve(s,"click",h),r=!0)},p(w,y){l=w,y&4&&n!==(n=l[5].code+"")&&N(f,n),y&6&&J(s,"active",l[1]===l[5].code)},d(w){w&&d(s),r=!1,u()}}}function We(o,l){let s,n,f,_;return n=new Xe({props:{content:l[5].body}}),{key:o,first:null,c(){s=i("div"),pe(n.$$.fragment),f=m(),b(s,"class","tab-item"),J(s,"active",l[1]===l[5].code),this.first=s},m(r,u){c(r,s,u),ue(n,s,null),a(s,f),_=!0},p(r,u){l=r;const h={};u&4&&(h.content=l[5].body),n.$set(h),(!_||u&6)&&J(s,"active",l[1]===l[5].code)},i(r){_||(G(n.$$.fragment,r),_=!0)},o(r){K(n.$$.fragment,r),_=!1},d(r){r&&d(s),me(n)}}}function xe(o){var Ce,Se,Ee,Ie;let l,s,n=o[0].name+"",f,_,r,u,h,w,y,R=o[0].name+"",V,be,fe,X,Y,P,Z,I,x,$,W,he,z,T,_e,ee,Q=o[0].name+"",te,ke,le,ve,ge,U,se,B,ae,q,oe,L,ne,A,ie,$e,ce,E,de,M,re,C,O,g=[],we=new Map,ye,D,k=[],Pe=new Map,S;P=new Ye({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(Ce=o[0])==null?void 0:Ce.name}').authWithPassword('test@example.com', '123456'); - - const result = await pb.collection('${(Se=o[0])==null?void 0:Se.name}').listExternalAuths( - pb.authStore.model.id - ); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(Ee=o[0])==null?void 0:Ee.name}').authWithPassword('test@example.com', '123456'); - - final result = await pb.collection('${(Ie=o[0])==null?void 0:Ie.name}').listExternalAuths( - pb.authStore.model.id, - ); - `}}),E=new Ze({});let j=F(o[2]);const Te=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eAuthorization:TOKEN header",se=m(),B=i("div"),B.textContent="Path Parameters",ae=m(),q=i("table"),q.innerHTML='Param Type Description id String ID of the auth record.',oe=m(),L=i("div"),L.textContent="Query parameters",ne=m(),A=i("table"),ie=i("thead"),ie.innerHTML='Param Type Description',$e=m(),ce=i("tbody"),pe(E.$$.fragment),de=m(),M=i("div"),M.textContent="Responses",re=m(),C=i("div"),O=i("div");for(let e=0;es(1,_=h.code);return o.$$set=h=>{"collection"in h&&s(0,f=h.collection)},o.$$.update=()=>{o.$$.dirty&1&&s(2,r=[{code:200,body:` - [ - { - "id": "8171022dc95a4e8", - "created": "2022-09-01 10:24:18.434", - "updated": "2022-09-01 10:24:18.889", - "recordId": "e22581b6f1d44ea", - "collectionId": "${f.id}", - "provider": "google", - "providerId": "2da15468800514p", - }, - { - "id": "171022dc895a4e8", - "created": "2022-09-01 10:24:18.434", - "updated": "2022-09-01 10:24:18.889", - "recordId": "e22581b6f1d44ea", - "collectionId": "${f.id}", - "provider": "twitter", - "providerId": "720688005140514", - } - ] - `},{code:401,body:` - { - "code": 401, - "message": "The request requires valid record authorization token to be set.", - "data": {} - } - `},{code:403,body:` - { - "code": 403, - "message": "The authorized record model is not allowed to perform this action.", - "data": {} - } - `},{code:404,body:` - { - "code": 404, - "message": "The requested resource wasn't found.", - "data": {} - } - `}])},s(3,n=Ke.getApiExampleUrl(Je.baseUrl)),[f,_,r,n,u]}class at extends ze{constructor(l){super(),Qe(this,l,et,xe,Ue,{collection:0})}}export{at as default}; diff --git a/ui/dist/assets/PageAdminConfirmPasswordReset-cc5d7138.js b/ui/dist/assets/PageAdminConfirmPasswordReset-cc5d7138.js deleted file mode 100644 index f4bf1dcf56..0000000000 --- a/ui/dist/assets/PageAdminConfirmPasswordReset-cc5d7138.js +++ /dev/null @@ -1,2 +0,0 @@ -import{S as E,i as G,s as I,F as K,c as R,m as A,t as B,a as N,d as T,C as M,q as J,e as _,w as P,b as k,f,r as L,g as b,h as c,u as j,v as O,j as Q,l as U,o as w,A as V,p as W,B as X,D as Y,x as Z,z as q}from"./index-bd739ba3.js";function y(i){let e,n,s;return{c(){e=P("for "),n=_("strong"),s=P(i[3]),f(n,"class","txt-nowrap")},m(l,t){b(l,e,t),b(l,n,t),c(n,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&(w(e),w(n))}}}function x(i){let e,n,s,l,t,u,p,d;return{c(){e=_("label"),n=P("New password"),l=k(),t=_("input"),f(e,"for",s=i[8]),f(t,"type","password"),f(t,"id",u=i[8]),t.required=!0,t.autofocus=!0},m(r,a){b(r,e,a),c(e,n),b(r,l,a),b(r,t,a),q(t,i[0]),t.focus(),p||(d=j(t,"input",i[6]),p=!0)},p(r,a){a&256&&s!==(s=r[8])&&f(e,"for",s),a&256&&u!==(u=r[8])&&f(t,"id",u),a&1&&t.value!==r[0]&&q(t,r[0])},d(r){r&&(w(e),w(l),w(t)),p=!1,d()}}}function ee(i){let e,n,s,l,t,u,p,d;return{c(){e=_("label"),n=P("New password confirm"),l=k(),t=_("input"),f(e,"for",s=i[8]),f(t,"type","password"),f(t,"id",u=i[8]),t.required=!0},m(r,a){b(r,e,a),c(e,n),b(r,l,a),b(r,t,a),q(t,i[1]),p||(d=j(t,"input",i[7]),p=!0)},p(r,a){a&256&&s!==(s=r[8])&&f(e,"for",s),a&256&&u!==(u=r[8])&&f(t,"id",u),a&2&&t.value!==r[1]&&q(t,r[1])},d(r){r&&(w(e),w(l),w(t)),p=!1,d()}}}function te(i){let e,n,s,l,t,u,p,d,r,a,g,S,C,v,h,F,z,m=i[3]&&y(i);return u=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:i}}}),d=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:o})=>({8:o}),({uniqueId:o})=>o?256:0]},$$scope:{ctx:i}}}),{c(){e=_("form"),n=_("div"),s=_("h4"),l=P(`Reset your admin password - `),m&&m.c(),t=k(),R(u.$$.fragment),p=k(),R(d.$$.fragment),r=k(),a=_("button"),g=_("span"),g.textContent="Set new password",S=k(),C=_("div"),v=_("a"),v.textContent="Back to login",f(s,"class","m-b-xs"),f(n,"class","content txt-center m-b-sm"),f(g,"class","txt"),f(a,"type","submit"),f(a,"class","btn btn-lg btn-block"),a.disabled=i[2],L(a,"btn-loading",i[2]),f(e,"class","m-b-base"),f(v,"href","/login"),f(v,"class","link-hint"),f(C,"class","content txt-center")},m(o,$){b(o,e,$),c(e,n),c(n,s),c(s,l),m&&m.m(s,null),c(e,t),A(u,e,null),c(e,p),A(d,e,null),c(e,r),c(e,a),c(a,g),b(o,S,$),b(o,C,$),c(C,v),h=!0,F||(z=[j(e,"submit",O(i[4])),Q(U.call(null,v))],F=!0)},p(o,$){o[3]?m?m.p(o,$):(m=y(o),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const D={};$&769&&(D.$$scope={dirty:$,ctx:o}),u.$set(D);const H={};$&770&&(H.$$scope={dirty:$,ctx:o}),d.$set(H),(!h||$&4)&&(a.disabled=o[2]),(!h||$&4)&&L(a,"btn-loading",o[2])},i(o){h||(B(u.$$.fragment,o),B(d.$$.fragment,o),h=!0)},o(o){N(u.$$.fragment,o),N(d.$$.fragment,o),h=!1},d(o){o&&(w(e),w(S),w(C)),m&&m.d(),T(u),T(d),F=!1,V(z)}}}function se(i){let e,n;return e=new K({props:{$$slots:{default:[te]},$$scope:{ctx:i}}}),{c(){R(e.$$.fragment)},m(s,l){A(e,s,l),n=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){n||(B(e.$$.fragment,s),n=!0)},o(s){N(e.$$.fragment,s),n=!1},d(s){T(e,s)}}}function le(i,e,n){let s,{params:l}=e,t="",u="",p=!1;async function d(){if(!p){n(2,p=!0);try{await W.admins.confirmPasswordReset(l==null?void 0:l.token,t,u),X("Successfully set a new admin password."),Y("/")}catch(g){W.error(g)}n(2,p=!1)}}function r(){t=this.value,n(0,t)}function a(){u=this.value,n(1,u)}return i.$$set=g=>{"params"in g&&n(5,l=g.params)},i.$$.update=()=>{i.$$.dirty&32&&n(3,s=M.getJWTPayload(l==null?void 0:l.token).email||"")},[t,u,p,s,d,l,r,a]}class ae extends E{constructor(e){super(),G(this,e,le,se,I,{params:5})}}export{ae as default}; diff --git a/ui/dist/assets/PageAdminRequestPasswordReset-183a65ef.js b/ui/dist/assets/PageAdminRequestPasswordReset-183a65ef.js deleted file mode 100644 index 826bda5e42..0000000000 --- a/ui/dist/assets/PageAdminRequestPasswordReset-183a65ef.js +++ /dev/null @@ -1 +0,0 @@ -import{S as M,i as T,s as j,F as z,c as R,m as S,t as w,a as y,d as E,b as v,e as _,f as p,g,h as d,j as A,l as B,k as N,n as D,o as k,p as C,q as G,r as F,u as H,v as I,w as h,x as J,y as P,z as L}from"./index-bd739ba3.js";function K(u){let e,s,n,l,t,o,c,m,r,a,b,f;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:i})=>({5:i}),({uniqueId:i})=>i?32:0]},$$scope:{ctx:u}}}),{c(){e=_("form"),s=_("div"),s.innerHTML='

Forgotten admin password

Enter the email associated with your account and we’ll send you a recovery link:

',n=v(),R(l.$$.fragment),t=v(),o=_("button"),c=_("i"),m=v(),r=_("span"),r.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(c,"class","ri-mail-send-line"),p(r,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=u[1],F(o,"btn-loading",u[1]),p(e,"class","m-b-base")},m(i,$){g(i,e,$),d(e,s),d(e,n),S(l,e,null),d(e,t),d(e,o),d(o,c),d(o,m),d(o,r),a=!0,b||(f=H(e,"submit",I(u[3])),b=!0)},p(i,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:i}),l.$set(q),(!a||$&2)&&(o.disabled=i[1]),(!a||$&2)&&F(o,"btn-loading",i[1])},i(i){a||(w(l.$$.fragment,i),a=!0)},o(i){y(l.$$.fragment,i),a=!1},d(i){i&&k(e),E(l),b=!1,f()}}}function O(u){let e,s,n,l,t,o,c,m,r;return{c(){e=_("div"),s=_("div"),s.innerHTML='',n=v(),l=_("div"),t=_("p"),o=h("Check "),c=_("strong"),m=h(u[0]),r=h(" for the recovery link."),p(s,"class","icon"),p(c,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){g(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,c),d(c,m),d(t,r)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&k(e)}}}function Q(u){let e,s,n,l,t,o,c,m;return{c(){e=_("label"),s=h("Email"),l=v(),t=_("input"),p(e,"for",n=u[5]),p(t,"type","email"),p(t,"id",o=u[5]),t.required=!0,t.autofocus=!0},m(r,a){g(r,e,a),d(e,s),g(r,l,a),g(r,t,a),L(t,u[0]),t.focus(),c||(m=H(t,"input",u[4]),c=!0)},p(r,a){a&32&&n!==(n=r[5])&&p(e,"for",n),a&32&&o!==(o=r[5])&&p(t,"id",o),a&1&&t.value!==r[0]&&L(t,r[0])},d(r){r&&(k(e),k(l),k(t)),c=!1,m()}}}function U(u){let e,s,n,l,t,o,c,m;const r=[O,K],a=[];function b(f,i){return f[2]?0:1}return e=b(u),s=a[e]=r[e](u),{c(){s.c(),n=v(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(f,i){a[e].m(f,i),g(f,n,i),g(f,l,i),d(l,t),o=!0,c||(m=A(B.call(null,t)),c=!0)},p(f,i){let $=e;e=b(f),e===$?a[e].p(f,i):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(f,i):(s=a[e]=r[e](f),s.c()),w(s,1),s.m(n.parentNode,n))},i(f){o||(w(s),o=!0)},o(f){y(s),o=!1},d(f){f&&(k(n),k(l)),a[e].d(f),c=!1,m()}}}function V(u){let e,s;return e=new z({props:{$$slots:{default:[U]},$$scope:{ctx:u}}}),{c(){R(e.$$.fragment)},m(n,l){S(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){E(e,n)}}}function W(u,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.error(m)}s(1,l=!1)}}function c(){n=this.value,s(0,n)}return[n,l,t,o,c]}class Y extends M{constructor(e){super(),T(this,e,W,V,j,{})}}export{Y as default}; diff --git a/ui/dist/assets/PageOAuth2Redirect-1b442cde.js b/ui/dist/assets/PageOAuth2Redirect-1b442cde.js deleted file mode 100644 index 0ef53e619c..0000000000 --- a/ui/dist/assets/PageOAuth2Redirect-1b442cde.js +++ /dev/null @@ -1 +0,0 @@ -import{S as o,i,s as c,e as r,f as l,g as u,y as s,o as d,I as h}from"./index-bd739ba3.js";function f(n){let t;return{c(){t=r("div"),t.innerHTML='

Auth completed.

You can go back to the app if this window is not automatically closed.
',l(t,"class","content txt-hint txt-center p-base")},m(e,a){u(e,t,a)},p:s,i:s,o:s,d(e){e&&d(t)}}}function m(n){return h(()=>{window.close()}),[]}class x extends o{constructor(t){super(),i(this,t,m,f,c,{})}}export{x as default}; diff --git a/ui/dist/assets/PageRecordConfirmEmailChange-49a1f9db.js b/ui/dist/assets/PageRecordConfirmEmailChange-49a1f9db.js deleted file mode 100644 index a1c3aeb365..0000000000 --- a/ui/dist/assets/PageRecordConfirmEmailChange-49a1f9db.js +++ /dev/null @@ -1,2 +0,0 @@ -import{S as G,i as I,s as J,F as M,c as S,m as L,t as h,a as v,d as z,C as N,E as R,g as _,k as W,n as Y,o as b,G as j,H as A,p as B,q as D,e as m,w as y,b as C,f as p,r as T,h as g,u as P,v as K,y as E,x as O,z as F}from"./index-bd739ba3.js";function Q(i){let e,t,n,l,s,o,f,a,r,u,k,$,d=i[3]&&H(i);return o=new D({props:{class:"form-field required",name:"password",$$slots:{default:[V,({uniqueId:c})=>({8:c}),({uniqueId:c})=>c?256:0]},$$scope:{ctx:i}}}),{c(){e=m("form"),t=m("div"),n=m("h5"),l=y(`Type your password to confirm changing your email address - `),d&&d.c(),s=C(),S(o.$$.fragment),f=C(),a=m("button"),r=m("span"),r.textContent="Confirm new email",p(t,"class","content txt-center m-b-base"),p(r,"class","txt"),p(a,"type","submit"),p(a,"class","btn btn-lg btn-block"),a.disabled=i[1],T(a,"btn-loading",i[1])},m(c,w){_(c,e,w),g(e,t),g(t,n),g(n,l),d&&d.m(n,null),g(e,s),L(o,e,null),g(e,f),g(e,a),g(a,r),u=!0,k||($=P(e,"submit",K(i[4])),k=!0)},p(c,w){c[3]?d?d.p(c,w):(d=H(c),d.c(),d.m(n,null)):d&&(d.d(1),d=null);const q={};w&769&&(q.$$scope={dirty:w,ctx:c}),o.$set(q),(!u||w&2)&&(a.disabled=c[1]),(!u||w&2)&&T(a,"btn-loading",c[1])},i(c){u||(h(o.$$.fragment,c),u=!0)},o(c){v(o.$$.fragment,c),u=!1},d(c){c&&b(e),d&&d.d(),z(o),k=!1,$()}}}function U(i){let e,t,n,l,s;return{c(){e=m("div"),e.innerHTML='

Successfully changed the user email address.

You can now sign in with your new email address.

',t=C(),n=m("button"),n.textContent="Close",p(e,"class","alert alert-success"),p(n,"type","button"),p(n,"class","btn btn-transparent btn-block")},m(o,f){_(o,e,f),_(o,t,f),_(o,n,f),l||(s=P(n,"click",i[6]),l=!0)},p:E,i:E,o:E,d(o){o&&(b(e),b(t),b(n)),l=!1,s()}}}function H(i){let e,t,n;return{c(){e=y("to "),t=m("strong"),n=y(i[3]),p(t,"class","txt-nowrap")},m(l,s){_(l,e,s),_(l,t,s),g(t,n)},p(l,s){s&8&&O(n,l[3])},d(l){l&&(b(e),b(t))}}}function V(i){let e,t,n,l,s,o,f,a;return{c(){e=m("label"),t=y("Password"),l=C(),s=m("input"),p(e,"for",n=i[8]),p(s,"type","password"),p(s,"id",o=i[8]),s.required=!0,s.autofocus=!0},m(r,u){_(r,e,u),g(e,t),_(r,l,u),_(r,s,u),F(s,i[0]),s.focus(),f||(a=P(s,"input",i[7]),f=!0)},p(r,u){u&256&&n!==(n=r[8])&&p(e,"for",n),u&256&&o!==(o=r[8])&&p(s,"id",o),u&1&&s.value!==r[0]&&F(s,r[0])},d(r){r&&(b(e),b(l),b(s)),f=!1,a()}}}function X(i){let e,t,n,l;const s=[U,Q],o=[];function f(a,r){return a[2]?0:1}return e=f(i),t=o[e]=s[e](i),{c(){t.c(),n=R()},m(a,r){o[e].m(a,r),_(a,n,r),l=!0},p(a,r){let u=e;e=f(a),e===u?o[e].p(a,r):(W(),v(o[u],1,1,()=>{o[u]=null}),Y(),t=o[e],t?t.p(a,r):(t=o[e]=s[e](a),t.c()),h(t,1),t.m(n.parentNode,n))},i(a){l||(h(t),l=!0)},o(a){v(t),l=!1},d(a){a&&b(n),o[e].d(a)}}}function Z(i){let e,t;return e=new M({props:{nobranding:!0,$$slots:{default:[X]},$$scope:{ctx:i}}}),{c(){S(e.$$.fragment)},m(n,l){L(e,n,l),t=!0},p(n,[l]){const s={};l&527&&(s.$$scope={dirty:l,ctx:n}),e.$set(s)},i(n){t||(h(e.$$.fragment,n),t=!0)},o(n){v(e.$$.fragment,n),t=!1},d(n){z(e,n)}}}function x(i,e,t){let n,{params:l}=e,s="",o=!1,f=!1;async function a(){if(o)return;t(1,o=!0);const k=new j("../");try{const $=A(l==null?void 0:l.token);await k.collection($.collectionId).confirmEmailChange(l==null?void 0:l.token,s),t(2,f=!0)}catch($){B.error($)}t(1,o=!1)}const r=()=>window.close();function u(){s=this.value,t(0,s)}return i.$$set=k=>{"params"in k&&t(5,l=k.params)},i.$$.update=()=>{i.$$.dirty&32&&t(3,n=N.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[s,o,f,n,a,l,r,u]}class te extends G{constructor(e){super(),I(this,e,x,Z,J,{params:5})}}export{te as default}; diff --git a/ui/dist/assets/PageRecordConfirmPasswordReset-438cefb2.js b/ui/dist/assets/PageRecordConfirmPasswordReset-438cefb2.js deleted file mode 100644 index 725eb43d09..0000000000 --- a/ui/dist/assets/PageRecordConfirmPasswordReset-438cefb2.js +++ /dev/null @@ -1,2 +0,0 @@ -import{S as J,i as M,s as W,F as Y,c as H,m as N,t as P,a as y,d as T,C as j,E as A,g as _,k as B,n as D,o as m,G as K,H as O,p as Q,q as E,e as b,w as q,b as C,f as p,r as G,h as w,u as S,v as U,y as F,x as V,z as R}from"./index-bd739ba3.js";function X(a){let e,l,s,n,t,o,c,r,i,u,v,g,k,h,d=a[4]&&I(a);return o=new E({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),r=new E({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:a}}}),{c(){e=b("form"),l=b("div"),s=b("h5"),n=q(`Reset your user password - `),d&&d.c(),t=C(),H(o.$$.fragment),c=C(),H(r.$$.fragment),i=C(),u=b("button"),v=b("span"),v.textContent="Set new password",p(l,"class","content txt-center m-b-base"),p(v,"class","txt"),p(u,"type","submit"),p(u,"class","btn btn-lg btn-block"),u.disabled=a[2],G(u,"btn-loading",a[2])},m(f,$){_(f,e,$),w(e,l),w(l,s),w(s,n),d&&d.m(s,null),w(e,t),N(o,e,null),w(e,c),N(r,e,null),w(e,i),w(e,u),w(u,v),g=!0,k||(h=S(e,"submit",U(a[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=I(f),d.c(),d.m(s,null)):d&&(d.d(1),d=null);const L={};$&3073&&(L.$$scope={dirty:$,ctx:f}),o.$set(L);const z={};$&3074&&(z.$$scope={dirty:$,ctx:f}),r.$set(z),(!g||$&4)&&(u.disabled=f[2]),(!g||$&4)&&G(u,"btn-loading",f[2])},i(f){g||(P(o.$$.fragment,f),P(r.$$.fragment,f),g=!0)},o(f){y(o.$$.fragment,f),y(r.$$.fragment,f),g=!1},d(f){f&&m(e),d&&d.d(),T(o),T(r),k=!1,h()}}}function Z(a){let e,l,s,n,t;return{c(){e=b("div"),e.innerHTML='

Successfully changed the user password.

You can now sign in with your new password.

',l=C(),s=b("button"),s.textContent="Close",p(e,"class","alert alert-success"),p(s,"type","button"),p(s,"class","btn btn-transparent btn-block")},m(o,c){_(o,e,c),_(o,l,c),_(o,s,c),n||(t=S(s,"click",a[7]),n=!0)},p:F,i:F,o:F,d(o){o&&(m(e),m(l),m(s)),n=!1,t()}}}function I(a){let e,l,s;return{c(){e=q("for "),l=b("strong"),s=q(a[4])},m(n,t){_(n,e,t),_(n,l,t),w(l,s)},p(n,t){t&16&&V(s,n[4])},d(n){n&&(m(e),m(l))}}}function x(a){let e,l,s,n,t,o,c,r;return{c(){e=b("label"),l=q("New password"),n=C(),t=b("input"),p(e,"for",s=a[10]),p(t,"type","password"),p(t,"id",o=a[10]),t.required=!0,t.autofocus=!0},m(i,u){_(i,e,u),w(e,l),_(i,n,u),_(i,t,u),R(t,a[0]),t.focus(),c||(r=S(t,"input",a[8]),c=!0)},p(i,u){u&1024&&s!==(s=i[10])&&p(e,"for",s),u&1024&&o!==(o=i[10])&&p(t,"id",o),u&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&(m(e),m(n),m(t)),c=!1,r()}}}function ee(a){let e,l,s,n,t,o,c,r;return{c(){e=b("label"),l=q("New password confirm"),n=C(),t=b("input"),p(e,"for",s=a[10]),p(t,"type","password"),p(t,"id",o=a[10]),t.required=!0},m(i,u){_(i,e,u),w(e,l),_(i,n,u),_(i,t,u),R(t,a[1]),c||(r=S(t,"input",a[9]),c=!0)},p(i,u){u&1024&&s!==(s=i[10])&&p(e,"for",s),u&1024&&o!==(o=i[10])&&p(t,"id",o),u&2&&t.value!==i[1]&&R(t,i[1])},d(i){i&&(m(e),m(n),m(t)),c=!1,r()}}}function te(a){let e,l,s,n;const t=[Z,X],o=[];function c(r,i){return r[3]?0:1}return e=c(a),l=o[e]=t[e](a),{c(){l.c(),s=A()},m(r,i){o[e].m(r,i),_(r,s,i),n=!0},p(r,i){let u=e;e=c(r),e===u?o[e].p(r,i):(B(),y(o[u],1,1,()=>{o[u]=null}),D(),l=o[e],l?l.p(r,i):(l=o[e]=t[e](r),l.c()),P(l,1),l.m(s.parentNode,s))},i(r){n||(P(l),n=!0)},o(r){y(l),n=!1},d(r){r&&m(s),o[e].d(r)}}}function se(a){let e,l;return e=new Y({props:{nobranding:!0,$$slots:{default:[te]},$$scope:{ctx:a}}}),{c(){H(e.$$.fragment)},m(s,n){N(e,s,n),l=!0},p(s,[n]){const t={};n&2079&&(t.$$scope={dirty:n,ctx:s}),e.$set(t)},i(s){l||(P(e.$$.fragment,s),l=!0)},o(s){y(e.$$.fragment,s),l=!1},d(s){T(e,s)}}}function le(a,e,l){let s,{params:n}=e,t="",o="",c=!1,r=!1;async function i(){if(c)return;l(2,c=!0);const k=new K("../");try{const h=O(n==null?void 0:n.token);await k.collection(h.collectionId).confirmPasswordReset(n==null?void 0:n.token,t,o),l(3,r=!0)}catch(h){Q.error(h)}l(2,c=!1)}const u=()=>window.close();function v(){t=this.value,l(0,t)}function g(){o=this.value,l(1,o)}return a.$$set=k=>{"params"in k&&l(6,n=k.params)},a.$$.update=()=>{a.$$.dirty&64&&l(4,s=j.getJWTPayload(n==null?void 0:n.token).email||"")},[t,o,c,r,s,i,n,u,v,g]}class oe extends J{constructor(e){super(),M(this,e,le,se,W,{params:6})}}export{oe as default}; diff --git a/ui/dist/assets/PageRecordConfirmVerification-aba10a6b.js b/ui/dist/assets/PageRecordConfirmVerification-aba10a6b.js deleted file mode 100644 index 9b31a9ca2b..0000000000 --- a/ui/dist/assets/PageRecordConfirmVerification-aba10a6b.js +++ /dev/null @@ -1 +0,0 @@ -import{S as v,i as y,s as g,F as w,c as C,m as x,t as $,a as H,d as L,G as P,H as T,E as M,g as a,o as r,e as f,b as _,f as d,u as b,y as p}from"./index-bd739ba3.js";function S(c){let t,s,e,n,l;return{c(){t=f("div"),t.innerHTML='

Invalid or expired verification token.

',s=_(),e=f("button"),e.textContent="Close",d(t,"class","alert alert-danger"),d(e,"type","button"),d(e,"class","btn btn-transparent btn-block")},m(i,o){a(i,t,o),a(i,s,o),a(i,e,o),n||(l=b(e,"click",c[4]),n=!0)},p,d(i){i&&(r(t),r(s),r(e)),n=!1,l()}}}function h(c){let t,s,e,n,l;return{c(){t=f("div"),t.innerHTML='

Successfully verified email address.

',s=_(),e=f("button"),e.textContent="Close",d(t,"class","alert alert-success"),d(e,"type","button"),d(e,"class","btn btn-transparent btn-block")},m(i,o){a(i,t,o),a(i,s,o),a(i,e,o),n||(l=b(e,"click",c[3]),n=!0)},p,d(i){i&&(r(t),r(s),r(e)),n=!1,l()}}}function F(c){let t;return{c(){t=f("div"),t.innerHTML='
Please wait...
',d(t,"class","txt-center")},m(s,e){a(s,t,e)},p,d(s){s&&r(t)}}}function I(c){let t;function s(l,i){return l[1]?F:l[0]?h:S}let e=s(c),n=e(c);return{c(){n.c(),t=M()},m(l,i){n.m(l,i),a(l,t,i)},p(l,i){e===(e=s(l))&&n?n.p(l,i):(n.d(1),n=e(l),n&&(n.c(),n.m(t.parentNode,t)))},d(l){l&&r(t),n.d(l)}}}function V(c){let t,s;return t=new w({props:{nobranding:!0,$$slots:{default:[I]},$$scope:{ctx:c}}}),{c(){C(t.$$.fragment)},m(e,n){x(t,e,n),s=!0},p(e,[n]){const l={};n&67&&(l.$$scope={dirty:n,ctx:e}),t.$set(l)},i(e){s||($(t.$$.fragment,e),s=!0)},o(e){H(t.$$.fragment,e),s=!1},d(e){L(t,e)}}}function q(c,t,s){let{params:e}=t,n=!1,l=!1;i();async function i(){s(1,l=!0);const u=new P("../");try{const m=T(e==null?void 0:e.token);await u.collection(m.collectionId).confirmVerification(e==null?void 0:e.token),s(0,n=!0)}catch{s(0,n=!1)}s(1,l=!1)}const o=()=>window.close(),k=()=>window.close();return c.$$set=u=>{"params"in u&&s(2,e=u.params)},[n,l,e,o,k]}class G extends v{constructor(t){super(),y(this,t,q,V,g,{params:2})}}export{G as default}; diff --git a/ui/dist/assets/RealtimeApiDocs-283ed473.js b/ui/dist/assets/RealtimeApiDocs-283ed473.js deleted file mode 100644 index 753b062f9f..0000000000 --- a/ui/dist/assets/RealtimeApiDocs-283ed473.js +++ /dev/null @@ -1,110 +0,0 @@ -import{S as re,i as ae,s as be,N as pe,C as P,e as p,w as y,b as a,c as se,f as u,g as s,h as I,m as ne,x as ue,t as ie,a as ce,o as n,d as le,p as me}from"./index-bd739ba3.js";import{S as de}from"./SdkTabs-cf570492.js";function fe(t){var B,U,W,A,H,L,T,q,M,N,j,J;let i,m,c=t[0].name+"",b,d,D,f,_,$,k,l,S,g,C,v,w,h,E,r,R;return l=new de({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${t[1]}'); - - ... - - // (Optionally) authenticate - await pb.collection('users').authWithPassword('test@example.com', '123456'); - - // Subscribe to changes in any ${(B=t[0])==null?void 0:B.name} record - pb.collection('${(U=t[0])==null?void 0:U.name}').subscribe('*', function (e) { - console.log(e.action); - console.log(e.record); - }); - - // Subscribe to changes only in the specified record - pb.collection('${(W=t[0])==null?void 0:W.name}').subscribe('RECORD_ID', function (e) { - console.log(e.action); - console.log(e.record); - }); - - // Unsubscribe - pb.collection('${(A=t[0])==null?void 0:A.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions - pb.collection('${(H=t[0])==null?void 0:H.name}').unsubscribe('*'); // remove all '*' topic subscriptions - pb.collection('${(L=t[0])==null?void 0:L.name}').unsubscribe(); // remove all subscriptions in the collection - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${t[1]}'); - - ... - - // (Optionally) authenticate - await pb.collection('users').authWithPassword('test@example.com', '123456'); - - // Subscribe to changes in any ${(T=t[0])==null?void 0:T.name} record - pb.collection('${(q=t[0])==null?void 0:q.name}').subscribe('*', (e) { - print(e.action); - print(e.record); - }); - - // Subscribe to changes only in the specified record - pb.collection('${(M=t[0])==null?void 0:M.name}').subscribe('RECORD_ID', (e) { - print(e.action); - print(e.record); - }); - - // Unsubscribe - pb.collection('${(N=t[0])==null?void 0:N.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions - pb.collection('${(j=t[0])==null?void 0:j.name}').unsubscribe('*'); // remove all '*' topic subscriptions - pb.collection('${(J=t[0])==null?void 0:J.name}').unsubscribe(); // remove all subscriptions in the collection - `}}),r=new pe({props:{content:JSON.stringify({action:"create",record:P.dummyCollectionRecord(t[0])},null,2).replace('"action": "create"','"action": "create" // create, update or delete')}}),{c(){i=p("h3"),m=y("Realtime ("),b=y(c),d=y(")"),D=a(),f=p("div"),f.innerHTML=`

Subscribe to realtime changes via Server-Sent Events (SSE).

Events are sent for create, update - and delete record operations (see "Event data format" section below).

`,_=a(),$=p("div"),$.innerHTML=`

You could subscribe to a single record or to an entire collection.

When you subscribe to a single record, the collection's - ViewRule will be used to determine whether the subscriber has access to receive the - event message.

When you subscribe to an entire collection, the collection's - ListRule will be used to determine whether the subscriber has access to receive the - event message.

`,k=a(),se(l.$$.fragment),S=a(),g=p("h6"),g.textContent="API details",C=a(),v=p("div"),v.innerHTML='SSE

/api/realtime

',w=a(),h=p("div"),h.textContent="Event data format",E=a(),se(r.$$.fragment),u(i,"class","m-b-sm"),u(f,"class","content txt-lg m-b-sm"),u($,"class","alert alert-info m-t-10 m-b-sm"),u(g,"class","m-b-xs"),u(v,"class","alert"),u(h,"class","section-title")},m(e,o){s(e,i,o),I(i,m),I(i,b),I(i,d),s(e,D,o),s(e,f,o),s(e,_,o),s(e,$,o),s(e,k,o),ne(l,e,o),s(e,S,o),s(e,g,o),s(e,C,o),s(e,v,o),s(e,w,o),s(e,h,o),s(e,E,o),ne(r,e,o),R=!0},p(e,[o]){var Y,z,F,G,K,Q,X,Z,x,ee,oe,te;(!R||o&1)&&c!==(c=e[0].name+"")&&ue(b,c);const O={};o&3&&(O.js=` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${e[1]}'); - - ... - - // (Optionally) authenticate - await pb.collection('users').authWithPassword('test@example.com', '123456'); - - // Subscribe to changes in any ${(Y=e[0])==null?void 0:Y.name} record - pb.collection('${(z=e[0])==null?void 0:z.name}').subscribe('*', function (e) { - console.log(e.action); - console.log(e.record); - }); - - // Subscribe to changes only in the specified record - pb.collection('${(F=e[0])==null?void 0:F.name}').subscribe('RECORD_ID', function (e) { - console.log(e.action); - console.log(e.record); - }); - - // Unsubscribe - pb.collection('${(G=e[0])==null?void 0:G.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions - pb.collection('${(K=e[0])==null?void 0:K.name}').unsubscribe('*'); // remove all '*' topic subscriptions - pb.collection('${(Q=e[0])==null?void 0:Q.name}').unsubscribe(); // remove all subscriptions in the collection - `),o&3&&(O.dart=` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${e[1]}'); - - ... - - // (Optionally) authenticate - await pb.collection('users').authWithPassword('test@example.com', '123456'); - - // Subscribe to changes in any ${(X=e[0])==null?void 0:X.name} record - pb.collection('${(Z=e[0])==null?void 0:Z.name}').subscribe('*', (e) { - print(e.action); - print(e.record); - }); - - // Subscribe to changes only in the specified record - pb.collection('${(x=e[0])==null?void 0:x.name}').subscribe('RECORD_ID', (e) { - print(e.action); - print(e.record); - }); - - // Unsubscribe - pb.collection('${(ee=e[0])==null?void 0:ee.name}').unsubscribe('RECORD_ID'); // remove all 'RECORD_ID' subscriptions - pb.collection('${(oe=e[0])==null?void 0:oe.name}').unsubscribe('*'); // remove all '*' topic subscriptions - pb.collection('${(te=e[0])==null?void 0:te.name}').unsubscribe(); // remove all subscriptions in the collection - `),l.$set(O);const V={};o&1&&(V.content=JSON.stringify({action:"create",record:P.dummyCollectionRecord(e[0])},null,2).replace('"action": "create"','"action": "create" // create, update or delete')),r.$set(V)},i(e){R||(ie(l.$$.fragment,e),ie(r.$$.fragment,e),R=!0)},o(e){ce(l.$$.fragment,e),ce(r.$$.fragment,e),R=!1},d(e){e&&(n(i),n(D),n(f),n(_),n($),n(k),n(S),n(g),n(C),n(v),n(w),n(h),n(E)),le(l,e),le(r,e)}}}function $e(t,i,m){let c,{collection:b}=i;return t.$$set=d=>{"collection"in d&&m(0,b=d.collection)},m(1,c=P.getApiExampleUrl(me.baseUrl)),[b,c]}class he extends re{constructor(i){super(),ae(this,i,$e,fe,be,{collection:0})}}export{he as default}; diff --git a/ui/dist/assets/RequestEmailChangeDocs-9b4c0db4.js b/ui/dist/assets/RequestEmailChangeDocs-9b4c0db4.js deleted file mode 100644 index 9150e3ad0d..0000000000 --- a/ui/dist/assets/RequestEmailChangeDocs-9b4c0db4.js +++ /dev/null @@ -1,64 +0,0 @@ -import{S as Ee,i as Be,s as Se,O as L,e as r,w as v,b as k,c as Ce,f as b,g as d,h as n,m as ye,x as N,P as ve,Q as Re,k as Me,R as Ae,n as We,t as ee,a as te,o as m,d as Te,C as ze,p as He,r as F,u as Oe,N as Ue}from"./index-bd739ba3.js";import{S as je}from"./SdkTabs-cf570492.js";function we(o,l,a){const s=o.slice();return s[5]=l[a],s}function $e(o,l,a){const s=o.slice();return s[5]=l[a],s}function qe(o,l){let a,s=l[5].code+"",h,f,i,p;function u(){return l[4](l[5])}return{key:o,first:null,c(){a=r("button"),h=v(s),f=k(),b(a,"class","tab-item"),F(a,"active",l[1]===l[5].code),this.first=a},m($,q){d($,a,q),n(a,h),n(a,f),i||(p=Oe(a,"click",u),i=!0)},p($,q){l=$,q&4&&s!==(s=l[5].code+"")&&N(h,s),q&6&&F(a,"active",l[1]===l[5].code)},d($){$&&m(a),i=!1,p()}}}function Pe(o,l){let a,s,h,f;return s=new Ue({props:{content:l[5].body}}),{key:o,first:null,c(){a=r("div"),Ce(s.$$.fragment),h=k(),b(a,"class","tab-item"),F(a,"active",l[1]===l[5].code),this.first=a},m(i,p){d(i,a,p),ye(s,a,null),n(a,h),f=!0},p(i,p){l=i;const u={};p&4&&(u.content=l[5].body),s.$set(u),(!f||p&6)&&F(a,"active",l[1]===l[5].code)},i(i){f||(ee(s.$$.fragment,i),f=!0)},o(i){te(s.$$.fragment,i),f=!1},d(i){i&&m(a),Te(s)}}}function De(o){var pe,ue,be,fe;let l,a,s=o[0].name+"",h,f,i,p,u,$,q,z=o[0].name+"",I,le,K,P,Q,T,G,w,H,ae,O,E,se,J,U=o[0].name+"",V,oe,ne,j,X,B,Y,S,Z,R,x,C,M,g=[],ie=new Map,ce,A,_=[],re=new Map,y;P=new je({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(pe=o[0])==null?void 0:pe.name}').authWithPassword('test@example.com', '1234567890'); - - await pb.collection('${(ue=o[0])==null?void 0:ue.name}').requestEmailChange('new@example.com'); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(be=o[0])==null?void 0:be.name}').authWithPassword('test@example.com', '1234567890'); - - await pb.collection('${(fe=o[0])==null?void 0:fe.name}').requestEmailChange('new@example.com'); - `}});let D=L(o[2]);const de=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eAuthorization:TOKEN header",X=k(),B=r("div"),B.textContent="Body Parameters",Y=k(),S=r("table"),S.innerHTML='Param Type Description
Required newEmail
String The new email address to send the change email request.',Z=k(),R=r("div"),R.textContent="Responses",x=k(),C=r("div"),M=r("div");for(let e=0;ea(1,f=u.code);return o.$$set=u=>{"collection"in u&&a(0,h=u.collection)},a(3,s=ze.getApiExampleUrl(He.baseUrl)),a(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "newEmail": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `},{code:401,body:` - { - "code": 401, - "message": "The request requires valid record authorization token to be set.", - "data": {} - } - `},{code:403,body:` - { - "code": 403, - "message": "The authorized record model is not allowed to perform this action.", - "data": {} - } - `}]),[h,f,i,s,p]}class Ie extends Ee{constructor(l){super(),Be(this,l,Le,De,Se,{collection:0})}}export{Ie as default}; diff --git a/ui/dist/assets/RequestPasswordResetDocs-3a7dc103.js b/ui/dist/assets/RequestPasswordResetDocs-3a7dc103.js deleted file mode 100644 index 1ae993e3ec..0000000000 --- a/ui/dist/assets/RequestPasswordResetDocs-3a7dc103.js +++ /dev/null @@ -1,44 +0,0 @@ -import{S as Pe,i as $e,s as qe,O as I,e as r,w as g,b as h,c as ve,f as b,g as d,h as n,m as ge,x as L,P as fe,Q as ye,k as Re,R as Be,n as Ce,t as x,a as ee,o as p,d as we,C as Se,p as Te,r as N,u as Me,N as Ae}from"./index-bd739ba3.js";import{S as Ue}from"./SdkTabs-cf570492.js";function be(o,s,l){const a=o.slice();return a[5]=s[l],a}function _e(o,s,l){const a=o.slice();return a[5]=s[l],a}function ke(o,s){let l,a=s[5].code+"",_,f,i,u;function m(){return s[4](s[5])}return{key:o,first:null,c(){l=r("button"),_=g(a),f=h(),b(l,"class","tab-item"),N(l,"active",s[1]===s[5].code),this.first=l},m(w,P){d(w,l,P),n(l,_),n(l,f),i||(u=Me(l,"click",m),i=!0)},p(w,P){s=w,P&4&&a!==(a=s[5].code+"")&&L(_,a),P&6&&N(l,"active",s[1]===s[5].code)},d(w){w&&p(l),i=!1,u()}}}function he(o,s){let l,a,_,f;return a=new Ae({props:{content:s[5].body}}),{key:o,first:null,c(){l=r("div"),ve(a.$$.fragment),_=h(),b(l,"class","tab-item"),N(l,"active",s[1]===s[5].code),this.first=l},m(i,u){d(i,l,u),ge(a,l,null),n(l,_),f=!0},p(i,u){s=i;const m={};u&4&&(m.content=s[5].body),a.$set(m),(!f||u&6)&&N(l,"active",s[1]===s[5].code)},i(i){f||(x(a.$$.fragment,i),f=!0)},o(i){ee(a.$$.fragment,i),f=!1},d(i){i&&p(l),we(a)}}}function je(o){var de,pe;let s,l,a=o[0].name+"",_,f,i,u,m,w,P,D=o[0].name+"",Q,te,z,$,G,B,J,q,H,se,O,C,le,K,E=o[0].name+"",V,ae,W,S,X,T,Y,M,Z,y,A,v=[],oe=new Map,ne,U,k=[],ie=new Map,R;$=new Ue({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(de=o[0])==null?void 0:de.name}').requestPasswordReset('test@example.com'); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(pe=o[0])==null?void 0:pe.name}').requestPasswordReset('test@example.com'); - `}});let F=I(o[2]);const ce=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description
Required email
String The auth record email address to send the password reset request (if exists).',Y=h(),M=r("div"),M.textContent="Responses",Z=h(),y=r("div"),A=r("div");for(let e=0;el(1,f=m.code);return o.$$set=m=>{"collection"in m&&l(0,_=m.collection)},l(3,a=Se.getApiExampleUrl(Te.baseUrl)),l(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "email": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}]),[_,f,i,a,u]}class Ee extends Pe{constructor(s){super(),$e(this,s,De,je,qe,{collection:0})}}export{Ee as default}; diff --git a/ui/dist/assets/RequestVerificationDocs-07df774c.js b/ui/dist/assets/RequestVerificationDocs-07df774c.js deleted file mode 100644 index 240775bc0c..0000000000 --- a/ui/dist/assets/RequestVerificationDocs-07df774c.js +++ /dev/null @@ -1,44 +0,0 @@ -import{S as qe,i as we,s as Pe,O as F,e as r,w as g,b as h,c as ve,f as b,g as d,h as n,m as ge,x as I,P as pe,Q as ye,k as Be,R as Ce,n as Se,t as x,a as ee,o as f,d as $e,C as Te,p as Re,r as L,u as Ve,N as Me}from"./index-bd739ba3.js";import{S as Ae}from"./SdkTabs-cf570492.js";function be(o,l,s){const a=o.slice();return a[5]=l[s],a}function _e(o,l,s){const a=o.slice();return a[5]=l[s],a}function ke(o,l){let s,a=l[5].code+"",_,p,i,u;function m(){return l[4](l[5])}return{key:o,first:null,c(){s=r("button"),_=g(a),p=h(),b(s,"class","tab-item"),L(s,"active",l[1]===l[5].code),this.first=s},m($,q){d($,s,q),n(s,_),n(s,p),i||(u=Ve(s,"click",m),i=!0)},p($,q){l=$,q&4&&a!==(a=l[5].code+"")&&I(_,a),q&6&&L(s,"active",l[1]===l[5].code)},d($){$&&f(s),i=!1,u()}}}function he(o,l){let s,a,_,p;return a=new Me({props:{content:l[5].body}}),{key:o,first:null,c(){s=r("div"),ve(a.$$.fragment),_=h(),b(s,"class","tab-item"),L(s,"active",l[1]===l[5].code),this.first=s},m(i,u){d(i,s,u),ge(a,s,null),n(s,_),p=!0},p(i,u){l=i;const m={};u&4&&(m.content=l[5].body),a.$set(m),(!p||u&6)&&L(s,"active",l[1]===l[5].code)},i(i){p||(x(a.$$.fragment,i),p=!0)},o(i){ee(a.$$.fragment,i),p=!1},d(i){i&&f(s),$e(a)}}}function Ue(o){var de,fe;let l,s,a=o[0].name+"",_,p,i,u,m,$,q,j=o[0].name+"",N,te,Q,w,z,C,G,P,D,le,H,S,se,J,O=o[0].name+"",K,ae,W,T,X,R,Y,V,Z,y,M,v=[],oe=new Map,ne,A,k=[],ie=new Map,B;w=new Ae({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${o[3]}'); - - ... - - await pb.collection('${(de=o[0])==null?void 0:de.name}').requestVerification('test@example.com'); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${o[3]}'); - - ... - - await pb.collection('${(fe=o[0])==null?void 0:fe.name}').requestVerification('test@example.com'); - `}});let E=F(o[2]);const ce=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eParam Type Description
Required email
String The auth record email address to send the verification request (if exists).',Y=h(),V=r("div"),V.textContent="Responses",Z=h(),y=r("div"),M=r("div");for(let e=0;es(1,p=m.code);return o.$$set=m=>{"collection"in m&&s(0,_=m.collection)},s(3,a=Te.getApiExampleUrl(Re.baseUrl)),s(2,i=[{code:204,body:"null"},{code:400,body:` - { - "code": 400, - "message": "Failed to authenticate.", - "data": { - "email": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `}]),[_,p,i,a,u]}class Oe extends qe{constructor(l){super(),we(this,l,je,Ue,Pe,{collection:0})}}export{Oe as default}; diff --git a/ui/dist/assets/SdkTabs-9b0b7a06.css b/ui/dist/assets/SdkTabs-9b0b7a06.css deleted file mode 100644 index 64e6ba64fd..0000000000 --- a/ui/dist/assets/SdkTabs-9b0b7a06.css +++ /dev/null @@ -1 +0,0 @@ -.sdk-tabs.svelte-1maocj6 .tabs-header .tab-item.svelte-1maocj6{min-width:100px} diff --git a/ui/dist/assets/SdkTabs-cf570492.js b/ui/dist/assets/SdkTabs-cf570492.js deleted file mode 100644 index ca51bb7595..0000000000 --- a/ui/dist/assets/SdkTabs-cf570492.js +++ /dev/null @@ -1 +0,0 @@ -import{S as B,i as F,s as J,O as j,e as v,b as S,f as h,g as w,h as m,P as D,Q as O,k as Q,R as Y,n as z,t as N,a as P,o as C,w as E,r as y,u as A,x as q,N as G,c as H,m as L,d as U}from"./index-bd739ba3.js";function K(o,e,l){const s=o.slice();return s[6]=e[l],s}function R(o,e,l){const s=o.slice();return s[6]=e[l],s}function T(o,e){let l,s,g=e[6].title+"",r,i,n,k;function c(){return e[5](e[6])}return{key:o,first:null,c(){l=v("button"),s=v("div"),r=E(g),i=S(),h(s,"class","txt"),h(l,"class","tab-item svelte-1maocj6"),y(l,"active",e[1]===e[6].language),this.first=l},m(_,f){w(_,l,f),m(l,s),m(s,r),m(l,i),n||(k=A(l,"click",c),n=!0)},p(_,f){e=_,f&4&&g!==(g=e[6].title+"")&&q(r,g),f&6&&y(l,"active",e[1]===e[6].language)},d(_){_&&C(l),n=!1,k()}}}function I(o,e){let l,s,g,r,i,n,k=e[6].title+"",c,_,f,p,d;return s=new G({props:{language:e[6].language,content:e[6].content}}),{key:o,first:null,c(){l=v("div"),H(s.$$.fragment),g=S(),r=v("div"),i=v("em"),n=v("a"),c=E(k),_=E(" SDK"),p=S(),h(n,"href",f=e[6].url),h(n,"target","_blank"),h(n,"rel","noopener noreferrer"),h(i,"class","txt-sm txt-hint"),h(r,"class","txt-right"),h(l,"class","tab-item svelte-1maocj6"),y(l,"active",e[1]===e[6].language),this.first=l},m(b,t){w(b,l,t),L(s,l,null),m(l,g),m(l,r),m(r,i),m(i,n),m(n,c),m(n,_),m(l,p),d=!0},p(b,t){e=b;const a={};t&4&&(a.language=e[6].language),t&4&&(a.content=e[6].content),s.$set(a),(!d||t&4)&&k!==(k=e[6].title+"")&&q(c,k),(!d||t&4&&f!==(f=e[6].url))&&h(n,"href",f),(!d||t&6)&&y(l,"active",e[1]===e[6].language)},i(b){d||(N(s.$$.fragment,b),d=!0)},o(b){P(s.$$.fragment,b),d=!1},d(b){b&&C(l),U(s)}}}function V(o){let e,l,s=[],g=new Map,r,i,n=[],k=new Map,c,_,f=j(o[2]);const p=t=>t[6].language;for(let t=0;tt[6].language;for(let t=0;tl(1,n=c.language);return o.$$set=c=>{"class"in c&&l(0,g=c.class),"js"in c&&l(3,r=c.js),"dart"in c&&l(4,i=c.dart)},o.$$.update=()=>{o.$$.dirty&2&&n&&localStorage.setItem(M,n),o.$$.dirty&24&&l(2,s=[{title:"JavaScript",language:"javascript",content:r,url:"https://github.com/pocketbase/js-sdk"},{title:"Dart",language:"dart",content:i,url:"https://github.com/pocketbase/dart-sdk"}])},[g,n,s,r,i,k]}class Z extends B{constructor(e){super(),F(this,e,W,V,J,{class:0,js:3,dart:4})}}export{Z as S}; diff --git a/ui/dist/assets/UnlinkExternalAuthDocs-0e96ba04.js b/ui/dist/assets/UnlinkExternalAuthDocs-0e96ba04.js deleted file mode 100644 index 441e54460f..0000000000 --- a/ui/dist/assets/UnlinkExternalAuthDocs-0e96ba04.js +++ /dev/null @@ -1,72 +0,0 @@ -import{S as Oe,i as De,s as Me,O as j,e as i,w as g,b as f,c as Be,f as b,g as d,h as a,m as Ue,x as I,P as Ae,Q as We,k as ze,R as He,n as Le,t as oe,a as ae,o as u,d as qe,C as Re,p as je,r as N,u as Ie,N as Ne}from"./index-bd739ba3.js";import{S as Ke}from"./SdkTabs-cf570492.js";function Ce(n,l,o){const s=n.slice();return s[5]=l[o],s}function Te(n,l,o){const s=n.slice();return s[5]=l[o],s}function Ee(n,l){let o,s=l[5].code+"",_,h,c,p;function m(){return l[4](l[5])}return{key:n,first:null,c(){o=i("button"),_=g(s),h=f(),b(o,"class","tab-item"),N(o,"active",l[1]===l[5].code),this.first=o},m($,P){d($,o,P),a(o,_),a(o,h),c||(p=Ie(o,"click",m),c=!0)},p($,P){l=$,P&4&&s!==(s=l[5].code+"")&&I(_,s),P&6&&N(o,"active",l[1]===l[5].code)},d($){$&&u(o),c=!1,p()}}}function Se(n,l){let o,s,_,h;return s=new Ne({props:{content:l[5].body}}),{key:n,first:null,c(){o=i("div"),Be(s.$$.fragment),_=f(),b(o,"class","tab-item"),N(o,"active",l[1]===l[5].code),this.first=o},m(c,p){d(c,o,p),Ue(s,o,null),a(o,_),h=!0},p(c,p){l=c;const m={};p&4&&(m.content=l[5].body),s.$set(m),(!h||p&6)&&N(o,"active",l[1]===l[5].code)},i(c){h||(oe(s.$$.fragment,c),h=!0)},o(c){ae(s.$$.fragment,c),h=!1},d(c){c&&u(o),qe(s)}}}function Qe(n){var _e,ke,ge,ve;let l,o,s=n[0].name+"",_,h,c,p,m,$,P,M=n[0].name+"",K,se,ne,Q,F,A,G,E,J,w,W,ie,z,y,ce,V,H=n[0].name+"",X,re,Y,de,Z,ue,L,x,S,ee,B,te,U,le,C,q,v=[],pe=new Map,me,O,k=[],be=new Map,T;A=new Ke({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${n[3]}'); - - ... - - await pb.collection('${(_e=n[0])==null?void 0:_e.name}').authWithPassword('test@example.com', '123456'); - - await pb.collection('${(ke=n[0])==null?void 0:ke.name}').unlinkExternalAuth( - pb.authStore.model.id, - 'google' - ); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${n[3]}'); - - ... - - await pb.collection('${(ge=n[0])==null?void 0:ge.name}').authWithPassword('test@example.com', '123456'); - - await pb.collection('${(ve=n[0])==null?void 0:ve.name}').unlinkExternalAuth( - pb.authStore.model.id, - 'google', - ); - `}});let R=j(n[2]);const he=e=>e[5].code;for(let e=0;ee[5].code;for(let e=0;eAuthorization:TOKEN header",x=f(),S=i("div"),S.textContent="Path Parameters",ee=f(),B=i("table"),B.innerHTML=`Param Type Description id String ID of the auth record. provider String The name of the auth provider to unlink, eg. google, twitter, - github, etc.`,te=f(),U=i("div"),U.textContent="Responses",le=f(),C=i("div"),q=i("div");for(let e=0;eo(1,h=m.code);return n.$$set=m=>{"collection"in m&&o(0,_=m.collection)},o(3,s=Re.getApiExampleUrl(je.baseUrl)),o(2,c=[{code:204,body:"null"},{code:401,body:` - { - "code": 401, - "message": "The request requires valid record authorization token to be set.", - "data": {} - } - `},{code:403,body:` - { - "code": 403, - "message": "The authorized record model is not allowed to perform this action.", - "data": {} - } - `},{code:404,body:` - { - "code": 404, - "message": "The requested resource wasn't found.", - "data": {} - } - `}]),[_,h,c,s,p]}class Ve extends Oe{constructor(l){super(),De(this,l,Fe,Qe,Me,{collection:0})}}export{Ve as default}; diff --git a/ui/dist/assets/UpdateApiDocs-42fbf2e9.js b/ui/dist/assets/UpdateApiDocs-42fbf2e9.js deleted file mode 100644 index fbc7700aea..0000000000 --- a/ui/dist/assets/UpdateApiDocs-42fbf2e9.js +++ /dev/null @@ -1,90 +0,0 @@ -import{S as $t,i as Mt,s as qt,C as I,O as Z,N as Ot,e as r,w as b,b as f,c as he,f as v,g as i,h as s,m as ye,x as J,P as Ee,Q as _t,k as Ht,R as Rt,n as Dt,t as ce,a as pe,o as d,d as ke,p as Lt,r as ve,u as Pt,y as ee}from"./index-bd739ba3.js";import{S as Ft}from"./SdkTabs-cf570492.js";import{F as Nt}from"./FieldsQueryParam-85406354.js";function ht(c,e,t){const n=c.slice();return n[8]=e[t],n}function yt(c,e,t){const n=c.slice();return n[8]=e[t],n}function kt(c,e,t){const n=c.slice();return n[13]=e[t],n}function vt(c){let e;return{c(){e=r("p"),e.innerHTML=`Note that in case of a password change all previously issued tokens for the current record - will be automatically invalidated and if you want your user to remain signed in you need to - reauthenticate manually after the update call.`},m(t,n){i(t,e,n)},d(t){t&&d(e)}}}function gt(c){let e;return{c(){e=r("p"),e.innerHTML="Requires admin Authorization:TOKEN header",v(e,"class","txt-hint txt-sm txt-right")},m(t,n){i(t,e,n)},d(t){t&&d(e)}}}function wt(c){let e,t,n,u,m,o,p,h,w,S,g,$,P,E,M,U,F;return{c(){e=r("tr"),e.innerHTML='Auth fields',t=f(),n=r("tr"),n.innerHTML='
Optional username
String The username of the auth record.',u=f(),m=r("tr"),m.innerHTML=`
Optional email
String The auth record email address. -
- This field can be updated only by admins or auth records with "Manage" access. -
- Regular accounts can update their email by calling "Request email change".`,o=f(),p=r("tr"),p.innerHTML='
Optional emailVisibility
Boolean Whether to show/hide the auth record email when fetching the record data.',h=f(),w=r("tr"),w.innerHTML=`
Optional oldPassword
String Old auth record password. -
- This field is required only when changing the record password. Admins and auth records with - "Manage" access can skip this field.`,S=f(),g=r("tr"),g.innerHTML='
Optional password
String New auth record password.',$=f(),P=r("tr"),P.innerHTML='
Optional passwordConfirm
String New auth record password confirmation.',E=f(),M=r("tr"),M.innerHTML=`
Optional verified
Boolean Indicates whether the auth record is verified or not. -
- This field can be set only by admins or auth records with "Manage" access.`,U=f(),F=r("tr"),F.innerHTML='Schema fields'},m(y,_){i(y,e,_),i(y,t,_),i(y,n,_),i(y,u,_),i(y,m,_),i(y,o,_),i(y,p,_),i(y,h,_),i(y,w,_),i(y,S,_),i(y,g,_),i(y,$,_),i(y,P,_),i(y,E,_),i(y,M,_),i(y,U,_),i(y,F,_)},d(y){y&&(d(e),d(t),d(n),d(u),d(m),d(o),d(p),d(h),d(w),d(S),d(g),d($),d(P),d(E),d(M),d(U),d(F))}}}function Bt(c){let e;return{c(){e=r("span"),e.textContent="Optional",v(e,"class","label label-warning")},m(t,n){i(t,e,n)},d(t){t&&d(e)}}}function jt(c){let e;return{c(){e=r("span"),e.textContent="Required",v(e,"class","label label-success")},m(t,n){i(t,e,n)},d(t){t&&d(e)}}}function At(c){var m;let e,t=((m=c[13].options)==null?void 0:m.maxSelect)>1?"ids":"id",n,u;return{c(){e=b("User "),n=b(t),u=b(".")},m(o,p){i(o,e,p),i(o,n,p),i(o,u,p)},p(o,p){var h;p&1&&t!==(t=((h=o[13].options)==null?void 0:h.maxSelect)>1?"ids":"id")&&J(n,t)},d(o){o&&(d(e),d(n),d(u))}}}function Et(c){var m;let e,t=((m=c[13].options)==null?void 0:m.maxSelect)>1?"ids":"id",n,u;return{c(){e=b("Relation record "),n=b(t),u=b(".")},m(o,p){i(o,e,p),i(o,n,p),i(o,u,p)},p(o,p){var h;p&1&&t!==(t=((h=o[13].options)==null?void 0:h.maxSelect)>1?"ids":"id")&&J(n,t)},d(o){o&&(d(e),d(n),d(u))}}}function Ut(c){let e,t,n,u,m;return{c(){e=b("File object."),t=r("br"),n=b(` - Set to `),u=r("code"),u.textContent="null",m=b(" to delete already uploaded file(s).")},m(o,p){i(o,e,p),i(o,t,p),i(o,n,p),i(o,u,p),i(o,m,p)},p:ee,d(o){o&&(d(e),d(t),d(n),d(u),d(m))}}}function It(c){let e;return{c(){e=b("URL address.")},m(t,n){i(t,e,n)},p:ee,d(t){t&&d(e)}}}function Jt(c){let e;return{c(){e=b("Email address.")},m(t,n){i(t,e,n)},p:ee,d(t){t&&d(e)}}}function Vt(c){let e;return{c(){e=b("JSON array or object.")},m(t,n){i(t,e,n)},p:ee,d(t){t&&d(e)}}}function Qt(c){let e;return{c(){e=b("Number value.")},m(t,n){i(t,e,n)},p:ee,d(t){t&&d(e)}}}function xt(c){let e;return{c(){e=b("Plain text value.")},m(t,n){i(t,e,n)},p:ee,d(t){t&&d(e)}}}function Tt(c,e){let t,n,u,m,o,p=e[13].name+"",h,w,S,g,$=I.getFieldValueType(e[13])+"",P,E,M,U;function F(C,O){return C[13].required?jt:Bt}let y=F(e),_=y(e);function N(C,O){if(C[13].type==="text")return xt;if(C[13].type==="number")return Qt;if(C[13].type==="json")return Vt;if(C[13].type==="email")return Jt;if(C[13].type==="url")return It;if(C[13].type==="file")return Ut;if(C[13].type==="relation")return Et;if(C[13].type==="user")return At}let B=N(e),T=B&&B(e);return{key:c,first:null,c(){t=r("tr"),n=r("td"),u=r("div"),_.c(),m=f(),o=r("span"),h=b(p),w=f(),S=r("td"),g=r("span"),P=b($),E=f(),M=r("td"),T&&T.c(),U=f(),v(u,"class","inline-flex"),v(g,"class","label"),this.first=t},m(C,O){i(C,t,O),s(t,n),s(n,u),_.m(u,null),s(u,m),s(u,o),s(o,h),s(t,w),s(t,S),s(S,g),s(g,P),s(t,E),s(t,M),T&&T.m(M,null),s(t,U)},p(C,O){e=C,y!==(y=F(e))&&(_.d(1),_=y(e),_&&(_.c(),_.m(u,m))),O&1&&p!==(p=e[13].name+"")&&J(h,p),O&1&&$!==($=I.getFieldValueType(e[13])+"")&&J(P,$),B===(B=N(e))&&T?T.p(e,O):(T&&T.d(1),T=B&&B(e),T&&(T.c(),T.m(M,null)))},d(C){C&&d(t),_.d(),T&&T.d()}}}function Ct(c,e){let t,n=e[8].code+"",u,m,o,p;function h(){return e[7](e[8])}return{key:c,first:null,c(){t=r("button"),u=b(n),m=f(),v(t,"class","tab-item"),ve(t,"active",e[1]===e[8].code),this.first=t},m(w,S){i(w,t,S),s(t,u),s(t,m),o||(p=Pt(t,"click",h),o=!0)},p(w,S){e=w,S&4&&n!==(n=e[8].code+"")&&J(u,n),S&6&&ve(t,"active",e[1]===e[8].code)},d(w){w&&d(t),o=!1,p()}}}function St(c,e){let t,n,u,m;return n=new Ot({props:{content:e[8].body}}),{key:c,first:null,c(){t=r("div"),he(n.$$.fragment),u=f(),v(t,"class","tab-item"),ve(t,"active",e[1]===e[8].code),this.first=t},m(o,p){i(o,t,p),ye(n,t,null),s(t,u),m=!0},p(o,p){e=o;const h={};p&4&&(h.content=e[8].body),n.$set(h),(!m||p&6)&&ve(t,"active",e[1]===e[8].code)},i(o){m||(ce(n.$$.fragment,o),m=!0)},o(o){pe(n.$$.fragment,o),m=!1},d(o){o&&d(t),ke(n)}}}function zt(c){var ct,pt,ut;let e,t,n=c[0].name+"",u,m,o,p,h,w,S,g=c[0].name+"",$,P,E,M,U,F,y,_,N,B,T,C,O,ue,Ue,fe,Y,Ie,ge,me=c[0].name+"",we,Je,Te,Ve,Ce,te,Se,le,Oe,ne,$e,V,Me,Qe,Q,qe,j=[],xe=new Map,He,ae,Re,x,De,ze,se,z,Le,Ke,Pe,We,q,Ye,G,Ge,Xe,Ze,Fe,et,Ne,tt,Be,lt,nt,X,je,ie,Ae,K,de,A=[],at=new Map,st,oe,H=[],it=new Map,W,R=c[6]&&vt();N=new Ft({props:{js:` -import PocketBase from 'pocketbase'; - -const pb = new PocketBase('${c[4]}'); - -... - -// example update data -const data = ${JSON.stringify(Object.assign({},c[3],I.dummyCollectionSchemaData(c[0])),null,4)}; - -const record = await pb.collection('${(ct=c[0])==null?void 0:ct.name}').update('RECORD_ID', data); - `,dart:` -import 'package:pocketbase/pocketbase.dart'; - -final pb = PocketBase('${c[4]}'); - -... - -// example update body -final body = ${JSON.stringify(Object.assign({},c[3],I.dummyCollectionSchemaData(c[0])),null,2)}; - -final record = await pb.collection('${(pt=c[0])==null?void 0:pt.name}').update('RECORD_ID', body: body); - `}});let D=c[5]&>(),L=c[6]&&wt(),be=Z((ut=c[0])==null?void 0:ut.schema);const dt=l=>l[13].name;for(let l=0;ll[8].code;for(let l=0;l<_e.length;l+=1){let a=yt(c,_e,l),k=ot(a);at.set(k,A[l]=Ct(k,a))}let re=Z(c[2]);const rt=l=>l[8].code;for(let l=0;lapplication/json or - multipart/form-data.`,U=f(),F=r("p"),F.innerHTML=`File upload is supported only via multipart/form-data. -
- For more info and examples you could check the detailed - Files upload and handling docs - .`,y=f(),R&&R.c(),_=f(),he(N.$$.fragment),B=f(),T=r("h6"),T.textContent="API details",C=f(),O=r("div"),ue=r("strong"),ue.textContent="PATCH",Ue=f(),fe=r("div"),Y=r("p"),Ie=b("/api/collections/"),ge=r("strong"),we=b(me),Je=b("/records/"),Te=r("strong"),Te.textContent=":id",Ve=f(),D&&D.c(),Ce=f(),te=r("div"),te.textContent="Path parameters",Se=f(),le=r("table"),le.innerHTML='Param Type Description id String ID of the record to update.',Oe=f(),ne=r("div"),ne.textContent="Body Parameters",$e=f(),V=r("table"),Me=r("thead"),Me.innerHTML='Param Type Description',Qe=f(),Q=r("tbody"),L&&L.c(),qe=f();for(let l=0;lParam Type Description',ze=f(),se=r("tbody"),z=r("tr"),Le=r("td"),Le.textContent="expand",Ke=f(),Pe=r("td"),Pe.innerHTML='String',We=f(),q=r("td"),Ye=b(`Auto expand relations when returning the updated record. Ex.: - `),he(G.$$.fragment),Ge=b(` - Supports up to 6-levels depth nested relations expansion. `),Xe=r("br"),Ze=b(` - The expanded relations will be appended to the record under the - `),Fe=r("code"),Fe.textContent="expand",et=b(" property (eg. "),Ne=r("code"),Ne.textContent='"expand": {"relField1": {...}, ...}',tt=b(`). Only - the relations that the user has permissions to `),Be=r("strong"),Be.textContent="view",lt=b(" will be expanded."),nt=f(),he(X.$$.fragment),je=f(),ie=r("div"),ie.textContent="Responses",Ae=f(),K=r("div"),de=r("div");for(let l=0;l${JSON.stringify(Object.assign({},l[3],I.dummyCollectionSchemaData(l[0])),null,2)}; - -final record = await pb.collection('${(mt=l[0])==null?void 0:mt.name}').update('RECORD_ID', body: body); - `),N.$set(k),(!W||a&1)&&me!==(me=l[0].name+"")&&J(we,me),l[5]?D||(D=gt(),D.c(),D.m(O,null)):D&&(D.d(1),D=null),l[6]?L||(L=wt(),L.c(),L.m(Q,qe)):L&&(L.d(1),L=null),a&1&&(be=Z((bt=l[0])==null?void 0:bt.schema),j=Ee(j,a,dt,1,l,be,xe,Q,_t,Tt,null,kt)),a&6&&(_e=Z(l[2]),A=Ee(A,a,ot,1,l,_e,at,de,_t,Ct,null,yt)),a&6&&(re=Z(l[2]),Ht(),H=Ee(H,a,rt,1,l,re,it,oe,Rt,St,null,ht),Dt())},i(l){if(!W){ce(N.$$.fragment,l),ce(G.$$.fragment,l),ce(X.$$.fragment,l);for(let a=0;at(1,p=g.code);return c.$$set=g=>{"collection"in g&&t(0,o=g.collection)},c.$$.update=()=>{var g,$;c.$$.dirty&1&&t(6,n=(o==null?void 0:o.type)==="auth"),c.$$.dirty&1&&t(5,u=(o==null?void 0:o.updateRule)===null),c.$$.dirty&1&&t(2,h=[{code:200,body:JSON.stringify(I.dummyCollectionRecord(o),null,2)},{code:400,body:` - { - "code": 400, - "message": "Failed to update record.", - "data": { - "${($=(g=o==null?void 0:o.schema)==null?void 0:g[0])==null?void 0:$.name}": { - "code": "validation_required", - "message": "Missing required value." - } - } - } - `},{code:403,body:` - { - "code": 403, - "message": "You are not allowed to perform this request.", - "data": {} - } - `},{code:404,body:` - { - "code": 404, - "message": "The requested resource wasn't found.", - "data": {} - } - `}]),c.$$.dirty&1&&(o.type==="auth"?t(3,w={username:"test_username_update",emailVisibility:!1,password:"87654321",passwordConfirm:"87654321",oldPassword:"12345678"}):t(3,w={}))},t(4,m=I.getApiExampleUrl(Lt.baseUrl)),[o,p,h,w,m,u,n,S]}class Xt extends $t{constructor(e){super(),Mt(this,e,Kt,zt,qt,{collection:0})}}export{Xt as default}; diff --git a/ui/dist/assets/ViewApiDocs-ef899903.js b/ui/dist/assets/ViewApiDocs-ef899903.js deleted file mode 100644 index 8029a849eb..0000000000 --- a/ui/dist/assets/ViewApiDocs-ef899903.js +++ /dev/null @@ -1,59 +0,0 @@ -import{S as lt,i as nt,s as st,N as tt,O as K,e as o,w as _,b as m,c as W,f as b,g as r,h as l,m as X,x as ve,P as Je,Q as ot,k as at,R as it,n as rt,t as Q,a as U,o as d,d as Y,C as Ke,p as dt,r as Z,u as ct}from"./index-bd739ba3.js";import{S as pt}from"./SdkTabs-cf570492.js";import{F as ut}from"./FieldsQueryParam-85406354.js";function We(a,n,s){const i=a.slice();return i[6]=n[s],i}function Xe(a,n,s){const i=a.slice();return i[6]=n[s],i}function Ye(a){let n;return{c(){n=o("p"),n.innerHTML="Requires admin Authorization:TOKEN header",b(n,"class","txt-hint txt-sm txt-right")},m(s,i){r(s,n,i)},d(s){s&&d(n)}}}function Ze(a,n){let s,i,v;function p(){return n[5](n[6])}return{key:a,first:null,c(){s=o("button"),s.textContent=`${n[6].code} `,b(s,"class","tab-item"),Z(s,"active",n[2]===n[6].code),this.first=s},m(c,f){r(c,s,f),i||(v=ct(s,"click",p),i=!0)},p(c,f){n=c,f&20&&Z(s,"active",n[2]===n[6].code)},d(c){c&&d(s),i=!1,v()}}}function et(a,n){let s,i,v,p;return i=new tt({props:{content:n[6].body}}),{key:a,first:null,c(){s=o("div"),W(i.$$.fragment),v=m(),b(s,"class","tab-item"),Z(s,"active",n[2]===n[6].code),this.first=s},m(c,f){r(c,s,f),X(i,s,null),l(s,v),p=!0},p(c,f){n=c,(!p||f&20)&&Z(s,"active",n[2]===n[6].code)},i(c){p||(Q(i.$$.fragment,c),p=!0)},o(c){U(i.$$.fragment,c),p=!1},d(c){c&&d(s),Y(i)}}}function ft(a){var je,Ve;let n,s,i=a[0].name+"",v,p,c,f,w,C,ee,j=a[0].name+"",te,$e,le,F,ne,x,se,$,V,ye,z,T,we,oe,G=a[0].name+"",ae,Ce,ie,Fe,re,B,de,A,ce,I,pe,R,ue,Re,M,O,fe,Oe,me,Pe,h,De,E,Te,Ee,Se,be,xe,_e,Be,Ae,Ie,he,Me,qe,S,ke,q,ge,P,H,y=[],He=new Map,Le,L,k=[],Ne=new Map,D;F=new pt({props:{js:` - import PocketBase from 'pocketbase'; - - const pb = new PocketBase('${a[3]}'); - - ... - - const record = await pb.collection('${(je=a[0])==null?void 0:je.name}').getOne('RECORD_ID', { - expand: 'relField1,relField2.subRelField', - }); - `,dart:` - import 'package:pocketbase/pocketbase.dart'; - - final pb = PocketBase('${a[3]}'); - - ... - - final record = await pb.collection('${(Ve=a[0])==null?void 0:Ve.name}').getOne('RECORD_ID', - expand: 'relField1,relField2.subRelField', - ); - `}});let g=a[1]&&Ye();E=new tt({props:{content:"?expand=relField1,relField2.subRelField"}}),S=new ut({});let J=K(a[4]);const Qe=e=>e[6].code;for(let e=0;ee[6].code;for(let e=0;eParam Type Description id String ID of the record to view.',ce=m(),I=o("div"),I.textContent="Query parameters",pe=m(),R=o("table"),ue=o("thead"),ue.innerHTML='Param Type Description',Re=m(),M=o("tbody"),O=o("tr"),fe=o("td"),fe.textContent="expand",Oe=m(),me=o("td"),me.innerHTML='String',Pe=m(),h=o("td"),De=_(`Auto expand record relations. Ex.: - `),W(E.$$.fragment),Te=_(` - Supports up to 6-levels depth nested relations expansion. `),Ee=o("br"),Se=_(` - The expanded relations will be appended to the record under the - `),be=o("code"),be.textContent="expand",xe=_(" property (eg. "),_e=o("code"),_e.textContent='"expand": {"relField1": {...}, ...}',Be=_(`). - `),Ae=o("br"),Ie=_(` - Only the relations to which the request user has permissions to `),he=o("strong"),he.textContent="view",Me=_(" will be expanded."),qe=m(),W(S.$$.fragment),ke=m(),q=o("div"),q.textContent="Responses",ge=m(),P=o("div"),H=o("div");for(let e=0;es(2,c=C.code);return a.$$set=C=>{"collection"in C&&s(0,p=C.collection)},a.$$.update=()=>{a.$$.dirty&1&&s(1,i=(p==null?void 0:p.viewRule)===null),a.$$.dirty&3&&p!=null&&p.id&&(f.push({code:200,body:JSON.stringify(Ke.dummyCollectionRecord(p),null,2)}),i&&f.push({code:403,body:` - { - "code": 403, - "message": "Only admins can access this action.", - "data": {} - } - `}),f.push({code:404,body:` - { - "code": 404, - "message": "The requested resource wasn't found.", - "data": {} - } - `}))},s(3,v=Ke.getApiExampleUrl(dt.baseUrl)),[p,i,c,v,f,w]}class kt extends lt{constructor(n){super(),nt(this,n,mt,ft,st,{collection:0})}}export{kt as default}; diff --git a/ui/dist/assets/docsAuthRefresh-Cq3xTY6M.js b/ui/dist/assets/docsAuthRefresh-Cq3xTY6M.js new file mode 100644 index 0000000000..2467d3ff6a --- /dev/null +++ b/ui/dist/assets/docsAuthRefresh-Cq3xTY6M.js @@ -0,0 +1,49 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r))},null,2)},{title:401,value:` + { + "status": 401, + "message": "The request requires valid record authorization token to be set.", + "data": {} + } + `},{title:403,value:` + { + "status": 403, + "message": "The authorized record model is not allowed to perform this action.", + "data": {} + } + `},{title:404,value:` + { + "status": 404, + "message": "Missing auth record context.", + "data": {} + } + `}];return t.div({pbEvent:`apiPreviewAuthRefresh`,className:`content`},t.p(null,`Returns a new auth response (token and record data) for an already authenticated record.`),t.p(null,`This method is usually called by users on page/screen reload to ensure that the previously stored data in `,t.code(null,`pb.authStore`),` is still valid and up-to-date.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + const authData = await pb.collection('${r.name}').authRefresh(); + + // after the above you can also access the refreshed auth data from the authStore + console.log(pb.authStore.isValid); + console.log(pb.authStore.token); + console.log(pb.authStore.record.id); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + final authData = await pb.collection('${r.name}').authRefresh(); + + // after the above you can also access the refreshed auth data from the authStore + print(pb.authStore.isValid); + print(pb.authStore.token); + print(pb.authStore.record.id); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl -X POST \\ + -H 'Authorization:TOKEN' \\ + '${i}/api/collections/${r.name}/auth-refresh' + `}]}),t.div({className:`m-t-base`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${r.name}/auth-refresh`),t.small({className:`extra`},`Requires`,t.br(),`Authorization:TOKEN header`)),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:o}))}export{r as docsAuthRefresh}; \ No newline at end of file diff --git a/ui/dist/assets/docsAuthWithOAuth2-qI6si9uJ.js b/ui/dist/assets/docsAuthWithOAuth2-qI6si9uJ.js new file mode 100644 index 0000000000..5fae43050b --- /dev/null +++ b/ui/dist/assets/docsAuthWithOAuth2-qI6si9uJ.js @@ -0,0 +1,67 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a={collectionId:r.id,collectionName:r.name},o=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(a,app.utils.getDummyFieldsData(r)),meta:{id:`abc123`,name:`John Doe`,username:`john.doe`,email:`test@example.com`,avatarURL:`https://example.com/avatar.png`,accessToken:`...`,refreshToken:`...`,expiry:`2022-01-01 10:00:00.123Z`,isNew:!1,rawUser:{}}},null,2)},{title:400,value:` + { + "status": 400, + "message": "An error occurred while submitting the form.", + "data": { + "provider": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}];return t.div({pbEvent:`apiPreviewAuthWithOAuth2`,className:`content`},t.p(null,`Authenticate with an OAuth2 provider and returns a new auth token and record data.`),t.p(null,`For more details please check the `,t.a({href:`https://pocketbase.io/docs/authentication#authenticate-with-oauth2`,target:`_blank`,rel:`noopener noreferrer`,textContent:`OAuth2 integration documentation`}),`.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + // OAuth2 authentication with a single realtime call. + // + // Make sure to register ${i}/api/oauth2-redirect + // as redirect url in the OAuth2 app configuration. + const authData = await pb.collection('${r.name}').authWithOAuth2({ provider: 'google' }); + + // OR authenticate with manual OAuth2 code exchange + // const authData = await pb.collection('${r.name}').authWithOAuth2Code(...); + + // after the above you can also access the auth data from the authStore + console.log(pb.authStore.isValid); + console.log(pb.authStore.token); + console.log(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + import 'package:url_launcher/url_launcher.dart'; + + final pb = PocketBase('${i}'); + + ... + + // OAuth2 authentication with a single realtime call. + // + // Make sure to register ${i}/api/oauth2-redirect + // as redirect url in the OAuth2 app configuration. + final authData = await pb.collection('${r.name}').authWithOAuth2('google', (url) async { + await launchUrl(url); + }); + + // OR authenticate with manual OAuth2 code exchange + // final authData = await pb.collection('${r.name}').authWithOAuth2Code(...); + + // after the above you can also access the auth data from the authStore + print(pb.authStore.isValid); + print(pb.authStore.token); + print(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # authenticate with manual OAuth2 code exchange + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "provider":"google", "code":"OAUTH2_CODE", "codeVerifier":"...", "redirectURL":"..." }' \\ + '${i}/api/collections/${r.name}/auth-with-oauth2' + `}]}),t.div({className:`m-t-base`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${r.name}/auth-with-oauth2`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`provider `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The name of the OAuth2 client provider (eg. "google").`)),t.tr(null,t.td({className:`min-width`},`code `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The authorization code returned from the initial request.`)),t.tr(null,t.td({className:`min-width`},`codeVerifier `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The code verifier sent with the initial request as part of the code_challenge.`)),t.tr(null,t.td({className:`min-width`},`redirectURL `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The redirect url sent with the initial request.`)),t.tr(null,t.td({className:`min-width`},`createData `,t.em(null,`(optional)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,t.p(null,`Optional data that will be used when creating the auth record on OAuth2 sign-up.`),t.p(null,`The created auth record must comply with the same requirements and validations in the regular create action.`),t.p(null,`The data can only be in JSON, aka. user uploaded files currently are not supported during OAuth2 sign-ups.`))))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:o}))}export{r as docsAuthWithOAuth2}; \ No newline at end of file diff --git a/ui/dist/assets/docsAuthWithOTP-75iz1Xmg.js b/ui/dist/assets/docsAuthWithOTP-75iz1Xmg.js new file mode 100644 index 0000000000..c8c0e34afc --- /dev/null +++ b/ui/dist/assets/docsAuthWithOTP-75iz1Xmg.js @@ -0,0 +1,95 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(e){let n=app.utils.getApiExampleURL(),r=[{title:`OTP request`,content:i},{title:`OTP auth`,content:a}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewAuthWithOTP`,className:`content`},t.p(null,`Authenticate with an one-time/short-lived password (OTP).`),t.p(null,`On successful authentication the user will be also marked as verified (if the OTP source is email and the user is not verified already).`),t.p(null,`Note that when requesting an OTP we return an `,t.code(null,`otpId`),` even if a user with the provided email doesn't exist as a very basic enumeration protection.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${n}'); + + ... + + // send OTP email to the provided auth record + const req = await pb.collection('${e.name}').requestOTP('test@example.com'); + + // ... show a screen/popup to enter the password from the email ... + + // authenticate with the requested OTP id and the email password + const authData = await pb.collection('${e.name}').authWithOTP( + req.otpId, + "YOUR_OTP", + ); + + // after the above you can also access the auth data from the authStore + console.log(pb.authStore.isValid); + console.log(pb.authStore.token); + console.log(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${n}'); + + ... + + // send OTP email to the provided auth record + final req = await pb.collection('${e.name}').requestOTP('test@example.com'); + + // ... show a screen/popup to enter the password from the email ... + + // authenticate with the requested OTP id and the email password + final authData = await pb.collection('${e.name}').authWithOTP( + req.otpId, + "YOUR_OTP", + ); + + // after the above you can also access the auth data from the authStore + print(pb.authStore.isValid); + print(pb.authStore.token); + print(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # OTP request (sends email to the user if exists) + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "email":"..." }' \\ + '${n}/api/collections/${e.name}/request-otp' + + # OTP auth + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "otpId":"...", "password":"..." }' \\ + '${n}/api/collections/${e.name}/auth-with-otp' + `}]}),t.nav({className:`btns m-t-base m-b-sm`},()=>r.map((e,n)=>t.button({type:`button`,className:()=>`btn sm expanded ${o.activeActionIndex==n?`active`:`secondary`}`,textContent:()=>e.title,onclick:()=>o.activeActionIndex=n}))),()=>r[o.activeActionIndex]?.content?.(e))}function i(e){return[t.div(null,t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/request-otp`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`email `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The auth record email address to send the OTP request (if exists).`)))),t.div({className:`m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:200,value:` + { + "otpId": "njvv1b1lkdbpp3m" + } + `},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "email": { + "code": "validation_is_email", + "message": "Must be a valid email address." + } + } + } + `},{title:429,value:` + { + "status": 429, + "message": "You've send too many OTP requests, please try again later.", + "data": {} + } + `}]})]}function a(r){let i={collectionId:r.id,collectionName:r.name},a=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(i,app.utils.getDummyFieldsData(r))},null,2)},{title:400,value:` + { + "status": 400, + "message": "Failed to authenticate.", + "data": { + "otpId": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}];return[t.div(null,t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${r.name}/auth-with-otp`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`otpId `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The id of the OTP request.`)),t.tr(null,t.td({className:`min-width`},`password `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The one-time/short-lived password from the OTP request.`)))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:a})]}export{r as docsAuthWithOTP}; \ No newline at end of file diff --git a/ui/dist/assets/docsAuthWithPassword-NXiq7pZI.js b/ui/dist/assets/docsAuthWithPassword-NXiq7pZI.js new file mode 100644 index 0000000000..9b126ffaf0 --- /dev/null +++ b/ui/dist/assets/docsAuthWithPassword-NXiq7pZI.js @@ -0,0 +1,55 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a=r.passwordAuth?.identityFields||[],o=a.length==0?`NONE`:`YOUR_`+a.join(`_OR_`).toUpperCase(),s={collectionId:r.id,collectionName:r.name},c=[{title:200,value:JSON.stringify({token:`...JWT...`,record:Object.assign(s,app.utils.getDummyFieldsData(r))},null,2)},{title:400,value:` + { + "status": 400, + "message": "Failed to authenticate.", + "data": { + "identity": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}];return t.div({pbEvent:`apiPreviewAuthWithPassword`,className:`content`},t.p(null,`Authenticate with combination of `,t.strong(null,a.join(`/`)),` and `,t.strong(null,`password`),`.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + const authData = await pb.collection('${r.name}').authWithPassword( + '${o}', + 'YOUR_PASSWORD', + ); + + // after the above you can also access the auth data from the authStore + console.log(pb.authStore.isValid); + console.log(pb.authStore.token); + console.log(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + final authData = await pb.collection('${r.name}').authWithPassword( + '${o}', + 'YOUR_PASSWORD', + ); + + // after the above you can also access the auth data from the authStore + print(pb.authStore.isValid); + print(pb.authStore.token); + print(pb.authStore.record.id); + + // "logout" + pb.authStore.clear(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "identity":"${o}", "password":"YOUR_PASSWORD" }' \\ + '${i}/api/collections/${r.name}/auth-with-password' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${r.name}/auth-with-password`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`identity `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,app.utils.sentenize(a.join(` or `),!1),` of the record to authenticate.`)),t.tr(null,t.td({className:`min-width`},`identityField `,t.em(null,`(optional)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`In case of multiple identity fields, explicitly set the field name to use when searching for the auth record.`,t.br(),`Leave it empty for auto detection.`)),t.tr(null,t.td({className:`min-width`},`password `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The auth record password.`)))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:c}))}export{r as docsAuthWithPassword}; \ No newline at end of file diff --git a/ui/dist/assets/docsBatch-CQUyIsim.js b/ui/dist/assets/docsBatch-CQUyIsim.js new file mode 100644 index 0000000000..bc63bc4210 --- /dev/null +++ b/ui/dist/assets/docsBatch-CQUyIsim.js @@ -0,0 +1,92 @@ +function e(e){let n=app.utils.getApiExampleURL(),r={collectionId:e.id,collectionName:e.name},i=[{title:200,value:JSON.stringify([{status:200,body:Object.assign(r,app.utils.getDummyFieldsData(e))},{status:200,body:Object.assign(r,app.utils.getDummyFieldsData(e))}],null,2)},{title:400,value:` + { + "status": 400, + "message": "Batch transaction failed.", + "data": { + "requests": { + "1": { + "code": "batch_request_failed", + "message": "Batch request failed.", + "response": { + "status": 400, + "message": "Failed to create record.", + "data": { + "id": { + "code": "validation_min_text_constraint", + "message": "Must be at least 3 character(s).", + "params": { "min": 3 } + } + } + } + } + } + } + } + `},{title:403,value:` + { + "status": 403, + "message": "Batch requests are not allowed.", + "data": {} + } + `}];return t.div({pbEvent:`apiPreviewBatch`,className:`content`},t.p(null,`Batch and transactional create/update/upsert/delete of multiple records in a single request.`),t.div({className:`alert warning`},t.p({className:`txt-bold`},`The batch Web API need to be explicitly enabled and configured from the `,t.a({href:`#/settings`,target:`_blank`,title:`Open in new tab`,textContent:`App settings`}),`.`),t.p(null,`Because this endpoint process the requests in a single DB transaction it could degrade the performance of your application if not used with proper care and configuration (use smaller max processing and body size limits, avoid large file uploads over slow S3 networks and custom hooks that communicate with slow external APIs).`)),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${n}'); + + ... + + const batch = pb.createBatch(); + + batch.collection('${e.name}').create({ ... }); + batch.collection('${e.name}').update('RECORD_ID', { ... }); + batch.collection('${e.name}').delete('RECORD_ID'); + batch.collection('${e.name}').upsert({ ... }); + + const result = await batch.send(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${n}'); + + ... + + final batch = pb.createBatch(); + + batch.collection('${e.name}').create(body: { ... }); + batch.collection('${e.name}').update('RECORD_ID', body: { ... }); + batch.collection('${e.name}').delete('RECORD_ID'); + batch.collection('${e.name}').upsert(body: { ... }); + + final result = await batch.send(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl -X POST \\ + -H 'Authorization:TOKEN' \\ + -H 'Content-Type:application/json' \\ + -d '{ "requests": [...] }' \\ + '${n}/api/batch' + `}]}),t.div({className:`block m-t-sm`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/batch`)),t.p(null,`The request accepts only 1 required `,t.code(null,`requests: Array`),` parameter that defines the list of the batch requests to process.`),t.p(null,`When using the official SDKs the batch requests are transparently constructed by their service handler.`),t.p(null,`For the cases when you don't use the SDKs, the she supported batch request actions are:`),t.ul(null,t.li(null,`record create - `,t.code(null,`POST /api/collections/{collection}/records`)),t.li(null,`record update - `,t.code(null,`PATCH /api/collections/{collection}/records`)),t.li(null,`record upsert - `,t.code(null,`PUT /api/collections/{collection}/records`),t.br(),t.small({className:`txt-hint`},`(the body must have an "id" field)`)),t.li(null,`record delete - `,t.code(null,`DELETE /api/collections/{collection}/records/{id}`))),t.p(null,`Each batch `,t.em(null,`Request`),` element has the following properties:`),t.ul(null,t.li(null,t.code(null,`url`),t.em(null,` (could include query parameters)`)),t.li(null,t.code(null,`method`),t.em(null,` (GET, POST, PUT, PATCH, DELETE)`)),t.li(null,t.code(null,`headers`),t.br(),t.em(null,`(custom per-request Authorization header is not supported at the moment, aka. all batch requests have the same auth state)`)),t.li(null,t.code(null,`body`),t.br(),`When the batch request is send as `,t.code(null,`multipart/form-data`),`, the regular batch action fields are expected to be submitted as serialized json under the `,t.code(null,`@jsonPayload`),` field and file keys need to follow the pattern `,t.code(null,`requests.N.fileField`),` or `,t.code(null,`requests[N].fileField`),`.`,t.br(),`Again this is handled transparently by the official SDKs, but for example if you prefer to manually construct a JS `,t.code(null,`FormData`),` body, then it could look something like:`,app.components.codeBlock({className:`m-t-10`,value:` + const batchBody = new FormData(); + + batchBody.append("@jsonPayload", JSON.stringify({ + requests: [ + // create + { + url: "/api/collections/users/records?expand=someRelField", + method: "POST", + body: { someField: "test1" } + }, + // update + { + url: "/api/collections/users/records/RECORD_ID", + method: "PATCH", + body: { someField: "test2" } + } + ] + })) + + // bind file to the first request + batchBody.append("requests.0.someFileField", new File(...)) + + // bind file to the second request + batchBody.append("requests.1.someFileField", new File(...)) + `}))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:i}))}export{e as docsBatch}; \ No newline at end of file diff --git a/ui/dist/assets/docsCreate-Ap21j9FE.js b/ui/dist/assets/docsCreate-Ap21j9FE.js new file mode 100644 index 0000000000..b986160c62 --- /dev/null +++ b/ui/dist/assets/docsCreate-Ap21j9FE.js @@ -0,0 +1,60 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let s=app.utils.getApiExampleURL(),c=r.createRule===null,l=r.type===`auth`,u=l?[`password`,`verified`,`email`,`emailVisibility`]:[],d=r.fields?.filter(e=>!e.hidden&&e.type!=`autodate`&&!u.includes(e.name))||[],f={collectionId:r.id,collectionName:r.name},p=[{title:200,value:JSON.stringify(Object.assign(f,app.utils.getDummyFieldsData(r)),null,2)},{title:400,value:` + { + "status": 400, + "message": "Failed to create record.", + "data": { + "${l?`email`:d.find(e=>!e.primaryKey)?.name||`someField`}": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}];return c&&p.push({title:403,value:` + { + "status": 403, + "message": "Only superusers can perform this action.", + "data": {} + } + `}),t.div({pbEvent:`apiPreviewCreate`,className:`content`},t.p(null,`Creates a new ${r.name} record.`),t.p(null,`Body parameters could be sent as `,t.code(null,`application/json`),` or `,t.code(null,`multipart/form-data`),`.`),t.p(null,`File upload is supported only via `,t.code(null,`multipart/form-data`),`. For more info and examples you could check the detailed `,t.a({href:`https://pocketbase.io/docs/files-handling`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Files upload and handling docs`}),`.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` +import PocketBase from 'pocketbase'; + +const pb = new PocketBase('${s}'); + +... + +// example create body +const body = ${i(JSON.stringify(a(r),null,2))}; + +const record = await pb.collection('${r.name}').create(body); +`+(l?` +// (optional) send an email verification request +await pb.collection('${r?.name}').requestVerification(record.email); +`:``),footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` +import 'package:pocketbase/pocketbase.dart'; + +final pb = PocketBase('${s}'); + +... + +// example create body +final body = ${JSON.stringify(o(r),null,2)}; + +final record = await pb.collection('${r.name}').create(body: body, files: []); +`+(l?` +// (optional) send an email verification request +await pb.collection('${r?.name}').requestVerification( + record.get('email'), +); +`:``),footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` +curl -X POST \\ + -H 'Authorization:TOKEN' \\ + -H 'Content-Type:application/json' \\ + -d '{ ... }' \\ + '${s}/api/collections/${r.name}/records' +`+(l?` +# (optional) send an email verification request +curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "email":"test@example.com" }' \\ + '${s}/api/collections/${r.name}/request-verification' +`:``)}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${r.name}/records`),()=>{if(c)return t.small({className:`extra`},`Requires superuser Authorization:TOKEN header`)}),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,()=>{if(l)return[t.tr(null,t.th({colSpan:99},`Auth specific fields`)),t.tr(null,t.td({className:`min-width`},`email `,()=>r.fields?.find(e=>e.name==`email`)?.required?t.em(null,`(required)`):t.em(null,`(optional)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`Auth record email address.`)),t.tr(null,t.td({className:`min-width`},`emailVisibility `,()=>r.fields?.find(e=>e.name==`emailVisibility`)?.required?t.em(null,`(required)`):t.em(null,`(optional)`)),t.td({className:`min-width`},t.span({className:`label`},`Boolean`)),t.td(null,`Whether to show/hide the auth record email when fetching the record data.`,t.br(),`Superusers and the owner of the record always have access to the email address.`)),t.tr(null,t.td({className:`min-width`},`password `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`Auth record password.`)),t.tr(null,t.td({className:`min-width`},`passwordConfirm `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`Auth record password confirmation.`)),t.tr(null,t.td({className:`min-width`},`verified `,t.em(null,`(optional)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,t.p(null,`Indicates whether the auth record is verified or not.`),t.p(null,`This field can be set only by superusers or auth records with "Manage" access.`))),t.tr(null,t.th({colSpan:99},`Other fields`))]},()=>d.map(e=>t.tr(null,t.td({className:`min-width`},e.name,t.em(null,e.required&&!e.autogeneratePattern?` (required)`:` (optional)`)),t.td({className:`min-width`},t.span({className:`label`},()=>{let n=app.fieldTypes[e.type]?.dummyData(e,!0),r=typeof n;return e.type==`file`?`File`:r===`string`?`String`:r==`number`?`Number`:r==`bool`?`Boolean`:Array.isArray(n)?`Array`:app.utils.isObject(n)?`Object`:`Mixed`})),t.td(null,t.code(null,e.type),` field type value.`,t.br(),t.small({className:`txt-hint`},`For more details you could check the `,t.a({href:`https://pocketbase.io/docs/collections/#fields`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Fields docs`}),`.`)))))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:p}))}function i(e){return e.replaceAll(`"[[`,``).replaceAll(`]]"`,``)}function a(e,n=!1){let r=app.utils.getDummyFieldsData(e,!0);return delete r.id,e.type==`auth`&&(n&&(r.oldPassword=`987654321`,delete r.email),r.password=`123456789`,r.passwordConfirm=`123456789`,delete r.verified),r}function o(e,n=!1){let r=a(e,n);for(let e in r){let n=typeof r[e];(r[e]?.startsWith?.(`[[`)||![`number`,`string`,`boolean`].includes(n)&&!Array.isArray(r[e]))&&delete r[e]}return r}export{r as docsCreate,a as fullDummyPayload,o as primitivesDummyPayload,i as replaceDummyPayloadPlaceholder}; \ No newline at end of file diff --git a/ui/dist/assets/docsDelete-CeK6pY9P.js b/ui/dist/assets/docsDelete-CeK6pY9P.js new file mode 100644 index 0000000000..9fc1493c07 --- /dev/null +++ b/ui/dist/assets/docsDelete-CeK6pY9P.js @@ -0,0 +1,39 @@ +function e(e){let n=app.utils.getApiExampleURL(),r=e.deleteRule===null,i=[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "Failed to delete record. Make sure that the record is not part of a required relation reference.", + "data": {} + } + `}];return r&&i.push({title:403,value:` + { + "status": 403, + "message": "Only superusers can access this action.", + "data": {} + } + `}),i.push({title:404,value:` + { + "status": 404, + "message": "The requested resource wasn't found.", + "data": {} + } + `}),t.div({pbEvent:`apiPreviewDelete`,className:`content`},t.p(null,`Delete a single ${e.name} record.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${n}'); + + ... + + await pb.collection('${e.name}').delete('RECORD_ID'); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${n}'); + + ... + + await pb.collection('${e.name}').delete('RECORD_ID'); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl -X DELETE \\ + -H 'Authorization:TOKEN' \\ + '${n}/api/collections/${e.name}/records/RECORD_ID' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert danger api-preview-alert`},t.span({className:`label method`},`DELETE`),t.span({className:`path`},`/api/collections/${e.name}/records/`,t.strong(null,`:id`)),()=>{if(r)return t.small({className:`extra`},`Requires superuser Authorization:TOKEN header`)}),t.table({className:`api-preview-table path-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Path params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`id`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`ID of the record to delete.`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:i}))}export{e as docsDelete}; \ No newline at end of file diff --git a/ui/dist/assets/docsEmailChange-VWbqXOVy.js b/ui/dist/assets/docsEmailChange-VWbqXOVy.js new file mode 100644 index 0000000000..adbfea2a16 --- /dev/null +++ b/ui/dist/assets/docsEmailChange-VWbqXOVy.js @@ -0,0 +1,99 @@ +function e(e){let i=app.utils.getApiExampleURL(),a=[{title:`Request email change`,content:n},{title:`Confirm email change`,content:r}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewEmailChange`,className:`content`},t.p(null,`Sends ${e.name} email change request.`),t.p(null,`On successful email change all previously issued auth tokens for the specific record will be invalidated (and the user will be marked as verified if not already).`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').authWithPassword( + 'test@example.com', + '1234567890' + ); + + await pb.collection('${e.name}').requestEmailChange('new@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + // note: all previous user auth tokens will be invalidated + // (and the user will be marked as verified if not already) + await pb.collection('${e.name}').confirmEmailChange( + 'EMAIL_CHANGE_TOKEN', + 'YOUR_PASSWORD', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').authWithPassword( + 'test@example.com', + '1234567890' + ); + + await pb.collection('${e.name}').requestEmailChange('new@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + // note: all previous user auth tokens will be invalidated + // (and the user will be marked as verified if not already) + await pb.collection('${e.name}').confirmEmailChange( + 'EMAIL_CHANGE_TOKEN', + 'YOUR_PASSWORD', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # Request email change + curl -X POST \\ + -H 'Authorization:TOKEN' \\ + -H 'Content-Type:application/json' \\ + -d '{ "newEmail":"..." }' \\ + '${i}/api/collections/${e.name}/request-email-change' + + # Confirm email change + # + # note: all previous user auth tokens will be invalidated + # (and the user will be marked as verified if not already) + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "token":"...", "password":"" }' \\ + '${i}/api/collections/${e.name}/confirm-email-change' + `}]}),t.nav({className:`btns m-t-base m-b-sm`},()=>a.map((e,n)=>t.button({type:`button`,className:()=>`btn sm expanded ${o.activeActionIndex==n?`active`:`secondary`}`,textContent:()=>e.title,onclick:()=>o.activeActionIndex=n}))),()=>a[o.activeActionIndex]?.content?.(e))}function n(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/request-email-change`),t.small({className:`extra`},`Requires`,t.br(),`Authorization:TOKEN header`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`newEmail `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The new email address to send the change email request.`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "newEmail": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `},{title:401,value:` + { + "status": 401, + "message": "The request requires valid record authorization token to be set.", + "data": {} + } + `},{title:403,value:` + { + "status": 403, + "message": "The authorized record model is not allowed to perform this action.", + "data": {} + } + `}]})]}function r(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/confirm-email-change`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`token `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The token from the change email request email.`)),t.tr(null,t.td({className:`min-width`},`password `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The account password to confirm the email change.`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "token": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}]})]}export{e as docsEmailChange}; \ No newline at end of file diff --git a/ui/dist/assets/docsList-DsIHfo2q.js b/ui/dist/assets/docsList-DsIHfo2q.js new file mode 100644 index 0000000000..0044f1cef1 --- /dev/null +++ b/ui/dist/assets/docsList-DsIHfo2q.js @@ -0,0 +1,64 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(){let e=store({show:!1});return t.div({className:`filter-details m-t-10`},t.button({type:`button`,className:`btn secondary sm`,onclick:()=>e.show=!e.show},()=>e.show?[t.span({className:`txt`},`Hide details`),t.i({className:`ri-arrow-up-s-line`,ariaHidden:!0})]:[t.span({className:`txt`},`Show details`),t.i({className:`ri-arrow-down-s-line`,ariaHidden:!0})]),app.components.slide(()=>e.show,t.div({className:`block p-t-5`},t.p(null,`The filter syntax follows the format `,t.code(null,t.span({className:`txt-success`},`OPERAND`),t.span({className:`txt-danger`},` OPERATOR `),t.span({className:`txt-success`},`OPERAND`)),`, where:`),t.ul(null,t.li(null,t.span({className:`txt-code txt-success`},`OPERAND`),` could be any of the above field literal, function, string (single or double quoted), number, null, true, false`),t.li(null,t.span({className:`txt-code txt-danger`},`OPERATOR`),` is one of:`,t.ul(null,t.li(null,t.code({className:`filter-op`},`=`),` Equal`),t.li(null,t.code({className:`filter-op`},`!=`),` Not equal`),t.li(null,t.code({className:`filter-op`},`>`),` Greater than`),t.li(null,t.code({className:`filter-op`},`>=`),` Greater than or equal`),t.li(null,t.code({className:`filter-op`},`<`),` Less than`),t.li(null,t.code({className:`filter-op`},`<=`),` Less than or equal`),t.li(null,t.code({className:`filter-op`},`~`),` Like/Contains`,t.div({className:`txt-sm txt-hint`},t.em(null,`(auto wraps the right string OPERAND in a "%" for wildcard match if not explicitly set)`))),t.li(null,t.code({className:`filter-op`},`!~`),` NOT Like/Contains`,t.div({className:`txt-sm txt-hint`},t.em(null,`(auto wraps the right string OPERAND in a "%" for wildcard match if not explicitly set)`))),t.li(null,t.code({className:`filter-op`},`?=`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Equal`),t.li(null,t.code({className:`filter-op`},`?!=`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Not equal`),t.li(null,t.code({className:`filter-op`},`?>`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Greater than`),t.li(null,t.code({className:`filter-op`},`?>=`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Greater than or equal`),t.li(null,t.code({className:`filter-op`},`?<`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Less than`),t.li(null,t.code({className:`filter-op`},`?<=`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Less than or equal`),t.li(null,t.code({className:`filter-op`},`?~`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` Like/Contains`,t.div({className:`txt-sm txt-hint`},t.em(null,`(auto wraps the right string OPERAND in a "%" for wildcard match if not explicitly set)`))),t.li(null,t.code({className:`filter-op`},`?!~`),t.span({className:`txt-hint`},` Any/At-least-one-of`),` NOT Like/Contains`,t.div({className:`txt-sm txt-hint`},t.em(null,`(auto wraps the right string OPERAND in a "%" for wildcard match if not explicitly set)`)))),t.p(null,`To group and combine several expressions you could use brackets `,t.code(null,`(...)`),`, `,t.code(null,`&&`),`, (AND) and `,t.code(null,`||`),` (OR) tokens.`))))))}function i(i){let a=app.utils.getApiExampleURL(),o=i.listRule===null,s={collectionId:i.id,collectionName:i.name},c=[{title:200,value:JSON.stringify({page:1,perPage:30,totalPages:1,totalItems:2,items:[Object.assign(s,app.utils.getDummyFieldsData(i)),Object.assign(s,app.utils.getDummyFieldsData(i))]},null,2)},{title:400,value:` + { + "status": 400, + "message": "Something went wrong while processing your request.", + "data": {} + } + `}];return o&&c.push({title:403,value:` + { + "status": 403, + "message": "Only superusers can access this action.", + "data": {} + } + `}),t.div({pbEvent:`apiPreviewList`,className:`content`},t.p(null,`Fetch a paginated ${i.name} records list, supporting sorting and filtering.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${a}'); + + ... + + // fetch a paginated records list + const resultList = await pb.collection('${i.name}').getList(1, 50, { + filter: 'someField1 != someField2', + }); + + // you can also fetch all records at once via getFullList + const records = await pb.collection('${i.name}').getFullList({ + sort: '-someField', + }); + + // or fetch only the first record that matches the specified filter + const record = await pb.collection('${i.name}').getFirstListItem( + 'someField="test"', + { expand: 'relField1,relField2.subRelField' }, + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${a}'); + + ... + + // fetch a paginated records list + final resultList = await pb.collection('${i.name}').getList( + page: 1, + perPage: 50, + filter: 'someField1 != someField2', + ); + + // you can also fetch all records at once via getFullList + final records = await pb.collection('${i.name}').getFullList( + sort: '-someField', + ); + + // or fetch only the first record that matches the specified filter + final record = await pb.collection('${i.name}').getFirstListItem( + 'someField="test"', + expand: 'relField1,relField2.subRelField', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl \\ + -H 'Authorization:TOKEN' \\ + '${a}/api/collections/${i.name}/records?perPage=50' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert info api-preview-alert`},t.span({className:`label method`},`GET`),t.span({className:`path`},`/api/collections/${i.name}/records`),()=>{if(o)return t.small({className:`extra`},`Requires superuser Authorization:TOKEN header`)}),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`page`),t.td({className:`min-width`},t.span({className:`label`},`Number`)),t.td(null,`The page (aka. offset) of the paginated list (default to 1).`)),t.tr(null,t.td({className:`min-width`},`perPage`),t.td({className:`min-width`},t.span({className:`label`},`Number`)),t.td(null,`Specify the max returned records per page (default to 30).`)),t.tr(null,t.td({className:`min-width`},`sort`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,t.p(null,`Specify the records order attribute(s).`,t.br(),`Add -/+ (default) in front of the attribute for DESC / ASC order.`),t.p(null,`For example:`,app.components.codeBlock({value:`// DESC by created and ASC by id +?sort=-created,id`})),t.p(null,`In addition to the collection non-hidden fields, the following special sort fields could be also used: `,t.code(null,`@random`),` `,t.code({hidden:()=>i.type==`view`},`@rowid`),`.`))),t.tr(null,t.td({className:`min-width`},`filter`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,t.p(null,`Filter the returned records. For example:`),app.components.codeBlock({value:`?filter=(id='abc' && created>'2022-01-01')`,footnote:`All query params must be properly URL encoded (the SDKs do this automatically).`}),r())),t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())),t.tr(null,t.td({className:`min-width`},`skipTotal`),t.td({className:`min-width`},t.span({className:`label`},`Boolean`)),t.td(null,t.p(null,`If set to `,t.code(null,`1/true`),` the total counts query will be skipped and the response fields `,t.code(null,`totalItems`),` and `,t.code(null,`totalPages`),` will have -1 value.`),t.p(null,`This could drastically speed up the search queries when the total counters are not needed or cursor based pagination is used.`,` For optimization purposes, it is set by default in the `,t.code(null,`getFirstListItem()`),` and `,t.code(null,`getFullList()`),` SDKs methods.`))))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:c}))}export{i as docsList}; \ No newline at end of file diff --git a/ui/dist/assets/docsListAuthMethods-CYETXzX7.js b/ui/dist/assets/docsListAuthMethods-CYETXzX7.js new file mode 100644 index 0000000000..7088b0ab71 --- /dev/null +++ b/ui/dist/assets/docsListAuthMethods-CYETXzX7.js @@ -0,0 +1,25 @@ +import{t as e}from"./fieldsInfo-CiLcXgNq.js";function n(n){let r=app.utils.getApiExampleURL(),i=store({isLoading:!1,authMethods:[],get responses(){return[{title:200,value:i.isLoading?`...`:JSON.stringify(i.authMethods,null,2)},{title:404,value:` + { + "status": 404, + "message": "Missing collection context.", + "data": {} + } + `}]}});async function a(){i.isLoading=!0;try{i.authMethods=await app.pb.collection(n.name).listAuthMethods(),i.isLoading=!1}catch(e){e?.isAbort||(app.checkApiError(e),i.isLoading=!1)}}return t.div({pbEvent:`apiPreviewListAuthMethods`,className:`content`,onmount:()=>{a()}},t.p(null,`Returns a public list with all allowed ${n.name} authentication methods.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${r}'); + + ... + + const result = await pb.collection('${n.name}').listAuthMethods(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${r}'); + + ... + + final result = await pb.collection('${n.name}').listAuthMethods(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl '${r}/api/collections/${n.name}/auth-methods' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert info api-preview-alert`},t.span({className:`label method`},`GET`),t.span({className:`path`},`/api/collections/${n.name}/auth-methods`)),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:()=>i.responses}))}export{n as docsListAuthMethods}; \ No newline at end of file diff --git a/ui/dist/assets/docsPasswordReset-UqmNW8HA.js b/ui/dist/assets/docsPasswordReset-UqmNW8HA.js new file mode 100644 index 0000000000..3284b8e2cd --- /dev/null +++ b/ui/dist/assets/docsPasswordReset-UqmNW8HA.js @@ -0,0 +1,78 @@ +function e(e){let i=app.utils.getApiExampleURL(),a=[{title:`Request password reset`,content:n},{title:`Confirm password reset`,content:r}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewPasswordReset`,className:`content`},t.p(null,`Sends ${e.name} password reset email request.`),t.p(null,`On successful password reset all previously issued auth tokens for the specific record will be invalidated (and the user will be marked as verified if not already).`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').requestPasswordReset('test@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + // note: all previous user auth tokens will be invalidated + // (and the user will be marked as verified if not already) + await pb.collection('${e.name}').confirmPasswordReset( + 'RESET_TOKEN', + 'NEW_PASSWORD', + 'NEW_PASSWORD_CONFIRM', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').requestPasswordReset('test@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + // note: all previous user auth tokens will be invalidated + // (and the user will be marked as verified if not already) + await pb.collection('${e.name}').confirmPasswordReset( + 'RESET_TOKEN', + 'NEW_PASSWORD', + 'NEW_PASSWORD_CONFIRM', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # Request password reset + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "email":"..." }' \\ + '${i}/api/collections/${e.name}/request-password-reset' + + # Confirm password reset + # + # note: all previous user auth tokens will be invalidated + # (and the user will be marked as verified if not already) + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "token":"...", "password":"", "passwordConfirm":"" }' \\ + '${i}/api/collections/${e.name}/confirm-password-reset' + `}]}),t.nav({className:`btns m-t-base m-b-sm`},()=>a.map((e,n)=>t.button({type:`button`,className:()=>`btn sm expanded ${o.activeActionIndex==n?`active`:`secondary`}`,textContent:()=>e.title,onclick:()=>o.activeActionIndex=n}))),()=>a[o.activeActionIndex]?.content?.(e))}function n(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/request-password-reset`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`email `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The auth record email address to send the password reset request (if exists).`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "email": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}]})]}function r(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/confirm-password-reset`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`token `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The token from the password reset request email.`)),t.tr(null,t.td({className:`min-width`},`password `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The new password to set.`)),t.tr(null,t.td({className:`min-width`},`passwordConfirm `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`Confirmation of the new password.`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "token": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}]})]}export{e as docsPasswordReset}; \ No newline at end of file diff --git a/ui/dist/assets/docsRealtime-BR9-cDED.js b/ui/dist/assets/docsRealtime-BR9-cDED.js new file mode 100644 index 0000000000..ba8d9f34d1 --- /dev/null +++ b/ui/dist/assets/docsRealtime-BR9-cDED.js @@ -0,0 +1,81 @@ +function e(e){let n=app.utils.getApiExampleURL(),r=Object.assign({collectionId:e.id,collectionName:e.name},app.utils.getDummyFieldsData(e));return t.div({pbEvent:`apiPreviewRealtime`,className:`content`},t.p(null,`Subscribe to realtime changes via Server-Sent Events (SSE).`),t.p(null,`Events are sent for `,t.strong(null,`create`),`, `,t.strong(null,`update`),` and `,t.strong(null,`delete`),` record operations (see "Event data format" below).`),t.div({className:`alert info`},t.p({className:`txt-bold`},`You could subscribe to a single record or to an entire collection.`),t.p(null,`When you subscribe to a `,t.strong(null,`single record`),`, the collection's `,t.strong(null,`View rule`),` will be used to determine whether the subscriber is allowed to receive the event message.`),t.p(null,`When you subscribe to an `,t.strong(null,`entire collection`),`, the collection's `,t.strong(null,`List/Search rule`),` will be used to determine whether the subscriber is allowed to receive the event message.`)),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${n}'); + + ... + + // (optionally) authenticate + await pb.collection('users').authWithPassword('test@example.com', '123456'); + + // subscribe to changes in any ${e.name} record + pb.collection('${e.name}').subscribe('*', function (e) { + console.log(e.action); + console.log(e.record); + }, { /* other options like: filter, expand, custom headers, etc. */ }); + + // subscribe to changes only in the specified record + pb.collection('${e.name}').subscribe('RECORD_ID', function (e) { + console.log(e.action); + console.log(e.record); + }, { /* other options like: filter, expand, custom headers, etc. */ }); + + ... + + // unsubscribe - remove all 'RECORD_ID' subscriptions + pb.collection('${e.name}').unsubscribe('RECORD_ID'); + + // unsubscribe - remove all '*' topic subscriptions + pb.collection('${e.name}').unsubscribe('*'); + + // unsubscribe - remove all collection subscriptions + pb.collection('${e.name}').unsubscribe(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${n}'); + + ... + + // (optionally) authenticate + await pb.collection('users').authWithPassword('test@example.com', '123456'); + + // subscribe to changes in any ${e.name} record + pb.collection('${e.name}').subscribe('*', (e) { + print(e.action); + print(e.record); + }, /* other options like: filter, expand, custom headers, etc. */); + + // subscribe to changes only in the specified record + pb.collection('${e.name}').subscribe('RECORD_ID', (e) { + print(e.action); + print(e.record); + }, /* other options like: filter, expand, custom headers, etc. */); + + ... + + // unsubscribe - remove all 'RECORD_ID' subscriptions + pb.collection('${e.name}').unsubscribe('RECORD_ID'); + + // unsubscribe - remove all '*' topic subscriptions + pb.collection('${e.name}').unsubscribe('*'); + + // unsubscribe - remove all collection subscriptions + pb.collection('${e.name}').unsubscribe(); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # init an SSE connection and start listening for messages + # (the first message is always PB_CONNECT with the connection "clientId") + curl -N '${n}/api/realtime' + + # open a new terminal and submit the subscription topic(s) + # with the "clientId" from the initial PB_CONNECT message + curl -X POST \\ + -H 'Authorization:TOKEN' \\ + -H 'Content-Type:application/json' \\ + -d '{ "clientId": "YOUR_CLIENT_ID", "subscriptions": ["${e.name}/*"] }' \\ + '${n}/api/realtime' + + # create/update/delete a record in the ${e.name} collection and + # you should see the event message(s) in the first terminal + # (as long as your client satisfies the topic API rule) + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert api-preview-alert`},t.span({className:`label method`},`GET/POST`),t.span({className:`path`},`/api/realtime`),t.div({className:`extra`},t.a({href:`https://pocketbase.io/docs/api-realtime/`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Realtime docs`}))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Event data format`)),app.components.codeBlock({value:JSON.stringify({action:`create`,record:r},null,2).replace(`"action": "create",`,`"action": "create", // create, update or delete`)}))}export{e as docsRealtime}; \ No newline at end of file diff --git a/ui/dist/assets/docsUpdate-DAnNYWFv.js b/ui/dist/assets/docsUpdate-DAnNYWFv.js new file mode 100644 index 0000000000..dd7a4cf2d1 --- /dev/null +++ b/ui/dist/assets/docsUpdate-DAnNYWFv.js @@ -0,0 +1,56 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";import{fullDummyPayload as r,primitivesDummyPayload as i,replaceDummyPayloadPlaceholder as a}from"./docsCreate-Ap21j9FE.js";function o(o){let s=app.utils.getApiExampleURL(),c=o.updateRule===null,l=o.type===`auth`?[`id`,`password`,`verified`,`email`,`emailVisibility`]:[`id`],u=o.fields?.filter(e=>!e.hidden&&e.type!=`autodate`&&!l.includes(e.name))||[],d={collectionId:o.id,collectionName:o.name},f=[{title:200,value:JSON.stringify(Object.assign(d,app.utils.getDummyFieldsData(o)),null,2)},{title:400,value:` + { + "status": 400, + "message": "Failed to create record.", + "data": { + "${u.find(e=>!e.primaryKey)?.name||`someField`}": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}];return c&&f.push({title:403,value:` + { + "status": 403, + "message": "Only superusers can perform this action.", + "data": {} + } + `}),f.push({title:404,value:` + { + "status": 404, + "message": "The requested resource wasn't found.", + "data": {} + } + `}),t.div({pbEvent:`apiPreviewUpdate`,className:`content`},t.p(null,`Updates an existing ${o.name} record.`),t.p(null,`Body parameters could be sent as `,t.code(null,`application/json`),` or `,t.code(null,`multipart/form-data`),`.`),t.p(null,`File upload is supported only via `,t.code(null,`multipart/form-data`),`. For more info and examples you could check the detailed `,t.a({href:`https://pocketbase.io/docs/files-handling`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Files upload and handling docs`}),`.`),t.p(null,t.em(null,`Note that in case of a password change all previously issued tokens for the current record will be automatically invalidated and if you want your user to remain signed in you need to reauthenticate manually after the update call.`)),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` +import PocketBase from 'pocketbase'; + +const pb = new PocketBase('${s}'); + +... + +// example update body +const body = ${a(JSON.stringify(r(o,!0),null,2))}; + +const record = await pb.collection('${o.name}').update('RECORD_ID', body); +`,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` +import 'package:pocketbase/pocketbase.dart'; + +final pb = PocketBase('${s}'); + +... + +// example update body +final body = ${JSON.stringify(i(o,!0),null,2)}; + +final record = await pb.collection('${o.name}').update( + 'RECORD_ID', + body: body, + files: [], +); +`,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl -X PATCH \\ + -H 'Authorization:TOKEN' \\ + -H 'Content-Type:application/json' \\ + -d '{ ... }' \\ + '${s}/api/collections/${o.name}/records/RECORD_ID' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert warning api-preview-alert`},t.span({className:`label method`},`PATCH`),t.span({className:`path`},`/api/collections/${o.name}/records/`,t.strong(null,`:id`)),()=>{if(c)return t.small({className:`extra`},`Requires superuser Authorization:TOKEN header`)}),t.table({className:`api-preview-table path-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Path params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`id`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`ID of the record to update.`)))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:f}))}export{o as docsUpdate}; \ No newline at end of file diff --git a/ui/dist/assets/docsVerification-CIbF1DIL.js b/ui/dist/assets/docsVerification-CIbF1DIL.js new file mode 100644 index 0000000000..f94d13bea5 --- /dev/null +++ b/ui/dist/assets/docsVerification-CIbF1DIL.js @@ -0,0 +1,63 @@ +function e(e){let i=app.utils.getApiExampleURL(),a=[{title:`Request verification`,content:n},{title:`Confirm verification`,content:r}],o=store({activeActionIndex:0});return t.div({pbEvent:`apiPreviewVerification`,className:`content`},t.p(null,`Sends ${e.name} account verification request.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').requestVerification('test@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + await pb.collection('${e.name}').confirmVerification('VERIFICATION_TOKEN'); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + await pb.collection('${e.name}').requestVerification('test@example.com'); + + // --- + // (optional) in your custom confirmation page: + // --- + + await pb.collection('${e.name}').confirmVerification('VERIFICATION_TOKEN'); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + # Request verification + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "email":"..." }' \\ + '${i}/api/collections/${e.name}/request-verification' + + # Confirm verification + curl -X POST \\ + -H 'Content-Type:application/json' \\ + -d '{ "token":"..." }' \\ + '${i}/api/collections/${e.name}/confirm-verification' + `}]}),t.nav({className:`btns m-t-base m-b-sm`},()=>a.map((e,n)=>t.button({type:`button`,className:()=>`btn sm expanded ${o.activeActionIndex==n?`active`:`secondary`}`,textContent:()=>e.title,onclick:()=>o.activeActionIndex=n}))),()=>a[o.activeActionIndex]?.content?.(e))}function n(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/request-verification`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`email `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The auth record email address to send the verification request (if exists).`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "email": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}]})]}function r(e){return[t.div({className:`block`},t.strong(null,`API details`)),t.div({className:`alert success api-preview-alert`},t.span({className:`label method`},`POST`),t.span({className:`path`},`/api/collections/${e.name}/confirm-verification`)),t.table({className:`api-preview-table body-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Body params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`token `,t.em(null,`(required)`)),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`The token from the verification request email.`)))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:[{title:204,value:`null`},{title:400,value:` + { + "status": 400, + "message": "An error occurred while validating the submitted data.", + "data": { + "token": { + "code": "validation_required", + "message": "Missing required value." + } + } + } + `}]})]}export{e as docsVerification}; \ No newline at end of file diff --git a/ui/dist/assets/docsView-oyuvqZP8.js b/ui/dist/assets/docsView-oyuvqZP8.js new file mode 100644 index 0000000000..14256842e6 --- /dev/null +++ b/ui/dist/assets/docsView-oyuvqZP8.js @@ -0,0 +1,37 @@ +import{t as e}from"./expandInfo-CJ9aeAaN.js";import{t as n}from"./fieldsInfo-CiLcXgNq.js";function r(r){let i=app.utils.getApiExampleURL(),a=r.viewRule===null,o={collectionId:r.id,collectionName:r.name},s=[{title:200,value:JSON.stringify(Object.assign(o,app.utils.getDummyFieldsData(r)),null,2)}];return a&&s.push({title:403,value:` + { + "status": 403, + "message": "Only superusers can access this action.", + "data": {} + } + `}),s.push({title:404,value:` + { + "status": 404, + "message": "The requested resource wasn't found.", + "data": {} + } + `}),t.div({pbEvent:`apiPreviewView`,className:`content`},t.p(null,`Fetch a single ${r.name} record.`),app.components.codeBlockTabs({className:`sdk-examples m-t-sm`,historyKey:`pbLastSDK`,tabs:[{title:`JS SDK`,language:`js`,value:` + import PocketBase from 'pocketbase'; + + const pb = new PocketBase('${i}'); + + ... + + const record = await pb.collection('${r.name}').getOne('RECORD_ID', { + expand: 'relField1,relField2.subRelField', + }); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/js-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`JS SDK docs`}))},{title:`Dart SDK`,language:`dart`,value:` + import 'package:pocketbase/pocketbase.dart'; + + final pb = PocketBase('${i}'); + + ... + + final record = await pb.collection('${r.name}').getOne('RECORD_ID', + expand: 'relField1,relField2.subRelField', + ); + `,footnote:t.div({className:`txt-right`},t.a({href:`https://github.com/pocketbase/dart-sdk`,target:`_blank`,rel:`noopener noreferrer`,textContent:`Dart SDK docs`}))},{title:`curl`,language:`bash`,value:` + curl \\ + -H 'Authorization:TOKEN' \\ + '${i}/api/collections/${r.name}/records/RECORD_ID' + `}]}),t.div({className:`block m-t-base`},t.strong(null,`API details`)),t.div({className:`alert info api-preview-alert`},t.span({className:`label method`},`GET`),t.span({className:`path`},`/api/collections/${r.name}/records/`,t.strong(null,`:id`)),()=>{if(a)return t.small({className:`extra`},`Requires superuser Authorization:TOKEN header`)}),t.table({className:`api-preview-table path-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`Path params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`id`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,`ID of the record to view.`)))),t.table({className:`api-preview-table query-params`},t.thead(null,t.tr(null,t.th({className:`min-width txt-primary`},`?query params`),t.th({className:`min-width`},`Type`),t.th(null,`Description`))),t.tbody(null,t.tr(null,t.td({className:`min-width`},`expand`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,e())),t.tr(null,t.td({className:`min-width`},`fields`),t.td({className:`min-width`},t.span({className:`label`},`String`)),t.td(null,n())))),t.div({className:`block m-t-base m-b-sm`},t.strong(null,`Example responses`)),app.components.codeBlockTabs({tabs:s}))}export{r as docsView}; \ No newline at end of file diff --git a/ui/dist/assets/expandInfo-CJ9aeAaN.js b/ui/dist/assets/expandInfo-CJ9aeAaN.js new file mode 100644 index 0000000000..9c5d2e3a8e --- /dev/null +++ b/ui/dist/assets/expandInfo-CJ9aeAaN.js @@ -0,0 +1 @@ +function e(){return t.div({className:`api-expand-info`},t.p(null,`Auto expand record relations. For example:`),app.components.codeBlock({value:`?expand=relField1,relField2.subRelField`}),t.p(null,`Supports up to 6-levels depth nested relations expansion.`,t.br(),`The expanded relations will be appended to each individual record under the `,t.code(null,`expand`),` property (eg. `,t.code(null,`"expand": {"relField1": {...}, ...}`),`).`),t.p(null,`Only the relations to which the request user has permissions to `,t.strong(null,`view`),` will be expanded.`))}export{e as t}; \ No newline at end of file diff --git a/ui/dist/assets/fieldsInfo-CiLcXgNq.js b/ui/dist/assets/fieldsInfo-CiLcXgNq.js new file mode 100644 index 0000000000..44aa70c99a --- /dev/null +++ b/ui/dist/assets/fieldsInfo-CiLcXgNq.js @@ -0,0 +1,3 @@ +function e(){return t.div({className:`api-fields-info`},t.p(null,`Comma separated string of the fields to return in the JSON response (by default returns all fields). For example:`),app.components.codeBlock({value:`// return all root level fields and only +// "relField.someField" from expand +?fields=*,expand.relField.someField`}),t.p(null,`Use `,t.code(null,`*`),` to target all keys from the specific depth level.`),t.p(null,`In addition, the following field modifiers are also supported:`),t.ul(null,t.li(null,t.code(null,`:excerpt(maxLength, withEllipsis?)`),t.br(),`Returns a short plain text version of the field string value. Ex.: `,t.code(null,`?fields=*,someTextField:excerpt(200,true)`))))}export{e as t}; \ No newline at end of file diff --git a/ui/dist/assets/index-51d240f2.css b/ui/dist/assets/index-51d240f2.css deleted file mode 100644 index 55d0768119..0000000000 --- a/ui/dist/assets/index-51d240f2.css +++ /dev/null @@ -1 +0,0 @@ -@charset "UTF-8";@font-face{font-family:remixicon;src:url(../fonts/remixicon/remixicon.woff2?v=1) format("woff2"),url(../fonts/remixicon/remixicon.woff?v=1) format("woff"),url(../fonts/remixicon/remixicon.ttf?v=1) format("truetype");font-display:swap}@font-face{font-family:Source Sans Pro;font-style:normal;font-weight:400;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-regular.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-regular.woff) format("woff")}@font-face{font-family:Source Sans Pro;font-style:italic;font-weight:400;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-italic.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-italic.woff) format("woff")}@font-face{font-family:Source Sans Pro;font-style:normal;font-weight:600;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-600.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-600.woff) format("woff")}@font-face{font-family:Source Sans Pro;font-style:italic;font-weight:600;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-600italic.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-600italic.woff) format("woff")}@font-face{font-family:Source Sans Pro;font-style:normal;font-weight:700;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-700.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-700.woff) format("woff")}@font-face{font-family:Source Sans Pro;font-style:italic;font-weight:700;src:local(""),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-700italic.woff2) format("woff2"),url(../fonts/source-sans-pro/source-sans-pro-v18-latin_cyrillic-700italic.woff) format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;src:local(""),url(../fonts/jetbrains-mono/jetbrains-mono-v12-latin-regular.woff2) format("woff2"),url(../fonts/jetbrains-mono/jetbrains-mono-v12-latin-regular.woff) format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:600;src:local(""),url(../fonts/jetbrains-mono/jetbrains-mono-v12-latin-600.woff2) format("woff2"),url(../fonts/jetbrains-mono/jetbrains-mono-v12-latin-600.woff) format("woff")}:root{--baseFontFamily: "Source Sans Pro", sans-serif, emoji;--monospaceFontFamily: "Ubuntu Mono", monospace, emoji;--iconFontFamily: "remixicon";--txtPrimaryColor: #16161a;--txtHintColor: #666f75;--txtDisabledColor: #a0a6ac;--primaryColor: #16161a;--bodyColor: #f8f9fa;--baseColor: #ffffff;--baseAlt1Color: #e4e9ec;--baseAlt2Color: #d7dde4;--baseAlt3Color: #c6cdd7;--baseAlt4Color: #a5b0c0;--infoColor: #5499e8;--infoAltColor: #cee2f8;--successColor: #32ad84;--successAltColor: #c4eedc;--dangerColor: #e34562;--dangerAltColor: #f7cad2;--warningColor: #ff944d;--warningAltColor: #ffd4b8;--overlayColor: rgba(53, 71, 104, .28);--tooltipColor: rgba(0, 0, 0, .85);--shadowColor: rgba(0, 0, 0, .06);--baseFontSize: 14.5px;--xsFontSize: 12px;--smFontSize: 13px;--lgFontSize: 15px;--xlFontSize: 16px;--baseLineHeight: 22px;--smLineHeight: 16px;--lgLineHeight: 24px;--inputHeight: 34px;--btnHeight: 40px;--xsBtnHeight: 22px;--smBtnHeight: 30px;--lgBtnHeight: 54px;--baseSpacing: 30px;--xsSpacing: 15px;--smSpacing: 20px;--lgSpacing: 50px;--xlSpacing: 60px;--wrapperWidth: 850px;--smWrapperWidth: 420px;--lgWrapperWidth: 1200px;--appSidebarWidth: 75px;--pageSidebarWidth: 12.5%;--baseAnimationSpeed: .15s;--activeAnimationSpeed: 70ms;--entranceAnimationSpeed: .25s;--baseRadius: 4px;--lgRadius: 12px;--btnRadius: 4px;accent-color:var(--primaryColor)}html,body,div,span,applet,object,iframe,h1,h2,.breadcrumbs .breadcrumb-item,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}i{font-family:remixicon!important;font-style:normal;font-weight:400;font-size:1.1238rem;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}i:before{vertical-align:top;margin-top:1px;display:inline-block}.ri-24-hours-fill:before{content:""}.ri-24-hours-line:before{content:""}.ri-4k-fill:before{content:""}.ri-4k-line:before{content:""}.ri-a-b:before{content:""}.ri-account-box-fill:before{content:""}.ri-account-box-line:before{content:""}.ri-account-circle-fill:before{content:""}.ri-account-circle-line:before{content:""}.ri-account-pin-box-fill:before{content:""}.ri-account-pin-box-line:before{content:""}.ri-account-pin-circle-fill:before{content:""}.ri-account-pin-circle-line:before{content:""}.ri-add-box-fill:before{content:""}.ri-add-box-line:before{content:""}.ri-add-circle-fill:before{content:""}.ri-add-circle-line:before{content:""}.ri-add-fill:before{content:""}.ri-add-line:before{content:""}.ri-admin-fill:before{content:""}.ri-admin-line:before{content:""}.ri-advertisement-fill:before{content:""}.ri-advertisement-line:before{content:""}.ri-airplay-fill:before{content:""}.ri-airplay-line:before{content:""}.ri-alarm-fill:before{content:""}.ri-alarm-line:before{content:""}.ri-alarm-warning-fill:before{content:""}.ri-alarm-warning-line:before{content:""}.ri-album-fill:before{content:""}.ri-album-line:before{content:""}.ri-alert-fill:before{content:""}.ri-alert-line:before{content:""}.ri-aliens-fill:before{content:""}.ri-aliens-line:before{content:""}.ri-align-bottom:before{content:""}.ri-align-center:before{content:""}.ri-align-justify:before{content:""}.ri-align-left:before{content:""}.ri-align-right:before{content:""}.ri-align-top:before{content:""}.ri-align-vertically:before{content:""}.ri-alipay-fill:before{content:""}.ri-alipay-line:before{content:""}.ri-amazon-fill:before{content:""}.ri-amazon-line:before{content:""}.ri-anchor-fill:before{content:""}.ri-anchor-line:before{content:""}.ri-ancient-gate-fill:before{content:""}.ri-ancient-gate-line:before{content:""}.ri-ancient-pavilion-fill:before{content:""}.ri-ancient-pavilion-line:before{content:""}.ri-android-fill:before{content:""}.ri-android-line:before{content:""}.ri-angularjs-fill:before{content:""}.ri-angularjs-line:before{content:""}.ri-anticlockwise-2-fill:before{content:""}.ri-anticlockwise-2-line:before{content:""}.ri-anticlockwise-fill:before{content:""}.ri-anticlockwise-line:before{content:""}.ri-app-store-fill:before{content:""}.ri-app-store-line:before{content:""}.ri-apple-fill:before{content:""}.ri-apple-line:before{content:""}.ri-apps-2-fill:before{content:""}.ri-apps-2-line:before{content:""}.ri-apps-fill:before{content:""}.ri-apps-line:before{content:""}.ri-archive-drawer-fill:before{content:""}.ri-archive-drawer-line:before{content:""}.ri-archive-fill:before{content:""}.ri-archive-line:before{content:""}.ri-arrow-down-circle-fill:before{content:""}.ri-arrow-down-circle-line:before{content:""}.ri-arrow-down-fill:before{content:""}.ri-arrow-down-line:before{content:""}.ri-arrow-down-s-fill:before{content:""}.ri-arrow-down-s-line:before{content:""}.ri-arrow-drop-down-fill:before{content:""}.ri-arrow-drop-down-line:before{content:""}.ri-arrow-drop-left-fill:before{content:""}.ri-arrow-drop-left-line:before{content:""}.ri-arrow-drop-right-fill:before{content:""}.ri-arrow-drop-right-line:before{content:""}.ri-arrow-drop-up-fill:before{content:""}.ri-arrow-drop-up-line:before{content:""}.ri-arrow-go-back-fill:before{content:""}.ri-arrow-go-back-line:before{content:""}.ri-arrow-go-forward-fill:before{content:""}.ri-arrow-go-forward-line:before{content:""}.ri-arrow-left-circle-fill:before{content:""}.ri-arrow-left-circle-line:before{content:""}.ri-arrow-left-down-fill:before{content:""}.ri-arrow-left-down-line:before{content:""}.ri-arrow-left-fill:before{content:""}.ri-arrow-left-line:before{content:""}.ri-arrow-left-right-fill:before{content:""}.ri-arrow-left-right-line:before{content:""}.ri-arrow-left-s-fill:before{content:""}.ri-arrow-left-s-line:before{content:""}.ri-arrow-left-up-fill:before{content:""}.ri-arrow-left-up-line:before{content:""}.ri-arrow-right-circle-fill:before{content:""}.ri-arrow-right-circle-line:before{content:""}.ri-arrow-right-down-fill:before{content:""}.ri-arrow-right-down-line:before{content:""}.ri-arrow-right-fill:before{content:""}.ri-arrow-right-line:before{content:""}.ri-arrow-right-s-fill:before{content:""}.ri-arrow-right-s-line:before{content:""}.ri-arrow-right-up-fill:before{content:""}.ri-arrow-right-up-line:before{content:""}.ri-arrow-up-circle-fill:before{content:""}.ri-arrow-up-circle-line:before{content:""}.ri-arrow-up-down-fill:before{content:""}.ri-arrow-up-down-line:before{content:""}.ri-arrow-up-fill:before{content:""}.ri-arrow-up-line:before{content:""}.ri-arrow-up-s-fill:before{content:""}.ri-arrow-up-s-line:before{content:""}.ri-artboard-2-fill:before{content:""}.ri-artboard-2-line:before{content:""}.ri-artboard-fill:before{content:""}.ri-artboard-line:before{content:""}.ri-article-fill:before{content:""}.ri-article-line:before{content:""}.ri-aspect-ratio-fill:before{content:""}.ri-aspect-ratio-line:before{content:""}.ri-asterisk:before{content:""}.ri-at-fill:before{content:""}.ri-at-line:before{content:""}.ri-attachment-2:before{content:""}.ri-attachment-fill:before{content:""}.ri-attachment-line:before{content:""}.ri-auction-fill:before{content:""}.ri-auction-line:before{content:""}.ri-award-fill:before{content:""}.ri-award-line:before{content:""}.ri-baidu-fill:before{content:""}.ri-baidu-line:before{content:""}.ri-ball-pen-fill:before{content:""}.ri-ball-pen-line:before{content:""}.ri-bank-card-2-fill:before{content:""}.ri-bank-card-2-line:before{content:""}.ri-bank-card-fill:before{content:""}.ri-bank-card-line:before{content:""}.ri-bank-fill:before{content:""}.ri-bank-line:before{content:""}.ri-bar-chart-2-fill:before{content:""}.ri-bar-chart-2-line:before{content:""}.ri-bar-chart-box-fill:before{content:""}.ri-bar-chart-box-line:before{content:""}.ri-bar-chart-fill:before{content:""}.ri-bar-chart-grouped-fill:before{content:""}.ri-bar-chart-grouped-line:before{content:""}.ri-bar-chart-horizontal-fill:before{content:""}.ri-bar-chart-horizontal-line:before{content:""}.ri-bar-chart-line:before{content:""}.ri-barcode-box-fill:before{content:""}.ri-barcode-box-line:before{content:""}.ri-barcode-fill:before{content:""}.ri-barcode-line:before{content:""}.ri-barricade-fill:before{content:""}.ri-barricade-line:before{content:""}.ri-base-station-fill:before{content:""}.ri-base-station-line:before{content:""}.ri-basketball-fill:before{content:""}.ri-basketball-line:before{content:""}.ri-battery-2-charge-fill:before{content:""}.ri-battery-2-charge-line:before{content:""}.ri-battery-2-fill:before{content:""}.ri-battery-2-line:before{content:""}.ri-battery-charge-fill:before{content:""}.ri-battery-charge-line:before{content:""}.ri-battery-fill:before{content:""}.ri-battery-line:before{content:""}.ri-battery-low-fill:before{content:""}.ri-battery-low-line:before{content:""}.ri-battery-saver-fill:before{content:""}.ri-battery-saver-line:before{content:""}.ri-battery-share-fill:before{content:""}.ri-battery-share-line:before{content:""}.ri-bear-smile-fill:before{content:""}.ri-bear-smile-line:before{content:""}.ri-behance-fill:before{content:""}.ri-behance-line:before{content:""}.ri-bell-fill:before{content:""}.ri-bell-line:before{content:""}.ri-bike-fill:before{content:""}.ri-bike-line:before{content:""}.ri-bilibili-fill:before{content:""}.ri-bilibili-line:before{content:""}.ri-bill-fill:before{content:""}.ri-bill-line:before{content:""}.ri-billiards-fill:before{content:""}.ri-billiards-line:before{content:""}.ri-bit-coin-fill:before{content:""}.ri-bit-coin-line:before{content:""}.ri-blaze-fill:before{content:""}.ri-blaze-line:before{content:""}.ri-bluetooth-connect-fill:before{content:""}.ri-bluetooth-connect-line:before{content:""}.ri-bluetooth-fill:before{content:""}.ri-bluetooth-line:before{content:""}.ri-blur-off-fill:before{content:""}.ri-blur-off-line:before{content:""}.ri-body-scan-fill:before{content:""}.ri-body-scan-line:before{content:""}.ri-bold:before{content:""}.ri-book-2-fill:before{content:""}.ri-book-2-line:before{content:""}.ri-book-3-fill:before{content:""}.ri-book-3-line:before{content:""}.ri-book-fill:before{content:""}.ri-book-line:before{content:""}.ri-book-mark-fill:before{content:""}.ri-book-mark-line:before{content:""}.ri-book-open-fill:before{content:""}.ri-book-open-line:before{content:""}.ri-book-read-fill:before{content:""}.ri-book-read-line:before{content:""}.ri-booklet-fill:before{content:""}.ri-booklet-line:before{content:""}.ri-bookmark-2-fill:before{content:""}.ri-bookmark-2-line:before{content:""}.ri-bookmark-3-fill:before{content:""}.ri-bookmark-3-line:before{content:""}.ri-bookmark-fill:before{content:""}.ri-bookmark-line:before{content:""}.ri-boxing-fill:before{content:""}.ri-boxing-line:before{content:""}.ri-braces-fill:before{content:""}.ri-braces-line:before{content:""}.ri-brackets-fill:before{content:""}.ri-brackets-line:before{content:""}.ri-briefcase-2-fill:before{content:""}.ri-briefcase-2-line:before{content:""}.ri-briefcase-3-fill:before{content:""}.ri-briefcase-3-line:before{content:""}.ri-briefcase-4-fill:before{content:""}.ri-briefcase-4-line:before{content:""}.ri-briefcase-5-fill:before{content:""}.ri-briefcase-5-line:before{content:""}.ri-briefcase-fill:before{content:""}.ri-briefcase-line:before{content:""}.ri-bring-forward:before{content:""}.ri-bring-to-front:before{content:""}.ri-broadcast-fill:before{content:""}.ri-broadcast-line:before{content:""}.ri-brush-2-fill:before{content:""}.ri-brush-2-line:before{content:""}.ri-brush-3-fill:before{content:""}.ri-brush-3-line:before{content:""}.ri-brush-4-fill:before{content:""}.ri-brush-4-line:before{content:""}.ri-brush-fill:before{content:""}.ri-brush-line:before{content:""}.ri-bubble-chart-fill:before{content:""}.ri-bubble-chart-line:before{content:""}.ri-bug-2-fill:before{content:""}.ri-bug-2-line:before{content:""}.ri-bug-fill:before{content:""}.ri-bug-line:before{content:""}.ri-building-2-fill:before{content:""}.ri-building-2-line:before{content:""}.ri-building-3-fill:before{content:""}.ri-building-3-line:before{content:""}.ri-building-4-fill:before{content:""}.ri-building-4-line:before{content:""}.ri-building-fill:before{content:""}.ri-building-line:before{content:""}.ri-bus-2-fill:before{content:""}.ri-bus-2-line:before{content:""}.ri-bus-fill:before{content:""}.ri-bus-line:before{content:""}.ri-bus-wifi-fill:before{content:""}.ri-bus-wifi-line:before{content:""}.ri-cactus-fill:before{content:""}.ri-cactus-line:before{content:""}.ri-cake-2-fill:before{content:""}.ri-cake-2-line:before{content:""}.ri-cake-3-fill:before{content:""}.ri-cake-3-line:before{content:""}.ri-cake-fill:before{content:""}.ri-cake-line:before{content:""}.ri-calculator-fill:before{content:""}.ri-calculator-line:before{content:""}.ri-calendar-2-fill:before{content:""}.ri-calendar-2-line:before{content:""}.ri-calendar-check-fill:before{content:""}.ri-calendar-check-line:before{content:""}.ri-calendar-event-fill:before{content:""}.ri-calendar-event-line:before{content:""}.ri-calendar-fill:before{content:""}.ri-calendar-line:before{content:""}.ri-calendar-todo-fill:before{content:""}.ri-calendar-todo-line:before{content:""}.ri-camera-2-fill:before{content:""}.ri-camera-2-line:before{content:""}.ri-camera-3-fill:before{content:""}.ri-camera-3-line:before{content:""}.ri-camera-fill:before{content:""}.ri-camera-lens-fill:before{content:""}.ri-camera-lens-line:before{content:""}.ri-camera-line:before{content:""}.ri-camera-off-fill:before{content:""}.ri-camera-off-line:before{content:""}.ri-camera-switch-fill:before{content:""}.ri-camera-switch-line:before{content:""}.ri-capsule-fill:before{content:""}.ri-capsule-line:before{content:""}.ri-car-fill:before{content:""}.ri-car-line:before{content:""}.ri-car-washing-fill:before{content:""}.ri-car-washing-line:before{content:""}.ri-caravan-fill:before{content:""}.ri-caravan-line:before{content:""}.ri-cast-fill:before{content:""}.ri-cast-line:before{content:""}.ri-cellphone-fill:before{content:""}.ri-cellphone-line:before{content:""}.ri-celsius-fill:before{content:""}.ri-celsius-line:before{content:""}.ri-centos-fill:before{content:""}.ri-centos-line:before{content:""}.ri-character-recognition-fill:before{content:""}.ri-character-recognition-line:before{content:""}.ri-charging-pile-2-fill:before{content:""}.ri-charging-pile-2-line:before{content:""}.ri-charging-pile-fill:before{content:""}.ri-charging-pile-line:before{content:""}.ri-chat-1-fill:before{content:""}.ri-chat-1-line:before{content:""}.ri-chat-2-fill:before{content:""}.ri-chat-2-line:before{content:""}.ri-chat-3-fill:before{content:""}.ri-chat-3-line:before{content:""}.ri-chat-4-fill:before{content:""}.ri-chat-4-line:before{content:""}.ri-chat-check-fill:before{content:""}.ri-chat-check-line:before{content:""}.ri-chat-delete-fill:before{content:""}.ri-chat-delete-line:before{content:""}.ri-chat-download-fill:before{content:""}.ri-chat-download-line:before{content:""}.ri-chat-follow-up-fill:before{content:""}.ri-chat-follow-up-line:before{content:""}.ri-chat-forward-fill:before{content:""}.ri-chat-forward-line:before{content:""}.ri-chat-heart-fill:before{content:""}.ri-chat-heart-line:before{content:""}.ri-chat-history-fill:before{content:""}.ri-chat-history-line:before{content:""}.ri-chat-new-fill:before{content:""}.ri-chat-new-line:before{content:""}.ri-chat-off-fill:before{content:""}.ri-chat-off-line:before{content:""}.ri-chat-poll-fill:before{content:""}.ri-chat-poll-line:before{content:""}.ri-chat-private-fill:before{content:""}.ri-chat-private-line:before{content:""}.ri-chat-quote-fill:before{content:""}.ri-chat-quote-line:before{content:""}.ri-chat-settings-fill:before{content:""}.ri-chat-settings-line:before{content:""}.ri-chat-smile-2-fill:before{content:""}.ri-chat-smile-2-line:before{content:""}.ri-chat-smile-3-fill:before{content:""}.ri-chat-smile-3-line:before{content:""}.ri-chat-smile-fill:before{content:""}.ri-chat-smile-line:before{content:""}.ri-chat-upload-fill:before{content:""}.ri-chat-upload-line:before{content:""}.ri-chat-voice-fill:before{content:""}.ri-chat-voice-line:before{content:""}.ri-check-double-fill:before{content:""}.ri-check-double-line:before{content:""}.ri-check-fill:before{content:""}.ri-check-line:before{content:""}.ri-checkbox-blank-circle-fill:before{content:""}.ri-checkbox-blank-circle-line:before{content:""}.ri-checkbox-blank-fill:before{content:""}.ri-checkbox-blank-line:before{content:""}.ri-checkbox-circle-fill:before{content:""}.ri-checkbox-circle-line:before{content:""}.ri-checkbox-fill:before{content:""}.ri-checkbox-indeterminate-fill:before{content:""}.ri-checkbox-indeterminate-line:before{content:""}.ri-checkbox-line:before{content:""}.ri-checkbox-multiple-blank-fill:before{content:""}.ri-checkbox-multiple-blank-line:before{content:""}.ri-checkbox-multiple-fill:before{content:""}.ri-checkbox-multiple-line:before{content:""}.ri-china-railway-fill:before{content:""}.ri-china-railway-line:before{content:""}.ri-chrome-fill:before{content:""}.ri-chrome-line:before{content:""}.ri-clapperboard-fill:before{content:""}.ri-clapperboard-line:before{content:""}.ri-clipboard-fill:before{content:""}.ri-clipboard-line:before{content:""}.ri-clockwise-2-fill:before{content:""}.ri-clockwise-2-line:before{content:""}.ri-clockwise-fill:before{content:""}.ri-clockwise-line:before{content:""}.ri-close-circle-fill:before{content:""}.ri-close-circle-line:before{content:""}.ri-close-fill:before{content:""}.ri-close-line:before{content:""}.ri-closed-captioning-fill:before{content:""}.ri-closed-captioning-line:before{content:""}.ri-cloud-fill:before{content:""}.ri-cloud-line:before{content:""}.ri-cloud-off-fill:before{content:""}.ri-cloud-off-line:before{content:""}.ri-cloud-windy-fill:before{content:""}.ri-cloud-windy-line:before{content:""}.ri-cloudy-2-fill:before{content:""}.ri-cloudy-2-line:before{content:""}.ri-cloudy-fill:before{content:""}.ri-cloudy-line:before{content:""}.ri-code-box-fill:before{content:""}.ri-code-box-line:before{content:""}.ri-code-fill:before{content:""}.ri-code-line:before{content:""}.ri-code-s-fill:before{content:""}.ri-code-s-line:before{content:""}.ri-code-s-slash-fill:before{content:""}.ri-code-s-slash-line:before{content:""}.ri-code-view:before{content:""}.ri-codepen-fill:before{content:""}.ri-codepen-line:before{content:""}.ri-coin-fill:before{content:""}.ri-coin-line:before{content:""}.ri-coins-fill:before{content:""}.ri-coins-line:before{content:""}.ri-collage-fill:before{content:""}.ri-collage-line:before{content:""}.ri-command-fill:before{content:""}.ri-command-line:before{content:""}.ri-community-fill:before{content:""}.ri-community-line:before{content:""}.ri-compass-2-fill:before{content:""}.ri-compass-2-line:before{content:""}.ri-compass-3-fill:before{content:""}.ri-compass-3-line:before{content:""}.ri-compass-4-fill:before{content:""}.ri-compass-4-line:before{content:""}.ri-compass-discover-fill:before{content:""}.ri-compass-discover-line:before{content:""}.ri-compass-fill:before{content:""}.ri-compass-line:before{content:""}.ri-compasses-2-fill:before{content:""}.ri-compasses-2-line:before{content:""}.ri-compasses-fill:before{content:""}.ri-compasses-line:before{content:""}.ri-computer-fill:before{content:""}.ri-computer-line:before{content:""}.ri-contacts-book-2-fill:before{content:""}.ri-contacts-book-2-line:before{content:""}.ri-contacts-book-fill:before{content:""}.ri-contacts-book-line:before{content:""}.ri-contacts-book-upload-fill:before{content:""}.ri-contacts-book-upload-line:before{content:""}.ri-contacts-fill:before{content:""}.ri-contacts-line:before{content:""}.ri-contrast-2-fill:before{content:""}.ri-contrast-2-line:before{content:""}.ri-contrast-drop-2-fill:before{content:""}.ri-contrast-drop-2-line:before{content:""}.ri-contrast-drop-fill:before{content:""}.ri-contrast-drop-line:before{content:""}.ri-contrast-fill:before{content:""}.ri-contrast-line:before{content:""}.ri-copper-coin-fill:before{content:""}.ri-copper-coin-line:before{content:""}.ri-copper-diamond-fill:before{content:""}.ri-copper-diamond-line:before{content:""}.ri-copyleft-fill:before{content:""}.ri-copyleft-line:before{content:""}.ri-copyright-fill:before{content:""}.ri-copyright-line:before{content:""}.ri-coreos-fill:before{content:""}.ri-coreos-line:before{content:""}.ri-coupon-2-fill:before{content:""}.ri-coupon-2-line:before{content:""}.ri-coupon-3-fill:before{content:""}.ri-coupon-3-line:before{content:""}.ri-coupon-4-fill:before{content:""}.ri-coupon-4-line:before{content:""}.ri-coupon-5-fill:before{content:""}.ri-coupon-5-line:before{content:""}.ri-coupon-fill:before{content:""}.ri-coupon-line:before{content:""}.ri-cpu-fill:before{content:""}.ri-cpu-line:before{content:""}.ri-creative-commons-by-fill:before{content:""}.ri-creative-commons-by-line:before{content:""}.ri-creative-commons-fill:before{content:""}.ri-creative-commons-line:before{content:""}.ri-creative-commons-nc-fill:before{content:""}.ri-creative-commons-nc-line:before{content:""}.ri-creative-commons-nd-fill:before{content:""}.ri-creative-commons-nd-line:before{content:""}.ri-creative-commons-sa-fill:before{content:""}.ri-creative-commons-sa-line:before{content:""}.ri-creative-commons-zero-fill:before{content:""}.ri-creative-commons-zero-line:before{content:""}.ri-criminal-fill:before{content:""}.ri-criminal-line:before{content:""}.ri-crop-2-fill:before{content:""}.ri-crop-2-line:before{content:""}.ri-crop-fill:before{content:""}.ri-crop-line:before{content:""}.ri-css3-fill:before{content:""}.ri-css3-line:before{content:""}.ri-cup-fill:before{content:""}.ri-cup-line:before{content:""}.ri-currency-fill:before{content:""}.ri-currency-line:before{content:""}.ri-cursor-fill:before{content:""}.ri-cursor-line:before{content:""}.ri-customer-service-2-fill:before{content:""}.ri-customer-service-2-line:before{content:""}.ri-customer-service-fill:before{content:""}.ri-customer-service-line:before{content:""}.ri-dashboard-2-fill:before{content:""}.ri-dashboard-2-line:before{content:""}.ri-dashboard-3-fill:before{content:""}.ri-dashboard-3-line:before{content:""}.ri-dashboard-fill:before{content:""}.ri-dashboard-line:before{content:""}.ri-database-2-fill:before{content:""}.ri-database-2-line:before{content:""}.ri-database-fill:before{content:""}.ri-database-line:before{content:""}.ri-delete-back-2-fill:before{content:""}.ri-delete-back-2-line:before{content:""}.ri-delete-back-fill:before{content:""}.ri-delete-back-line:before{content:""}.ri-delete-bin-2-fill:before{content:""}.ri-delete-bin-2-line:before{content:""}.ri-delete-bin-3-fill:before{content:""}.ri-delete-bin-3-line:before{content:""}.ri-delete-bin-4-fill:before{content:""}.ri-delete-bin-4-line:before{content:""}.ri-delete-bin-5-fill:before{content:""}.ri-delete-bin-5-line:before{content:""}.ri-delete-bin-6-fill:before{content:""}.ri-delete-bin-6-line:before{content:""}.ri-delete-bin-7-fill:before{content:""}.ri-delete-bin-7-line:before{content:""}.ri-delete-bin-fill:before{content:""}.ri-delete-bin-line:before{content:""}.ri-delete-column:before{content:""}.ri-delete-row:before{content:""}.ri-device-fill:before{content:""}.ri-device-line:before{content:""}.ri-device-recover-fill:before{content:""}.ri-device-recover-line:before{content:""}.ri-dingding-fill:before{content:""}.ri-dingding-line:before{content:""}.ri-direction-fill:before{content:""}.ri-direction-line:before{content:""}.ri-disc-fill:before{content:""}.ri-disc-line:before{content:""}.ri-discord-fill:before{content:""}.ri-discord-line:before{content:""}.ri-discuss-fill:before{content:""}.ri-discuss-line:before{content:""}.ri-dislike-fill:before{content:""}.ri-dislike-line:before{content:""}.ri-disqus-fill:before{content:""}.ri-disqus-line:before{content:""}.ri-divide-fill:before{content:""}.ri-divide-line:before{content:""}.ri-donut-chart-fill:before{content:""}.ri-donut-chart-line:before{content:""}.ri-door-closed-fill:before{content:""}.ri-door-closed-line:before{content:""}.ri-door-fill:before{content:""}.ri-door-line:before{content:""}.ri-door-lock-box-fill:before{content:""}.ri-door-lock-box-line:before{content:""}.ri-door-lock-fill:before{content:""}.ri-door-lock-line:before{content:""}.ri-door-open-fill:before{content:""}.ri-door-open-line:before{content:""}.ri-dossier-fill:before{content:""}.ri-dossier-line:before{content:""}.ri-douban-fill:before{content:""}.ri-douban-line:before{content:""}.ri-double-quotes-l:before{content:""}.ri-double-quotes-r:before{content:""}.ri-download-2-fill:before{content:""}.ri-download-2-line:before{content:""}.ri-download-cloud-2-fill:before{content:""}.ri-download-cloud-2-line:before{content:""}.ri-download-cloud-fill:before{content:""}.ri-download-cloud-line:before{content:""}.ri-download-fill:before{content:""}.ri-download-line:before{content:""}.ri-draft-fill:before{content:""}.ri-draft-line:before{content:""}.ri-drag-drop-fill:before{content:""}.ri-drag-drop-line:before{content:""}.ri-drag-move-2-fill:before{content:""}.ri-drag-move-2-line:before{content:""}.ri-drag-move-fill:before{content:""}.ri-drag-move-line:before{content:""}.ri-dribbble-fill:before{content:""}.ri-dribbble-line:before{content:""}.ri-drive-fill:before{content:""}.ri-drive-line:before{content:""}.ri-drizzle-fill:before{content:""}.ri-drizzle-line:before{content:""}.ri-drop-fill:before{content:""}.ri-drop-line:before{content:""}.ri-dropbox-fill:before{content:""}.ri-dropbox-line:before{content:""}.ri-dual-sim-1-fill:before{content:""}.ri-dual-sim-1-line:before{content:""}.ri-dual-sim-2-fill:before{content:""}.ri-dual-sim-2-line:before{content:""}.ri-dv-fill:before{content:""}.ri-dv-line:before{content:""}.ri-dvd-fill:before{content:""}.ri-dvd-line:before{content:""}.ri-e-bike-2-fill:before{content:""}.ri-e-bike-2-line:before{content:""}.ri-e-bike-fill:before{content:""}.ri-e-bike-line:before{content:""}.ri-earth-fill:before{content:""}.ri-earth-line:before{content:""}.ri-earthquake-fill:before{content:""}.ri-earthquake-line:before{content:""}.ri-edge-fill:before{content:""}.ri-edge-line:before{content:""}.ri-edit-2-fill:before{content:""}.ri-edit-2-line:before{content:""}.ri-edit-box-fill:before{content:""}.ri-edit-box-line:before{content:""}.ri-edit-circle-fill:before{content:""}.ri-edit-circle-line:before{content:""}.ri-edit-fill:before{content:""}.ri-edit-line:before{content:""}.ri-eject-fill:before{content:""}.ri-eject-line:before{content:""}.ri-emotion-2-fill:before{content:""}.ri-emotion-2-line:before{content:""}.ri-emotion-fill:before{content:""}.ri-emotion-happy-fill:before{content:""}.ri-emotion-happy-line:before{content:""}.ri-emotion-laugh-fill:before{content:""}.ri-emotion-laugh-line:before{content:""}.ri-emotion-line:before{content:""}.ri-emotion-normal-fill:before{content:""}.ri-emotion-normal-line:before{content:""}.ri-emotion-sad-fill:before{content:""}.ri-emotion-sad-line:before{content:""}.ri-emotion-unhappy-fill:before{content:""}.ri-emotion-unhappy-line:before{content:""}.ri-empathize-fill:before{content:""}.ri-empathize-line:before{content:""}.ri-emphasis-cn:before{content:""}.ri-emphasis:before{content:""}.ri-english-input:before{content:""}.ri-equalizer-fill:before{content:""}.ri-equalizer-line:before{content:""}.ri-eraser-fill:before{content:""}.ri-eraser-line:before{content:""}.ri-error-warning-fill:before{content:""}.ri-error-warning-line:before{content:""}.ri-evernote-fill:before{content:""}.ri-evernote-line:before{content:""}.ri-exchange-box-fill:before{content:""}.ri-exchange-box-line:before{content:""}.ri-exchange-cny-fill:before{content:""}.ri-exchange-cny-line:before{content:""}.ri-exchange-dollar-fill:before{content:""}.ri-exchange-dollar-line:before{content:""}.ri-exchange-fill:before{content:""}.ri-exchange-funds-fill:before{content:""}.ri-exchange-funds-line:before{content:""}.ri-exchange-line:before{content:""}.ri-external-link-fill:before{content:""}.ri-external-link-line:before{content:""}.ri-eye-2-fill:before{content:""}.ri-eye-2-line:before{content:""}.ri-eye-close-fill:before{content:""}.ri-eye-close-line:before{content:""}.ri-eye-fill:before{content:""}.ri-eye-line:before{content:""}.ri-eye-off-fill:before{content:""}.ri-eye-off-line:before{content:""}.ri-facebook-box-fill:before{content:""}.ri-facebook-box-line:before{content:""}.ri-facebook-circle-fill:before{content:""}.ri-facebook-circle-line:before{content:""}.ri-facebook-fill:before{content:""}.ri-facebook-line:before{content:""}.ri-fahrenheit-fill:before{content:""}.ri-fahrenheit-line:before{content:""}.ri-feedback-fill:before{content:""}.ri-feedback-line:before{content:""}.ri-file-2-fill:before{content:""}.ri-file-2-line:before{content:""}.ri-file-3-fill:before{content:""}.ri-file-3-line:before{content:""}.ri-file-4-fill:before{content:""}.ri-file-4-line:before{content:""}.ri-file-add-fill:before{content:""}.ri-file-add-line:before{content:""}.ri-file-chart-2-fill:before{content:""}.ri-file-chart-2-line:before{content:""}.ri-file-chart-fill:before{content:""}.ri-file-chart-line:before{content:""}.ri-file-cloud-fill:before{content:""}.ri-file-cloud-line:before{content:""}.ri-file-code-fill:before{content:""}.ri-file-code-line:before{content:""}.ri-file-copy-2-fill:before{content:""}.ri-file-copy-2-line:before{content:""}.ri-file-copy-fill:before{content:""}.ri-file-copy-line:before{content:""}.ri-file-damage-fill:before{content:""}.ri-file-damage-line:before{content:""}.ri-file-download-fill:before{content:""}.ri-file-download-line:before{content:""}.ri-file-edit-fill:before{content:""}.ri-file-edit-line:before{content:""}.ri-file-excel-2-fill:before{content:""}.ri-file-excel-2-line:before{content:""}.ri-file-excel-fill:before{content:""}.ri-file-excel-line:before{content:""}.ri-file-fill:before{content:""}.ri-file-forbid-fill:before{content:""}.ri-file-forbid-line:before{content:""}.ri-file-gif-fill:before{content:""}.ri-file-gif-line:before{content:""}.ri-file-history-fill:before{content:""}.ri-file-history-line:before{content:""}.ri-file-hwp-fill:before{content:""}.ri-file-hwp-line:before{content:""}.ri-file-info-fill:before{content:""}.ri-file-info-line:before{content:""}.ri-file-line:before{content:""}.ri-file-list-2-fill:before{content:""}.ri-file-list-2-line:before{content:""}.ri-file-list-3-fill:before{content:""}.ri-file-list-3-line:before{content:""}.ri-file-list-fill:before{content:""}.ri-file-list-line:before{content:""}.ri-file-lock-fill:before{content:""}.ri-file-lock-line:before{content:""}.ri-file-mark-fill:before{content:""}.ri-file-mark-line:before{content:""}.ri-file-music-fill:before{content:""}.ri-file-music-line:before{content:""}.ri-file-paper-2-fill:before{content:""}.ri-file-paper-2-line:before{content:""}.ri-file-paper-fill:before{content:""}.ri-file-paper-line:before{content:""}.ri-file-pdf-fill:before{content:""}.ri-file-pdf-line:before{content:""}.ri-file-ppt-2-fill:before{content:""}.ri-file-ppt-2-line:before{content:""}.ri-file-ppt-fill:before{content:""}.ri-file-ppt-line:before{content:""}.ri-file-reduce-fill:before{content:""}.ri-file-reduce-line:before{content:""}.ri-file-search-fill:before{content:""}.ri-file-search-line:before{content:""}.ri-file-settings-fill:before{content:""}.ri-file-settings-line:before{content:""}.ri-file-shield-2-fill:before{content:""}.ri-file-shield-2-line:before{content:""}.ri-file-shield-fill:before{content:""}.ri-file-shield-line:before{content:""}.ri-file-shred-fill:before{content:""}.ri-file-shred-line:before{content:""}.ri-file-text-fill:before{content:""}.ri-file-text-line:before{content:""}.ri-file-transfer-fill:before{content:""}.ri-file-transfer-line:before{content:""}.ri-file-unknow-fill:before{content:""}.ri-file-unknow-line:before{content:""}.ri-file-upload-fill:before{content:""}.ri-file-upload-line:before{content:""}.ri-file-user-fill:before{content:""}.ri-file-user-line:before{content:""}.ri-file-warning-fill:before{content:""}.ri-file-warning-line:before{content:""}.ri-file-word-2-fill:before{content:""}.ri-file-word-2-line:before{content:""}.ri-file-word-fill:before{content:""}.ri-file-word-line:before{content:""}.ri-file-zip-fill:before{content:""}.ri-file-zip-line:before{content:""}.ri-film-fill:before{content:""}.ri-film-line:before{content:""}.ri-filter-2-fill:before{content:""}.ri-filter-2-line:before{content:""}.ri-filter-3-fill:before{content:""}.ri-filter-3-line:before{content:""}.ri-filter-fill:before{content:""}.ri-filter-line:before{content:""}.ri-filter-off-fill:before{content:""}.ri-filter-off-line:before{content:""}.ri-find-replace-fill:before{content:""}.ri-find-replace-line:before{content:""}.ri-finder-fill:before{content:""}.ri-finder-line:before{content:""}.ri-fingerprint-2-fill:before{content:""}.ri-fingerprint-2-line:before{content:""}.ri-fingerprint-fill:before{content:""}.ri-fingerprint-line:before{content:""}.ri-fire-fill:before{content:""}.ri-fire-line:before{content:""}.ri-firefox-fill:before{content:""}.ri-firefox-line:before{content:""}.ri-first-aid-kit-fill:before{content:""}.ri-first-aid-kit-line:before{content:""}.ri-flag-2-fill:before{content:""}.ri-flag-2-line:before{content:""}.ri-flag-fill:before{content:""}.ri-flag-line:before{content:""}.ri-flashlight-fill:before{content:""}.ri-flashlight-line:before{content:""}.ri-flask-fill:before{content:""}.ri-flask-line:before{content:""}.ri-flight-land-fill:before{content:""}.ri-flight-land-line:before{content:""}.ri-flight-takeoff-fill:before{content:""}.ri-flight-takeoff-line:before{content:""}.ri-flood-fill:before{content:""}.ri-flood-line:before{content:""}.ri-flow-chart:before{content:""}.ri-flutter-fill:before{content:""}.ri-flutter-line:before{content:""}.ri-focus-2-fill:before{content:""}.ri-focus-2-line:before{content:""}.ri-focus-3-fill:before{content:""}.ri-focus-3-line:before{content:""}.ri-focus-fill:before{content:""}.ri-focus-line:before{content:""}.ri-foggy-fill:before{content:""}.ri-foggy-line:before{content:""}.ri-folder-2-fill:before{content:""}.ri-folder-2-line:before{content:""}.ri-folder-3-fill:before{content:""}.ri-folder-3-line:before{content:""}.ri-folder-4-fill:before{content:""}.ri-folder-4-line:before{content:""}.ri-folder-5-fill:before{content:""}.ri-folder-5-line:before{content:""}.ri-folder-add-fill:before{content:""}.ri-folder-add-line:before{content:""}.ri-folder-chart-2-fill:before{content:""}.ri-folder-chart-2-line:before{content:""}.ri-folder-chart-fill:before{content:""}.ri-folder-chart-line:before{content:""}.ri-folder-download-fill:before{content:""}.ri-folder-download-line:before{content:""}.ri-folder-fill:before{content:""}.ri-folder-forbid-fill:before{content:""}.ri-folder-forbid-line:before{content:""}.ri-folder-history-fill:before{content:""}.ri-folder-history-line:before{content:""}.ri-folder-info-fill:before{content:""}.ri-folder-info-line:before{content:""}.ri-folder-keyhole-fill:before{content:""}.ri-folder-keyhole-line:before{content:""}.ri-folder-line:before{content:""}.ri-folder-lock-fill:before{content:""}.ri-folder-lock-line:before{content:""}.ri-folder-music-fill:before{content:""}.ri-folder-music-line:before{content:""}.ri-folder-open-fill:before{content:""}.ri-folder-open-line:before{content:""}.ri-folder-received-fill:before{content:""}.ri-folder-received-line:before{content:""}.ri-folder-reduce-fill:before{content:""}.ri-folder-reduce-line:before{content:""}.ri-folder-settings-fill:before{content:""}.ri-folder-settings-line:before{content:""}.ri-folder-shared-fill:before{content:""}.ri-folder-shared-line:before{content:""}.ri-folder-shield-2-fill:before{content:""}.ri-folder-shield-2-line:before{content:""}.ri-folder-shield-fill:before{content:""}.ri-folder-shield-line:before{content:""}.ri-folder-transfer-fill:before{content:""}.ri-folder-transfer-line:before{content:""}.ri-folder-unknow-fill:before{content:""}.ri-folder-unknow-line:before{content:""}.ri-folder-upload-fill:before{content:""}.ri-folder-upload-line:before{content:""}.ri-folder-user-fill:before{content:""}.ri-folder-user-line:before{content:""}.ri-folder-warning-fill:before{content:""}.ri-folder-warning-line:before{content:""}.ri-folder-zip-fill:before{content:""}.ri-folder-zip-line:before{content:""}.ri-folders-fill:before{content:""}.ri-folders-line:before{content:""}.ri-font-color:before{content:""}.ri-font-size-2:before{content:""}.ri-font-size:before{content:""}.ri-football-fill:before{content:""}.ri-football-line:before{content:""}.ri-footprint-fill:before{content:""}.ri-footprint-line:before{content:""}.ri-forbid-2-fill:before{content:""}.ri-forbid-2-line:before{content:""}.ri-forbid-fill:before{content:""}.ri-forbid-line:before{content:""}.ri-format-clear:before{content:""}.ri-fridge-fill:before{content:""}.ri-fridge-line:before{content:""}.ri-fullscreen-exit-fill:before{content:""}.ri-fullscreen-exit-line:before{content:""}.ri-fullscreen-fill:before{content:""}.ri-fullscreen-line:before{content:""}.ri-function-fill:before{content:""}.ri-function-line:before{content:""}.ri-functions:before{content:""}.ri-funds-box-fill:before{content:""}.ri-funds-box-line:before{content:""}.ri-funds-fill:before{content:""}.ri-funds-line:before{content:""}.ri-gallery-fill:before{content:""}.ri-gallery-line:before{content:""}.ri-gallery-upload-fill:before{content:""}.ri-gallery-upload-line:before{content:""}.ri-game-fill:before{content:""}.ri-game-line:before{content:""}.ri-gamepad-fill:before{content:""}.ri-gamepad-line:before{content:""}.ri-gas-station-fill:before{content:""}.ri-gas-station-line:before{content:""}.ri-gatsby-fill:before{content:""}.ri-gatsby-line:before{content:""}.ri-genderless-fill:before{content:""}.ri-genderless-line:before{content:""}.ri-ghost-2-fill:before{content:""}.ri-ghost-2-line:before{content:""}.ri-ghost-fill:before{content:""}.ri-ghost-line:before{content:""}.ri-ghost-smile-fill:before{content:""}.ri-ghost-smile-line:before{content:""}.ri-gift-2-fill:before{content:""}.ri-gift-2-line:before{content:""}.ri-gift-fill:before{content:""}.ri-gift-line:before{content:""}.ri-git-branch-fill:before{content:""}.ri-git-branch-line:before{content:""}.ri-git-commit-fill:before{content:""}.ri-git-commit-line:before{content:""}.ri-git-merge-fill:before{content:""}.ri-git-merge-line:before{content:""}.ri-git-pull-request-fill:before{content:""}.ri-git-pull-request-line:before{content:""}.ri-git-repository-commits-fill:before{content:""}.ri-git-repository-commits-line:before{content:""}.ri-git-repository-fill:before{content:""}.ri-git-repository-line:before{content:""}.ri-git-repository-private-fill:before{content:""}.ri-git-repository-private-line:before{content:""}.ri-github-fill:before{content:""}.ri-github-line:before{content:""}.ri-gitlab-fill:before{content:""}.ri-gitlab-line:before{content:""}.ri-global-fill:before{content:""}.ri-global-line:before{content:""}.ri-globe-fill:before{content:""}.ri-globe-line:before{content:""}.ri-goblet-fill:before{content:""}.ri-goblet-line:before{content:""}.ri-google-fill:before{content:""}.ri-google-line:before{content:""}.ri-google-play-fill:before{content:""}.ri-google-play-line:before{content:""}.ri-government-fill:before{content:""}.ri-government-line:before{content:""}.ri-gps-fill:before{content:""}.ri-gps-line:before{content:""}.ri-gradienter-fill:before{content:""}.ri-gradienter-line:before{content:""}.ri-grid-fill:before{content:""}.ri-grid-line:before{content:""}.ri-group-2-fill:before{content:""}.ri-group-2-line:before{content:""}.ri-group-fill:before{content:""}.ri-group-line:before{content:""}.ri-guide-fill:before{content:""}.ri-guide-line:before{content:""}.ri-h-1:before{content:""}.ri-h-2:before{content:""}.ri-h-3:before{content:""}.ri-h-4:before{content:""}.ri-h-5:before{content:""}.ri-h-6:before{content:""}.ri-hail-fill:before{content:""}.ri-hail-line:before{content:""}.ri-hammer-fill:before{content:""}.ri-hammer-line:before{content:""}.ri-hand-coin-fill:before{content:""}.ri-hand-coin-line:before{content:""}.ri-hand-heart-fill:before{content:""}.ri-hand-heart-line:before{content:""}.ri-hand-sanitizer-fill:before{content:""}.ri-hand-sanitizer-line:before{content:""}.ri-handbag-fill:before{content:""}.ri-handbag-line:before{content:""}.ri-hard-drive-2-fill:before{content:""}.ri-hard-drive-2-line:before{content:""}.ri-hard-drive-fill:before{content:""}.ri-hard-drive-line:before{content:""}.ri-hashtag:before{content:""}.ri-haze-2-fill:before{content:""}.ri-haze-2-line:before{content:""}.ri-haze-fill:before{content:""}.ri-haze-line:before{content:""}.ri-hd-fill:before{content:""}.ri-hd-line:before{content:""}.ri-heading:before{content:""}.ri-headphone-fill:before{content:""}.ri-headphone-line:before{content:""}.ri-health-book-fill:before{content:""}.ri-health-book-line:before{content:""}.ri-heart-2-fill:before{content:""}.ri-heart-2-line:before{content:""}.ri-heart-3-fill:before{content:""}.ri-heart-3-line:before{content:""}.ri-heart-add-fill:before{content:""}.ri-heart-add-line:before{content:""}.ri-heart-fill:before{content:""}.ri-heart-line:before{content:""}.ri-heart-pulse-fill:before{content:""}.ri-heart-pulse-line:before{content:""}.ri-hearts-fill:before{content:""}.ri-hearts-line:before{content:""}.ri-heavy-showers-fill:before{content:""}.ri-heavy-showers-line:before{content:""}.ri-history-fill:before{content:""}.ri-history-line:before{content:""}.ri-home-2-fill:before{content:""}.ri-home-2-line:before{content:""}.ri-home-3-fill:before{content:""}.ri-home-3-line:before{content:""}.ri-home-4-fill:before{content:""}.ri-home-4-line:before{content:""}.ri-home-5-fill:before{content:""}.ri-home-5-line:before{content:""}.ri-home-6-fill:before{content:""}.ri-home-6-line:before{content:""}.ri-home-7-fill:before{content:""}.ri-home-7-line:before{content:""}.ri-home-8-fill:before{content:""}.ri-home-8-line:before{content:""}.ri-home-fill:before{content:""}.ri-home-gear-fill:before{content:""}.ri-home-gear-line:before{content:""}.ri-home-heart-fill:before{content:""}.ri-home-heart-line:before{content:""}.ri-home-line:before{content:""}.ri-home-smile-2-fill:before{content:""}.ri-home-smile-2-line:before{content:""}.ri-home-smile-fill:before{content:""}.ri-home-smile-line:before{content:""}.ri-home-wifi-fill:before{content:""}.ri-home-wifi-line:before{content:""}.ri-honor-of-kings-fill:before{content:""}.ri-honor-of-kings-line:before{content:""}.ri-honour-fill:before{content:""}.ri-honour-line:before{content:""}.ri-hospital-fill:before{content:""}.ri-hospital-line:before{content:""}.ri-hotel-bed-fill:before{content:""}.ri-hotel-bed-line:before{content:""}.ri-hotel-fill:before{content:""}.ri-hotel-line:before{content:""}.ri-hotspot-fill:before{content:""}.ri-hotspot-line:before{content:""}.ri-hq-fill:before{content:""}.ri-hq-line:before{content:""}.ri-html5-fill:before{content:""}.ri-html5-line:before{content:""}.ri-ie-fill:before{content:""}.ri-ie-line:before{content:""}.ri-image-2-fill:before{content:""}.ri-image-2-line:before{content:""}.ri-image-add-fill:before{content:""}.ri-image-add-line:before{content:""}.ri-image-edit-fill:before{content:""}.ri-image-edit-line:before{content:""}.ri-image-fill:before{content:""}.ri-image-line:before{content:""}.ri-inbox-archive-fill:before{content:""}.ri-inbox-archive-line:before{content:""}.ri-inbox-fill:before{content:""}.ri-inbox-line:before{content:""}.ri-inbox-unarchive-fill:before{content:""}.ri-inbox-unarchive-line:before{content:""}.ri-increase-decrease-fill:before{content:""}.ri-increase-decrease-line:before{content:""}.ri-indent-decrease:before{content:""}.ri-indent-increase:before{content:""}.ri-indeterminate-circle-fill:before{content:""}.ri-indeterminate-circle-line:before{content:""}.ri-information-fill:before{content:""}.ri-information-line:before{content:""}.ri-infrared-thermometer-fill:before{content:""}.ri-infrared-thermometer-line:before{content:""}.ri-ink-bottle-fill:before{content:""}.ri-ink-bottle-line:before{content:""}.ri-input-cursor-move:before{content:""}.ri-input-method-fill:before{content:""}.ri-input-method-line:before{content:""}.ri-insert-column-left:before{content:""}.ri-insert-column-right:before{content:""}.ri-insert-row-bottom:before{content:""}.ri-insert-row-top:before{content:""}.ri-instagram-fill:before{content:""}.ri-instagram-line:before{content:""}.ri-install-fill:before{content:""}.ri-install-line:before{content:""}.ri-invision-fill:before{content:""}.ri-invision-line:before{content:""}.ri-italic:before{content:""}.ri-kakao-talk-fill:before{content:""}.ri-kakao-talk-line:before{content:""}.ri-key-2-fill:before{content:""}.ri-key-2-line:before{content:""}.ri-key-fill:before{content:""}.ri-key-line:before{content:""}.ri-keyboard-box-fill:before{content:""}.ri-keyboard-box-line:before{content:""}.ri-keyboard-fill:before{content:""}.ri-keyboard-line:before{content:""}.ri-keynote-fill:before{content:""}.ri-keynote-line:before{content:""}.ri-knife-blood-fill:before{content:""}.ri-knife-blood-line:before{content:""}.ri-knife-fill:before{content:""}.ri-knife-line:before{content:""}.ri-landscape-fill:before{content:""}.ri-landscape-line:before{content:""}.ri-layout-2-fill:before{content:""}.ri-layout-2-line:before{content:""}.ri-layout-3-fill:before{content:""}.ri-layout-3-line:before{content:""}.ri-layout-4-fill:before{content:""}.ri-layout-4-line:before{content:""}.ri-layout-5-fill:before{content:""}.ri-layout-5-line:before{content:""}.ri-layout-6-fill:before{content:""}.ri-layout-6-line:before{content:""}.ri-layout-bottom-2-fill:before{content:""}.ri-layout-bottom-2-line:before{content:""}.ri-layout-bottom-fill:before{content:""}.ri-layout-bottom-line:before{content:""}.ri-layout-column-fill:before{content:""}.ri-layout-column-line:before{content:""}.ri-layout-fill:before{content:""}.ri-layout-grid-fill:before{content:""}.ri-layout-grid-line:before{content:""}.ri-layout-left-2-fill:before{content:""}.ri-layout-left-2-line:before{content:""}.ri-layout-left-fill:before{content:""}.ri-layout-left-line:before{content:""}.ri-layout-line:before{content:""}.ri-layout-masonry-fill:before{content:""}.ri-layout-masonry-line:before{content:""}.ri-layout-right-2-fill:before{content:""}.ri-layout-right-2-line:before{content:""}.ri-layout-right-fill:before{content:""}.ri-layout-right-line:before{content:""}.ri-layout-row-fill:before{content:""}.ri-layout-row-line:before{content:""}.ri-layout-top-2-fill:before{content:""}.ri-layout-top-2-line:before{content:""}.ri-layout-top-fill:before{content:""}.ri-layout-top-line:before{content:""}.ri-leaf-fill:before{content:""}.ri-leaf-line:before{content:""}.ri-lifebuoy-fill:before{content:""}.ri-lifebuoy-line:before{content:""}.ri-lightbulb-fill:before{content:""}.ri-lightbulb-flash-fill:before{content:""}.ri-lightbulb-flash-line:before{content:""}.ri-lightbulb-line:before{content:""}.ri-line-chart-fill:before{content:""}.ri-line-chart-line:before{content:""}.ri-line-fill:before{content:""}.ri-line-height:before{content:""}.ri-line-line:before{content:""}.ri-link-m:before{content:""}.ri-link-unlink-m:before{content:""}.ri-link-unlink:before{content:""}.ri-link:before{content:""}.ri-linkedin-box-fill:before{content:""}.ri-linkedin-box-line:before{content:""}.ri-linkedin-fill:before{content:""}.ri-linkedin-line:before{content:""}.ri-links-fill:before{content:""}.ri-links-line:before{content:""}.ri-list-check-2:before{content:""}.ri-list-check:before{content:""}.ri-list-ordered:before{content:""}.ri-list-settings-fill:before{content:""}.ri-list-settings-line:before{content:""}.ri-list-unordered:before{content:""}.ri-live-fill:before{content:""}.ri-live-line:before{content:""}.ri-loader-2-fill:before{content:""}.ri-loader-2-line:before{content:""}.ri-loader-3-fill:before{content:""}.ri-loader-3-line:before{content:""}.ri-loader-4-fill:before{content:""}.ri-loader-4-line:before{content:""}.ri-loader-5-fill:before{content:""}.ri-loader-5-line:before{content:""}.ri-loader-fill:before{content:""}.ri-loader-line:before{content:""}.ri-lock-2-fill:before{content:""}.ri-lock-2-line:before{content:""}.ri-lock-fill:before{content:""}.ri-lock-line:before{content:""}.ri-lock-password-fill:before{content:""}.ri-lock-password-line:before{content:""}.ri-lock-unlock-fill:before{content:""}.ri-lock-unlock-line:before{content:""}.ri-login-box-fill:before{content:""}.ri-login-box-line:before{content:""}.ri-login-circle-fill:before{content:""}.ri-login-circle-line:before{content:""}.ri-logout-box-fill:before{content:""}.ri-logout-box-line:before{content:""}.ri-logout-box-r-fill:before{content:""}.ri-logout-box-r-line:before{content:""}.ri-logout-circle-fill:before{content:""}.ri-logout-circle-line:before{content:""}.ri-logout-circle-r-fill:before{content:""}.ri-logout-circle-r-line:before{content:""}.ri-luggage-cart-fill:before{content:""}.ri-luggage-cart-line:before{content:""}.ri-luggage-deposit-fill:before{content:""}.ri-luggage-deposit-line:before{content:""}.ri-lungs-fill:before{content:""}.ri-lungs-line:before{content:""}.ri-mac-fill:before{content:""}.ri-mac-line:before{content:""}.ri-macbook-fill:before{content:""}.ri-macbook-line:before{content:""}.ri-magic-fill:before{content:""}.ri-magic-line:before{content:""}.ri-mail-add-fill:before{content:""}.ri-mail-add-line:before{content:""}.ri-mail-check-fill:before{content:""}.ri-mail-check-line:before{content:""}.ri-mail-close-fill:before{content:""}.ri-mail-close-line:before{content:""}.ri-mail-download-fill:before{content:""}.ri-mail-download-line:before{content:""}.ri-mail-fill:before{content:""}.ri-mail-forbid-fill:before{content:""}.ri-mail-forbid-line:before{content:""}.ri-mail-line:before{content:""}.ri-mail-lock-fill:before{content:""}.ri-mail-lock-line:before{content:""}.ri-mail-open-fill:before{content:""}.ri-mail-open-line:before{content:""}.ri-mail-send-fill:before{content:""}.ri-mail-send-line:before{content:""}.ri-mail-settings-fill:before{content:""}.ri-mail-settings-line:before{content:""}.ri-mail-star-fill:before{content:""}.ri-mail-star-line:before{content:""}.ri-mail-unread-fill:before{content:""}.ri-mail-unread-line:before{content:""}.ri-mail-volume-fill:before{content:""}.ri-mail-volume-line:before{content:""}.ri-map-2-fill:before{content:""}.ri-map-2-line:before{content:""}.ri-map-fill:before{content:""}.ri-map-line:before{content:""}.ri-map-pin-2-fill:before{content:""}.ri-map-pin-2-line:before{content:""}.ri-map-pin-3-fill:before{content:""}.ri-map-pin-3-line:before{content:""}.ri-map-pin-4-fill:before{content:""}.ri-map-pin-4-line:before{content:""}.ri-map-pin-5-fill:before{content:""}.ri-map-pin-5-line:before{content:""}.ri-map-pin-add-fill:before{content:""}.ri-map-pin-add-line:before{content:""}.ri-map-pin-fill:before{content:""}.ri-map-pin-line:before{content:""}.ri-map-pin-range-fill:before{content:""}.ri-map-pin-range-line:before{content:""}.ri-map-pin-time-fill:before{content:""}.ri-map-pin-time-line:before{content:""}.ri-map-pin-user-fill:before{content:""}.ri-map-pin-user-line:before{content:""}.ri-mark-pen-fill:before{content:""}.ri-mark-pen-line:before{content:""}.ri-markdown-fill:before{content:""}.ri-markdown-line:before{content:""}.ri-markup-fill:before{content:""}.ri-markup-line:before{content:""}.ri-mastercard-fill:before{content:""}.ri-mastercard-line:before{content:""}.ri-mastodon-fill:before{content:""}.ri-mastodon-line:before{content:""}.ri-medal-2-fill:before{content:""}.ri-medal-2-line:before{content:""}.ri-medal-fill:before{content:""}.ri-medal-line:before{content:""}.ri-medicine-bottle-fill:before{content:""}.ri-medicine-bottle-line:before{content:""}.ri-medium-fill:before{content:""}.ri-medium-line:before{content:""}.ri-men-fill:before{content:""}.ri-men-line:before{content:""}.ri-mental-health-fill:before{content:""}.ri-mental-health-line:before{content:""}.ri-menu-2-fill:before{content:""}.ri-menu-2-line:before{content:""}.ri-menu-3-fill:before{content:""}.ri-menu-3-line:before{content:""}.ri-menu-4-fill:before{content:""}.ri-menu-4-line:before{content:""}.ri-menu-5-fill:before{content:""}.ri-menu-5-line:before{content:""}.ri-menu-add-fill:before{content:""}.ri-menu-add-line:before{content:""}.ri-menu-fill:before{content:""}.ri-menu-fold-fill:before{content:""}.ri-menu-fold-line:before{content:""}.ri-menu-line:before{content:""}.ri-menu-unfold-fill:before{content:""}.ri-menu-unfold-line:before{content:""}.ri-merge-cells-horizontal:before{content:""}.ri-merge-cells-vertical:before{content:""}.ri-message-2-fill:before{content:""}.ri-message-2-line:before{content:""}.ri-message-3-fill:before{content:""}.ri-message-3-line:before{content:""}.ri-message-fill:before{content:""}.ri-message-line:before{content:""}.ri-messenger-fill:before{content:""}.ri-messenger-line:before{content:""}.ri-meteor-fill:before{content:""}.ri-meteor-line:before{content:""}.ri-mic-2-fill:before{content:""}.ri-mic-2-line:before{content:""}.ri-mic-fill:before{content:""}.ri-mic-line:before{content:""}.ri-mic-off-fill:before{content:""}.ri-mic-off-line:before{content:""}.ri-mickey-fill:before{content:""}.ri-mickey-line:before{content:""}.ri-microscope-fill:before{content:""}.ri-microscope-line:before{content:""}.ri-microsoft-fill:before{content:""}.ri-microsoft-line:before{content:""}.ri-mind-map:before{content:""}.ri-mini-program-fill:before{content:""}.ri-mini-program-line:before{content:""}.ri-mist-fill:before{content:""}.ri-mist-line:before{content:""}.ri-money-cny-box-fill:before{content:""}.ri-money-cny-box-line:before{content:""}.ri-money-cny-circle-fill:before{content:""}.ri-money-cny-circle-line:before{content:""}.ri-money-dollar-box-fill:before{content:""}.ri-money-dollar-box-line:before{content:""}.ri-money-dollar-circle-fill:before{content:""}.ri-money-dollar-circle-line:before{content:""}.ri-money-euro-box-fill:before{content:""}.ri-money-euro-box-line:before{content:""}.ri-money-euro-circle-fill:before{content:""}.ri-money-euro-circle-line:before{content:""}.ri-money-pound-box-fill:before{content:""}.ri-money-pound-box-line:before{content:""}.ri-money-pound-circle-fill:before{content:""}.ri-money-pound-circle-line:before{content:""}.ri-moon-clear-fill:before{content:""}.ri-moon-clear-line:before{content:""}.ri-moon-cloudy-fill:before{content:""}.ri-moon-cloudy-line:before{content:""}.ri-moon-fill:before{content:""}.ri-moon-foggy-fill:before{content:""}.ri-moon-foggy-line:before{content:""}.ri-moon-line:before{content:""}.ri-more-2-fill:before{content:""}.ri-more-2-line:before{content:""}.ri-more-fill:before{content:""}.ri-more-line:before{content:""}.ri-motorbike-fill:before{content:""}.ri-motorbike-line:before{content:""}.ri-mouse-fill:before{content:""}.ri-mouse-line:before{content:""}.ri-movie-2-fill:before{content:""}.ri-movie-2-line:before{content:""}.ri-movie-fill:before{content:""}.ri-movie-line:before{content:""}.ri-music-2-fill:before{content:""}.ri-music-2-line:before{content:""}.ri-music-fill:before{content:""}.ri-music-line:before{content:""}.ri-mv-fill:before{content:""}.ri-mv-line:before{content:""}.ri-navigation-fill:before{content:""}.ri-navigation-line:before{content:""}.ri-netease-cloud-music-fill:before{content:""}.ri-netease-cloud-music-line:before{content:""}.ri-netflix-fill:before{content:""}.ri-netflix-line:before{content:""}.ri-newspaper-fill:before{content:""}.ri-newspaper-line:before{content:""}.ri-node-tree:before{content:""}.ri-notification-2-fill:before{content:""}.ri-notification-2-line:before{content:""}.ri-notification-3-fill:before{content:""}.ri-notification-3-line:before{content:""}.ri-notification-4-fill:before{content:""}.ri-notification-4-line:before{content:""}.ri-notification-badge-fill:before{content:""}.ri-notification-badge-line:before{content:""}.ri-notification-fill:before{content:""}.ri-notification-line:before{content:""}.ri-notification-off-fill:before{content:""}.ri-notification-off-line:before{content:""}.ri-npmjs-fill:before{content:""}.ri-npmjs-line:before{content:""}.ri-number-0:before{content:""}.ri-number-1:before{content:""}.ri-number-2:before{content:""}.ri-number-3:before{content:""}.ri-number-4:before{content:""}.ri-number-5:before{content:""}.ri-number-6:before{content:""}.ri-number-7:before{content:""}.ri-number-8:before{content:""}.ri-number-9:before{content:""}.ri-numbers-fill:before{content:""}.ri-numbers-line:before{content:""}.ri-nurse-fill:before{content:""}.ri-nurse-line:before{content:""}.ri-oil-fill:before{content:""}.ri-oil-line:before{content:""}.ri-omega:before{content:""}.ri-open-arm-fill:before{content:""}.ri-open-arm-line:before{content:""}.ri-open-source-fill:before{content:""}.ri-open-source-line:before{content:""}.ri-opera-fill:before{content:""}.ri-opera-line:before{content:""}.ri-order-play-fill:before{content:""}.ri-order-play-line:before{content:""}.ri-organization-chart:before{content:""}.ri-outlet-2-fill:before{content:""}.ri-outlet-2-line:before{content:""}.ri-outlet-fill:before{content:""}.ri-outlet-line:before{content:""}.ri-page-separator:before{content:""}.ri-pages-fill:before{content:""}.ri-pages-line:before{content:""}.ri-paint-brush-fill:before{content:""}.ri-paint-brush-line:before{content:""}.ri-paint-fill:before{content:""}.ri-paint-line:before{content:""}.ri-palette-fill:before{content:""}.ri-palette-line:before{content:""}.ri-pantone-fill:before{content:""}.ri-pantone-line:before{content:""}.ri-paragraph:before{content:""}.ri-parent-fill:before{content:""}.ri-parent-line:before{content:""}.ri-parentheses-fill:before{content:""}.ri-parentheses-line:before{content:""}.ri-parking-box-fill:before{content:""}.ri-parking-box-line:before{content:""}.ri-parking-fill:before{content:""}.ri-parking-line:before{content:""}.ri-passport-fill:before{content:""}.ri-passport-line:before{content:""}.ri-patreon-fill:before{content:""}.ri-patreon-line:before{content:""}.ri-pause-circle-fill:before{content:""}.ri-pause-circle-line:before{content:""}.ri-pause-fill:before{content:""}.ri-pause-line:before{content:""}.ri-pause-mini-fill:before{content:""}.ri-pause-mini-line:before{content:""}.ri-paypal-fill:before{content:""}.ri-paypal-line:before{content:""}.ri-pen-nib-fill:before{content:""}.ri-pen-nib-line:before{content:""}.ri-pencil-fill:before{content:""}.ri-pencil-line:before{content:""}.ri-pencil-ruler-2-fill:before{content:""}.ri-pencil-ruler-2-line:before{content:""}.ri-pencil-ruler-fill:before{content:""}.ri-pencil-ruler-line:before{content:""}.ri-percent-fill:before{content:""}.ri-percent-line:before{content:""}.ri-phone-camera-fill:before{content:""}.ri-phone-camera-line:before{content:""}.ri-phone-fill:before{content:""}.ri-phone-find-fill:before{content:""}.ri-phone-find-line:before{content:""}.ri-phone-line:before{content:""}.ri-phone-lock-fill:before{content:""}.ri-phone-lock-line:before{content:""}.ri-picture-in-picture-2-fill:before{content:""}.ri-picture-in-picture-2-line:before{content:""}.ri-picture-in-picture-exit-fill:before{content:""}.ri-picture-in-picture-exit-line:before{content:""}.ri-picture-in-picture-fill:before{content:""}.ri-picture-in-picture-line:before{content:""}.ri-pie-chart-2-fill:before{content:""}.ri-pie-chart-2-line:before{content:""}.ri-pie-chart-box-fill:before{content:""}.ri-pie-chart-box-line:before{content:""}.ri-pie-chart-fill:before{content:""}.ri-pie-chart-line:before{content:""}.ri-pin-distance-fill:before{content:""}.ri-pin-distance-line:before{content:""}.ri-ping-pong-fill:before{content:""}.ri-ping-pong-line:before{content:""}.ri-pinterest-fill:before{content:""}.ri-pinterest-line:before{content:""}.ri-pinyin-input:before{content:""}.ri-pixelfed-fill:before{content:""}.ri-pixelfed-line:before{content:""}.ri-plane-fill:before{content:""}.ri-plane-line:before{content:""}.ri-plant-fill:before{content:""}.ri-plant-line:before{content:""}.ri-play-circle-fill:before{content:""}.ri-play-circle-line:before{content:""}.ri-play-fill:before{content:""}.ri-play-line:before{content:""}.ri-play-list-2-fill:before{content:""}.ri-play-list-2-line:before{content:""}.ri-play-list-add-fill:before{content:""}.ri-play-list-add-line:before{content:""}.ri-play-list-fill:before{content:""}.ri-play-list-line:before{content:""}.ri-play-mini-fill:before{content:""}.ri-play-mini-line:before{content:""}.ri-playstation-fill:before{content:""}.ri-playstation-line:before{content:""}.ri-plug-2-fill:before{content:""}.ri-plug-2-line:before{content:""}.ri-plug-fill:before{content:""}.ri-plug-line:before{content:""}.ri-polaroid-2-fill:before{content:""}.ri-polaroid-2-line:before{content:""}.ri-polaroid-fill:before{content:""}.ri-polaroid-line:before{content:""}.ri-police-car-fill:before{content:""}.ri-police-car-line:before{content:""}.ri-price-tag-2-fill:before{content:""}.ri-price-tag-2-line:before{content:""}.ri-price-tag-3-fill:before{content:""}.ri-price-tag-3-line:before{content:""}.ri-price-tag-fill:before{content:""}.ri-price-tag-line:before{content:""}.ri-printer-cloud-fill:before{content:""}.ri-printer-cloud-line:before{content:""}.ri-printer-fill:before{content:""}.ri-printer-line:before{content:""}.ri-product-hunt-fill:before{content:""}.ri-product-hunt-line:before{content:""}.ri-profile-fill:before{content:""}.ri-profile-line:before{content:""}.ri-projector-2-fill:before{content:""}.ri-projector-2-line:before{content:""}.ri-projector-fill:before{content:""}.ri-projector-line:before{content:""}.ri-psychotherapy-fill:before{content:""}.ri-psychotherapy-line:before{content:""}.ri-pulse-fill:before{content:""}.ri-pulse-line:before{content:""}.ri-pushpin-2-fill:before{content:""}.ri-pushpin-2-line:before{content:""}.ri-pushpin-fill:before{content:""}.ri-pushpin-line:before{content:""}.ri-qq-fill:before{content:""}.ri-qq-line:before{content:""}.ri-qr-code-fill:before{content:""}.ri-qr-code-line:before{content:""}.ri-qr-scan-2-fill:before{content:""}.ri-qr-scan-2-line:before{content:""}.ri-qr-scan-fill:before{content:""}.ri-qr-scan-line:before{content:""}.ri-question-answer-fill:before{content:""}.ri-question-answer-line:before{content:""}.ri-question-fill:before{content:""}.ri-question-line:before{content:""}.ri-question-mark:before{content:""}.ri-questionnaire-fill:before{content:""}.ri-questionnaire-line:before{content:""}.ri-quill-pen-fill:before{content:""}.ri-quill-pen-line:before{content:""}.ri-radar-fill:before{content:""}.ri-radar-line:before{content:""}.ri-radio-2-fill:before{content:""}.ri-radio-2-line:before{content:""}.ri-radio-button-fill:before{content:""}.ri-radio-button-line:before{content:""}.ri-radio-fill:before{content:""}.ri-radio-line:before{content:""}.ri-rainbow-fill:before{content:""}.ri-rainbow-line:before{content:""}.ri-rainy-fill:before{content:""}.ri-rainy-line:before{content:""}.ri-reactjs-fill:before{content:""}.ri-reactjs-line:before{content:""}.ri-record-circle-fill:before{content:""}.ri-record-circle-line:before{content:""}.ri-record-mail-fill:before{content:""}.ri-record-mail-line:before{content:""}.ri-recycle-fill:before{content:""}.ri-recycle-line:before{content:""}.ri-red-packet-fill:before{content:""}.ri-red-packet-line:before{content:""}.ri-reddit-fill:before{content:""}.ri-reddit-line:before{content:""}.ri-refresh-fill:before{content:""}.ri-refresh-line:before{content:""}.ri-refund-2-fill:before{content:""}.ri-refund-2-line:before{content:""}.ri-refund-fill:before{content:""}.ri-refund-line:before{content:""}.ri-registered-fill:before{content:""}.ri-registered-line:before{content:""}.ri-remixicon-fill:before{content:""}.ri-remixicon-line:before{content:""}.ri-remote-control-2-fill:before{content:""}.ri-remote-control-2-line:before{content:""}.ri-remote-control-fill:before{content:""}.ri-remote-control-line:before{content:""}.ri-repeat-2-fill:before{content:""}.ri-repeat-2-line:before{content:""}.ri-repeat-fill:before{content:""}.ri-repeat-line:before{content:""}.ri-repeat-one-fill:before{content:""}.ri-repeat-one-line:before{content:""}.ri-reply-all-fill:before{content:""}.ri-reply-all-line:before{content:""}.ri-reply-fill:before{content:""}.ri-reply-line:before{content:""}.ri-reserved-fill:before{content:""}.ri-reserved-line:before{content:""}.ri-rest-time-fill:before{content:""}.ri-rest-time-line:before{content:""}.ri-restart-fill:before{content:""}.ri-restart-line:before{content:""}.ri-restaurant-2-fill:before{content:""}.ri-restaurant-2-line:before{content:""}.ri-restaurant-fill:before{content:""}.ri-restaurant-line:before{content:""}.ri-rewind-fill:before{content:""}.ri-rewind-line:before{content:""}.ri-rewind-mini-fill:before{content:""}.ri-rewind-mini-line:before{content:""}.ri-rhythm-fill:before{content:""}.ri-rhythm-line:before{content:""}.ri-riding-fill:before{content:""}.ri-riding-line:before{content:""}.ri-road-map-fill:before{content:""}.ri-road-map-line:before{content:""}.ri-roadster-fill:before{content:""}.ri-roadster-line:before{content:""}.ri-robot-fill:before{content:""}.ri-robot-line:before{content:""}.ri-rocket-2-fill:before{content:""}.ri-rocket-2-line:before{content:""}.ri-rocket-fill:before{content:""}.ri-rocket-line:before{content:""}.ri-rotate-lock-fill:before{content:""}.ri-rotate-lock-line:before{content:""}.ri-rounded-corner:before{content:""}.ri-route-fill:before{content:""}.ri-route-line:before{content:""}.ri-router-fill:before{content:""}.ri-router-line:before{content:""}.ri-rss-fill:before{content:""}.ri-rss-line:before{content:""}.ri-ruler-2-fill:before{content:""}.ri-ruler-2-line:before{content:""}.ri-ruler-fill:before{content:""}.ri-ruler-line:before{content:""}.ri-run-fill:before{content:""}.ri-run-line:before{content:""}.ri-safari-fill:before{content:""}.ri-safari-line:before{content:""}.ri-safe-2-fill:before{content:""}.ri-safe-2-line:before{content:""}.ri-safe-fill:before{content:""}.ri-safe-line:before{content:""}.ri-sailboat-fill:before{content:""}.ri-sailboat-line:before{content:""}.ri-save-2-fill:before{content:""}.ri-save-2-line:before{content:""}.ri-save-3-fill:before{content:""}.ri-save-3-line:before{content:""}.ri-save-fill:before{content:""}.ri-save-line:before{content:""}.ri-scales-2-fill:before{content:""}.ri-scales-2-line:before{content:""}.ri-scales-3-fill:before{content:""}.ri-scales-3-line:before{content:""}.ri-scales-fill:before{content:""}.ri-scales-line:before{content:""}.ri-scan-2-fill:before{content:""}.ri-scan-2-line:before{content:""}.ri-scan-fill:before{content:""}.ri-scan-line:before{content:""}.ri-scissors-2-fill:before{content:""}.ri-scissors-2-line:before{content:""}.ri-scissors-cut-fill:before{content:""}.ri-scissors-cut-line:before{content:""}.ri-scissors-fill:before{content:""}.ri-scissors-line:before{content:""}.ri-screenshot-2-fill:before{content:""}.ri-screenshot-2-line:before{content:""}.ri-screenshot-fill:before{content:""}.ri-screenshot-line:before{content:""}.ri-sd-card-fill:before{content:""}.ri-sd-card-line:before{content:""}.ri-sd-card-mini-fill:before{content:""}.ri-sd-card-mini-line:before{content:""}.ri-search-2-fill:before{content:""}.ri-search-2-line:before{content:""}.ri-search-eye-fill:before{content:""}.ri-search-eye-line:before{content:""}.ri-search-fill:before{content:""}.ri-search-line:before{content:""}.ri-secure-payment-fill:before{content:""}.ri-secure-payment-line:before{content:""}.ri-seedling-fill:before{content:""}.ri-seedling-line:before{content:""}.ri-send-backward:before{content:""}.ri-send-plane-2-fill:before{content:""}.ri-send-plane-2-line:before{content:""}.ri-send-plane-fill:before{content:""}.ri-send-plane-line:before{content:""}.ri-send-to-back:before{content:""}.ri-sensor-fill:before{content:""}.ri-sensor-line:before{content:""}.ri-separator:before{content:""}.ri-server-fill:before{content:""}.ri-server-line:before{content:""}.ri-service-fill:before{content:""}.ri-service-line:before{content:""}.ri-settings-2-fill:before{content:""}.ri-settings-2-line:before{content:""}.ri-settings-3-fill:before{content:""}.ri-settings-3-line:before{content:""}.ri-settings-4-fill:before{content:""}.ri-settings-4-line:before{content:""}.ri-settings-5-fill:before{content:""}.ri-settings-5-line:before{content:""}.ri-settings-6-fill:before{content:""}.ri-settings-6-line:before{content:""}.ri-settings-fill:before{content:""}.ri-settings-line:before{content:""}.ri-shape-2-fill:before{content:""}.ri-shape-2-line:before{content:""}.ri-shape-fill:before{content:""}.ri-shape-line:before{content:""}.ri-share-box-fill:before{content:""}.ri-share-box-line:before{content:""}.ri-share-circle-fill:before{content:""}.ri-share-circle-line:before{content:""}.ri-share-fill:before{content:""}.ri-share-forward-2-fill:before{content:""}.ri-share-forward-2-line:before{content:""}.ri-share-forward-box-fill:before{content:""}.ri-share-forward-box-line:before{content:""}.ri-share-forward-fill:before{content:""}.ri-share-forward-line:before{content:""}.ri-share-line:before{content:""}.ri-shield-check-fill:before{content:""}.ri-shield-check-line:before{content:""}.ri-shield-cross-fill:before{content:""}.ri-shield-cross-line:before{content:""}.ri-shield-fill:before{content:""}.ri-shield-flash-fill:before{content:""}.ri-shield-flash-line:before{content:""}.ri-shield-keyhole-fill:before{content:""}.ri-shield-keyhole-line:before{content:""}.ri-shield-line:before{content:""}.ri-shield-star-fill:before{content:""}.ri-shield-star-line:before{content:""}.ri-shield-user-fill:before{content:""}.ri-shield-user-line:before{content:""}.ri-ship-2-fill:before{content:""}.ri-ship-2-line:before{content:""}.ri-ship-fill:before{content:""}.ri-ship-line:before{content:""}.ri-shirt-fill:before{content:""}.ri-shirt-line:before{content:""}.ri-shopping-bag-2-fill:before{content:""}.ri-shopping-bag-2-line:before{content:""}.ri-shopping-bag-3-fill:before{content:""}.ri-shopping-bag-3-line:before{content:""}.ri-shopping-bag-fill:before{content:""}.ri-shopping-bag-line:before{content:""}.ri-shopping-basket-2-fill:before{content:""}.ri-shopping-basket-2-line:before{content:""}.ri-shopping-basket-fill:before{content:""}.ri-shopping-basket-line:before{content:""}.ri-shopping-cart-2-fill:before{content:""}.ri-shopping-cart-2-line:before{content:""}.ri-shopping-cart-fill:before{content:""}.ri-shopping-cart-line:before{content:""}.ri-showers-fill:before{content:""}.ri-showers-line:before{content:""}.ri-shuffle-fill:before{content:""}.ri-shuffle-line:before{content:""}.ri-shut-down-fill:before{content:""}.ri-shut-down-line:before{content:""}.ri-side-bar-fill:before{content:""}.ri-side-bar-line:before{content:""}.ri-signal-tower-fill:before{content:""}.ri-signal-tower-line:before{content:""}.ri-signal-wifi-1-fill:before{content:""}.ri-signal-wifi-1-line:before{content:""}.ri-signal-wifi-2-fill:before{content:""}.ri-signal-wifi-2-line:before{content:""}.ri-signal-wifi-3-fill:before{content:""}.ri-signal-wifi-3-line:before{content:""}.ri-signal-wifi-error-fill:before{content:""}.ri-signal-wifi-error-line:before{content:""}.ri-signal-wifi-fill:before{content:""}.ri-signal-wifi-line:before{content:""}.ri-signal-wifi-off-fill:before{content:""}.ri-signal-wifi-off-line:before{content:""}.ri-sim-card-2-fill:before{content:""}.ri-sim-card-2-line:before{content:""}.ri-sim-card-fill:before{content:""}.ri-sim-card-line:before{content:""}.ri-single-quotes-l:before{content:""}.ri-single-quotes-r:before{content:""}.ri-sip-fill:before{content:""}.ri-sip-line:before{content:""}.ri-skip-back-fill:before{content:""}.ri-skip-back-line:before{content:""}.ri-skip-back-mini-fill:before{content:""}.ri-skip-back-mini-line:before{content:""}.ri-skip-forward-fill:before{content:""}.ri-skip-forward-line:before{content:""}.ri-skip-forward-mini-fill:before{content:""}.ri-skip-forward-mini-line:before{content:""}.ri-skull-2-fill:before{content:""}.ri-skull-2-line:before{content:""}.ri-skull-fill:before{content:""}.ri-skull-line:before{content:""}.ri-skype-fill:before{content:""}.ri-skype-line:before{content:""}.ri-slack-fill:before{content:""}.ri-slack-line:before{content:""}.ri-slice-fill:before{content:""}.ri-slice-line:before{content:""}.ri-slideshow-2-fill:before{content:""}.ri-slideshow-2-line:before{content:""}.ri-slideshow-3-fill:before{content:""}.ri-slideshow-3-line:before{content:""}.ri-slideshow-4-fill:before{content:""}.ri-slideshow-4-line:before{content:""}.ri-slideshow-fill:before{content:""}.ri-slideshow-line:before{content:""}.ri-smartphone-fill:before{content:""}.ri-smartphone-line:before{content:""}.ri-snapchat-fill:before{content:""}.ri-snapchat-line:before{content:""}.ri-snowy-fill:before{content:""}.ri-snowy-line:before{content:""}.ri-sort-asc:before{content:""}.ri-sort-desc:before{content:""}.ri-sound-module-fill:before{content:""}.ri-sound-module-line:before{content:""}.ri-soundcloud-fill:before{content:""}.ri-soundcloud-line:before{content:""}.ri-space-ship-fill:before{content:""}.ri-space-ship-line:before{content:""}.ri-space:before{content:""}.ri-spam-2-fill:before{content:""}.ri-spam-2-line:before{content:""}.ri-spam-3-fill:before{content:""}.ri-spam-3-line:before{content:""}.ri-spam-fill:before{content:""}.ri-spam-line:before{content:""}.ri-speaker-2-fill:before{content:""}.ri-speaker-2-line:before{content:""}.ri-speaker-3-fill:before{content:""}.ri-speaker-3-line:before{content:""}.ri-speaker-fill:before{content:""}.ri-speaker-line:before{content:""}.ri-spectrum-fill:before{content:""}.ri-spectrum-line:before{content:""}.ri-speed-fill:before{content:""}.ri-speed-line:before{content:""}.ri-speed-mini-fill:before{content:""}.ri-speed-mini-line:before{content:""}.ri-split-cells-horizontal:before{content:""}.ri-split-cells-vertical:before{content:""}.ri-spotify-fill:before{content:""}.ri-spotify-line:before{content:""}.ri-spy-fill:before{content:""}.ri-spy-line:before{content:""}.ri-stack-fill:before{content:""}.ri-stack-line:before{content:""}.ri-stack-overflow-fill:before{content:""}.ri-stack-overflow-line:before{content:""}.ri-stackshare-fill:before{content:""}.ri-stackshare-line:before{content:""}.ri-star-fill:before{content:""}.ri-star-half-fill:before{content:""}.ri-star-half-line:before{content:""}.ri-star-half-s-fill:before{content:""}.ri-star-half-s-line:before{content:""}.ri-star-line:before{content:""}.ri-star-s-fill:before{content:""}.ri-star-s-line:before{content:""}.ri-star-smile-fill:before{content:""}.ri-star-smile-line:before{content:""}.ri-steam-fill:before{content:""}.ri-steam-line:before{content:""}.ri-steering-2-fill:before{content:""}.ri-steering-2-line:before{content:""}.ri-steering-fill:before{content:""}.ri-steering-line:before{content:""}.ri-stethoscope-fill:before{content:""}.ri-stethoscope-line:before{content:""}.ri-sticky-note-2-fill:before{content:""}.ri-sticky-note-2-line:before{content:""}.ri-sticky-note-fill:before{content:""}.ri-sticky-note-line:before{content:""}.ri-stock-fill:before{content:""}.ri-stock-line:before{content:""}.ri-stop-circle-fill:before{content:""}.ri-stop-circle-line:before{content:""}.ri-stop-fill:before{content:""}.ri-stop-line:before{content:""}.ri-stop-mini-fill:before{content:""}.ri-stop-mini-line:before{content:""}.ri-store-2-fill:before{content:""}.ri-store-2-line:before{content:""}.ri-store-3-fill:before{content:""}.ri-store-3-line:before{content:""}.ri-store-fill:before{content:""}.ri-store-line:before{content:""}.ri-strikethrough-2:before{content:""}.ri-strikethrough:before{content:""}.ri-subscript-2:before{content:""}.ri-subscript:before{content:""}.ri-subtract-fill:before{content:""}.ri-subtract-line:before{content:""}.ri-subway-fill:before{content:""}.ri-subway-line:before{content:""}.ri-subway-wifi-fill:before{content:""}.ri-subway-wifi-line:before{content:""}.ri-suitcase-2-fill:before{content:""}.ri-suitcase-2-line:before{content:""}.ri-suitcase-3-fill:before{content:""}.ri-suitcase-3-line:before{content:""}.ri-suitcase-fill:before{content:""}.ri-suitcase-line:before{content:""}.ri-sun-cloudy-fill:before{content:""}.ri-sun-cloudy-line:before{content:""}.ri-sun-fill:before{content:""}.ri-sun-foggy-fill:before{content:""}.ri-sun-foggy-line:before{content:""}.ri-sun-line:before{content:""}.ri-superscript-2:before{content:""}.ri-superscript:before{content:""}.ri-surgical-mask-fill:before{content:""}.ri-surgical-mask-line:before{content:""}.ri-surround-sound-fill:before{content:""}.ri-surround-sound-line:before{content:""}.ri-survey-fill:before{content:""}.ri-survey-line:before{content:""}.ri-swap-box-fill:before{content:""}.ri-swap-box-line:before{content:""}.ri-swap-fill:before{content:""}.ri-swap-line:before{content:""}.ri-switch-fill:before{content:""}.ri-switch-line:before{content:""}.ri-sword-fill:before{content:""}.ri-sword-line:before{content:""}.ri-syringe-fill:before{content:""}.ri-syringe-line:before{content:""}.ri-t-box-fill:before{content:""}.ri-t-box-line:before{content:""}.ri-t-shirt-2-fill:before{content:""}.ri-t-shirt-2-line:before{content:""}.ri-t-shirt-air-fill:before{content:""}.ri-t-shirt-air-line:before{content:""}.ri-t-shirt-fill:before{content:""}.ri-t-shirt-line:before{content:""}.ri-table-2:before{content:""}.ri-table-alt-fill:before{content:""}.ri-table-alt-line:before{content:""}.ri-table-fill:before{content:""}.ri-table-line:before{content:""}.ri-tablet-fill:before{content:""}.ri-tablet-line:before{content:""}.ri-takeaway-fill:before{content:""}.ri-takeaway-line:before{content:""}.ri-taobao-fill:before{content:""}.ri-taobao-line:before{content:""}.ri-tape-fill:before{content:""}.ri-tape-line:before{content:""}.ri-task-fill:before{content:""}.ri-task-line:before{content:""}.ri-taxi-fill:before{content:""}.ri-taxi-line:before{content:""}.ri-taxi-wifi-fill:before{content:""}.ri-taxi-wifi-line:before{content:""}.ri-team-fill:before{content:""}.ri-team-line:before{content:""}.ri-telegram-fill:before{content:""}.ri-telegram-line:before{content:""}.ri-temp-cold-fill:before{content:""}.ri-temp-cold-line:before{content:""}.ri-temp-hot-fill:before{content:""}.ri-temp-hot-line:before{content:""}.ri-terminal-box-fill:before{content:""}.ri-terminal-box-line:before{content:""}.ri-terminal-fill:before{content:""}.ri-terminal-line:before{content:""}.ri-terminal-window-fill:before{content:""}.ri-terminal-window-line:before{content:""}.ri-test-tube-fill:before{content:""}.ri-test-tube-line:before{content:""}.ri-text-direction-l:before{content:""}.ri-text-direction-r:before{content:""}.ri-text-spacing:before{content:""}.ri-text-wrap:before{content:""}.ri-text:before{content:""}.ri-thermometer-fill:before{content:""}.ri-thermometer-line:before{content:""}.ri-thumb-down-fill:before{content:""}.ri-thumb-down-line:before{content:""}.ri-thumb-up-fill:before{content:""}.ri-thumb-up-line:before{content:""}.ri-thunderstorms-fill:before{content:""}.ri-thunderstorms-line:before{content:""}.ri-ticket-2-fill:before{content:""}.ri-ticket-2-line:before{content:""}.ri-ticket-fill:before{content:""}.ri-ticket-line:before{content:""}.ri-time-fill:before{content:""}.ri-time-line:before{content:""}.ri-timer-2-fill:before{content:""}.ri-timer-2-line:before{content:""}.ri-timer-fill:before{content:""}.ri-timer-flash-fill:before{content:""}.ri-timer-flash-line:before{content:""}.ri-timer-line:before{content:""}.ri-todo-fill:before{content:""}.ri-todo-line:before{content:""}.ri-toggle-fill:before{content:""}.ri-toggle-line:before{content:""}.ri-tools-fill:before{content:""}.ri-tools-line:before{content:""}.ri-tornado-fill:before{content:""}.ri-tornado-line:before{content:""}.ri-trademark-fill:before{content:""}.ri-trademark-line:before{content:""}.ri-traffic-light-fill:before{content:""}.ri-traffic-light-line:before{content:""}.ri-train-fill:before{content:""}.ri-train-line:before{content:""}.ri-train-wifi-fill:before{content:""}.ri-train-wifi-line:before{content:""}.ri-translate-2:before{content:""}.ri-translate:before{content:""}.ri-travesti-fill:before{content:""}.ri-travesti-line:before{content:""}.ri-treasure-map-fill:before{content:""}.ri-treasure-map-line:before{content:""}.ri-trello-fill:before{content:""}.ri-trello-line:before{content:""}.ri-trophy-fill:before{content:""}.ri-trophy-line:before{content:""}.ri-truck-fill:before{content:""}.ri-truck-line:before{content:""}.ri-tumblr-fill:before{content:""}.ri-tumblr-line:before{content:""}.ri-tv-2-fill:before{content:""}.ri-tv-2-line:before{content:""}.ri-tv-fill:before{content:""}.ri-tv-line:before{content:""}.ri-twitch-fill:before{content:""}.ri-twitch-line:before{content:""}.ri-twitter-fill:before{content:""}.ri-twitter-line:before{content:""}.ri-typhoon-fill:before{content:""}.ri-typhoon-line:before{content:""}.ri-u-disk-fill:before{content:""}.ri-u-disk-line:before{content:""}.ri-ubuntu-fill:before{content:""}.ri-ubuntu-line:before{content:""}.ri-umbrella-fill:before{content:""}.ri-umbrella-line:before{content:""}.ri-underline:before{content:""}.ri-uninstall-fill:before{content:""}.ri-uninstall-line:before{content:""}.ri-unsplash-fill:before{content:""}.ri-unsplash-line:before{content:""}.ri-upload-2-fill:before{content:""}.ri-upload-2-line:before{content:""}.ri-upload-cloud-2-fill:before{content:""}.ri-upload-cloud-2-line:before{content:""}.ri-upload-cloud-fill:before{content:""}.ri-upload-cloud-line:before{content:""}.ri-upload-fill:before{content:""}.ri-upload-line:before{content:""}.ri-usb-fill:before{content:""}.ri-usb-line:before{content:""}.ri-user-2-fill:before{content:""}.ri-user-2-line:before{content:""}.ri-user-3-fill:before{content:""}.ri-user-3-line:before{content:""}.ri-user-4-fill:before{content:""}.ri-user-4-line:before{content:""}.ri-user-5-fill:before{content:""}.ri-user-5-line:before{content:""}.ri-user-6-fill:before{content:""}.ri-user-6-line:before{content:""}.ri-user-add-fill:before{content:""}.ri-user-add-line:before{content:""}.ri-user-fill:before{content:""}.ri-user-follow-fill:before{content:""}.ri-user-follow-line:before{content:""}.ri-user-heart-fill:before{content:""}.ri-user-heart-line:before{content:""}.ri-user-line:before{content:""}.ri-user-location-fill:before{content:""}.ri-user-location-line:before{content:""}.ri-user-received-2-fill:before{content:""}.ri-user-received-2-line:before{content:""}.ri-user-received-fill:before{content:""}.ri-user-received-line:before{content:""}.ri-user-search-fill:before{content:""}.ri-user-search-line:before{content:""}.ri-user-settings-fill:before{content:""}.ri-user-settings-line:before{content:""}.ri-user-shared-2-fill:before{content:""}.ri-user-shared-2-line:before{content:""}.ri-user-shared-fill:before{content:""}.ri-user-shared-line:before{content:""}.ri-user-smile-fill:before{content:""}.ri-user-smile-line:before{content:""}.ri-user-star-fill:before{content:""}.ri-user-star-line:before{content:""}.ri-user-unfollow-fill:before{content:""}.ri-user-unfollow-line:before{content:""}.ri-user-voice-fill:before{content:""}.ri-user-voice-line:before{content:""}.ri-video-add-fill:before{content:""}.ri-video-add-line:before{content:""}.ri-video-chat-fill:before{content:""}.ri-video-chat-line:before{content:""}.ri-video-download-fill:before{content:""}.ri-video-download-line:before{content:""}.ri-video-fill:before{content:""}.ri-video-line:before{content:""}.ri-video-upload-fill:before{content:""}.ri-video-upload-line:before{content:""}.ri-vidicon-2-fill:before{content:""}.ri-vidicon-2-line:before{content:""}.ri-vidicon-fill:before{content:""}.ri-vidicon-line:before{content:""}.ri-vimeo-fill:before{content:""}.ri-vimeo-line:before{content:""}.ri-vip-crown-2-fill:before{content:""}.ri-vip-crown-2-line:before{content:""}.ri-vip-crown-fill:before{content:""}.ri-vip-crown-line:before{content:""}.ri-vip-diamond-fill:before{content:""}.ri-vip-diamond-line:before{content:""}.ri-vip-fill:before{content:""}.ri-vip-line:before{content:""}.ri-virus-fill:before{content:""}.ri-virus-line:before{content:""}.ri-visa-fill:before{content:""}.ri-visa-line:before{content:""}.ri-voice-recognition-fill:before{content:""}.ri-voice-recognition-line:before{content:""}.ri-voiceprint-fill:before{content:""}.ri-voiceprint-line:before{content:""}.ri-volume-down-fill:before{content:""}.ri-volume-down-line:before{content:""}.ri-volume-mute-fill:before{content:""}.ri-volume-mute-line:before{content:""}.ri-volume-off-vibrate-fill:before{content:""}.ri-volume-off-vibrate-line:before{content:""}.ri-volume-up-fill:before{content:""}.ri-volume-up-line:before{content:""}.ri-volume-vibrate-fill:before{content:""}.ri-volume-vibrate-line:before{content:""}.ri-vuejs-fill:before{content:""}.ri-vuejs-line:before{content:""}.ri-walk-fill:before{content:""}.ri-walk-line:before{content:""}.ri-wallet-2-fill:before{content:""}.ri-wallet-2-line:before{content:""}.ri-wallet-3-fill:before{content:""}.ri-wallet-3-line:before{content:""}.ri-wallet-fill:before{content:""}.ri-wallet-line:before{content:""}.ri-water-flash-fill:before{content:""}.ri-water-flash-line:before{content:""}.ri-webcam-fill:before{content:""}.ri-webcam-line:before{content:""}.ri-wechat-2-fill:before{content:""}.ri-wechat-2-line:before{content:""}.ri-wechat-fill:before{content:""}.ri-wechat-line:before{content:""}.ri-wechat-pay-fill:before{content:""}.ri-wechat-pay-line:before{content:""}.ri-weibo-fill:before{content:""}.ri-weibo-line:before{content:""}.ri-whatsapp-fill:before{content:""}.ri-whatsapp-line:before{content:""}.ri-wheelchair-fill:before{content:""}.ri-wheelchair-line:before{content:""}.ri-wifi-fill:before{content:""}.ri-wifi-line:before{content:""}.ri-wifi-off-fill:before{content:""}.ri-wifi-off-line:before{content:""}.ri-window-2-fill:before{content:""}.ri-window-2-line:before{content:""}.ri-window-fill:before{content:""}.ri-window-line:before{content:""}.ri-windows-fill:before{content:""}.ri-windows-line:before{content:""}.ri-windy-fill:before{content:""}.ri-windy-line:before{content:""}.ri-wireless-charging-fill:before{content:""}.ri-wireless-charging-line:before{content:""}.ri-women-fill:before{content:""}.ri-women-line:before{content:""}.ri-wubi-input:before{content:""}.ri-xbox-fill:before{content:""}.ri-xbox-line:before{content:""}.ri-xing-fill:before{content:""}.ri-xing-line:before{content:""}.ri-youtube-fill:before{content:""}.ri-youtube-line:before{content:""}.ri-zcool-fill:before{content:""}.ri-zcool-line:before{content:""}.ri-zhihu-fill:before{content:""}.ri-zhihu-line:before{content:""}.ri-zoom-in-fill:before{content:""}.ri-zoom-in-line:before{content:""}.ri-zoom-out-fill:before{content:""}.ri-zoom-out-line:before{content:""}.ri-zzz-fill:before{content:""}.ri-zzz-line:before{content:""}.ri-arrow-down-double-fill:before{content:""}.ri-arrow-down-double-line:before{content:""}.ri-arrow-left-double-fill:before{content:""}.ri-arrow-left-double-line:before{content:""}.ri-arrow-right-double-fill:before{content:""}.ri-arrow-right-double-line:before{content:""}.ri-arrow-turn-back-fill:before{content:""}.ri-arrow-turn-back-line:before{content:""}.ri-arrow-turn-forward-fill:before{content:""}.ri-arrow-turn-forward-line:before{content:""}.ri-arrow-up-double-fill:before{content:""}.ri-arrow-up-double-line:before{content:""}.ri-bard-fill:before{content:""}.ri-bard-line:before{content:""}.ri-bootstrap-fill:before{content:""}.ri-bootstrap-line:before{content:""}.ri-box-1-fill:before{content:""}.ri-box-1-line:before{content:""}.ri-box-2-fill:before{content:""}.ri-box-2-line:before{content:""}.ri-box-3-fill:before{content:""}.ri-box-3-line:before{content:""}.ri-brain-fill:before{content:""}.ri-brain-line:before{content:""}.ri-candle-fill:before{content:""}.ri-candle-line:before{content:""}.ri-cash-fill:before{content:""}.ri-cash-line:before{content:""}.ri-contract-left-fill:before{content:""}.ri-contract-left-line:before{content:""}.ri-contract-left-right-fill:before{content:""}.ri-contract-left-right-line:before{content:""}.ri-contract-right-fill:before{content:""}.ri-contract-right-line:before{content:""}.ri-contract-up-down-fill:before{content:""}.ri-contract-up-down-line:before{content:""}.ri-copilot-fill:before{content:""}.ri-copilot-line:before{content:""}.ri-corner-down-left-fill:before{content:""}.ri-corner-down-left-line:before{content:""}.ri-corner-down-right-fill:before{content:""}.ri-corner-down-right-line:before{content:""}.ri-corner-left-down-fill:before{content:""}.ri-corner-left-down-line:before{content:""}.ri-corner-left-up-fill:before{content:""}.ri-corner-left-up-line:before{content:""}.ri-corner-right-down-fill:before{content:""}.ri-corner-right-down-line:before{content:""}.ri-corner-right-up-fill:before{content:""}.ri-corner-right-up-line:before{content:""}.ri-corner-up-left-double-fill:before{content:""}.ri-corner-up-left-double-line:before{content:""}.ri-corner-up-left-fill:before{content:""}.ri-corner-up-left-line:before{content:""}.ri-corner-up-right-double-fill:before{content:""}.ri-corner-up-right-double-line:before{content:""}.ri-corner-up-right-fill:before{content:""}.ri-corner-up-right-line:before{content:""}.ri-cross-fill:before{content:""}.ri-cross-line:before{content:""}.ri-edge-new-fill:before{content:""}.ri-edge-new-line:before{content:""}.ri-equal-fill:before{content:""}.ri-equal-line:before{content:""}.ri-expand-left-fill:before{content:""}.ri-expand-left-line:before{content:""}.ri-expand-left-right-fill:before{content:""}.ri-expand-left-right-line:before{content:""}.ri-expand-right-fill:before{content:""}.ri-expand-right-line:before{content:""}.ri-expand-up-down-fill:before{content:""}.ri-expand-up-down-line:before{content:""}.ri-flickr-fill:before{content:""}.ri-flickr-line:before{content:""}.ri-forward-10-fill:before{content:""}.ri-forward-10-line:before{content:""}.ri-forward-15-fill:before{content:""}.ri-forward-15-line:before{content:""}.ri-forward-30-fill:before{content:""}.ri-forward-30-line:before{content:""}.ri-forward-5-fill:before{content:""}.ri-forward-5-line:before{content:""}.ri-graduation-cap-fill:before{content:""}.ri-graduation-cap-line:before{content:""}.ri-home-office-fill:before{content:""}.ri-home-office-line:before{content:""}.ri-hourglass-2-fill:before{content:""}.ri-hourglass-2-line:before{content:""}.ri-hourglass-fill:before{content:""}.ri-hourglass-line:before{content:""}.ri-javascript-fill:before{content:""}.ri-javascript-line:before{content:""}.ri-loop-left-fill:before{content:""}.ri-loop-left-line:before{content:""}.ri-loop-right-fill:before{content:""}.ri-loop-right-line:before{content:""}.ri-memories-fill:before{content:""}.ri-memories-line:before{content:""}.ri-meta-fill:before{content:""}.ri-meta-line:before{content:""}.ri-microsoft-loop-fill:before{content:""}.ri-microsoft-loop-line:before{content:""}.ri-nft-fill:before{content:""}.ri-nft-line:before{content:""}.ri-notion-fill:before{content:""}.ri-notion-line:before{content:""}.ri-openai-fill:before{content:""}.ri-openai-line:before{content:""}.ri-overline:before{content:""}.ri-p2p-fill:before{content:""}.ri-p2p-line:before{content:""}.ri-presentation-fill:before{content:""}.ri-presentation-line:before{content:""}.ri-replay-10-fill:before{content:""}.ri-replay-10-line:before{content:""}.ri-replay-15-fill:before{content:""}.ri-replay-15-line:before{content:""}.ri-replay-30-fill:before{content:""}.ri-replay-30-line:before{content:""}.ri-replay-5-fill:before{content:""}.ri-replay-5-line:before{content:""}.ri-school-fill:before{content:""}.ri-school-line:before{content:""}.ri-shining-2-fill:before{content:""}.ri-shining-2-line:before{content:""}.ri-shining-fill:before{content:""}.ri-shining-line:before{content:""}.ri-sketching:before{content:""}.ri-skip-down-fill:before{content:""}.ri-skip-down-line:before{content:""}.ri-skip-left-fill:before{content:""}.ri-skip-left-line:before{content:""}.ri-skip-right-fill:before{content:""}.ri-skip-right-line:before{content:""}.ri-skip-up-fill:before{content:""}.ri-skip-up-line:before{content:""}.ri-slow-down-fill:before{content:""}.ri-slow-down-line:before{content:""}.ri-sparkling-2-fill:before{content:""}.ri-sparkling-2-line:before{content:""}.ri-sparkling-fill:before{content:""}.ri-sparkling-line:before{content:""}.ri-speak-fill:before{content:""}.ri-speak-line:before{content:""}.ri-speed-up-fill:before{content:""}.ri-speed-up-line:before{content:""}.ri-tiktok-fill:before{content:""}.ri-tiktok-line:before{content:""}.ri-token-swap-fill:before{content:""}.ri-token-swap-line:before{content:""}.ri-unpin-fill:before{content:""}.ri-unpin-line:before{content:""}.ri-wechat-channels-fill:before{content:""}.ri-wechat-channels-line:before{content:""}.ri-wordpress-fill:before{content:""}.ri-wordpress-line:before{content:""}.ri-blender-fill:before{content:""}.ri-blender-line:before{content:""}.ri-emoji-sticker-fill:before{content:""}.ri-emoji-sticker-line:before{content:""}.ri-git-close-pull-request-fill:before{content:""}.ri-git-close-pull-request-line:before{content:""}.ri-instance-fill:before{content:""}.ri-instance-line:before{content:""}.ri-megaphone-fill:before{content:""}.ri-megaphone-line:before{content:""}.ri-pass-expired-fill:before{content:""}.ri-pass-expired-line:before{content:""}.ri-pass-pending-fill:before{content:""}.ri-pass-pending-line:before{content:""}.ri-pass-valid-fill:before{content:""}.ri-pass-valid-line:before{content:""}.ri-ai-generate:before{content:""}.ri-calendar-close-fill:before{content:""}.ri-calendar-close-line:before{content:""}.ri-draggable:before{content:""}.ri-font-family:before{content:""}.ri-font-mono:before{content:""}.ri-font-sans-serif:before{content:""}.ri-font-sans:before{content:""}.ri-hard-drive-3-fill:before{content:""}.ri-hard-drive-3-line:before{content:""}.ri-kick-fill:before{content:""}.ri-kick-line:before{content:""}.ri-list-check-3:before{content:""}.ri-list-indefinite:before{content:""}.ri-list-ordered-2:before{content:""}.ri-list-radio:before{content:""}.ri-openbase-fill:before{content:""}.ri-openbase-line:before{content:""}.ri-planet-fill:before{content:""}.ri-planet-line:before{content:""}.ri-prohibited-fill:before{content:""}.ri-prohibited-line:before{content:""}.ri-quote-text:before{content:""}.ri-seo-fill:before{content:""}.ri-seo-line:before{content:""}.ri-slash-commands:before{content:""}.ri-archive-2-fill:before{content:""}.ri-archive-2-line:before{content:""}.ri-inbox-2-fill:before{content:""}.ri-inbox-2-line:before{content:""}.ri-shake-hands-fill:before{content:""}.ri-shake-hands-line:before{content:""}.ri-supabase-fill:before{content:""}.ri-supabase-line:before{content:""}.ri-water-percent-fill:before{content:""}.ri-water-percent-line:before{content:""}.ri-yuque-fill:before{content:""}.ri-yuque-line:before{content:""}.ri-crosshair-2-fill:before{content:""}.ri-crosshair-2-line:before{content:""}.ri-crosshair-fill:before{content:""}.ri-crosshair-line:before{content:""}.ri-file-close-fill:before{content:""}.ri-file-close-line:before{content:""}.ri-infinity-fill:before{content:""}.ri-infinity-line:before{content:""}.ri-rfid-fill:before{content:""}.ri-rfid-line:before{content:""}.ri-slash-commands-2:before{content:""}.ri-user-forbid-fill:before{content:""}.ri-user-forbid-line:before{content:""}.ri-beer-fill:before{content:""}.ri-beer-line:before{content:""}.ri-circle-fill:before{content:""}.ri-circle-line:before{content:""}.ri-dropdown-list:before{content:""}.ri-file-image-fill:before{content:""}.ri-file-image-line:before{content:""}.ri-file-pdf-2-fill:before{content:""}.ri-file-pdf-2-line:before{content:""}.ri-file-video-fill:before{content:""}.ri-file-video-line:before{content:""}.ri-folder-image-fill:before{content:""}.ri-folder-image-line:before{content:""}.ri-folder-video-fill:before{content:""}.ri-folder-video-line:before{content:""}.ri-hexagon-fill:before{content:""}.ri-hexagon-line:before{content:""}.ri-menu-search-fill:before{content:""}.ri-menu-search-line:before{content:""}.ri-octagon-fill:before{content:""}.ri-octagon-line:before{content:""}.ri-pentagon-fill:before{content:""}.ri-pentagon-line:before{content:""}.ri-rectangle-fill:before{content:""}.ri-rectangle-line:before{content:""}.ri-robot-2-fill:before{content:""}.ri-robot-2-line:before{content:""}.ri-shapes-fill:before{content:""}.ri-shapes-line:before{content:""}.ri-square-fill:before{content:""}.ri-square-line:before{content:""}.ri-tent-fill:before{content:""}.ri-tent-line:before{content:""}.ri-threads-fill:before{content:""}.ri-threads-line:before{content:""}.ri-tree-fill:before{content:""}.ri-tree-line:before{content:""}.ri-triangle-fill:before{content:""}.ri-triangle-line:before{content:""}.ri-twitter-x-fill:before{content:""}.ri-twitter-x-line:before{content:""}.ri-verified-badge-fill:before{content:""}.ri-verified-badge-line:before{content:""}@keyframes rotate{to{transform:rotate(360deg)}}@keyframes expand{0%{transform:rotateY(90deg)}to{opacity:1;transform:rotateY(0)}}@keyframes slideIn{0%{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes shine{to{background-position-x:-200%}}@keyframes loaderShow{0%{opacity:0;transform:scale(0)}to{opacity:1;transform:scale(1)}}@keyframes entranceLeft{0%{opacity:0;transform:translate(-5px)}to{opacity:1;transform:translate(0)}}@keyframes entranceRight{0%{opacity:0;transform:translate(5px)}to{opacity:1;transform:translate(0)}}@keyframes entranceTop{0%{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:translateY(0)}}@keyframes entranceBottom{0%{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}@media screen and (min-width: 550px){::-webkit-scrollbar{width:8px;height:8px;border-radius:var(--baseRadius)}::-webkit-scrollbar-track{background:transparent;border-radius:var(--baseRadius)}::-webkit-scrollbar-thumb{background-color:var(--baseAlt2Color);border-radius:15px;border:2px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:hover,::-webkit-scrollbar-thumb:active{background-color:var(--baseAlt3Color)}html{scrollbar-color:var(--baseAlt2Color) transparent;scrollbar-width:thin;scroll-behavior:smooth}html *{scrollbar-width:inherit}}:focus-visible{outline-color:var(--primaryColor);outline-style:solid}html,body{line-height:var(--baseLineHeight);font-family:var(--baseFontFamily);font-size:var(--baseFontSize);color:var(--txtPrimaryColor);background:var(--bodyColor)}#app{overflow:auto;display:block;width:100%;height:100vh}.schema-field,.flatpickr-inline-container,.accordion .accordion-content,.accordion,.tabs,.tabs-content,.select .txt-missing,.form-field .form-field-block,.list,.skeleton-loader,.clearfix,.content,.form-field .help-block,.overlay-panel .panel-content,.sub-panel,.panel,.block,.code-block,blockquote,p{display:block;width:100%}h1,h2,.breadcrumbs .breadcrumb-item,h3,h4,h5,h6{margin:0;font-weight:400}h1{font-size:22px;line-height:28px}h2,.breadcrumbs .breadcrumb-item{font-size:20px;line-height:26px}h3{font-size:19px;line-height:24px}h4{font-size:18px;line-height:24px}h5{font-size:17px;line-height:24px}h6{font-size:16px;line-height:22px}em{font-style:italic}ins{color:var(--txtPrimaryColor);background:var(--successAltColor);text-decoration:none}del{color:var(--txtPrimaryColor);background:var(--dangerAltColor);text-decoration:none}strong{font-weight:600}small{font-size:var(--smFontSize);line-height:var(--smLineHeight)}sub,sup{position:relative;font-size:.75em;line-height:1}sup{vertical-align:top}sub{vertical-align:bottom}p{margin:5px 0}blockquote{position:relative;padding-left:var(--smSpacing);font-style:italic;color:var(--txtHintColor)}blockquote:before{content:"";position:absolute;top:0;left:0;width:2px;height:100%;background:var(--baseColor)}code{display:inline-block;font-family:var(--monospaceFontFamily);font-style:normal;font-size:1em;line-height:1.379rem;padding:0 4px;white-space:nowrap;color:var(--txtPrimaryColor);background:var(--baseAlt2Color);border-radius:var(--baseRadius)}.code-block{overflow:auto;padding:var(--xsSpacing);white-space:pre-wrap;background:var(--baseAlt1Color)}ol,ul{margin:10px 0;list-style:decimal;padding-left:var(--baseSpacing)}ol li,ul li{margin-top:5px;margin-bottom:5px}ul{list-style:disc}img{max-width:100%;vertical-align:top}hr{display:block;border:0;height:1px;width:100%;background:var(--baseAlt1Color);margin:var(--baseSpacing) 0}hr.dark{background:var(--baseAlt2Color)}a{color:inherit}a:hover{text-decoration:none}a i,a .txt{display:inline-block;vertical-align:top}.txt-mono{font-family:var(--monospaceFontFamily)}.txt-nowrap{white-space:nowrap}.txt-ellipsis{display:inline-block;vertical-align:top;flex-shrink:1;max-width:100%;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.txt-base{font-size:var(--baseFontSize)!important}.txt-xs{font-size:var(--xsFontSize)!important;line-height:var(--smLineHeight)}.txt-sm{font-size:var(--smFontSize)!important;line-height:var(--smLineHeight)}.txt-lg{font-size:var(--lgFontSize)!important}.txt-xl{font-size:var(--xlFontSize)!important}.txt-bold{font-weight:600!important}.txt-strikethrough{text-decoration:line-through!important}.txt-break{white-space:pre-wrap!important}.txt-center{text-align:center!important}.txt-justify{text-align:justify!important}.txt-left{text-align:left!important}.txt-right{text-align:right!important}.txt-main{color:var(--txtPrimaryColor)!important}.txt-hint{color:var(--txtHintColor)!important}.txt-disabled{color:var(--txtDisabledColor)!important}.link-hint{-webkit-user-select:none;user-select:none;cursor:pointer;color:var(--txtHintColor)!important;text-decoration:none;transition:color var(--baseAnimationSpeed)}.link-hint:hover,.link-hint:focus-visible,.link-hint:active{color:var(--txtPrimaryColor)!important}.link-fade{opacity:1;-webkit-user-select:none;user-select:none;cursor:pointer;text-decoration:none;color:var(--txtPrimaryColor);transition:opacity var(--baseAnimationSpeed)}.link-fade:focus-visible,.link-fade:hover,.link-fade:active{opacity:.8}.txt-primary{color:var(--primaryColor)!important}.bg-primary{background:var(--primaryColor)!important}.link-primary{cursor:pointer;color:var(--primaryColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-primary:focus-visible,.link-primary:hover,.link-primary:active{opacity:.8}.txt-info{color:var(--infoColor)!important}.bg-info{background:var(--infoColor)!important}.link-info{cursor:pointer;color:var(--infoColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-info:focus-visible,.link-info:hover,.link-info:active{opacity:.8}.txt-info-alt{color:var(--infoAltColor)!important}.bg-info-alt{background:var(--infoAltColor)!important}.link-info-alt{cursor:pointer;color:var(--infoAltColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-info-alt:focus-visible,.link-info-alt:hover,.link-info-alt:active{opacity:.8}.txt-success{color:var(--successColor)!important}.bg-success{background:var(--successColor)!important}.link-success{cursor:pointer;color:var(--successColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-success:focus-visible,.link-success:hover,.link-success:active{opacity:.8}.txt-success-alt{color:var(--successAltColor)!important}.bg-success-alt{background:var(--successAltColor)!important}.link-success-alt{cursor:pointer;color:var(--successAltColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-success-alt:focus-visible,.link-success-alt:hover,.link-success-alt:active{opacity:.8}.txt-danger{color:var(--dangerColor)!important}.bg-danger{background:var(--dangerColor)!important}.link-danger{cursor:pointer;color:var(--dangerColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-danger:focus-visible,.link-danger:hover,.link-danger:active{opacity:.8}.txt-danger-alt{color:var(--dangerAltColor)!important}.bg-danger-alt{background:var(--dangerAltColor)!important}.link-danger-alt{cursor:pointer;color:var(--dangerAltColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-danger-alt:focus-visible,.link-danger-alt:hover,.link-danger-alt:active{opacity:.8}.txt-warning{color:var(--warningColor)!important}.bg-warning{background:var(--warningColor)!important}.link-warning{cursor:pointer;color:var(--warningColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-warning:focus-visible,.link-warning:hover,.link-warning:active{opacity:.8}.txt-warning-alt{color:var(--warningAltColor)!important}.bg-warning-alt{background:var(--warningAltColor)!important}.link-warning-alt{cursor:pointer;color:var(--warningAltColor)!important;text-decoration:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed)}.link-warning-alt:focus-visible,.link-warning-alt:hover,.link-warning-alt:active{opacity:.8}.fade{opacity:.6}a.fade,.btn.fade,[tabindex].fade,[class*=link-].fade,.handle.fade{transition:all var(--baseAnimationSpeed)}a.fade:hover,.btn.fade:hover,[tabindex].fade:hover,[class*=link-].fade:hover,.handle.fade:hover{opacity:1}.noborder{border:0px!important}.hidden{display:none!important}.hidden-empty:empty{display:none!important}.no-pointer-events{pointer-events:none}.content,.form-field .help-block,.overlay-panel .panel-content,.sub-panel,.panel{min-width:0}.content>:first-child,.form-field .help-block>:first-child,.overlay-panel .panel-content>:first-child,.sub-panel>:first-child,.panel>:first-child{margin-top:0}.content>:last-child,.form-field .help-block>:last-child,.overlay-panel .panel-content>:last-child,.sub-panel>:last-child,.panel>:last-child{margin-bottom:0}.panel{background:var(--baseColor);border-radius:var(--lgRadius);padding:calc(var(--baseSpacing) - 5px) var(--baseSpacing);box-shadow:0 2px 5px 0 var(--shadowColor)}.sub-panel{background:var(--baseColor);border-radius:var(--baseRadius);padding:calc(var(--smSpacing) - 5px) var(--smSpacing);border:1px solid var(--baseAlt1Color)}.shadowize{box-shadow:0 2px 5px 0 var(--shadowColor)}.clearfix{clear:both}.clearfix:after{content:"";display:table;clear:both}.flex{position:relative;display:flex;align-items:center;width:100%;min-width:0;gap:var(--smSpacing)}.flex-fill{flex:1 1 auto!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.inline-flex{position:relative;display:inline-flex;vertical-align:top;align-items:center;flex-wrap:wrap;min-width:0;gap:10px}.flex-order-0{order:0}.flex-order-1{order:1}.flex-order-2{order:2}.flex-order-3{order:3}.flex-order-4{order:4}.flex-order-5{order:5}.flex-order-6{order:6}.flex-gap-base{gap:var(--baseSpacing)!important}.flex-gap-xs{gap:var(--xsSpacing)!important}.flex-gap-sm{gap:var(--smSpacing)!important}.flex-gap-lg{gap:var(--lgSpacing)!important}.flex-gap-xl{gap:var(--xlSpacing)!important}.flex-gap-0{gap:0px!important}.flex-gap-5{gap:5px!important}.flex-gap-10{gap:10px!important}.flex-gap-15{gap:15px!important}.flex-gap-20{gap:20px!important}.flex-gap-25{gap:25px!important}.flex-gap-30{gap:30px!important}.flex-gap-35{gap:35px!important}.flex-gap-40{gap:40px!important}.flex-gap-45{gap:45px!important}.flex-gap-50{gap:50px!important}.flex-gap-55{gap:55px!important}.flex-gap-60{gap:60px!important}.m-base{margin:var(--baseSpacing)!important}.p-base{padding:var(--baseSpacing)!important}.m-xs{margin:var(--xsSpacing)!important}.p-xs{padding:var(--xsSpacing)!important}.m-sm{margin:var(--smSpacing)!important}.p-sm{padding:var(--smSpacing)!important}.m-lg{margin:var(--lgSpacing)!important}.p-lg{padding:var(--lgSpacing)!important}.m-xl{margin:var(--xlSpacing)!important}.p-xl{padding:var(--xlSpacing)!important}.m-t-auto{margin-top:auto!important}.p-t-auto{padding-top:auto!important}.m-t-base{margin-top:var(--baseSpacing)!important}.p-t-base{padding-top:var(--baseSpacing)!important}.m-t-xs{margin-top:var(--xsSpacing)!important}.p-t-xs{padding-top:var(--xsSpacing)!important}.m-t-sm{margin-top:var(--smSpacing)!important}.p-t-sm{padding-top:var(--smSpacing)!important}.m-t-lg{margin-top:var(--lgSpacing)!important}.p-t-lg{padding-top:var(--lgSpacing)!important}.m-t-xl{margin-top:var(--xlSpacing)!important}.p-t-xl{padding-top:var(--xlSpacing)!important}.m-r-auto{margin-right:auto!important}.p-r-auto{padding-right:auto!important}.m-r-base{margin-right:var(--baseSpacing)!important}.p-r-base{padding-right:var(--baseSpacing)!important}.m-r-xs{margin-right:var(--xsSpacing)!important}.p-r-xs{padding-right:var(--xsSpacing)!important}.m-r-sm{margin-right:var(--smSpacing)!important}.p-r-sm{padding-right:var(--smSpacing)!important}.m-r-lg{margin-right:var(--lgSpacing)!important}.p-r-lg{padding-right:var(--lgSpacing)!important}.m-r-xl{margin-right:var(--xlSpacing)!important}.p-r-xl{padding-right:var(--xlSpacing)!important}.m-b-auto{margin-bottom:auto!important}.p-b-auto{padding-bottom:auto!important}.m-b-base{margin-bottom:var(--baseSpacing)!important}.p-b-base{padding-bottom:var(--baseSpacing)!important}.m-b-xs{margin-bottom:var(--xsSpacing)!important}.p-b-xs{padding-bottom:var(--xsSpacing)!important}.m-b-sm{margin-bottom:var(--smSpacing)!important}.p-b-sm{padding-bottom:var(--smSpacing)!important}.m-b-lg{margin-bottom:var(--lgSpacing)!important}.p-b-lg{padding-bottom:var(--lgSpacing)!important}.m-b-xl{margin-bottom:var(--xlSpacing)!important}.p-b-xl{padding-bottom:var(--xlSpacing)!important}.m-l-auto{margin-left:auto!important}.p-l-auto{padding-left:auto!important}.m-l-base{margin-left:var(--baseSpacing)!important}.p-l-base{padding-left:var(--baseSpacing)!important}.m-l-xs{margin-left:var(--xsSpacing)!important}.p-l-xs{padding-left:var(--xsSpacing)!important}.m-l-sm{margin-left:var(--smSpacing)!important}.p-l-sm{padding-left:var(--smSpacing)!important}.m-l-lg{margin-left:var(--lgSpacing)!important}.p-l-lg{padding-left:var(--lgSpacing)!important}.m-l-xl{margin-left:var(--xlSpacing)!important}.p-l-xl{padding-left:var(--xlSpacing)!important}.m-0{margin:0!important}.p-0{padding:0!important}.m-t-0{margin-top:0!important}.p-t-0{padding-top:0!important}.m-r-0{margin-right:0!important}.p-r-0{padding-right:0!important}.m-b-0{margin-bottom:0!important}.p-b-0{padding-bottom:0!important}.m-l-0{margin-left:0!important}.p-l-0{padding-left:0!important}.m-5{margin:5px!important}.p-5{padding:5px!important}.m-t-5{margin-top:5px!important}.p-t-5{padding-top:5px!important}.m-r-5{margin-right:5px!important}.p-r-5{padding-right:5px!important}.m-b-5{margin-bottom:5px!important}.p-b-5{padding-bottom:5px!important}.m-l-5{margin-left:5px!important}.p-l-5{padding-left:5px!important}.m-10{margin:10px!important}.p-10{padding:10px!important}.m-t-10{margin-top:10px!important}.p-t-10{padding-top:10px!important}.m-r-10{margin-right:10px!important}.p-r-10{padding-right:10px!important}.m-b-10{margin-bottom:10px!important}.p-b-10{padding-bottom:10px!important}.m-l-10{margin-left:10px!important}.p-l-10{padding-left:10px!important}.m-15{margin:15px!important}.p-15{padding:15px!important}.m-t-15{margin-top:15px!important}.p-t-15{padding-top:15px!important}.m-r-15{margin-right:15px!important}.p-r-15{padding-right:15px!important}.m-b-15{margin-bottom:15px!important}.p-b-15{padding-bottom:15px!important}.m-l-15{margin-left:15px!important}.p-l-15{padding-left:15px!important}.m-20{margin:20px!important}.p-20{padding:20px!important}.m-t-20{margin-top:20px!important}.p-t-20{padding-top:20px!important}.m-r-20{margin-right:20px!important}.p-r-20{padding-right:20px!important}.m-b-20{margin-bottom:20px!important}.p-b-20{padding-bottom:20px!important}.m-l-20{margin-left:20px!important}.p-l-20{padding-left:20px!important}.m-25{margin:25px!important}.p-25{padding:25px!important}.m-t-25{margin-top:25px!important}.p-t-25{padding-top:25px!important}.m-r-25{margin-right:25px!important}.p-r-25{padding-right:25px!important}.m-b-25{margin-bottom:25px!important}.p-b-25{padding-bottom:25px!important}.m-l-25{margin-left:25px!important}.p-l-25{padding-left:25px!important}.m-30{margin:30px!important}.p-30{padding:30px!important}.m-t-30{margin-top:30px!important}.p-t-30{padding-top:30px!important}.m-r-30{margin-right:30px!important}.p-r-30{padding-right:30px!important}.m-b-30{margin-bottom:30px!important}.p-b-30{padding-bottom:30px!important}.m-l-30{margin-left:30px!important}.p-l-30{padding-left:30px!important}.m-35{margin:35px!important}.p-35{padding:35px!important}.m-t-35{margin-top:35px!important}.p-t-35{padding-top:35px!important}.m-r-35{margin-right:35px!important}.p-r-35{padding-right:35px!important}.m-b-35{margin-bottom:35px!important}.p-b-35{padding-bottom:35px!important}.m-l-35{margin-left:35px!important}.p-l-35{padding-left:35px!important}.m-40{margin:40px!important}.p-40{padding:40px!important}.m-t-40{margin-top:40px!important}.p-t-40{padding-top:40px!important}.m-r-40{margin-right:40px!important}.p-r-40{padding-right:40px!important}.m-b-40{margin-bottom:40px!important}.p-b-40{padding-bottom:40px!important}.m-l-40{margin-left:40px!important}.p-l-40{padding-left:40px!important}.m-45{margin:45px!important}.p-45{padding:45px!important}.m-t-45{margin-top:45px!important}.p-t-45{padding-top:45px!important}.m-r-45{margin-right:45px!important}.p-r-45{padding-right:45px!important}.m-b-45{margin-bottom:45px!important}.p-b-45{padding-bottom:45px!important}.m-l-45{margin-left:45px!important}.p-l-45{padding-left:45px!important}.m-50{margin:50px!important}.p-50{padding:50px!important}.m-t-50{margin-top:50px!important}.p-t-50{padding-top:50px!important}.m-r-50{margin-right:50px!important}.p-r-50{padding-right:50px!important}.m-b-50{margin-bottom:50px!important}.p-b-50{padding-bottom:50px!important}.m-l-50{margin-left:50px!important}.p-l-50{padding-left:50px!important}.m-55{margin:55px!important}.p-55{padding:55px!important}.m-t-55{margin-top:55px!important}.p-t-55{padding-top:55px!important}.m-r-55{margin-right:55px!important}.p-r-55{padding-right:55px!important}.m-b-55{margin-bottom:55px!important}.p-b-55{padding-bottom:55px!important}.m-l-55{margin-left:55px!important}.p-l-55{padding-left:55px!important}.m-60{margin:60px!important}.p-60{padding:60px!important}.m-t-60{margin-top:60px!important}.p-t-60{padding-top:60px!important}.m-r-60{margin-right:60px!important}.p-r-60{padding-right:60px!important}.m-b-60{margin-bottom:60px!important}.p-b-60{padding-bottom:60px!important}.m-l-60{margin-left:60px!important}.p-l-60{padding-left:60px!important}.no-min-width{min-width:0!important}.wrapper{position:relative;width:var(--wrapperWidth);margin:0 auto;max-width:100%}.wrapper.wrapper-sm{width:var(--smWrapperWidth)}.wrapper.wrapper-lg{width:var(--lgWrapperWidth)}.label{--labelVPadding: 3px;--labelHPadding: 9px;display:inline-flex;align-items:center;justify-content:center;vertical-align:top;gap:5px;padding:var(--labelVPadding) var(--labelHPadding);min-height:24px;max-width:100%;text-align:center;line-height:var(--smLineHeight);font-size:var(--smFontSize);background:var(--baseAlt2Color);color:var(--txtPrimaryColor);white-space:nowrap;border-radius:30px}.label .btn:last-child{margin-right:calc(-.5 * var(--labelHPadding))}.label .btn:first-child{margin-left:calc(-.5 * var(--labelHPadding))}.label.label-sm{--labelHPadding: 5px;font-size:var(--xsFontSize);min-height:18px;line-height:1}.label.label-primary{color:var(--baseColor);background:var(--primaryColor)}.label.label-info{background:var(--infoAltColor)}.label.label-success{background:var(--successAltColor)}.label.label-danger{background:var(--dangerAltColor)}.label.label-warning{background:var(--warningAltColor)}.thumb{--thumbSize: 40px;display:inline-flex;vertical-align:top;position:relative;flex-shrink:0;align-items:center;justify-content:center;line-height:1;width:var(--thumbSize);height:var(--thumbSize);background:var(--baseAlt2Color);border-radius:var(--baseRadius);color:var(--txtPrimaryColor);box-shadow:0 2px 5px 0 var(--shadowColor)}.thumb i{font-size:inherit}.thumb img{width:100%;height:100%;border-radius:inherit;overflow:hidden}.thumb.thumb-xs{--thumbSize: 24px;font-size:.85rem}.thumb.thumb-sm{--thumbSize: 32px;font-size:.92rem}.thumb.thumb-lg{--thumbSize: 60px;font-size:1.3rem}.thumb.thumb-xl{--thumbSize: 80px;font-size:1.5rem}.thumb.thumb-circle{border-radius:50%}.thumb.thumb-active{box-shadow:0 0 0 2px var(--primaryColor)}a.thumb:not(.thumb-active){transition:box-shadow var(--baseAnimationSpeed)}a.thumb:not(.thumb-active):hover,a.thumb:not(.thumb-active):focus{box-shadow:0 2px 5px 0 var(--shadowColor),0 2px 4px 1px var(--shadowColor)}.section-title{display:flex;align-items:center;width:100%;column-gap:10px;row-gap:5px;margin:0 0 var(--xsSpacing);font-weight:600;font-size:var(--baseFontSize);line-height:var(--smLineHeight);color:var(--txtHintColor)}.logo{position:relative;vertical-align:top;display:inline-flex;align-items:center;gap:10px;font-size:23px;text-decoration:none;color:inherit;-webkit-user-select:none;user-select:none}.logo strong{font-weight:700}.logo .version{position:absolute;right:0;top:-5px;line-height:1;font-size:10px;font-weight:400;padding:2px 4px;border-radius:var(--baseRadius);background:var(--dangerAltColor);color:var(--txtPrimaryColor)}.logo.logo-sm{font-size:20px}.drag-handle{position:relative;display:inline-flex;align-items:center;justify-content:center;text-align:center;flex-shrink:0;color:var(--txtDisabledColor);-webkit-user-select:none;user-select:none;cursor:pointer;transition:color var(--baseAnimationSpeed),transform var(--baseAnimationSpeed),opacity var(--baseAnimationSpeed),visibility var(--baseAnimationSpeed)}.drag-handle:before{content:"";line-height:1;font-family:var(--iconFontFamily);padding-right:5px;text-shadow:5px 0px currentColor}.drag-handle:hover,.drag-handle:focus-visible{color:var(--txtHintColor)}.drag-handle:active{transition-duration:var(--activeAnimationSpeed);color:var(--txtPrimaryColor)}.loader{--loaderSize: 32px;position:relative;display:inline-flex;vertical-align:top;flex-direction:column;align-items:center;justify-content:center;row-gap:10px;margin:0;color:var(--txtDisabledColor);text-align:center;font-weight:400}.loader:before{content:"";display:inline-block;vertical-align:top;clear:both;width:var(--loaderSize);height:var(--loaderSize);line-height:var(--loaderSize);font-size:var(--loaderSize);font-weight:400;font-family:var(--iconFontFamily);color:inherit;text-align:center;animation:loaderShow var(--baseAnimationSpeed),rotate .9s var(--baseAnimationSpeed) infinite linear}.loader.loader-primary{color:var(--primaryColor)}.loader.loader-info{color:var(--infoColor)}.loader.loader-info-alt{color:var(--infoAltColor)}.loader.loader-success{color:var(--successColor)}.loader.loader-success-alt{color:var(--successAltColor)}.loader.loader-danger{color:var(--dangerColor)}.loader.loader-danger-alt{color:var(--dangerAltColor)}.loader.loader-warning{color:var(--warningColor)}.loader.loader-warning-alt{color:var(--warningAltColor)}.loader.loader-xs{--loaderSize: 18px}.loader.loader-sm{--loaderSize: 24px}.loader.loader-lg{--loaderSize: 42px}.skeleton-loader{position:relative;height:12px;margin:5px 0;border-radius:var(--baseRadius);background:var(--baseAlt1Color);animation:fadeIn .4s}.skeleton-loader:before{content:"";width:100%;height:100%;display:block;border-radius:inherit;background:linear-gradient(90deg,var(--baseAlt1Color) 8%,var(--bodyColor) 18%,var(--baseAlt1Color) 33%);background-size:200% 100%;animation:shine 1s linear infinite}.placeholder-section{display:flex;width:100%;align-items:center;justify-content:center;text-align:center;flex-direction:column;gap:var(--smSpacing);color:var(--txtHintColor)}.placeholder-section .icon{font-size:50px;height:50px;line-height:1;opacity:.3}.placeholder-section .icon i{font-size:inherit;vertical-align:top}.list{position:relative;overflow:auto;overflow:overlay;border:1px solid var(--baseAlt2Color);border-radius:var(--baseRadius)}.list .list-item{word-break:break-word;position:relative;display:flex;align-items:center;width:100%;gap:var(--xsSpacing);outline:0;padding:10px var(--xsSpacing);min-height:50px;border-top:1px solid var(--baseAlt2Color);transition:background var(--baseAnimationSpeed)}.list .list-item:first-child{border-top:0}.list .list-item:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.list .list-item .content,.list .list-item .form-field .help-block,.form-field .list .list-item .help-block,.list .list-item .overlay-panel .panel-content,.overlay-panel .list .list-item .panel-content,.list .list-item .panel,.list .list-item .sub-panel{display:flex;align-items:center;gap:5px;min-width:0;max-width:100%;-webkit-user-select:text;user-select:text}.list .list-item .actions{gap:10px;flex-shrink:0;display:inline-flex;align-items:center;margin:-1px -5px -1px 0}.list .list-item .actions.nonintrusive{opacity:0;transform:translate(5px);transition:transform var(--baseAnimationSpeed),opacity var(--baseAnimationSpeed),visibility var(--baseAnimationSpeed)}.list .list-item:hover,.list .list-item:focus-visible,.list .list-item:focus-within,.list .list-item:active{background:var(--bodyColor)}.list .list-item:hover .actions.nonintrusive,.list .list-item:focus-visible .actions.nonintrusive,.list .list-item:focus-within .actions.nonintrusive,.list .list-item:active .actions.nonintrusive{opacity:1;transform:translate(0)}.list .list-item.selected{background:var(--bodyColor)}.list .list-item.handle:not(.disabled){cursor:pointer;-webkit-user-select:none;user-select:none}.list .list-item.handle:not(.disabled):hover,.list .list-item.handle:not(.disabled):focus-visible{background:var(--baseAlt1Color)}.list .list-item.handle:not(.disabled):active{background:var(--baseAlt2Color)}.list .list-item.disabled:not(.selected){cursor:default;opacity:.6}.list .list-item-placeholder{color:var(--txtHintColor)}.list .list-item-btn{padding:5px;min-height:auto}.list .list-item-placeholder:hover,.list .list-item-placeholder:focus-visible,.list .list-item-placeholder:focus-within,.list .list-item-placeholder:active,.list .list-item-btn:hover,.list .list-item-btn:focus-visible,.list .list-item-btn:focus-within,.list .list-item-btn:active{background:none}.list.list-compact .list-item{gap:10px;min-height:40px}.entrance-top{animation:entranceTop var(--entranceAnimationSpeed)}.entrance-bottom{animation:entranceBottom var(--entranceAnimationSpeed)}.entrance-left{animation:entranceLeft var(--entranceAnimationSpeed)}.entrance-right{animation:entranceRight var(--entranceAnimationSpeed)}.provider-logo{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:32px;height:32px;border-radius:var(--baseRadius);background:var(--bodyColor);padding:0;gap:0}.provider-logo img{max-width:20px;max-height:20px;height:auto;flex-shrink:0}.provider-card{display:flex;align-items:center;width:100%;height:100%;gap:10px;padding:10px;border-radius:var(--baseRadius);border:1px solid var(--baseAlt1Color)}.grid{--gridGap: var(--baseSpacing);position:relative;display:flex;flex-grow:1;flex-wrap:wrap;row-gap:var(--gridGap);margin:0 calc(-.5 * var(--gridGap))}.grid.grid-center{align-items:center}.grid.grid-sm{--gridGap: var(--smSpacing)}.grid .form-field{margin-bottom:0}.grid>*{margin:0 calc(.5 * var(--gridGap))}.col-xxl-1,.col-xxl-2,.col-xxl-3,.col-xxl-4,.col-xxl-5,.col-xxl-6,.col-xxl-7,.col-xxl-8,.col-xxl-9,.col-xxl-10,.col-xxl-11,.col-xxl-12,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12{position:relative;width:100%;min-height:1px}.col-auto{flex:0 0 auto;width:auto}.col-12{width:calc(100% - var(--gridGap))}.col-11{width:calc(91.6666666667% - var(--gridGap))}.col-10{width:calc(83.3333333333% - var(--gridGap))}.col-9{width:calc(75% - var(--gridGap))}.col-8{width:calc(66.6666666667% - var(--gridGap))}.col-7{width:calc(58.3333333333% - var(--gridGap))}.col-6{width:calc(50% - var(--gridGap))}.col-5{width:calc(41.6666666667% - var(--gridGap))}.col-4{width:calc(33.3333333333% - var(--gridGap))}.col-3{width:calc(25% - var(--gridGap))}.col-2{width:calc(16.6666666667% - var(--gridGap))}.col-1{width:calc(8.3333333333% - var(--gridGap))}@media (min-width: 576px){.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-12{width:calc(100% - var(--gridGap))}.col-sm-11{width:calc(91.6666666667% - var(--gridGap))}.col-sm-10{width:calc(83.3333333333% - var(--gridGap))}.col-sm-9{width:calc(75% - var(--gridGap))}.col-sm-8{width:calc(66.6666666667% - var(--gridGap))}.col-sm-7{width:calc(58.3333333333% - var(--gridGap))}.col-sm-6{width:calc(50% - var(--gridGap))}.col-sm-5{width:calc(41.6666666667% - var(--gridGap))}.col-sm-4{width:calc(33.3333333333% - var(--gridGap))}.col-sm-3{width:calc(25% - var(--gridGap))}.col-sm-2{width:calc(16.6666666667% - var(--gridGap))}.col-sm-1{width:calc(8.3333333333% - var(--gridGap))}}@media (min-width: 768px){.col-md-auto{flex:0 0 auto;width:auto}.col-md-12{width:calc(100% - var(--gridGap))}.col-md-11{width:calc(91.6666666667% - var(--gridGap))}.col-md-10{width:calc(83.3333333333% - var(--gridGap))}.col-md-9{width:calc(75% - var(--gridGap))}.col-md-8{width:calc(66.6666666667% - var(--gridGap))}.col-md-7{width:calc(58.3333333333% - var(--gridGap))}.col-md-6{width:calc(50% - var(--gridGap))}.col-md-5{width:calc(41.6666666667% - var(--gridGap))}.col-md-4{width:calc(33.3333333333% - var(--gridGap))}.col-md-3{width:calc(25% - var(--gridGap))}.col-md-2{width:calc(16.6666666667% - var(--gridGap))}.col-md-1{width:calc(8.3333333333% - var(--gridGap))}}@media (min-width: 992px){.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-12{width:calc(100% - var(--gridGap))}.col-lg-11{width:calc(91.6666666667% - var(--gridGap))}.col-lg-10{width:calc(83.3333333333% - var(--gridGap))}.col-lg-9{width:calc(75% - var(--gridGap))}.col-lg-8{width:calc(66.6666666667% - var(--gridGap))}.col-lg-7{width:calc(58.3333333333% - var(--gridGap))}.col-lg-6{width:calc(50% - var(--gridGap))}.col-lg-5{width:calc(41.6666666667% - var(--gridGap))}.col-lg-4{width:calc(33.3333333333% - var(--gridGap))}.col-lg-3{width:calc(25% - var(--gridGap))}.col-lg-2{width:calc(16.6666666667% - var(--gridGap))}.col-lg-1{width:calc(8.3333333333% - var(--gridGap))}}@media (min-width: 1200px){.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-12{width:calc(100% - var(--gridGap))}.col-xl-11{width:calc(91.6666666667% - var(--gridGap))}.col-xl-10{width:calc(83.3333333333% - var(--gridGap))}.col-xl-9{width:calc(75% - var(--gridGap))}.col-xl-8{width:calc(66.6666666667% - var(--gridGap))}.col-xl-7{width:calc(58.3333333333% - var(--gridGap))}.col-xl-6{width:calc(50% - var(--gridGap))}.col-xl-5{width:calc(41.6666666667% - var(--gridGap))}.col-xl-4{width:calc(33.3333333333% - var(--gridGap))}.col-xl-3{width:calc(25% - var(--gridGap))}.col-xl-2{width:calc(16.6666666667% - var(--gridGap))}.col-xl-1{width:calc(8.3333333333% - var(--gridGap))}}@media (min-width: 1400px){.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-12{width:calc(100% - var(--gridGap))}.col-xxl-11{width:calc(91.6666666667% - var(--gridGap))}.col-xxl-10{width:calc(83.3333333333% - var(--gridGap))}.col-xxl-9{width:calc(75% - var(--gridGap))}.col-xxl-8{width:calc(66.6666666667% - var(--gridGap))}.col-xxl-7{width:calc(58.3333333333% - var(--gridGap))}.col-xxl-6{width:calc(50% - var(--gridGap))}.col-xxl-5{width:calc(41.6666666667% - var(--gridGap))}.col-xxl-4{width:calc(33.3333333333% - var(--gridGap))}.col-xxl-3{width:calc(25% - var(--gridGap))}.col-xxl-2{width:calc(16.6666666667% - var(--gridGap))}.col-xxl-1{width:calc(8.3333333333% - var(--gridGap))}}.app-tooltip{position:fixed;z-index:999999;top:0;left:0;display:inline-block;vertical-align:top;max-width:275px;padding:3px 5px;color:#fff;text-align:center;font-family:var(--baseFontFamily);font-size:var(--smFontSize);line-height:var(--smLineHeight);border-radius:var(--baseRadius);background:var(--tooltipColor);pointer-events:none;-webkit-user-select:none;user-select:none;transition:opacity var(--baseAnimationSpeed),visibility var(--baseAnimationSpeed),transform var(--baseAnimationSpeed);transform:translateY(1px);backface-visibility:hidden;white-space:pre-line;word-break:break-word;opacity:0;visibility:hidden}.app-tooltip.code{font-family:monospace;white-space:pre-wrap;text-align:left;min-width:150px;max-width:340px}.app-tooltip.active{transform:scale(1);opacity:1;visibility:visible}.dropdown{position:absolute;z-index:99;right:0;left:auto;top:100%;cursor:default;display:inline-block;vertical-align:top;padding:5px;margin:5px 0 0;width:auto;min-width:140px;max-width:450px;max-height:330px;overflow-x:hidden;overflow-y:auto;background:var(--baseColor);border-radius:var(--baseRadius);border:1px solid var(--baseAlt2Color);box-shadow:0 2px 5px 0 var(--shadowColor)}.dropdown hr{margin:5px 0}.dropdown .dropdown-item{border:0;background:none;position:relative;outline:0;display:flex;align-items:center;column-gap:8px;width:100%;height:auto;min-height:0;text-align:left;padding:8px 10px;margin:0 0 5px;cursor:pointer;color:var(--txtPrimaryColor);font-weight:400;font-size:var(--baseFontSize);font-family:var(--baseFontFamily);line-height:var(--baseLineHeight);border-radius:var(--baseRadius);text-decoration:none;word-break:break-word;-webkit-user-select:none;user-select:none;transition:background var(--baseAnimationSpeed),color var(--baseAnimationSpeed)}.dropdown .dropdown-item:last-child{margin-bottom:0}.dropdown .dropdown-item.selected{background:var(--baseAlt2Color)}.dropdown .dropdown-item:focus-visible,.dropdown .dropdown-item:hover{background:var(--baseAlt1Color)}.dropdown .dropdown-item:active{transition-duration:var(--activeAnimationSpeed);background:var(--baseAlt2Color)}.dropdown .dropdown-item.disabled{color:var(--txtDisabledColor);background:none;pointer-events:none}.dropdown .dropdown-item.separator{cursor:default;background:none;text-transform:uppercase;padding-top:0;padding-bottom:0;margin-top:15px;color:var(--txtDisabledColor);font-weight:600;font-size:var(--smFontSize)}.dropdown.dropdown-upside{top:auto;bottom:100%;margin:0 0 5px}.dropdown.dropdown-left{right:auto;left:0}.dropdown.dropdown-center{right:auto;left:50%;transform:translate(-50%)}.dropdown.dropdown-sm{margin-top:5px;min-width:100px}.dropdown.dropdown-sm .dropdown-item{column-gap:7px;font-size:var(--smFontSize);margin:0 0 2px;padding:5px 7px}.dropdown.dropdown-sm .dropdown-item:last-child{margin-bottom:0}.dropdown.dropdown-sm.dropdown-upside{margin-top:0;margin-bottom:5px}.dropdown.dropdown-block{width:100%;min-width:130px;max-width:100%}.dropdown.dropdown-nowrap{white-space:nowrap}.overlay-panel{position:relative;z-index:1;display:flex;flex-direction:column;align-self:flex-end;margin-left:auto;background:var(--baseColor);height:100%;width:580px;max-width:100%;word-wrap:break-word;box-shadow:0 2px 5px 0 var(--shadowColor)}.overlay-panel .overlay-panel-section{position:relative;width:100%;margin:0;padding:var(--baseSpacing);transition:box-shadow var(--baseAnimationSpeed)}.overlay-panel .overlay-panel-section:empty{display:none}.overlay-panel .overlay-panel-section:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.overlay-panel .overlay-panel-section:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.overlay-panel .overlay-panel-section .btn{flex-grow:0}.overlay-panel img{max-width:100%}.overlay-panel .panel-header{position:relative;z-index:2;display:flex;flex-wrap:wrap;align-items:center;column-gap:10px;row-gap:var(--baseSpacing);padding:calc(var(--baseSpacing) - 7px) var(--baseSpacing)}.overlay-panel .panel-header>*{margin-top:0;margin-bottom:0}.overlay-panel .panel-header .btn-back{margin-left:-10px}.overlay-panel .panel-header .overlay-close{z-index:3;outline:0;position:absolute;right:100%;top:20px;margin:0;display:inline-flex;align-items:center;justify-content:center;width:35px;height:35px;cursor:pointer;text-align:center;font-size:1.6rem;line-height:1;border-radius:50% 0 0 50%;color:#fff;background:var(--primaryColor);opacity:.5;transition:opacity var(--baseAnimationSpeed);-webkit-user-select:none;user-select:none}.overlay-panel .panel-header .overlay-close i{font-size:inherit}.overlay-panel .panel-header .overlay-close:hover,.overlay-panel .panel-header .overlay-close:focus-visible,.overlay-panel .panel-header .overlay-close:active{opacity:.7}.overlay-panel .panel-header .overlay-close:active{transition-duration:var(--activeAnimationSpeed);opacity:1}.overlay-panel .panel-header .btn-close{margin-right:-10px}.overlay-panel .panel-header .tabs-header{margin-bottom:-24px}.overlay-panel .panel-content{z-index:auto;flex-grow:1;overflow-x:hidden;overflow-y:auto;overflow-y:overlay;scroll-behavior:smooth}.tox-fullscreen .overlay-panel .panel-content{z-index:9}.overlay-panel .panel-header~.panel-content{padding-top:5px}.overlay-panel .panel-footer{z-index:2;column-gap:var(--smSpacing);display:flex;align-items:center;justify-content:flex-end;border-top:1px solid var(--baseAlt2Color);padding:calc(var(--baseSpacing) - 7px) var(--baseSpacing)}.overlay-panel.scrollable .panel-header{box-shadow:0 4px 5px #0000000d}.overlay-panel.scrollable .panel-footer{box-shadow:0 -4px 5px #0000000d}.overlay-panel.scrollable.scroll-top-reached .panel-header,.overlay-panel.scrollable.scroll-bottom-reached .panel-footer{box-shadow:none}.overlay-panel.overlay-panel-xl{width:850px}.overlay-panel.overlay-panel-lg{width:700px}.overlay-panel.overlay-panel-sm{width:460px}.overlay-panel.popup{height:auto;max-height:100%;align-self:center;border-radius:var(--baseRadius);margin:0 auto}.overlay-panel.popup .panel-footer{background:var(--bodyColor)}.overlay-panel.hide-content .panel-content{display:none}.overlay-panel.colored-header .panel-header{background:var(--bodyColor);border-bottom:1px solid var(--baseAlt1Color)}.overlay-panel.colored-header .panel-header .tabs-header{border-bottom:0}.overlay-panel.colored-header .panel-header .tabs-header .tab-item{border:1px solid transparent;border-bottom:0}.overlay-panel.colored-header .panel-header .tabs-header .tab-item:hover,.overlay-panel.colored-header .panel-header .tabs-header .tab-item:focus-visible{background:var(--baseAlt1Color)}.overlay-panel.colored-header .panel-header .tabs-header .tab-item:after{content:none;display:none}.overlay-panel.colored-header .panel-header .tabs-header .tab-item.active{background:var(--baseColor);border-color:var(--baseAlt1Color)}.overlay-panel.colored-header .panel-header~.panel-content{padding-top:calc(var(--baseSpacing) - 5px)}.overlay-panel.compact-header .panel-header{row-gap:var(--smSpacing)}.overlay-panel.full-width-popup{width:100%}.overlay-panel.preview .panel-header{position:absolute;z-index:99;box-shadow:none}.overlay-panel.preview .panel-header .overlay-close{left:100%;right:auto;border-radius:0 50% 50% 0}.overlay-panel.preview .panel-header .overlay-close i{margin-right:5px}.overlay-panel.preview .panel-header,.overlay-panel.preview .panel-footer{padding:10px 15px}.overlay-panel.preview .panel-content{padding:0;text-align:center;display:flex;align-items:center;justify-content:center}.overlay-panel.preview img{max-width:100%;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius)}.overlay-panel.preview object{position:absolute;z-index:1;left:0;top:0;width:100%;height:100%}.overlay-panel.preview.preview-image{width:auto;min-width:320px;min-height:300px;max-width:75%;max-height:90%}.overlay-panel.preview.preview-document,.overlay-panel.preview.preview-video{width:75%;height:90%}.overlay-panel.preview.preview-audio{min-width:320px;min-height:300px;max-width:90%;max-height:90%}@media (max-width: 900px){.overlay-panel .overlay-panel-section{padding:var(--smSpacing)}}.overlay-panel-container{display:flex;position:fixed;z-index:1000;flex-direction:row;align-items:center;top:0;left:0;width:100%;height:100%;overflow:hidden;margin:0;padding:0;outline:0}.overlay-panel-container .overlay{position:absolute;z-index:0;left:0;top:0;width:100%;height:100%;-webkit-user-select:none;user-select:none;background:var(--overlayColor)}.overlay-panel-container.padded{padding:10px}.overlay-panel-wrapper{position:relative;z-index:1000;outline:0}.alert{position:relative;display:flex;column-gap:15px;align-items:center;width:100%;min-height:50px;max-width:100%;word-break:break-word;margin:0 0 var(--baseSpacing);border-radius:var(--baseRadius);padding:12px 15px;background:var(--baseAlt1Color);color:var(--txtAltColor)}.alert .content,.alert .form-field .help-block,.form-field .alert .help-block,.alert .panel,.alert .sub-panel,.alert .overlay-panel .panel-content,.overlay-panel .alert .panel-content{flex-grow:1}.alert .icon,.alert .close{display:inline-flex;align-items:center;justify-content:center;flex-grow:0;flex-shrink:0;text-align:center}.alert .icon{align-self:stretch;font-size:1.2em;padding-right:15px;font-weight:400;border-right:1px solid rgba(0,0,0,.05);color:var(--txtHintColor)}.alert .close{display:inline-flex;margin-right:-5px;width:28px;height:28px;outline:0;cursor:pointer;text-align:center;font-size:var(--smFontSize);line-height:28px;border-radius:28px;text-decoration:none;color:inherit;opacity:.5;transition:opacity var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}.alert .close:hover,.alert .close:focus{opacity:1;background:rgba(255,255,255,.2)}.alert .close:active{opacity:1;background:rgba(255,255,255,.3);transition-duration:var(--activeAnimationSpeed)}.alert code,.alert hr{background:rgba(0,0,0,.1)}.alert.alert-info{background:var(--infoAltColor)}.alert.alert-info .icon{color:var(--infoColor)}.alert.alert-warning{background:var(--warningAltColor)}.alert.alert-warning .icon{color:var(--warningColor)}.alert.alert-success{background:var(--successAltColor)}.alert.alert-success .icon{color:var(--successColor)}.alert.alert-danger{background:var(--dangerAltColor)}.alert.alert-danger .icon{color:var(--dangerColor)}.toasts-wrapper{position:fixed;z-index:999999;bottom:0;left:0;right:0;padding:0 var(--smSpacing);width:auto;display:block;text-align:center;pointer-events:none}.toasts-wrapper .alert{text-align:left;pointer-events:auto;width:var(--smWrapperWidth);margin:var(--baseSpacing) auto;box-shadow:0 2px 5px 0 var(--shadowColor)}@media screen and (min-width: 980px){body:not(.overlay-active):has(.app-sidebar) .toasts-wrapper{left:var(--appSidebarWidth)}body:not(.overlay-active):has(.page-sidebar) .toasts-wrapper{left:calc(var(--appSidebarWidth) + var(--pageSidebarWidth))}}button{outline:0;border:0;background:none;padding:0;text-align:left;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}.btn{position:relative;z-index:1;display:inline-flex;vertical-align:top;align-items:center;justify-content:center;outline:0;border:0;margin:0;flex-shrink:0;cursor:pointer;padding:5px 20px;column-gap:7px;-webkit-user-select:none;user-select:none;min-width:var(--btnHeight);min-height:var(--btnHeight);text-align:center;text-decoration:none;line-height:1;font-weight:600;color:#fff;font-size:var(--baseFontSize);font-family:var(--baseFontFamily);border-radius:var(--btnRadius);background:none;transition:color var(--baseAnimationSpeed)}.btn i{font-size:1.1428em;vertical-align:middle;display:inline-block}.btn .dropdown{-webkit-user-select:text;user-select:text}.btn:before{content:"";border-radius:inherit;position:absolute;left:0;top:0;z-index:-1;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;user-select:none;backface-visibility:hidden;background:var(--primaryColor);transition:filter var(--baseAnimationSpeed),opacity var(--baseAnimationSpeed),transform var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}.btn:hover:before,.btn:focus-visible:before{opacity:.9}.btn.active,.btn:active{z-index:999}.btn.active:before,.btn:active:before{opacity:.8;transition-duration:var(--activeAnimationSpeed)}.btn.btn-info:before{background:var(--infoColor)}.btn.btn-info:hover:before,.btn.btn-info:focus-visible:before{opacity:.8}.btn.btn-info:active:before{opacity:.7}.btn.btn-success:before{background:var(--successColor)}.btn.btn-success:hover:before,.btn.btn-success:focus-visible:before{opacity:.8}.btn.btn-success:active:before{opacity:.7}.btn.btn-danger:before{background:var(--dangerColor)}.btn.btn-danger:hover:before,.btn.btn-danger:focus-visible:before{opacity:.8}.btn.btn-danger:active:before{opacity:.7}.btn.btn-warning:before{background:var(--warningColor)}.btn.btn-warning:hover:before,.btn.btn-warning:focus-visible:before{opacity:.8}.btn.btn-warning:active:before{opacity:.7}.btn.btn-hint:before{background:var(--baseAlt4Color)}.btn.btn-hint:hover:before,.btn.btn-hint:focus-visible:before{opacity:.8}.btn.btn-hint:active:before{opacity:.7}.btn.btn-outline{border:2px solid currentColor;background:#fff}.btn.btn-secondary,.btn.btn-transparent,.btn.btn-outline{box-shadow:none;color:var(--txtPrimaryColor)}.btn.btn-secondary:before,.btn.btn-transparent:before,.btn.btn-outline:before{opacity:0}.btn.btn-secondary:focus-visible:before,.btn.btn-secondary:hover:before,.btn.btn-transparent:focus-visible:before,.btn.btn-transparent:hover:before,.btn.btn-outline:focus-visible:before,.btn.btn-outline:hover:before{opacity:.3}.btn.btn-secondary.active:before,.btn.btn-secondary:active:before,.btn.btn-transparent.active:before,.btn.btn-transparent:active:before,.btn.btn-outline.active:before,.btn.btn-outline:active:before{opacity:.45}.btn.btn-secondary:before,.btn.btn-transparent:before,.btn.btn-outline:before{background:var(--baseAlt3Color)}.btn.btn-secondary.btn-info,.btn.btn-transparent.btn-info,.btn.btn-outline.btn-info{color:var(--infoColor)}.btn.btn-secondary.btn-info:before,.btn.btn-transparent.btn-info:before,.btn.btn-outline.btn-info:before{opacity:0}.btn.btn-secondary.btn-info:focus-visible:before,.btn.btn-secondary.btn-info:hover:before,.btn.btn-transparent.btn-info:focus-visible:before,.btn.btn-transparent.btn-info:hover:before,.btn.btn-outline.btn-info:focus-visible:before,.btn.btn-outline.btn-info:hover:before{opacity:.15}.btn.btn-secondary.btn-info.active:before,.btn.btn-secondary.btn-info:active:before,.btn.btn-transparent.btn-info.active:before,.btn.btn-transparent.btn-info:active:before,.btn.btn-outline.btn-info.active:before,.btn.btn-outline.btn-info:active:before{opacity:.25}.btn.btn-secondary.btn-info:before,.btn.btn-transparent.btn-info:before,.btn.btn-outline.btn-info:before{background:var(--infoColor)}.btn.btn-secondary.btn-success,.btn.btn-transparent.btn-success,.btn.btn-outline.btn-success{color:var(--successColor)}.btn.btn-secondary.btn-success:before,.btn.btn-transparent.btn-success:before,.btn.btn-outline.btn-success:before{opacity:0}.btn.btn-secondary.btn-success:focus-visible:before,.btn.btn-secondary.btn-success:hover:before,.btn.btn-transparent.btn-success:focus-visible:before,.btn.btn-transparent.btn-success:hover:before,.btn.btn-outline.btn-success:focus-visible:before,.btn.btn-outline.btn-success:hover:before{opacity:.15}.btn.btn-secondary.btn-success.active:before,.btn.btn-secondary.btn-success:active:before,.btn.btn-transparent.btn-success.active:before,.btn.btn-transparent.btn-success:active:before,.btn.btn-outline.btn-success.active:before,.btn.btn-outline.btn-success:active:before{opacity:.25}.btn.btn-secondary.btn-success:before,.btn.btn-transparent.btn-success:before,.btn.btn-outline.btn-success:before{background:var(--successColor)}.btn.btn-secondary.btn-danger,.btn.btn-transparent.btn-danger,.btn.btn-outline.btn-danger{color:var(--dangerColor)}.btn.btn-secondary.btn-danger:before,.btn.btn-transparent.btn-danger:before,.btn.btn-outline.btn-danger:before{opacity:0}.btn.btn-secondary.btn-danger:focus-visible:before,.btn.btn-secondary.btn-danger:hover:before,.btn.btn-transparent.btn-danger:focus-visible:before,.btn.btn-transparent.btn-danger:hover:before,.btn.btn-outline.btn-danger:focus-visible:before,.btn.btn-outline.btn-danger:hover:before{opacity:.15}.btn.btn-secondary.btn-danger.active:before,.btn.btn-secondary.btn-danger:active:before,.btn.btn-transparent.btn-danger.active:before,.btn.btn-transparent.btn-danger:active:before,.btn.btn-outline.btn-danger.active:before,.btn.btn-outline.btn-danger:active:before{opacity:.25}.btn.btn-secondary.btn-danger:before,.btn.btn-transparent.btn-danger:before,.btn.btn-outline.btn-danger:before{background:var(--dangerColor)}.btn.btn-secondary.btn-warning,.btn.btn-transparent.btn-warning,.btn.btn-outline.btn-warning{color:var(--warningColor)}.btn.btn-secondary.btn-warning:before,.btn.btn-transparent.btn-warning:before,.btn.btn-outline.btn-warning:before{opacity:0}.btn.btn-secondary.btn-warning:focus-visible:before,.btn.btn-secondary.btn-warning:hover:before,.btn.btn-transparent.btn-warning:focus-visible:before,.btn.btn-transparent.btn-warning:hover:before,.btn.btn-outline.btn-warning:focus-visible:before,.btn.btn-outline.btn-warning:hover:before{opacity:.15}.btn.btn-secondary.btn-warning.active:before,.btn.btn-secondary.btn-warning:active:before,.btn.btn-transparent.btn-warning.active:before,.btn.btn-transparent.btn-warning:active:before,.btn.btn-outline.btn-warning.active:before,.btn.btn-outline.btn-warning:active:before{opacity:.25}.btn.btn-secondary.btn-warning:before,.btn.btn-transparent.btn-warning:before,.btn.btn-outline.btn-warning:before{background:var(--warningColor)}.btn.btn-secondary.btn-hint,.btn.btn-transparent.btn-hint,.btn.btn-outline.btn-hint{color:var(--baseAlt4Color)}.btn.btn-secondary.btn-hint:before,.btn.btn-transparent.btn-hint:before,.btn.btn-outline.btn-hint:before{opacity:0}.btn.btn-secondary.btn-hint:focus-visible:before,.btn.btn-secondary.btn-hint:hover:before,.btn.btn-transparent.btn-hint:focus-visible:before,.btn.btn-transparent.btn-hint:hover:before,.btn.btn-outline.btn-hint:focus-visible:before,.btn.btn-outline.btn-hint:hover:before{opacity:.15}.btn.btn-secondary.btn-hint.active:before,.btn.btn-secondary.btn-hint:active:before,.btn.btn-transparent.btn-hint.active:before,.btn.btn-transparent.btn-hint:active:before,.btn.btn-outline.btn-hint.active:before,.btn.btn-outline.btn-hint:active:before{opacity:.25}.btn.btn-secondary.btn-hint:before,.btn.btn-transparent.btn-hint:before,.btn.btn-outline.btn-hint:before{background:var(--baseAlt4Color)}.btn.btn-secondary.btn-hint,.btn.btn-transparent.btn-hint,.btn.btn-outline.btn-hint{color:var(--txtHintColor)}.btn.btn-secondary.btn-hint:focus-visible,.btn.btn-secondary.btn-hint:hover,.btn.btn-secondary.btn-hint:active,.btn.btn-secondary.btn-hint.active,.btn.btn-transparent.btn-hint:focus-visible,.btn.btn-transparent.btn-hint:hover,.btn.btn-transparent.btn-hint:active,.btn.btn-transparent.btn-hint.active,.btn.btn-outline.btn-hint:focus-visible,.btn.btn-outline.btn-hint:hover,.btn.btn-outline.btn-hint:active,.btn.btn-outline.btn-hint.active{color:var(--txtPrimaryColor)}.btn.btn-secondary:before{opacity:.35}.btn.btn-secondary:focus-visible:before,.btn.btn-secondary:hover:before{opacity:.5}.btn.btn-secondary.active:before,.btn.btn-secondary:active:before{opacity:.7}.btn.btn-secondary.btn-info:before{opacity:.15}.btn.btn-secondary.btn-info:focus-visible:before,.btn.btn-secondary.btn-info:hover:before{opacity:.25}.btn.btn-secondary.btn-info.active:before,.btn.btn-secondary.btn-info:active:before{opacity:.3}.btn.btn-secondary.btn-success:before{opacity:.15}.btn.btn-secondary.btn-success:focus-visible:before,.btn.btn-secondary.btn-success:hover:before{opacity:.25}.btn.btn-secondary.btn-success.active:before,.btn.btn-secondary.btn-success:active:before{opacity:.3}.btn.btn-secondary.btn-danger:before{opacity:.15}.btn.btn-secondary.btn-danger:focus-visible:before,.btn.btn-secondary.btn-danger:hover:before{opacity:.25}.btn.btn-secondary.btn-danger.active:before,.btn.btn-secondary.btn-danger:active:before{opacity:.3}.btn.btn-secondary.btn-warning:before{opacity:.15}.btn.btn-secondary.btn-warning:focus-visible:before,.btn.btn-secondary.btn-warning:hover:before{opacity:.25}.btn.btn-secondary.btn-warning.active:before,.btn.btn-secondary.btn-warning:active:before{opacity:.3}.btn.btn-secondary.btn-hint:before{opacity:.15}.btn.btn-secondary.btn-hint:focus-visible:before,.btn.btn-secondary.btn-hint:hover:before{opacity:.25}.btn.btn-secondary.btn-hint.active:before,.btn.btn-secondary.btn-hint:active:before{opacity:.3}.btn.btn-disabled,.btn[disabled]{box-shadow:none;cursor:default;background:var(--baseAlt1Color);color:var(--txtDisabledColor)!important}.btn.btn-disabled:before,.btn[disabled]:before{display:none}.btn.btn-disabled.btn-transparent,.btn[disabled].btn-transparent{background:none}.btn.btn-disabled.btn-outline,.btn[disabled].btn-outline{border-color:var(--baseAlt2Color)}.btn.btn-expanded{min-width:150px}.btn.btn-expanded-sm{min-width:90px}.btn.btn-expanded-lg{min-width:170px}.btn.btn-lg{column-gap:10px;font-size:var(--lgFontSize);min-height:var(--lgBtnHeight);min-width:var(--lgBtnHeight);padding-left:30px;padding-right:30px}.btn.btn-lg i{font-size:1.2666em}.btn.btn-lg.btn-expanded{min-width:240px}.btn.btn-lg.btn-expanded-sm{min-width:160px}.btn.btn-lg.btn-expanded-lg{min-width:300px}.btn.btn-sm,.btn.btn-xs{column-gap:5px;font-size:var(--smFontSize);min-height:var(--smBtnHeight);min-width:var(--smBtnHeight);padding-left:12px;padding-right:12px}.btn.btn-sm i,.btn.btn-xs i{font-size:1rem}.btn.btn-sm.btn-expanded,.btn.btn-xs.btn-expanded{min-width:100px}.btn.btn-sm.btn-expanded-sm,.btn.btn-xs.btn-expanded-sm{min-width:80px}.btn.btn-sm.btn-expanded-lg,.btn.btn-xs.btn-expanded-lg{min-width:130px}.btn.btn-xs{padding-left:7px;padding-right:7px;min-width:var(--xsBtnHeight);min-height:var(--xsBtnHeight)}.btn.btn-block{display:flex;width:100%}.btn.btn-pill{border-radius:30px}.btn.btn-circle{border-radius:50%;padding:0;gap:0}.btn.btn-circle i{font-size:1.2857rem;text-align:center;width:19px;height:19px;line-height:19px}.btn.btn-circle i:before{margin:0;display:block}.btn.btn-circle.btn-sm i{font-size:1.1rem}.btn.btn-circle.btn-xs i{font-size:1.05rem}.btn.btn-loading{--loaderSize: 24px;cursor:default;pointer-events:none}.btn.btn-loading:after{content:"";position:absolute;display:inline-block;vertical-align:top;left:50%;top:50%;width:var(--loaderSize);height:var(--loaderSize);line-height:var(--loaderSize);font-size:var(--loaderSize);color:inherit;text-align:center;font-weight:400;margin-left:calc(var(--loaderSize) * -.5);margin-top:calc(var(--loaderSize) * -.5);font-family:var(--iconFontFamily);animation:loaderShow var(--baseAnimationSpeed),rotate .9s var(--baseAnimationSpeed) infinite linear}.btn.btn-loading>*{opacity:0;transform:scale(.9)}.btn.btn-loading.btn-sm,.btn.btn-loading.btn-xs{--loaderSize: 20px}.btn.btn-loading.btn-lg{--loaderSize: 28px}.btn.btn-prev i,.btn.btn-next i{transition:transform var(--baseAnimationSpeed)}.btn.btn-prev:hover i,.btn.btn-prev:focus-within i,.btn.btn-next:hover i,.btn.btn-next:focus-within i{transform:translate(3px)}.btn.btn-prev:hover i,.btn.btn-prev:focus-within i{transform:translate(-3px)}.btns-group{display:inline-flex;align-items:center;gap:var(--xsSpacing)}.btns-group.no-gap{gap:0}.btns-group.no-gap>*{border-radius:0;min-width:0;box-shadow:-1px 0 #ffffff1a}.btns-group.no-gap>*:first-child{border-top-left-radius:var(--btnRadius);border-bottom-left-radius:var(--btnRadius);box-shadow:none}.btns-group.no-gap>*:last-child{border-top-right-radius:var(--btnRadius);border-bottom-right-radius:var(--btnRadius)}.tinymce-wrapper,.code-editor,.select .selected-container,input,select,textarea{display:block;width:100%;outline:0;border:0;margin:0;background:none;padding:5px 10px;line-height:20px;min-width:0;min-height:var(--inputHeight);background:var(--baseAlt1Color);color:var(--txtPrimaryColor);font-size:var(--baseFontSize);font-family:var(--baseFontFamily);font-weight:400;border-radius:var(--baseRadius);overflow:auto;overflow:overlay}.tinymce-wrapper::placeholder,.code-editor::placeholder,.select .selected-container::placeholder,input::placeholder,select::placeholder,textarea::placeholder{color:var(--txtDisabledColor)}@media screen and (min-width: 550px){.tinymce-wrapper:focus,.code-editor:focus,.select .selected-container:focus,input:focus,select:focus,textarea:focus,.tinymce-wrapper:focus-within,.code-editor:focus-within,.select .selected-container:focus-within,input:focus-within,select:focus-within,textarea:focus-within{scrollbar-color:var(--baseAlt3Color) transparent;scrollbar-width:thin;scroll-behavior:smooth}.tinymce-wrapper:focus::-webkit-scrollbar,.code-editor:focus::-webkit-scrollbar,.select .selected-container:focus::-webkit-scrollbar,input:focus::-webkit-scrollbar,select:focus::-webkit-scrollbar,textarea:focus::-webkit-scrollbar,.tinymce-wrapper:focus-within::-webkit-scrollbar,.code-editor:focus-within::-webkit-scrollbar,.select .selected-container:focus-within::-webkit-scrollbar,input:focus-within::-webkit-scrollbar,select:focus-within::-webkit-scrollbar,textarea:focus-within::-webkit-scrollbar{width:8px;height:8px;border-radius:var(--baseRadius)}.tinymce-wrapper:focus::-webkit-scrollbar-track,.code-editor:focus::-webkit-scrollbar-track,.select .selected-container:focus::-webkit-scrollbar-track,input:focus::-webkit-scrollbar-track,select:focus::-webkit-scrollbar-track,textarea:focus::-webkit-scrollbar-track,.tinymce-wrapper:focus-within::-webkit-scrollbar-track,.code-editor:focus-within::-webkit-scrollbar-track,.select .selected-container:focus-within::-webkit-scrollbar-track,input:focus-within::-webkit-scrollbar-track,select:focus-within::-webkit-scrollbar-track,textarea:focus-within::-webkit-scrollbar-track{background:transparent;border-radius:var(--baseRadius)}.tinymce-wrapper:focus::-webkit-scrollbar-thumb,.code-editor:focus::-webkit-scrollbar-thumb,.select .selected-container:focus::-webkit-scrollbar-thumb,input:focus::-webkit-scrollbar-thumb,select:focus::-webkit-scrollbar-thumb,textarea:focus::-webkit-scrollbar-thumb,.tinymce-wrapper:focus-within::-webkit-scrollbar-thumb,.code-editor:focus-within::-webkit-scrollbar-thumb,.select .selected-container:focus-within::-webkit-scrollbar-thumb,input:focus-within::-webkit-scrollbar-thumb,select:focus-within::-webkit-scrollbar-thumb,textarea:focus-within::-webkit-scrollbar-thumb{background-color:var(--baseAlt3Color);border-radius:15px;border:2px solid transparent;background-clip:padding-box}.tinymce-wrapper:focus::-webkit-scrollbar-thumb:hover,.code-editor:focus::-webkit-scrollbar-thumb:hover,.select .selected-container:focus::-webkit-scrollbar-thumb:hover,input:focus::-webkit-scrollbar-thumb:hover,select:focus::-webkit-scrollbar-thumb:hover,textarea:focus::-webkit-scrollbar-thumb:hover,.tinymce-wrapper:focus::-webkit-scrollbar-thumb:active,.code-editor:focus::-webkit-scrollbar-thumb:active,.select .selected-container:focus::-webkit-scrollbar-thumb:active,input:focus::-webkit-scrollbar-thumb:active,select:focus::-webkit-scrollbar-thumb:active,textarea:focus::-webkit-scrollbar-thumb:active,.tinymce-wrapper:focus-within::-webkit-scrollbar-thumb:hover,.code-editor:focus-within::-webkit-scrollbar-thumb:hover,.select .selected-container:focus-within::-webkit-scrollbar-thumb:hover,input:focus-within::-webkit-scrollbar-thumb:hover,select:focus-within::-webkit-scrollbar-thumb:hover,textarea:focus-within::-webkit-scrollbar-thumb:hover,.tinymce-wrapper:focus-within::-webkit-scrollbar-thumb:active,.code-editor:focus-within::-webkit-scrollbar-thumb:active,.select .selected-container:focus-within::-webkit-scrollbar-thumb:active,input:focus-within::-webkit-scrollbar-thumb:active,select:focus-within::-webkit-scrollbar-thumb:active,textarea:focus-within::-webkit-scrollbar-thumb:active{background-color:var(--baseAlt4Color)}}[readonly].tinymce-wrapper,[readonly].code-editor,.select [readonly].selected-container,input[readonly],select[readonly],textarea[readonly],.readonly.tinymce-wrapper,.readonly.code-editor,.select .readonly.selected-container,input.readonly,select.readonly,textarea.readonly{cursor:default;color:var(--txtHintColor)}[disabled].tinymce-wrapper,[disabled].code-editor,.select [disabled].selected-container,input[disabled],select[disabled],textarea[disabled],.disabled.tinymce-wrapper,.disabled.code-editor,.select .disabled.selected-container,input.disabled,select.disabled,textarea.disabled{cursor:default;color:var(--txtDisabledColor)}.txt-mono.tinymce-wrapper,.txt-mono.code-editor,.select .txt-mono.selected-container,input.txt-mono,select.txt-mono,textarea.txt-mono{line-height:var(--smLineHeight)}.code.tinymce-wrapper,.code.code-editor,.select .code.selected-container,input.code,select.code,textarea.code{font-size:15px;line-height:1.379rem;font-family:var(--monospaceFontFamily)}input{height:var(--inputHeight)}input:-webkit-autofill{-webkit-text-fill-color:var(--txtPrimaryColor);-webkit-box-shadow:inset 0 0 0 50px var(--baseAlt1Color)}.form-field:focus-within input:-webkit-autofill,input:-webkit-autofill:focus{-webkit-box-shadow:inset 0 0 0 50px var(--baseAlt2Color)}input[type=file]{padding:9px}input[type=checkbox],input[type=radio]{width:auto;height:auto;display:inline}input[type=number]{-moz-appearance:textfield;-webkit-appearance:textfield;appearance:textfield}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none}textarea{min-height:80px;resize:vertical}select{padding-left:8px}.form-field{--hPadding: 15px;position:relative;display:block;width:100%;margin-bottom:var(--baseSpacing)}.form-field .tinymce-wrapper,.form-field .code-editor,.form-field .select .selected-container,.select .form-field .selected-container,.form-field input,.form-field select,.form-field textarea{z-index:0;padding-left:var(--hPadding);padding-right:var(--hPadding)}.form-field select{padding-left:8px}.form-field label{display:flex;width:100%;column-gap:5px;align-items:center;-webkit-user-select:none;user-select:none;font-weight:600;font-size:var(--smFontSize);letter-spacing:.1px;color:var(--txtHintColor);line-height:1;padding-top:12px;padding-bottom:3px;padding-left:var(--hPadding);padding-right:var(--hPadding);border:0;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius)}.form-field label~.tinymce-wrapper,.form-field label~.code-editor,.form-field .select label~.selected-container,.select .form-field label~.selected-container,.form-field label~input,.form-field label~select,.form-field label~textarea{border-top:0;padding-top:2px;padding-bottom:8px;border-top-left-radius:0;border-top-right-radius:0}.form-field label i{font-size:.96rem;margin-bottom:-1px}.form-field label i:before{margin:0}.form-field .tinymce-wrapper,.form-field .code-editor,.form-field .select .selected-container,.select .form-field .selected-container,.form-field input,.form-field select,.form-field textarea,.form-field label{background:var(--baseAlt1Color);transition:color var(--baseAnimationSpeed),background var(--baseAnimationSpeed),box-shadow var(--baseAnimationSpeed)}.form-field:focus-within .tinymce-wrapper,.form-field:focus-within .code-editor,.form-field:focus-within .select .selected-container,.select .form-field:focus-within .selected-container,.form-field:focus-within input,.form-field:focus-within select,.form-field:focus-within textarea,.form-field:focus-within label{background:var(--baseAlt2Color)}.form-field:focus-within label{color:var(--txtPrimaryColor)}.form-field .form-field-addon{position:absolute;display:inline-flex;align-items:center;z-index:1;top:0;right:var(--hPadding);min-height:var(--inputHeight);color:var(--txtHintColor)}.form-field .form-field-addon .btn{margin-right:-5px}.form-field .form-field-addon:not(.prefix)~.tinymce-wrapper,.form-field .form-field-addon:not(.prefix)~.code-editor,.form-field .select .form-field-addon:not(.prefix)~.selected-container,.select .form-field .form-field-addon:not(.prefix)~.selected-container,.form-field .form-field-addon:not(.prefix)~input,.form-field .form-field-addon:not(.prefix)~select,.form-field .form-field-addon:not(.prefix)~textarea{padding-right:45px}.form-field .form-field-addon.prefix{right:auto;left:var(--hPadding)}.form-field .form-field-addon.prefix~.tinymce-wrapper,.form-field .form-field-addon.prefix~.code-editor,.form-field .select .form-field-addon.prefix~.selected-container,.select .form-field .form-field-addon.prefix~.selected-container,.form-field .form-field-addon.prefix~input,.form-field .form-field-addon.prefix~select,.form-field .form-field-addon.prefix~textarea{padding-left:45px}.form-field label~.form-field-addon{min-height:calc(26px + var(--inputHeight))}.form-field .help-block{position:relative;margin-top:8px;font-size:var(--smFontSize);line-height:var(--smLineHeight);color:var(--txtHintColor);word-break:break-word}.form-field .help-block pre{white-space:pre-wrap}.form-field .help-block-error{color:var(--dangerColor)}.form-field.error>label,.form-field.invalid>label{color:var(--dangerColor)}.form-field.invalid label,.form-field.invalid .tinymce-wrapper,.form-field.invalid .code-editor,.form-field.invalid .select .selected-container,.select .form-field.invalid .selected-container,.form-field.invalid input,.form-field.invalid select,.form-field.invalid textarea{background:var(--dangerAltColor)}.form-field.required:not(.form-field-toggle)>label:after{content:"*";color:var(--dangerColor);margin-top:-2px;margin-left:-2px}.form-field.readonly label,.form-field.readonly .tinymce-wrapper,.form-field.readonly .code-editor,.form-field.readonly .select .selected-container,.select .form-field.readonly .selected-container,.form-field.readonly input,.form-field.readonly select,.form-field.readonly textarea,.form-field.disabled label,.form-field.disabled .tinymce-wrapper,.form-field.disabled .code-editor,.form-field.disabled .select .selected-container,.select .form-field.disabled .selected-container,.form-field.disabled input,.form-field.disabled select,.form-field.disabled textarea{background:var(--baseAlt1Color)}.form-field.readonly>label,.form-field.disabled>label{color:var(--txtHintColor)}.form-field.readonly.required>label:after,.form-field.disabled.required>label:after{opacity:.5}.form-field.disabled label,.form-field.disabled .tinymce-wrapper,.form-field.disabled .code-editor,.form-field.disabled .select .selected-container,.select .form-field.disabled .selected-container,.form-field.disabled input,.form-field.disabled select,.form-field.disabled textarea{box-shadow:inset 0 0 0 var(--btnHeight) #ffffff73}.form-field.disabled>label{color:var(--txtDisabledColor)}.form-field input[type=radio],.form-field input[type=checkbox]{position:absolute;z-index:-1;left:0;width:0;height:0;min-height:0;min-width:0;border:0;background:none;-webkit-user-select:none;user-select:none;pointer-events:none;box-shadow:none;opacity:0}.form-field input[type=radio]~label,.form-field input[type=checkbox]~label{border:0;margin:0;outline:0;background:none;display:inline-flex;vertical-align:top;align-items:center;width:auto;column-gap:5px;-webkit-user-select:none;user-select:none;padding:0 0 0 27px;line-height:20px;min-height:20px;font-weight:400;font-size:var(--baseFontSize);text-transform:none;color:var(--txtPrimaryColor)}.form-field input[type=radio]~label:before,.form-field input[type=checkbox]~label:before{content:"";display:inline-block;vertical-align:top;position:absolute;z-index:0;left:0;top:0;width:20px;height:20px;line-height:16px;font-family:var(--iconFontFamily);font-size:1.2rem;text-align:center;color:var(--baseColor);cursor:pointer;background:var(--baseColor);border-radius:var(--baseRadius);border:2px solid var(--baseAlt3Color);transition:transform var(--baseAnimationSpeed),border-color var(--baseAnimationSpeed),color var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}.form-field input[type=radio]~label:active:before,.form-field input[type=checkbox]~label:active:before{transform:scale(.9)}.form-field input[type=radio]:focus~label:before,.form-field input[type=radio]~label:hover:before,.form-field input[type=checkbox]:focus~label:before,.form-field input[type=checkbox]~label:hover:before{border-color:var(--baseAlt4Color)}.form-field input[type=radio]:checked~label:before,.form-field input[type=checkbox]:checked~label:before{content:"";box-shadow:none;mix-blend-mode:unset;background:var(--successColor);border-color:var(--successColor)}.form-field input[type=radio]:disabled~label,.form-field input[type=checkbox]:disabled~label{pointer-events:none;cursor:not-allowed;color:var(--txtDisabledColor)}.form-field input[type=radio]:disabled~label:before,.form-field input[type=checkbox]:disabled~label:before{opacity:.5}.form-field input[type=radio]~label:before{border-radius:50%;font-size:1rem}.form-field .form-field-block{position:relative;margin:0 0 var(--xsSpacing)}.form-field .form-field-block:last-child{margin-bottom:0}.form-field.form-field-toggle input[type=radio]~label,.form-field.form-field-toggle input[type=checkbox]~label{position:relative}.form-field.form-field-toggle input[type=radio]~label:before,.form-field.form-field-toggle input[type=checkbox]~label:before{content:"";border:0;box-shadow:none;background:var(--baseAlt3Color);transition:background var(--activeAnimationSpeed)}.form-field.form-field-toggle input[type=radio]~label:after,.form-field.form-field-toggle input[type=checkbox]~label:after{content:"";position:absolute;z-index:1;cursor:pointer;background:var(--baseColor);transition:left var(--activeAnimationSpeed),transform var(--activeAnimationSpeed),background var(--activeAnimationSpeed);box-shadow:0 2px 5px 0 var(--shadowColor)}.form-field.form-field-toggle input[type=radio]~label:active:before,.form-field.form-field-toggle input[type=checkbox]~label:active:before{transform:none}.form-field.form-field-toggle input[type=radio]~label:active:after,.form-field.form-field-toggle input[type=checkbox]~label:active:after{transform:scale(.9)}.form-field.form-field-toggle input[type=radio]:focus-visible~label:before,.form-field.form-field-toggle input[type=checkbox]:focus-visible~label:before{box-shadow:0 0 0 2px var(--baseAlt2Color)}.form-field.form-field-toggle input[type=radio]~label:hover:before,.form-field.form-field-toggle input[type=checkbox]~label:hover:before{background:var(--baseAlt4Color)}.form-field.form-field-toggle input[type=radio]:checked~label:before,.form-field.form-field-toggle input[type=checkbox]:checked~label:before{background:var(--successColor)}.form-field.form-field-toggle input[type=radio]:checked~label:after,.form-field.form-field-toggle input[type=checkbox]:checked~label:after{background:var(--baseColor)}.form-field.form-field-toggle input[type=radio]~label,.form-field.form-field-toggle input[type=checkbox]~label{min-height:24px;padding-left:47px}.form-field.form-field-toggle input[type=radio]~label:empty,.form-field.form-field-toggle input[type=checkbox]~label:empty{padding-left:40px}.form-field.form-field-toggle input[type=radio]~label:before,.form-field.form-field-toggle input[type=checkbox]~label:before{width:40px;height:24px;border-radius:24px}.form-field.form-field-toggle input[type=radio]~label:after,.form-field.form-field-toggle input[type=checkbox]~label:after{top:4px;left:4px;width:16px;height:16px;border-radius:16px}.form-field.form-field-toggle input[type=radio]:checked~label:after,.form-field.form-field-toggle input[type=checkbox]:checked~label:after{left:20px}.form-field.form-field-toggle.form-field-sm input[type=radio]~label,.form-field.form-field-toggle.form-field-sm input[type=checkbox]~label{min-height:20px;padding-left:39px}.form-field.form-field-toggle.form-field-sm input[type=radio]~label:empty,.form-field.form-field-toggle.form-field-sm input[type=checkbox]~label:empty{padding-left:32px}.form-field.form-field-toggle.form-field-sm input[type=radio]~label:before,.form-field.form-field-toggle.form-field-sm input[type=checkbox]~label:before{width:32px;height:20px;border-radius:20px}.form-field.form-field-toggle.form-field-sm input[type=radio]~label:after,.form-field.form-field-toggle.form-field-sm input[type=checkbox]~label:after{top:4px;left:4px;width:12px;height:12px;border-radius:12px}.form-field.form-field-toggle.form-field-sm input[type=radio]:checked~label:after,.form-field.form-field-toggle.form-field-sm input[type=checkbox]:checked~label:after{left:16px}.form-field-group{display:flex;width:100%;align-items:center}.form-field-group>.form-field{flex-grow:1;border-left:1px solid var(--baseAlt2Color)}.form-field-group>.form-field:first-child{border-left:0}.form-field-group>.form-field:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.form-field-group>.form-field:not(:first-child)>label{border-top-left-radius:0}.form-field-group>.form-field:not(:first-child)>.tinymce-wrapper,.form-field-group>.form-field:not(:first-child)>.code-editor,.select .form-field-group>.form-field:not(:first-child)>.selected-container,.form-field-group>.form-field:not(:first-child)>input,.form-field-group>.form-field:not(:first-child)>select,.form-field-group>.form-field:not(:first-child)>textarea,.form-field-group>.form-field:not(:first-child)>.select .selected-container{border-bottom-left-radius:0}.form-field-group>.form-field:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.form-field-group>.form-field:not(:last-child)>label{border-top-right-radius:0}.form-field-group>.form-field:not(:last-child)>.tinymce-wrapper,.form-field-group>.form-field:not(:last-child)>.code-editor,.select .form-field-group>.form-field:not(:last-child)>.selected-container,.form-field-group>.form-field:not(:last-child)>input,.form-field-group>.form-field:not(:last-child)>select,.form-field-group>.form-field:not(:last-child)>textarea,.form-field-group>.form-field:not(:last-child)>.select .selected-container{border-bottom-right-radius:0}.form-field-group .form-field.col-12{width:100%}.form-field-group .form-field.col-11{width:91.6666666667%}.form-field-group .form-field.col-10{width:83.3333333333%}.form-field-group .form-field.col-9{width:75%}.form-field-group .form-field.col-8{width:66.6666666667%}.form-field-group .form-field.col-7{width:58.3333333333%}.form-field-group .form-field.col-6{width:50%}.form-field-group .form-field.col-5{width:41.6666666667%}.form-field-group .form-field.col-4{width:33.3333333333%}.form-field-group .form-field.col-3{width:25%}.form-field-group .form-field.col-2{width:16.6666666667%}.form-field-group .form-field.col-1{width:8.3333333333%}.select{position:relative;display:block;outline:0}.select .option{-webkit-user-select:none;user-select:none;column-gap:5px}.select .option .icon{min-width:20px;text-align:center;line-height:inherit}.select .option .icon i{vertical-align:middle;line-height:inherit}.select .txt-placeholder{color:var(--txtHintColor)}label~.select .selected-container{border-top:0}.select .selected-container{position:relative;display:flex;flex-wrap:wrap;width:100%;align-items:center;padding-top:0;padding-bottom:0;padding-right:35px!important;-webkit-user-select:none;user-select:none}.select .selected-container:after{content:"";position:absolute;right:5px;top:50%;width:20px;height:20px;line-height:20px;text-align:center;margin-top:-10px;display:inline-block;vertical-align:top;font-size:1rem;font-family:var(--iconFontFamily);align-self:flex-end;color:var(--txtHintColor);transition:color var(--baseAnimationSpeed),transform var(--baseAnimationSpeed)}.select .selected-container:active,.select .selected-container.active{border-bottom-left-radius:0;border-bottom-right-radius:0}.select .selected-container:active:after,.select .selected-container.active:after{color:var(--txtPrimaryColor);transform:rotate(180deg)}.select .selected-container .option{display:flex;width:100%;align-items:center;max-width:100%;-webkit-user-select:text;user-select:text}.select .selected-container .clear{margin-left:auto;cursor:pointer;color:var(--txtHintColor);transition:color var(--baseAnimationSpeed)}.select .selected-container .clear i{display:inline-block;vertical-align:middle;line-height:1}.select .selected-container .clear:hover{color:var(--txtPrimaryColor)}.select.multiple .selected-container{display:flex;align-items:center;padding-left:2px;row-gap:3px;column-gap:4px}.select.multiple .selected-container .txt-placeholder{margin-left:5px}.select.multiple .selected-container .option{display:inline-flex;width:auto;padding:3px 5px;line-height:1;border-radius:var(--baseRadius);background:var(--baseColor)}.select:not(.multiple) .selected-container .label{margin-left:-2px}.select:not(.multiple) .selected-container .option .txt{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:100%;line-height:normal}.select:not(.disabled) .selected-container:hover{cursor:pointer}.select.readonly,.select.disabled{color:var(--txtHintColor);pointer-events:none}.select.readonly .txt-placeholder,.select.disabled .txt-placeholder,.select.readonly .selected-container,.select.disabled .selected-container{color:inherit}.select.readonly .selected-container .link-hint,.select.disabled .selected-container .link-hint{pointer-events:auto}.select.readonly .selected-container *:not(.link-hint),.select.disabled .selected-container *:not(.link-hint){color:inherit!important}.select.readonly .selected-container:after,.select.readonly .selected-container .clear,.select.disabled .selected-container:after,.select.disabled .selected-container .clear{display:none}.select.readonly .selected-container:hover,.select.disabled .selected-container:hover{cursor:inherit}.select.disabled{color:var(--txtDisabledColor)}.select .txt-missing{color:var(--txtHintColor);padding:5px 12px;margin:0}.select .options-dropdown{max-height:none;border:0;overflow:auto;border-top-left-radius:0;border-top-right-radius:0;margin-top:-2px;box-shadow:0 2px 5px 0 var(--shadowColor),inset 0 0 0 2px var(--baseAlt2Color)}.select .options-dropdown .input-group:focus-within{box-shadow:none}.select .options-dropdown .form-field.options-search{margin:0 0 5px;padding:0 0 2px;color:var(--txtHintColor);border-bottom:1px solid var(--baseAlt2Color)}.select .options-dropdown .form-field.options-search .input-group{border-radius:0;padding:0 0 0 10px;margin:0;background:none;column-gap:0;border:0}.select .options-dropdown .form-field.options-search input{border:0;padding-left:9px;padding-right:9px;background:none}.select .options-dropdown .options-list{overflow:auto;max-height:240px;width:auto;margin-left:0;margin-right:-5px;padding-right:5px}.select .options-list:not(:empty)~[slot=afterOptions]:not(:empty){margin:5px -5px -5px}.select .options-list:not(:empty)~[slot=afterOptions]:not(:empty) .btn-block{border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:var(--baseRadius);border-bottom-right-radius:var(--baseRadius)}label~.select .selected-container{padding-bottom:4px;border-top-left-radius:0;border-top-right-radius:0}label~.select.multiple .selected-container{padding-top:3px;padding-bottom:3px;padding-left:10px}.select.block-options.multiple .selected-container .option{width:100%;box-shadow:0 2px 5px 0 var(--shadowColor)}.field-type-select .options-dropdown{padding:2px 1px 1px 2px}.field-type-select .options-dropdown .form-field.options-search{margin:0}.field-type-select .options-dropdown .options-list{max-height:490px;display:flex;flex-direction:row;flex-wrap:wrap;width:100%;padding:0}.field-type-select .options-dropdown .dropdown-item{width:50%;margin:0;padding-left:12px;border-radius:0;border-bottom:1px solid var(--baseAlt2Color);border-right:1px solid var(--baseAlt2Color)}.field-type-select .options-dropdown .dropdown-item.selected{background:var(--baseAlt1Color)}.form-field-list{border-radius:var(--baseRadius);transition:box-shadow var(--baseAnimationSpeed)}.form-field-list label{padding-bottom:10px}.form-field-list .list{background:var(--baseAlt1Color);border:0;border-radius:0;border-bottom-left-radius:var(--baseRadius);border-bottom-right-radius:var(--baseRadius);transition:background var(--baseAnimationSpeed)}.form-field-list .list .list-item{border-top:1px solid var(--baseAlt2Color)}.form-field-list .list .list-item:hover,.form-field-list .list .list-item:focus,.form-field-list .list .list-item:focus-within,.form-field-list .list .list-item:focus-visible,.form-field-list .list .list-item:active{background:none}.form-field-list .list .list-item.selected{background:var(--baseAlt2Color)}.form-field-list .list .list-item.handle:not(.disabled):hover,.form-field-list .list .list-item.handle:not(.disabled):focus-visible{background:var(--baseAlt2Color)}.form-field-list .list .list-item.handle:not(.disabled):active{background:var(--baseAlt3Color)}.form-field-list .list .list-item.dragging{z-index:9;box-shadow:inset 0 0 0 1px var(--baseAlt3Color)}.form-field-list .list .list-item.dragover{background:var(--baseAlt2Color)}.form-field-list:focus-within .list,.form-field-list:focus-within label{background:var(--baseAlt1Color)}.form-field-list.dragover:not(:has(.dragging)){box-shadow:0 0 0 2px var(--warningColor)}.code-editor{display:flex;flex-direction:column;width:100%}.form-field label~.code-editor{padding-bottom:6px;padding-top:4px}.code-editor .cm-editor{flex-grow:1;border:0!important;outline:none!important}.code-editor .cm-editor .cm-line{padding-left:0;padding-right:0}.code-editor .cm-editor .cm-tooltip-autocomplete{box-shadow:0 2px 5px 0 var(--shadowColor);border-radius:var(--baseRadius);background:var(--baseColor);border:0;z-index:9999;padding:0 3px;font-size:.92rem}.code-editor .cm-editor .cm-tooltip-autocomplete ul{margin:0;border-radius:inherit}.code-editor .cm-editor .cm-tooltip-autocomplete ul>:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.code-editor .cm-editor .cm-tooltip-autocomplete ul>:last-child{border-bottom-left-radius:inherit;border-bottom-right-radius:inherit}.code-editor .cm-editor .cm-tooltip-autocomplete ul li[aria-selected]{background:var(--infoColor)}.code-editor .cm-editor .cm-scroller{flex-grow:1;outline:0!important;font-family:var(--monospaceFontFamily);font-size:var(--baseFontSize);line-height:var(--baseLineHeight)}.code-editor .cm-editor .cm-cursorLayer .cm-cursor{margin-left:0!important}.code-editor .cm-editor .cm-placeholder{color:var(--txtDisabledColor);font-family:var(--monospaceFontFamily);font-size:var(--baseFontSize);line-height:var(--baseLineHeight)}.code-editor .cm-editor .cm-selectionMatch{background:var(--infoAltColor)}.code-editor .cm-editor.cm-focused .cm-matchingBracket{background-color:#328c821a}.code-editor .ͼf{color:var(--dangerColor)}.tinymce-wrapper{min-height:277px}.tinymce-wrapper .tox-tinymce{border-radius:var(--baseRadius);border:0}.form-field label~.tinymce-wrapper{position:relative;z-index:auto;padding:5px 2px 2px}.form-field label~.tinymce-wrapper:before{content:"";position:absolute;z-index:-1;top:5px;left:2px;right:2px;bottom:2px;background:#fff;border-radius:var(--baseRadius)}body .tox .tox-tbtn{height:30px}body .tox .tox-tbtn svg{transform:scale(.85)}body .tox .tox-tbtn:not(.tox-tbtn--select){width:30px}body .tox .tox-button,body .tox .tox-button--secondary{font-size:var(--smFontSize)}body .tox .tox-toolbar-overlord{box-shadow:0 2px 5px 0 var(--shadowColor)}body .tox .tox-listboxfield .tox-listbox--select,body .tox .tox-textarea,body .tox .tox-textfield,body .tox .tox-toolbar-textfield{padding:3px 5px}body .tox-swatch:not(.tox-swatch--remove):not(.tox-collection__item--enabled) svg{display:none}body.tox-fullscreen .overlay-panel-section{overflow:hidden}.main-menu{--menuItemSize: 45px;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;row-gap:var(--smSpacing);font-size:var(--xlFontSize);color:var(--txtPrimaryColor)}.main-menu i{font-size:24px;line-height:1}.main-menu .menu-item{position:relative;outline:0;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;text-align:center;justify-content:center;-webkit-user-select:none;user-select:none;color:inherit;min-width:var(--menuItemSize);min-height:var(--menuItemSize);border:2px solid transparent;border-radius:var(--lgRadius);transition:background var(--baseAnimationSpeed),border var(--baseAnimationSpeed)}.main-menu .menu-item:focus-visible,.main-menu .menu-item:hover{background:var(--baseAlt1Color)}.main-menu .menu-item:active{background:var(--baseAlt2Color);transition-duration:var(--activeAnimationSpeed)}.main-menu .menu-item.active,.main-menu .menu-item.current-route{background:var(--baseColor);border-color:var(--primaryColor)}.app-sidebar{position:relative;z-index:1;display:flex;flex-grow:0;flex-shrink:0;flex-direction:column;align-items:center;width:var(--appSidebarWidth);padding:var(--smSpacing) 0px var(--smSpacing);background:var(--baseColor);border-right:1px solid var(--baseAlt2Color)}.app-sidebar .main-menu{flex-grow:1;justify-content:flex-start;overflow-x:hidden;overflow-y:auto;overflow-y:overlay;margin-top:34px;margin-bottom:var(--baseSpacing)}.app-layout{display:flex;width:100%;height:100vh}.app-layout .app-body{flex-grow:1;min-width:0;height:100%;display:flex;align-items:stretch}.app-layout .app-sidebar~.app-body{min-width:650px}.page-sidebar{--sidebarListItemMargin: 10px;z-index:0;display:flex;flex-direction:column;width:var(--pageSidebarWidth);min-width:220px;max-width:265px;flex-shrink:0;flex-grow:0;overflow-x:hidden;overflow-y:auto;background:var(--baseColor);padding:calc(var(--baseSpacing) - 5px) 0 var(--smSpacing);border-right:1px solid var(--baseAlt2Color)}.page-sidebar>*{padding:0 var(--smSpacing)}.page-sidebar .sidebar-content{overflow-x:hidden;overflow-y:auto;overflow-y:overlay}.page-sidebar .sidebar-content>:first-child{margin-top:0}.page-sidebar .sidebar-content>:last-child{margin-bottom:0}.page-sidebar .sidebar-footer{margin-top:var(--smSpacing)}.page-sidebar .search{display:flex;align-items:center;width:auto;column-gap:5px;margin:0 0 var(--xsSpacing);color:var(--txtHintColor);opacity:.7;transition:opacity var(--baseAnimationSpeed),color var(--baseAnimationSpeed)}.page-sidebar .search input{border:0;background:var(--baseColor);transition:box-shadow var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}.page-sidebar .search .btn-clear{margin-right:-8px}.page-sidebar .search:hover,.page-sidebar .search:focus-within,.page-sidebar .search.active{opacity:1;color:var(--txtPrimaryColor)}.page-sidebar .search:hover input,.page-sidebar .search:focus-within input,.page-sidebar .search.active input{background:var(--baseAlt2Color)}.page-sidebar .sidebar-title{display:flex;align-items:center;gap:5px;width:100%;margin:var(--baseSpacing) 0 var(--xsSpacing);font-weight:600;font-size:1rem;line-height:var(--smLineHeight);color:var(--txtHintColor)}.page-sidebar .sidebar-title .label{font-weight:400}.page-sidebar .sidebar-list-item{cursor:pointer;outline:0;text-decoration:none;position:relative;display:flex;width:100%;align-items:center;column-gap:10px;margin:var(--sidebarListItemMargin) 0;padding:3px 10px;font-size:var(--xlFontSize);min-height:var(--btnHeight);min-width:0;color:var(--txtHintColor);border-radius:var(--baseRadius);-webkit-user-select:none;user-select:none;transition:background var(--baseAnimationSpeed),color var(--baseAnimationSpeed)}.page-sidebar .sidebar-list-item i{font-size:18px}.page-sidebar .sidebar-list-item .txt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.page-sidebar .sidebar-list-item:focus-visible,.page-sidebar .sidebar-list-item:hover,.page-sidebar .sidebar-list-item:active,.page-sidebar .sidebar-list-item.active{color:var(--txtPrimaryColor);background:var(--baseAlt1Color)}.page-sidebar .sidebar-list-item:active{background:var(--baseAlt2Color);transition-duration:var(--activeAnimationSpeed)}.page-sidebar .sidebar-content-compact .sidebar-list-item{--sidebarListItemMargin: 5px}@media screen and (max-height: 600px){.page-sidebar{--sidebarListItemMargin: 5px}}@media screen and (max-width: 1100px){.page-sidebar{min-width:190px}.page-sidebar>*{padding-left:10px;padding-right:10px}}.page-header{display:flex;align-items:center;width:100%;min-height:var(--btnHeight);gap:var(--xsSpacing);margin:0 0 var(--baseSpacing)}.page-header .btns-group{margin-left:auto;justify-content:end}@media screen and (max-width: 1050px){.page-header{flex-wrap:wrap}.page-header .btns-group{width:100%}.page-header .btns-group .btn{flex-grow:1;flex-basis:0}}.page-header-wrapper{background:var(--baseColor);width:auto;margin-top:calc(-1 * (var(--baseSpacing) - 5px));margin-left:calc(-1 * var(--baseSpacing));margin-right:calc(-1 * var(--baseSpacing));margin-bottom:var(--baseSpacing);padding:calc(var(--baseSpacing) - 5px) var(--baseSpacing);border-bottom:1px solid var(--baseAlt2Color)}.breadcrumbs{display:flex;align-items:center;gap:30px;color:var(--txtDisabledColor)}.breadcrumbs .breadcrumb-item{position:relative;margin:0;line-height:1;font-weight:400}.breadcrumbs .breadcrumb-item:after{content:"/";position:absolute;right:-20px;top:0;width:10px;text-align:center;pointer-events:none;opacity:.4}.breadcrumbs .breadcrumb-item:last-child{word-break:break-word;color:var(--txtPrimaryColor)}.breadcrumbs .breadcrumb-item:last-child:after{content:none;display:none}.breadcrumbs a{text-decoration:none;color:inherit;transition:color var(--baseAnimationSpeed)}.breadcrumbs a:hover{color:var(--txtPrimaryColor)}.page-content{position:relative;display:block;width:100%;flex-grow:1;padding:calc(var(--baseSpacing) - 5px) var(--baseSpacing)}.page-footer{display:flex;gap:5px;align-items:center;justify-content:right;text-align:right;padding:0px var(--baseSpacing) 15px;color:var(--txtDisabledColor);font-size:var(--xsFontSize);line-height:var(--smLineHeight)}.page-footer i{font-size:1.2em}.page-footer a{color:inherit;text-decoration:none;transition:color var(--baseAnimationSpeed)}.page-footer a:focus-visible,.page-footer a:hover,.page-footer a:active{color:var(--txtPrimaryColor)}.page-wrapper{display:flex;flex-direction:column;flex-grow:1;width:100%;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;scroll-behavior:smooth}.overlay-active .page-wrapper{overflow-y:hidden}.page-wrapper.full-page{background:var(--baseColor)}.page-wrapper.center-content .page-content{display:flex;align-items:center}@keyframes tabChange{0%{opacity:.7}to{opacity:1}}.tabs-header{display:flex;align-items:stretch;justify-content:flex-start;column-gap:10px;width:100%;min-height:50px;-webkit-user-select:none;user-select:none;margin:0 0 var(--baseSpacing);border-bottom:2px solid var(--baseAlt2Color)}.tabs-header .tab-item{position:relative;outline:0;border:0;background:none;display:inline-flex;align-items:center;justify-content:center;min-width:70px;gap:5px;padding:10px;margin:0;font-size:var(--lgFontSize);line-height:var(--baseLineHeight);font-family:var(--baseFontFamily);color:var(--txtHintColor);text-align:center;text-decoration:none;cursor:pointer;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius);transition:color var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}.tabs-header .tab-item:after{content:"";position:absolute;display:block;left:0;bottom:-2px;width:100%;height:2px;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius);background:var(--primaryColor);transform:rotateY(90deg);transition:transform .2s}.tabs-header .tab-item .txt,.tabs-header .tab-item i{display:inline-block;vertical-align:top}.tabs-header .tab-item:hover,.tabs-header .tab-item:focus-visible,.tabs-header .tab-item:active{color:var(--txtPrimaryColor)}.tabs-header .tab-item:focus-visible,.tabs-header .tab-item:active{transition-duration:var(--activeAnimationSpeed);background:var(--baseAlt2Color)}.tabs-header .tab-item.active{color:var(--txtPrimaryColor)}.tabs-header .tab-item.active:after{transform:rotateY(0)}.tabs-header .tab-item.disabled{pointer-events:none;color:var(--txtDisabledColor)}.tabs-header .tab-item.disabled:after{display:none}.tabs-header.right{justify-content:flex-end}.tabs-header.center{justify-content:center}.tabs-header.stretched .tab-item{flex-grow:1;flex-basis:0}.tabs-header.compact{min-height:30px;margin-bottom:var(--smSpacing)}.tabs-header.combined{border:0;margin-bottom:-2px}.tabs-header.combined .tab-item:after{content:none;display:none}.tabs-header.combined .tab-item.active{background:var(--baseAlt1Color)}.tabs-content{position:relative}.tabs-content>.tab-item{width:100%;display:none}.tabs-content>.tab-item.active{display:block;opacity:0;animation:tabChange .2s forwards}.tabs-content>.tab-item>:first-child{margin-top:0}.tabs-content>.tab-item>:last-child{margin-bottom:0}.tabs{position:relative}.accordion{outline:0;position:relative;border-radius:var(--baseRadius);background:var(--baseColor);border:1px solid var(--baseAlt2Color);transition:border-radius var(--baseAnimationSpeed),box-shadow var(--baseAnimationSpeed),margin var(--baseAnimationSpeed)}.accordion .accordion-header{outline:0;position:relative;display:flex;min-height:52px;align-items:center;row-gap:10px;column-gap:var(--smSpacing);padding:12px 20px 10px;width:100%;-webkit-user-select:none;user-select:none;color:var(--txtPrimaryColor);border-radius:inherit;transition:border-radius var(--baseAnimationSpeed),background var(--baseAnimationSpeed),box-shadow var(--baseAnimationSpeed)}.accordion .accordion-header .icon{width:18px;text-align:center}.accordion .accordion-header .icon i{display:inline-block;vertical-align:top;font-size:1.1rem}.accordion .accordion-header.interactive{padding-right:50px;cursor:pointer}.accordion .accordion-header.interactive:after{content:"";position:absolute;right:15px;top:50%;margin-top:-12.5px;width:25px;height:25px;line-height:25px;color:var(--txtHintColor);font-family:var(--iconFontFamily);font-size:1.3em;text-align:center;transition:color var(--baseAnimationSpeed)}.accordion .accordion-header:hover:after,.accordion .accordion-header.focus:after,.accordion .accordion-header:focus-visible:after{color:var(--txtPrimaryColor)}.accordion .accordion-header:active{transition-duration:var(--activeAnimationSpeed)}.accordion .accordion-content{padding:20px}.accordion:hover,.accordion:focus-visible,.accordion.active{z-index:9}.accordion:hover .accordion-header.interactive,.accordion:focus-visible .accordion-header.interactive,.accordion.active .accordion-header.interactive{background:var(--baseAlt1Color)}.accordion.drag-over .accordion-header{background:var(--bodyColor)}.accordion.active{box-shadow:0 2px 5px 0 var(--shadowColor)}.accordion.active .accordion-header{position:relative;top:0;z-index:9;box-shadow:0 0 0 1px var(--baseAlt2Color);border-bottom-left-radius:0;border-bottom-right-radius:0;background:var(--bodyColor)}.accordion.active .accordion-header.interactive{background:var(--bodyColor)}.accordion.active .accordion-header.interactive:after{color:inherit;content:""}.accordion.disabled{z-index:0;border-color:var(--baseAlt1Color)}.accordion.disabled .accordion-header{color:var(--txtDisabledColor)}.accordions .accordion{border-radius:0;margin:-1px 0 0}.accordions .accordion:has(+.accordion.active){border-bottom-left-radius:var(--baseRadius);border-bottom-right-radius:var(--baseRadius)}.accordions>.accordion.active,.accordions>.accordion-wrapper>.accordion.active{margin:var(--smSpacing) 0;border-radius:var(--baseRadius)}.accordions>.accordion.active+.accordion,.accordions>.accordion-wrapper>.accordion.active+.accordion{border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius)}.accordions>.accordion:first-child,.accordions>.accordion-wrapper:first-child>.accordion{margin-top:0;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius)}.accordions>.accordion:last-child,.accordions>.accordion-wrapper:last-child>.accordion{margin-bottom:0;border-bottom-left-radius:var(--baseRadius);border-bottom-right-radius:var(--baseRadius)}table{--entranceAnimationSpeed: .3s;border-collapse:separate;min-width:100%;transition:opacity var(--baseAnimationSpeed)}table .form-field{margin:0;line-height:1;text-align:left}table .txt-ellipsis{flex-shrink:0}table td,table th{outline:0;vertical-align:middle;position:relative;text-align:left;padding:10px;border-bottom:1px solid var(--baseAlt2Color)}table td:first-child,table th:first-child{padding-left:20px}table td:last-child,table th:last-child{padding-right:20px}table th{color:var(--txtHintColor);font-weight:600;font-size:1rem;-webkit-user-select:none;user-select:none;height:50px;line-height:var(--smLineHeight)}table th i{font-size:inherit}table td{height:60px;word-break:break-word}table .min-width{width:1%!important;white-space:nowrap}table .nowrap{white-space:nowrap}table .col-sort{cursor:pointer;border-top-left-radius:var(--baseRadius);border-top-right-radius:var(--baseRadius);padding-right:30px;transition:color var(--baseAnimationSpeed),background var(--baseAnimationSpeed)}table .col-sort:after{content:"";position:absolute;right:10px;top:50%;margin-top:-12.5px;line-height:25px;height:25px;font-family:var(--iconFontFamily);font-weight:400;color:var(--txtHintColor);opacity:0;transition:color var(--baseAnimationSpeed),opacity var(--baseAnimationSpeed)}table .col-sort.sort-desc:after{content:""}table .col-sort.sort-asc:after{content:""}table .col-sort.sort-active:after{opacity:1}table .col-sort:hover,table .col-sort:focus-visible{background:var(--baseAlt1Color)}table .col-sort:hover:after,table .col-sort:focus-visible:after{opacity:1}table .col-sort:active{transition-duration:var(--activeAnimationSpeed);background:var(--baseAlt2Color)}table .col-sort.col-sort-disabled{cursor:default;background:none}table .col-sort.col-sort-disabled:after{display:none}table .col-header-content{display:inline-flex;align-items:center;flex-wrap:nowrap;gap:5px}table .col-header-content .txt{max-width:140px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}table .col-field-created,table .col-field-updated,table .col-type-action{width:1%!important;white-space:nowrap}table .col-type-action{white-space:nowrap;text-align:right;color:var(--txtHintColor)}table .col-type-action i{display:inline-block;vertical-align:top;transition:transform var(--baseAnimationSpeed)}table td.col-type-json{font-family:monospace;font-size:var(--smFontSize);line-height:var(--smLineHeight);max-width:300px}table .col-type-text{max-width:300px}table .col-type-editor{min-width:250px}table .col-type-select{min-width:150px}table .col-type-email{min-width:120px;white-space:nowrap}table .col-type-file{min-width:100px}table td.col-field-id,table td.col-field-username{width:0;white-space:nowrap}table tr{outline:0;background:var(--bodyColor);transition:background var(--baseAnimationSpeed)}table tr.row-handle{cursor:pointer;-webkit-user-select:none;user-select:none}table tr.row-handle:focus-visible,table tr.row-handle:hover,table tr.row-handle:active{background:var(--baseAlt1Color)}table tr.row-handle:focus-visible .action-col,table tr.row-handle:hover .action-col,table tr.row-handle:active .action-col{color:var(--txtPrimaryColor)}table tr.row-handle:focus-visible .action-col i,table tr.row-handle:hover .action-col i,table tr.row-handle:active .action-col i{transform:translate(3px)}table tr.row-handle:active{transition-duration:var(--activeAnimationSpeed)}table.table-border{border:1px solid var(--baseAlt2Color);border-radius:var(--baseRadius)}table.table-border tr{background:var(--baseColor)}table.table-border td,table.table-border th{height:45px}table.table-border th{background:var(--baseAlt1Color)}table.table-border>:last-child>:last-child th,table.table-border>:last-child>:last-child td{border-bottom:0}table.table-border>tr:first-child>:first-child,table.table-border>:first-child>tr:first-child>:first-child{border-top-left-radius:var(--baseRadius)}table.table-border>tr:first-child>:last-child,table.table-border>:first-child>tr:first-child>:last-child{border-top-right-radius:var(--baseRadius)}table.table-border>tr:last-child>:first-child,table.table-border>:last-child>tr:last-child>:first-child{border-bottom-left-radius:var(--baseRadius)}table.table-border>tr:last-child>:last-child,table.table-border>:last-child>tr:last-child>:last-child{border-bottom-right-radius:var(--baseRadius)}table.table-compact td,table.table-compact th{height:auto}table.table-animate tr{animation:entranceTop var(--entranceAnimationSpeed)}table.table-loading{pointer-events:none;opacity:.7}.table-wrapper{width:auto;padding:0;max-width:calc(100% + 2 * var(--baseSpacing));margin-left:calc(var(--baseSpacing) * -1);margin-right:calc(var(--baseSpacing) * -1)}.table-wrapper .bulk-select-col{min-width:70px}.table-wrapper td,.table-wrapper th{position:relative}.table-wrapper td:first-child,.table-wrapper th:first-child{padding-left:calc(var(--baseSpacing) + 3px)}.table-wrapper td:last-child,.table-wrapper th:last-child{padding-right:calc(var(--baseSpacing) + 3px)}.table-wrapper .bulk-select-col,.table-wrapper .col-type-action{position:sticky;z-index:99;transition:box-shadow var(--baseAnimationSpeed)}.table-wrapper .bulk-select-col{left:0}.table-wrapper .col-type-action{right:0}.table-wrapper .bulk-select-col,.table-wrapper .col-type-action{background:inherit}.table-wrapper th.bulk-select-col,.table-wrapper th.col-type-action{background:var(--bodyColor)}.table-wrapper.scrollable .bulk-select-col{box-shadow:3px 0 5px 0 var(--shadowColor)}.table-wrapper.scrollable .col-type-action{box-shadow:-3px 0 5px 0 var(--shadowColor)}.table-wrapper.scrollable.scroll-start .bulk-select-col,.table-wrapper.scrollable.scroll-end .col-type-action{box-shadow:none}.searchbar{--searchHeight: 44px;outline:0;display:flex;align-items:center;min-height:var(--searchHeight);width:100%;flex-grow:1;padding:5px 7px;margin:0;white-space:nowrap;color:var(--txtHintColor);background:var(--baseAlt1Color);border-radius:var(--btnHeight);transition:color var(--baseAnimationSpeed),background var(--baseAnimationSpeed),box-shadow var(--baseAnimationSpeed)}.searchbar>:first-child{border-top-left-radius:var(--btnHeight);border-bottom-left-radius:var(--btnHeight)}.searchbar>:last-child{border-top-right-radius:var(--btnHeight);border-bottom-right-radius:var(--btnHeight)}.searchbar .btn{border-radius:var(--btnHeight)}.searchbar .code-editor,.searchbar input,.searchbar input:focus{font-size:var(--baseFontSize);font-family:var(--monospaceFontFamily);border:0;background:none;min-height:0;padding-top:0;padding-bottom:0}.searchbar label>i{line-height:inherit}.searchbar .search-options{flex-shrink:0;width:90px}.searchbar .search-options .selected-container{border-radius:inherit;background:none;padding-right:25px!important}.searchbar .search-options:not(:focus-within) .selected-container{color:var(--txtHintColor)}.searchbar:focus-within{color:var(--txtPrimaryColor);background:var(--baseAlt2Color)}.bulkbar{position:sticky;bottom:var(--baseSpacing);z-index:101;gap:10px;display:flex;justify-content:center;align-items:center;width:var(--smWrapperWidth);max-width:100%;margin:var(--smSpacing) auto;padding:10px var(--smSpacing);border-radius:var(--btnHeight);background:var(--baseColor);border:1px solid var(--baseAlt2Color);box-shadow:0 2px 5px 0 var(--shadowColor)}.flatpickr-calendar{opacity:0;display:none;text-align:center;visibility:hidden;padding:0;animation:none;direction:ltr;border:0;font-size:1rem;line-height:24px;position:absolute;width:298px;box-sizing:border-box;-webkit-user-select:none;user-select:none;color:var(--txtPrimaryColor);background:var(--baseColor);border-radius:var(--baseRadius);box-shadow:0 2px 5px 0 var(--shadowColor),0 0 0 1px var(--baseAlt2Color)}.flatpickr-calendar input,.flatpickr-calendar select{box-shadow:none;min-height:0;height:var(--smBtnHeight);padding-top:3px;padding-bottom:3px;background:none;border-radius:var(--baseRadius);border:1px solid var(--baseAlt1Color)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown .3s cubic-bezier(.23,1,.32,1);animation:fpFadeInDown .3s cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:0;width:100%}.flatpickr-calendar.static{position:absolute;top:100%;margin-top:2px;margin-bottom:10px;width:100%}.flatpickr-calendar.static .flatpickr-days{width:100%}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none!important;box-shadow:none!important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 var(--baseAlt2Color),5px 0 0 var(--baseAlt2Color);box-shadow:-2px 0 0 var(--baseAlt2Color),5px 0 0 var(--baseAlt2Color)}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid var(--baseAlt2Color)}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:"";height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:var(--baseColor)}.flatpickr-calendar.arrowTop:after{border-bottom-color:var(--baseColor)}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:var(--baseColor)}.flatpickr-calendar.arrowBottom:after{border-top-color:var(--baseColor)}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative}.flatpickr-months{display:flex;align-items:center;padding:5px 0}.flatpickr-months .flatpickr-month{display:flex;align-items:center;justify-content:center;background:transparent;color:var(--txtPrimaryColor);fill:var(--txtPrimaryColor);line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{display:flex;align-items:center;text-decoration:none;cursor:pointer;height:34px;padding:5px 12px;z-index:3;color:var(--txtPrimaryColor);fill:var(--txtPrimaryColor)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{left:0}.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{right:0}.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover,.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:var(--txtHintColor)}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto;border-radius:var(--baseRadius)}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,.15);box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,.1)}.numInputWrapper span:active{background:rgba(0,0,0,.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:#00000080}.numInputWrapper:hover{background:var(--baseAlt1Color)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{line-height:inherit;color:inherit;width:85%;padding:1px 0;line-height:1;display:flex;gap:10px;align-items:center;justify-content:center;text-align:center}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:var(--baseAlt1Color)}.flatpickr-current-month .numInputWrapper{display:inline-flex;align-items:center;justify-content:center;width:62px}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:var(--txtPrimaryColor)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:var(--txtPrimaryColor)}.flatpickr-current-month input.cur-year{background:transparent;box-sizing:border-box;color:inherit;cursor:text;margin:0;display:inline-block;font-size:inherit;font-family:inherit;line-height:inherit;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{color:var(--txtDisabledColor);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;line-height:inherit;outline:none;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:var(--baseAlt1Color)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{display:block;flex:1;margin:0;cursor:default;line-height:1;background:transparent;color:var(--txtHintColor);text-align:center;font-weight:bolder;font-size:var(--smFontSize)}.dayContainer,.flatpickr-weeks{padding:1px 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:100%;box-sizing:border-box;display:inline-block;display:flex;flex-wrap:wrap;transform:translateZ(0);opacity:1}.dayContainer+.dayContainer{-webkit-box-shadow:-1px 0 0 var(--baseAlt2Color);box-shadow:-1px 0 0 var(--baseAlt2Color)}.flatpickr-day{background:none;border:1px solid transparent;border-radius:var(--baseRadius);box-sizing:border-box;color:var(--txtPrimaryColor);cursor:pointer;font-weight:400;width:calc(14.2857143% - 2px);flex-basis:calc(14.2857143% - 2px);height:39px;margin:1px;display:inline-flex;align-items:center;justify-content:center;position:relative;text-align:center;flex-direction:column}.flatpickr-day.weekend,.flatpickr-day:nth-child(7n+6),.flatpickr-day:nth-child(7n+7){color:var(--dangerColor)}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:var(--baseAlt2Color);border-color:var(--baseAlt2Color)}.flatpickr-day.today{border-color:var(--baseColor)}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:var(--primaryColor);background:var(--primaryColor);color:var(--baseColor)}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:var(--primaryColor);-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:var(--primaryColor)}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange+.endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange+.endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 var(--primaryColor);box-shadow:-10px 0 0 var(--primaryColor)}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;box-shadow:-5px 0 0 var(--baseAlt2Color),5px 0 0 var(--baseAlt2Color)}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:var(--txtDisabledColor);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:var(--txtDisabledColor);background:var(--baseAlt2Color)}.flatpickr-day.week.selected{border-radius:0;box-shadow:-5px 0 0 var(--primaryColor),5px 0 0 var(--primaryColor)}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 var(--baseAlt2Color);box-shadow:1px 0 0 var(--baseAlt2Color)}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:var(--txtHintColor);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:flex;box-sizing:border-box;overflow:hidden;padding:5px}.flatpickr-rContainer{display:inline-block;padding:0;width:100%;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:var(--txtPrimaryColor)}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:var(--txtPrimaryColor)}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;box-shadow:none;border:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:var(--txtPrimaryColor);font-size:14px;position:relative;box-sizing:border-box;background:var(--baseColor);-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:700}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:var(--txtPrimaryColor);font-weight:700;width:2%;-webkit-user-select:none;user-select:none;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:var(--baseAlt1Color)}.flatpickr-input[readonly]{cursor:pointer}@keyframes fpFadeInDown{0%{opacity:0;transform:translate3d(0,10px,0)}to{opacity:1;transform:translateZ(0)}}.flatpickr-hide-prev-next-month-days .flatpickr-calendar .prevMonthDay{visibility:hidden}.flatpickr-hide-prev-next-month-days .flatpickr-calendar .nextMonthDay,.flatpickr-inline-container .flatpickr-input{display:none}.flatpickr-inline-container .flatpickr-calendar{margin:0;box-shadow:none;border:1px solid var(--baseAlt2Color)}.docs-sidebar{--itemsSpacing: 10px;--itemsHeight: 40px;position:relative;min-width:180px;max-width:300px;height:100%;flex-shrink:0;overflow-x:hidden;overflow-y:auto;overflow-y:overlay;background:var(--bodyColor);padding:var(--smSpacing) var(--xsSpacing);border-right:1px solid var(--baseAlt1Color)}.docs-sidebar .sidebar-content{display:block;width:100%}.docs-sidebar .sidebar-item{position:relative;outline:0;cursor:pointer;text-decoration:none;display:flex;width:100%;gap:10px;align-items:center;text-align:right;justify-content:start;padding:5px 15px;margin:0 0 var(--itemsSpacing) 0;font-size:var(--lgFontSize);min-height:var(--itemsHeight);border-radius:var(--baseRadius);-webkit-user-select:none;user-select:none;color:var(--txtHintColor);transition:background var(--baseAnimationSpeed),color var(--baseAnimationSpeed)}.docs-sidebar .sidebar-item:last-child{margin-bottom:0}.docs-sidebar .sidebar-item:focus-visible,.docs-sidebar .sidebar-item:hover,.docs-sidebar .sidebar-item:active,.docs-sidebar .sidebar-item.active{color:var(--txtPrimaryColor);background:var(--baseAlt1Color)}.docs-sidebar .sidebar-item:active{background:var(--baseAlt2Color);transition-duration:var(--activeAnimationSpeed)}.docs-sidebar.compact .sidebar-item{--itemsSpacing: 7px}.docs-content{width:100%;display:block;padding:calc(var(--baseSpacing) - 3px) var(--baseSpacing);overflow:auto}.docs-content-wrapper{display:flex;width:100%;height:100%}.docs-panel{width:960px;height:100%}.docs-panel .overlay-panel-section.panel-header{padding:0;border:0;box-shadow:none}.docs-panel .overlay-panel-section.panel-content{padding:0!important}.docs-panel .overlay-panel-section.panel-footer{display:none}@media screen and (max-width: 1000px){.docs-panel .overlay-panel-section.panel-footer{display:flex}}.schema-field-header{position:relative;display:flex;width:100%;min-height:42px;gap:5px;padding:0 5px;align-items:center;justify-content:stretch;background:var(--baseAlt1Color);border-radius:var(--baseRadius);transition:border-radius var(--baseAnimationSpeed)}.schema-field-header .form-field{margin:0}.schema-field-header .form-field .form-field-addon.prefix{left:10px}.schema-field-header .form-field .form-field-addon.prefix~input,.schema-field-header .form-field .form-field-addon.prefix~select,.schema-field-header .form-field .form-field-addon.prefix~textarea,.schema-field-header .form-field .select .form-field-addon.prefix~.selected-container,.select .schema-field-header .form-field .form-field-addon.prefix~.selected-container,.schema-field-header .form-field .form-field-addon.prefix~.code-editor,.schema-field-header .form-field .form-field-addon.prefix~.tinymce-wrapper{padding-left:37px}.schema-field-header .options-trigger{padding:2px;margin:0 3px}.schema-field-header .options-trigger i{transition:transform var(--baseAnimationSpeed)}.schema-field-header .separator{flex-shrink:0;width:1px;height:42px;background:rgba(0,0,0,.05)}.schema-field-header .drag-handle-wrapper{position:absolute;top:0;left:auto;right:100%;height:100%;display:flex;align-items:center}.schema-field-header .drag-handle{padding:0 5px;transform:translate(5px);opacity:0;visibility:hidden}.schema-field-header .form-field-single-multiple-select{width:100px;flex-shrink:0}.schema-field-header .form-field-single-multiple-select .dropdown{min-width:0}.schema-field-header .markers{position:absolute;z-index:1;left:4px;top:4px;display:inline-flex;flex-direction:column;align-items:center;gap:5px}.schema-field-header .markers .marker{display:block;width:4px;height:4px;border-radius:4px;background:var(--baseAlt4Color)}.schema-field-options{background:#fff;padding:var(--xsSpacing);border-bottom-left-radius:var(--baseRadius);border-bottom-right-radius:var(--baseRadius)}.schema-field-options-footer{display:flex;flex-wrap:wrap;align-items:center;width:100%;min-width:0;gap:var(--baseSpacing)}.schema-field-options-footer .form-field{margin:0;width:auto}.schema-field{position:relative;margin:0 0 var(--xsSpacing);border-radius:var(--baseRadius);background:var(--baseAlt1Color);transition:border var(--baseAnimationSpeed),box-shadow var(--baseAnimationSpeed);border:2px solid var(--baseAlt1Color)}.schema-field:not(.deleted):hover .drag-handle{transform:translate(0);opacity:1;visibility:visible}.dragover .schema-field,.schema-field.dragover{opacity:.5}.schema-field.expanded{box-shadow:0 2px 5px 0 var(--shadowColor);border-color:var(--baseAlt2Color)}.schema-field.expanded .schema-field-header{border-bottom-left-radius:0;border-bottom-right-radius:0}.schema-field.expanded .schema-field-header .options-trigger i{transform:rotate(-60deg)}.schema-field.expanded .schema-field-options{border-top:2px solid var(--baseAlt2Color)}.schema-field.deleted .schema-field-header{background:var(--bodyColor)}.schema-field.deleted .markers,.schema-field.deleted .separator{opacity:.5}.schema-field.deleted input,.schema-field.deleted select,.schema-field.deleted textarea,.schema-field.deleted .select .selected-container,.select .schema-field.deleted .selected-container,.schema-field.deleted .code-editor,.schema-field.deleted .tinymce-wrapper{background:none;box-shadow:none}.panel-wrapper.svelte-lxxzfu{animation:slideIn .2s}@keyframes svelte-1bvelc2-refresh{to{transform:rotate(180deg)}}.btn.refreshing.svelte-1bvelc2 i.svelte-1bvelc2{animation:svelte-1bvelc2-refresh .15s ease-out}.datetime.svelte-zdiknu{width:100%;display:block;line-height:var(--smLineHeight)}.time.svelte-zdiknu{font-size:var(--smFontSize);color:var(--txtHintColor)}.horizontal-scroller.svelte-wc2j9h{width:auto;overflow-x:auto}.horizontal-scroller-wrapper.svelte-wc2j9h{position:relative}.horizontal-scroller-wrapper .columns-dropdown{top:40px;z-index:100;max-height:340px}.chart-wrapper.svelte-vh4sl8.svelte-vh4sl8{position:relative;display:block;width:100%}.chart-wrapper.loading.svelte-vh4sl8 .chart-canvas.svelte-vh4sl8{pointer-events:none;opacity:.5}.chart-loader.svelte-vh4sl8.svelte-vh4sl8{position:absolute;z-index:999;top:50%;left:50%;transform:translate(-50%,-50%)}.prism-light code[class*=language-],.prism-light pre[class*=language-]{color:#111b27;background:0 0;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}.prism-light code[class*=language-] ::-moz-selection,.prism-light code[class*=language-]::-moz-selection,.prism-light pre[class*=language-] ::-moz-selection,.prism-light pre[class*=language-]::-moz-selection{background:#8da1b9}.prism-light code[class*=language-] ::selection,.prism-light code[class*=language-]::selection,.prism-light pre[class*=language-] ::selection,.prism-light pre[class*=language-]::selection{background:#8da1b9}.prism-light pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}.prism-light :not(pre)>code[class*=language-],.prism-light pre[class*=language-]{background:#e3eaf2}.prism-light :not(pre)>code[class*=language-]{padding:.1em .3em;border-radius:.3em;white-space:normal}.prism-light .token.cdata,.prism-light .token.comment,.prism-light .token.doctype,.prism-light .token.prolog{color:#3c526d}.prism-light .token.punctuation{color:#111b27}.prism-light .token.delimiter.important,.prism-light .token.selector .parent,.prism-light .token.tag,.prism-light .token.tag .token.punctuation{color:#006d6d}.prism-light .token.attr-name,.prism-light .token.boolean,.prism-light .token.boolean.important,.prism-light .token.constant,.prism-light .token.number,.prism-light .token.selector .token.attribute{color:#755f00}.prism-light .token.class-name,.prism-light .token.key,.prism-light .token.parameter,.prism-light .token.property,.prism-light .token.property-access,.prism-light .token.variable{color:#005a8e}.prism-light .token.attr-value,.prism-light .token.color,.prism-light .token.inserted,.prism-light .token.selector .token.value,.prism-light .token.string,.prism-light .token.string .token.url-link{color:#116b00}.prism-light .token.builtin,.prism-light .token.keyword-array,.prism-light .token.package,.prism-light .token.regex{color:#af00af}.prism-light .token.function,.prism-light .token.selector .token.class,.prism-light .token.selector .token.id{color:#7c00aa}.prism-light .token.atrule .token.rule,.prism-light .token.combinator,.prism-light .token.keyword,.prism-light .token.operator,.prism-light .token.pseudo-class,.prism-light .token.pseudo-element,.prism-light .token.selector,.prism-light .token.unit{color:#a04900}.prism-light .token.deleted,.prism-light .token.important{color:#c22f2e}.prism-light .token.keyword-this,.prism-light .token.this{color:#005a8e}.prism-light .token.bold,.prism-light .token.important,.prism-light .token.keyword-this,.prism-light .token.this{font-weight:700}.prism-light .token.delimiter.important{font-weight:inherit}.prism-light .token.italic{font-style:italic}.prism-light .token.entity{cursor:help}.prism-light .language-markdown .token.title,.prism-light .language-markdown .token.title .token.punctuation{color:#005a8e;font-weight:700}.prism-light .language-markdown .token.blockquote.punctuation{color:#af00af}.prism-light .language-markdown .token.code{color:#006d6d}.prism-light .language-markdown .token.hr.punctuation{color:#005a8e}.prism-light .language-markdown .token.url>.token.content{color:#116b00}.prism-light .language-markdown .token.url-link{color:#755f00}.prism-light .language-markdown .token.list.punctuation{color:#af00af}.prism-light .language-markdown .token.table-header,.prism-light .language-json .token.operator{color:#111b27}.prism-light .language-scss .token.variable{color:#006d6d}.prism-light .token.token.cr:before,.prism-light .token.token.lf:before,.prism-light .token.token.space:before,.prism-light .token.token.tab:not(:empty):before{color:#3c526d}.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>a,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>button{color:#e3eaf2;background:#005a8e}.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>a:focus,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>a:hover,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>button:focus,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>button:hover{color:#e3eaf2;background:rgba(0,90,142,.8549019608);text-decoration:none}.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>span,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>span:focus,.prism-light div.code-toolbar>.toolbar.toolbar>.toolbar-item>span:hover{color:#e3eaf2;background:#3c526d}.prism-light .line-highlight.line-highlight{background:rgba(141,161,185,.1843137255);background:linear-gradient(to right,rgba(141,161,185,.1843137255) 70%,rgba(141,161,185,.1450980392))}.prism-light .line-highlight.line-highlight:before,.prism-light .line-highlight.line-highlight[data-end]:after{background-color:#3c526d;color:#e3eaf2;box-shadow:0 1px #8da1b9}.prism-light pre[id].linkable-line-numbers.linkable-line-numbers span.line-numbers-rows>span:hover:before{background-color:#3c526d1f}.prism-light .line-numbers.line-numbers .line-numbers-rows{border-right:1px solid rgba(141,161,185,.4784313725);background:rgba(208,218,231,.4784313725)}.prism-light .line-numbers .line-numbers-rows>span:before{color:#3c526dda}.prism-light .rainbow-braces .token.token.punctuation.brace-level-1,.prism-light .rainbow-braces .token.token.punctuation.brace-level-5,.prism-light .rainbow-braces .token.token.punctuation.brace-level-9{color:#755f00}.prism-light .rainbow-braces .token.token.punctuation.brace-level-10,.prism-light .rainbow-braces .token.token.punctuation.brace-level-2,.prism-light .rainbow-braces .token.token.punctuation.brace-level-6{color:#af00af}.prism-light .rainbow-braces .token.token.punctuation.brace-level-11,.prism-light .rainbow-braces .token.token.punctuation.brace-level-3,.prism-light .rainbow-braces .token.token.punctuation.brace-level-7{color:#005a8e}.prism-light .rainbow-braces .token.token.punctuation.brace-level-12,.prism-light .rainbow-braces .token.token.punctuation.brace-level-4,.prism-light .rainbow-braces .token.token.punctuation.brace-level-8{color:#7c00aa}.prism-light pre.diff-highlight>code .token.token.deleted:not(.prefix),.prism-light pre>code.diff-highlight .token.token.deleted:not(.prefix){background-color:#c22f2e1f}.prism-light pre.diff-highlight>code .token.token.inserted:not(.prefix),.prism-light pre>code.diff-highlight .token.token.inserted:not(.prefix){background-color:#116b001f}.prism-light .command-line .command-line-prompt{border-right:1px solid rgba(141,161,185,.4784313725)}.prism-light .command-line .command-line-prompt>span:before{color:#3c526dda}code.svelte-10s5tkd.svelte-10s5tkd{display:block;width:100%;padding:10px 15px;white-space:pre-wrap;word-break:break-word}.code-wrapper.svelte-10s5tkd.svelte-10s5tkd{display:block;width:100%;max-height:100%;overflow:auto;overflow:overlay}.prism-light.svelte-10s5tkd code.svelte-10s5tkd{color:var(--txtPrimaryColor);background:var(--baseAlt1Color)}.indexes-list.svelte-167lbwu{display:flex;flex-wrap:wrap;width:100%;gap:10px}.label.svelte-167lbwu{overflow:hidden;min-width:50px}.field-types-btn.active.svelte-1gz9b6p{border-bottom-left-radius:0;border-bottom-right-radius:0}.field-types-dropdown{display:flex;flex-wrap:wrap;width:100%;max-width:none;padding:10px;margin-top:2px;border:0;box-shadow:0 0 0 2px var(--primaryColor);border-top-left-radius:0;border-top-right-radius:0}.field-types-dropdown .dropdown-item.svelte-1gz9b6p{width:25%}.form-field-file-max-select{width:100px;flex-shrink:0}.draggable.svelte-28orm4{-webkit-user-select:none;user-select:none;outline:0;min-width:0}.lock-toggle.svelte-1akuazq.svelte-1akuazq{position:absolute;right:0;top:0;min-width:135px;padding:10px;border-top-left-radius:0;border-bottom-right-radius:0;background:rgba(53,71,104,.09)}.rule-field .code-editor .cm-placeholder{font-family:var(--baseFontFamily)}.input-wrapper.svelte-1akuazq.svelte-1akuazq{position:relative}.unlock-overlay.svelte-1akuazq.svelte-1akuazq{--hoverAnimationSpeed:.2s;position:absolute;z-index:1;left:0;top:0;width:100%;height:100%;display:flex;padding:20px;gap:10px;align-items:center;justify-content:end;text-align:center;border-radius:var(--baseRadius);background:rgba(255,255,255,.2);outline:0;cursor:pointer;text-decoration:none;color:var(--successColor);border:2px solid var(--baseAlt1Color);transition:border-color var(--baseAnimationSpeed)}.unlock-overlay.svelte-1akuazq i.svelte-1akuazq{font-size:inherit}.unlock-overlay.svelte-1akuazq .icon.svelte-1akuazq{color:var(--successColor);font-size:1.15rem;line-height:1;font-weight:400;transition:transform var(--hoverAnimationSpeed)}.unlock-overlay.svelte-1akuazq .txt.svelte-1akuazq{opacity:0;font-size:var(--xsFontSize);font-weight:600;line-height:var(--smLineHeight);transform:translate(5px);transition:transform var(--hoverAnimationSpeed),opacity var(--hoverAnimationSpeed)}.unlock-overlay.svelte-1akuazq.svelte-1akuazq:hover,.unlock-overlay.svelte-1akuazq.svelte-1akuazq:focus-visible,.unlock-overlay.svelte-1akuazq.svelte-1akuazq:active{border-color:var(--baseAlt3Color)}.unlock-overlay.svelte-1akuazq:hover .icon.svelte-1akuazq,.unlock-overlay.svelte-1akuazq:focus-visible .icon.svelte-1akuazq,.unlock-overlay.svelte-1akuazq:active .icon.svelte-1akuazq{transform:scale(1.1)}.unlock-overlay.svelte-1akuazq:hover .txt.svelte-1akuazq,.unlock-overlay.svelte-1akuazq:focus-visible .txt.svelte-1akuazq,.unlock-overlay.svelte-1akuazq:active .txt.svelte-1akuazq{opacity:1;transform:scale(1)}.unlock-overlay.svelte-1akuazq.svelte-1akuazq:active{transition-duration:var(--activeAnimationSpeed);border-color:var(--baseAlt3Color)}.changes-list.svelte-xqpcsf.svelte-xqpcsf{word-break:break-word;line-height:var(--smLineHeight)}.changes-list.svelte-xqpcsf li.svelte-xqpcsf{margin-top:10px;margin-bottom:10px}.upsert-panel-title.svelte-12y0yzb{display:inline-flex;align-items:center;min-height:var(--smBtnHeight)}.tabs-content.svelte-12y0yzb:focus-within{z-index:9}.secret.svelte-1md8247{font-family:monospace;font-weight:400;-webkit-user-select:all;user-select:all}.email-visibility-addon.svelte-1751a4d~input.svelte-1751a4d{padding-right:100px}textarea.svelte-1x1pbts{resize:none;padding-top:4px!important;padding-bottom:5px!important;min-height:var(--inputHeight);height:var(--inputHeight)}.clear-btn.svelte-11df51y{margin-top:20px}.json-state.svelte-p6ecb8{position:absolute;right:10px}.record-info.svelte-fhw3qk.svelte-fhw3qk{display:inline-flex;vertical-align:top;align-items:center;max-width:100%;min-width:0;gap:5px;line-height:normal}.record-info.svelte-fhw3qk>.svelte-fhw3qk{line-height:inherit}.record-info.svelte-fhw3qk .thumb{box-shadow:none}.picker-list.svelte-1u8jhky{max-height:380px}.selected-list.svelte-1u8jhky{display:flex;flex-wrap:wrap;align-items:center;gap:10px;max-height:220px;overflow:auto}.relations-list.svelte-1ynw0pc{max-height:300px;overflow:auto;overflow:overlay}.panel-title.svelte-qc5ngu{line-height:var(--smBtnHeight)}.fallback-block.svelte-jdf51v{max-height:100px;overflow:auto}.col-field.svelte-1nt58f7{max-width:1px}.export-preview.svelte-jm5c4z.svelte-jm5c4z{position:relative;height:500px}.export-preview.svelte-jm5c4z .copy-schema.svelte-jm5c4z{position:absolute;right:15px;top:15px}.collections-diff-table.svelte-lmkr38.svelte-lmkr38{color:var(--txtHintColor);border:2px solid var(--primaryColor)}.collections-diff-table.svelte-lmkr38 tr.svelte-lmkr38{background:none}.collections-diff-table.svelte-lmkr38 th.svelte-lmkr38,.collections-diff-table.svelte-lmkr38 td.svelte-lmkr38{height:auto;padding:2px 15px;border-bottom:1px solid rgba(0,0,0,.07)}.collections-diff-table.svelte-lmkr38 th.svelte-lmkr38{height:35px;padding:4px 15px;color:var(--txtPrimaryColor)}.collections-diff-table.svelte-lmkr38 thead tr.svelte-lmkr38{background:var(--primaryColor)}.collections-diff-table.svelte-lmkr38 thead tr th.svelte-lmkr38{color:var(--baseColor);background:none}.collections-diff-table.svelte-lmkr38 .label.svelte-lmkr38{font-weight:400}.collections-diff-table.svelte-lmkr38 .changed-none-col.svelte-lmkr38{color:var(--txtDisabledColor);background:var(--baseAlt1Color)}.collections-diff-table.svelte-lmkr38 .changed-old-col.svelte-lmkr38{color:var(--txtPrimaryColor);background:var(--dangerAltColor)}.collections-diff-table.svelte-lmkr38 .changed-new-col.svelte-lmkr38{color:var(--txtPrimaryColor);background:var(--successAltColor)}.collections-diff-table.svelte-lmkr38 .field-key-col.svelte-lmkr38{padding-left:30px}.list-label.svelte-1jx20fl{min-width:65px}.popup-title.svelte-1fcgldh{max-width:80%}.list-content.svelte-1ulbkf5.svelte-1ulbkf5{overflow:auto;max-height:342px}.list-content.svelte-1ulbkf5 .list-item.svelte-1ulbkf5{min-height:49px}.backup-name.svelte-1ulbkf5.svelte-1ulbkf5{max-width:300px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} diff --git a/ui/dist/assets/index-808c8630.js b/ui/dist/assets/index-808c8630.js deleted file mode 100644 index 8e922f078c..0000000000 --- a/ui/dist/assets/index-808c8630.js +++ /dev/null @@ -1,13 +0,0 @@ -class I{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){let s=[];return this.decompose(0,e,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(t,this.length,s,1),Ke.from(s,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){let i=[];return this.decompose(e,t,i,0),Ke.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),s=new oi(this),r=new oi(e);for(let o=t,l=t;;){if(s.next(o),r.next(o),o=0,s.lineBreak!=r.lineBreak||s.done!=r.done||s.value!=r.value)return!1;if(l+=s.value.length,s.done||l>=i)return!0}}iter(e=1){return new oi(this,e)}iterRange(e,t=this.length){return new hl(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let s=this.line(e).from;i=this.iterRange(s,Math.max(s,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new cl(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?I.empty:e.length<=32?new G(e):Ke.from(G.split(e,[]))}}class G extends I{constructor(e,t=tc(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.text[r],l=s+o.length;if((t?i:l)>=e)return new ic(s,l,i,o);s=l+1,i++}}decompose(e,t,i,s){let r=e<=0&&t>=this.length?this:new G(Ar(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(s&1){let o=i.pop(),l=Gi(r.text,o.text.slice(),0,r.length);if(l.length<=32)i.push(new G(l,o.length+r.length));else{let a=l.length>>1;i.push(new G(l.slice(0,a)),new G(l.slice(a)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof G))return super.replace(e,t,i);let s=Gi(this.text,Gi(i.text,Ar(this.text,0,e)),t),r=this.length+i.length-(t-e);return s.length<=32?new G(s,r):Ke.from(G.split(s,[]),r)}sliceString(e,t=this.length,i=` -`){let s="";for(let r=0,o=0;r<=t&&oe&&o&&(s+=i),er&&(s+=l.slice(Math.max(0,e-r),t-r)),r=a+1}return s}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],s=-1;for(let r of e)i.push(r),s+=r.length+1,i.length==32&&(t.push(new G(i,s)),i=[],s=-1);return s>-1&&t.push(new G(i,s)),t}}class Ke extends I{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.children[r],l=s+o.length,a=i+o.lines-1;if((t?a:l)>=e)return o.lineInner(e,t,i,s);s=l+1,i=a+1}}decompose(e,t,i,s){for(let r=0,o=0;o<=t&&r=o){let h=s&((o<=e?1:0)|(a>=t?2:0));o>=e&&a<=t&&!h?i.push(l):l.decompose(e-o,t-o,i,h)}o=a+1}}replace(e,t,i){if(i.lines=r&&t<=l){let a=o.replace(e-r,t-r,i),h=this.lines-o.lines+a.lines;if(a.lines>5-1&&a.lines>h>>5+1){let c=this.children.slice();return c[s]=a,new Ke(c,this.length-(t-e)+i.length)}return super.replace(r,l,a)}r=l+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=` -`){let s="";for(let r=0,o=0;re&&r&&(s+=i),eo&&(s+=l.sliceString(e-o,t-o,i)),o=a+1}return s}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof Ke))return 0;let i=0,[s,r,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;s+=t,r+=t){if(s==o||r==l)return i;let a=this.children[s],h=e.children[r];if(a!=h)return i+a.scanIdentical(h,t);i+=a.length+1}}static from(e,t=e.reduce((i,s)=>i+s.length+1,-1)){let i=0;for(let d of e)i+=d.lines;if(i<32){let d=[];for(let p of e)p.flatten(d);return new G(d,t)}let s=Math.max(32,i>>5),r=s<<1,o=s>>1,l=[],a=0,h=-1,c=[];function f(d){let p;if(d.lines>r&&d instanceof Ke)for(let g of d.children)f(g);else d.lines>o&&(a>o||!a)?(u(),l.push(d)):d instanceof G&&a&&(p=c[c.length-1])instanceof G&&d.lines+p.lines<=32?(a+=d.lines,h+=d.length+1,c[c.length-1]=new G(p.text.concat(d.text),p.length+1+d.length)):(a+d.lines>s&&u(),a+=d.lines,h+=d.length+1,c.push(d))}function u(){a!=0&&(l.push(c.length==1?c[0]:Ke.from(c,h)),h=-1,a=c.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new Ke(l,t)}}I.empty=new G([""],0);function tc(n){let e=-1;for(let t of n)e+=t.length+1;return e}function Gi(n,e,t=0,i=1e9){for(let s=0,r=0,o=!0;r=t&&(a>i&&(l=l.slice(0,i-s)),s0?1:(e instanceof G?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,s=this.nodes[i],r=this.offsets[i],o=r>>1,l=s instanceof G?s.text.length:s.children.length;if(o==(t>0?l:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=` -`,this;e--}else if(s instanceof G){let a=s.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,a.length>Math.max(0,e))return this.value=e==0?a:t>0?a.slice(e):a.slice(0,a.length-e),this;e-=a.length}else{let a=s.children[o+(t<0?-1:0)];e>a.length?(e-=a.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(a),this.offsets.push(t>0?1:(a instanceof G?a.text.length:a.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}}class hl{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new oi(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:s}=this.cursor.next(e);return this.pos+=(s.length+e)*t,this.value=s.length<=i?s:t<0?s.slice(s.length-i):s.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}}class cl{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:s}=this.inner.next(e);return t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=s,this.afterBreak=!1),this}get lineBreak(){return!1}}typeof Symbol<"u"&&(I.prototype[Symbol.iterator]=function(){return this.iter()},oi.prototype[Symbol.iterator]=hl.prototype[Symbol.iterator]=cl.prototype[Symbol.iterator]=function(){return this});class ic{constructor(e,t,i,s){this.from=e,this.to=t,this.number=i,this.text=s}get length(){return this.to-this.from}}let It="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(n=>n?parseInt(n,36):1);for(let n=1;nn)return It[e-1]<=n;return!1}function Mr(n){return n>=127462&&n<=127487}const Dr=8205;function ce(n,e,t=!0,i=!0){return(t?fl:sc)(n,e,i)}function fl(n,e,t){if(e==n.length)return e;e&&ul(n.charCodeAt(e))&&dl(n.charCodeAt(e-1))&&e--;let i=ne(n,e);for(e+=Oe(i);e=0&&Mr(ne(n,o));)r++,o-=2;if(r%2==0)break;e+=2}else break}return e}function sc(n,e,t){for(;e>0;){let i=fl(n,e-2,t);if(i=56320&&n<57344}function dl(n){return n>=55296&&n<56320}function ne(n,e){let t=n.charCodeAt(e);if(!dl(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return ul(i)?(t-55296<<10)+(i-56320)+65536:t}function _s(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function Oe(n){return n<65536?1:2}const ns=/\r\n?|\n/;var he=function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n}(he||(he={}));class Je{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return r+(e-s);r+=l}else{if(i!=he.Simple&&h>=e&&(i==he.TrackDel&&se||i==he.TrackBefore&&se))return null;if(h>e||h==e&&t<0&&!l)return e==s||t<0?r:r+a;r+=a}s=h}if(e>s)throw new RangeError(`Position ${e} is out of range for changeset of length ${s}`);return r}touchesRange(e,t=e){for(let i=0,s=0;i=0&&s<=t&&l>=e)return st?"cover":!0;s=l}return!1}toString(){let e="";for(let t=0;t=0?":"+s:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new Je(e)}static create(e){return new Je(e)}}class Z extends Je{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return ss(this,(t,i,s,r,o)=>e=e.replace(s,s+(i-t),o),!1),e}mapDesc(e,t=!1){return rs(this,e,t,!0)}invert(e){let t=this.sections.slice(),i=[];for(let s=0,r=0;s=0){t[s]=l,t[s+1]=o;let a=s>>1;for(;i.length0&&nt(i,t,r.text),r.forward(c),l+=c}let h=e[o++];for(;l>1].toJSON()))}return e}static of(e,t,i){let s=[],r=[],o=0,l=null;function a(c=!1){if(!c&&!s.length)return;ou||f<0||u>t)throw new RangeError(`Invalid change range ${f} to ${u} (in doc of length ${t})`);let p=d?typeof d=="string"?I.of(d.split(i||ns)):d:I.empty,g=p.length;if(f==u&&g==0)return;fo&&ae(s,f-o,-1),ae(s,u-f,g),nt(r,s,p),o=u}}return h(e),a(!l),l}static empty(e){return new Z(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],i=[];for(let s=0;sl&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(r.length==1)t.push(r[0],0);else{for(;i.length=0&&t<=0&&t==n[s+1]?n[s]+=e:e==0&&n[s]==0?n[s+1]+=t:i?(n[s]+=e,n[s+1]+=t):n.push(e,t)}function nt(n,e,t){if(t.length==0)return;let i=e.length-2>>1;if(i>1])),!(t||o==n.sections.length||n.sections[o+1]<0);)l=n.sections[o++],a=n.sections[o++];e(s,h,r,c,f),s=h,r=c}}}function rs(n,e,t,i=!1){let s=[],r=i?[]:null,o=new hi(n),l=new hi(e);for(let a=-1;;)if(o.ins==-1&&l.ins==-1){let h=Math.min(o.len,l.len);ae(s,h,-1),o.forward(h),l.forward(h)}else if(l.ins>=0&&(o.ins<0||a==o.i||o.off==0&&(l.len=0&&a=0){let h=0,c=o.len;for(;c;)if(l.ins==-1){let f=Math.min(c,l.len);h+=f,c-=f,l.forward(f)}else if(l.ins==0&&l.lena||o.ins>=0&&o.len>a)&&(l||i.length>h),r.forward2(a),o.forward(a)}}}}class hi{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?I.empty:e[t]}textBit(e){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length&&!e?I.empty:t[i].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}}class gt{constructor(e,t,i){this.from=e,this.to=t,this.flags=i}get anchor(){return this.flags&16?this.to:this.from}get head(){return this.flags&16?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&4?-1:this.flags&8?1:0}get bidiLevel(){let e=this.flags&3;return e==3?null:e}get goalColumn(){let e=this.flags>>5;return e==33554431?void 0:e}map(e,t=-1){let i,s;return this.empty?i=s=e.mapPos(this.from,t):(i=e.mapPos(this.from,1),s=e.mapPos(this.to,-1)),i==this.from&&s==this.to?this:new gt(i,s,this.flags)}extend(e,t=e){if(e<=this.anchor&&t>=this.anchor)return b.range(e,t);let i=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return b.range(this.anchor,i)}eq(e){return this.anchor==e.anchor&&this.head==e.head}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return b.range(e.anchor,e.head)}static create(e,t,i){return new gt(e,t,i)}}class b{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:b.create(this.ranges.map(i=>i.map(e,t)),this.mainIndex)}eq(e){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let t=0;te.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new b(e.ranges.map(t=>gt.fromJSON(t)),e.main)}static single(e,t=e){return new b([b.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let i=0,s=0;se?4:0)|r)}static normalized(e,t=0){let i=e[t];e.sort((s,r)=>s.from-r.from),t=e.indexOf(i);for(let s=1;sr.head?b.range(a,l):b.range(l,a))}}return new b(e,t)}}function gl(n,e){for(let t of n.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}let Xs=0;class D{constructor(e,t,i,s,r){this.combine=e,this.compareInput=t,this.compare=i,this.isStatic=s,this.id=Xs++,this.default=e([]),this.extensions=typeof r=="function"?r(this):r}static define(e={}){return new D(e.combine||(t=>t),e.compareInput||((t,i)=>t===i),e.compare||(e.combine?(t,i)=>t===i:Ys),!!e.static,e.enables)}of(e){return new Ji([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new Ji(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new Ji(e,this,2,t)}from(e,t){return t||(t=i=>i),this.compute([e],i=>t(i.field(e)))}}function Ys(n,e){return n==e||n.length==e.length&&n.every((t,i)=>t===e[i])}class Ji{constructor(e,t,i,s){this.dependencies=e,this.facet=t,this.type=i,this.value=s,this.id=Xs++}dynamicSlot(e){var t;let i=this.value,s=this.facet.compareInput,r=this.id,o=e[r]>>1,l=this.type==2,a=!1,h=!1,c=[];for(let f of this.dependencies)f=="doc"?a=!0:f=="selection"?h=!0:((t=e[f.id])!==null&&t!==void 0?t:1)&1||c.push(e[f.id]);return{create(f){return f.values[o]=i(f),1},update(f,u){if(a&&u.docChanged||h&&(u.docChanged||u.selection)||os(f,c)){let d=i(f);if(l?!Or(d,f.values[o],s):!s(d,f.values[o]))return f.values[o]=d,1}return 0},reconfigure:(f,u)=>{let d,p=u.config.address[r];if(p!=null){let g=nn(u,p);if(this.dependencies.every(m=>m instanceof D?u.facet(m)===f.facet(m):m instanceof be?u.field(m,!1)==f.field(m,!1):!0)||(l?Or(d=i(f),g,s):s(d=i(f),g)))return f.values[o]=g,0}else d=i(f);return f.values[o]=d,1}}}}function Or(n,e,t){if(n.length!=e.length)return!1;for(let i=0;in[a.id]),s=t.map(a=>a.type),r=i.filter(a=>!(a&1)),o=n[e.id]>>1;function l(a){let h=[];for(let c=0;ci===s),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(Tr).find(i=>i.field==this);return((t==null?void 0:t.create)||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:i=>(i.values[t]=this.create(i),1),update:(i,s)=>{let r=i.values[t],o=this.updateF(r,s);return this.compareF(r,o)?0:(i.values[t]=o,1)},reconfigure:(i,s)=>s.config.address[this.id]!=null?(i.values[t]=s.field(this),0):(i.values[t]=this.create(i),1)}}init(e){return[this,Tr.of({field:this,create:e})]}get extension(){return this}}const pt={lowest:4,low:3,default:2,high:1,highest:0};function Yt(n){return e=>new ml(e,n)}const At={highest:Yt(pt.highest),high:Yt(pt.high),default:Yt(pt.default),low:Yt(pt.low),lowest:Yt(pt.lowest)};class ml{constructor(e,t){this.inner=e,this.prec=t}}class An{of(e){return new ls(this,e)}reconfigure(e){return An.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}}class ls{constructor(e,t){this.compartment=e,this.inner=t}}class tn{constructor(e,t,i,s,r,o){for(this.base=e,this.compartments=t,this.dynamicSlots=i,this.address=s,this.staticValues=r,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,i){let s=[],r=Object.create(null),o=new Map;for(let u of oc(e,t,o))u instanceof be?s.push(u):(r[u.facet.id]||(r[u.facet.id]=[])).push(u);let l=Object.create(null),a=[],h=[];for(let u of s)l[u.id]=h.length<<1,h.push(d=>u.slot(d));let c=i==null?void 0:i.config.facets;for(let u in r){let d=r[u],p=d[0].facet,g=c&&c[u]||[];if(d.every(m=>m.type==0))if(l[p.id]=a.length<<1|1,Ys(g,d))a.push(i.facet(p));else{let m=p.combine(d.map(y=>y.value));a.push(i&&p.compare(m,i.facet(p))?i.facet(p):m)}else{for(let m of d)m.type==0?(l[m.id]=a.length<<1|1,a.push(m.value)):(l[m.id]=h.length<<1,h.push(y=>m.dynamicSlot(y)));l[p.id]=h.length<<1,h.push(m=>rc(m,p,d))}}let f=h.map(u=>u(l));return new tn(e,o,f,l,a,r)}}function oc(n,e,t){let i=[[],[],[],[],[]],s=new Map;function r(o,l){let a=s.get(o);if(a!=null){if(a<=l)return;let h=i[a].indexOf(o);h>-1&&i[a].splice(h,1),o instanceof ls&&t.delete(o.compartment)}if(s.set(o,l),Array.isArray(o))for(let h of o)r(h,l);else if(o instanceof ls){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let h=e.get(o.compartment)||o.inner;t.set(o.compartment,h),r(h,l)}else if(o instanceof ml)r(o.inner,o.prec);else if(o instanceof be)i[l].push(o),o.provides&&r(o.provides,l);else if(o instanceof Ji)i[l].push(o),o.facet.extensions&&r(o.facet.extensions,pt.default);else{let h=o.extension;if(!h)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);r(h,l)}}return r(n,pt.default),i.reduce((o,l)=>o.concat(l))}function li(n,e){if(e&1)return 2;let t=e>>1,i=n.status[t];if(i==4)throw new Error("Cyclic dependency between fields and/or facets");if(i&2)return i;n.status[t]=4;let s=n.computeSlot(n,n.config.dynamicSlots[t]);return n.status[t]=2|s}function nn(n,e){return e&1?n.config.staticValues[e>>1]:n.values[e>>1]}const yl=D.define(),bl=D.define({combine:n=>n.some(e=>e),static:!0}),xl=D.define({combine:n=>n.length?n[0]:void 0,static:!0}),wl=D.define(),kl=D.define(),Sl=D.define(),vl=D.define({combine:n=>n.length?n[0]:!1});class Ze{constructor(e,t){this.type=e,this.value=t}static define(){return new lc}}class lc{of(e){return new Ze(this,e)}}class ac{constructor(e){this.map=e}of(e){return new E(this,e)}}class E{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new E(this.type,t)}is(e){return this.type==e}static define(e={}){return new ac(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let i=[];for(let s of e){let r=s.map(t);r&&i.push(r)}return i}}E.reconfigure=E.define();E.appendConfig=E.define();class ee{constructor(e,t,i,s,r,o){this.startState=e,this.changes=t,this.selection=i,this.effects=s,this.annotations=r,this.scrollIntoView=o,this._doc=null,this._state=null,i&&gl(i,t.newLength),r.some(l=>l.type==ee.time)||(this.annotations=r.concat(ee.time.of(Date.now())))}static create(e,t,i,s,r,o){return new ee(e,t,i,s,r,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(ee.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}}ee.time=Ze.define();ee.userEvent=Ze.define();ee.addToHistory=Ze.define();ee.remote=Ze.define();function hc(n,e){let t=[];for(let i=0,s=0;;){let r,o;if(i=n[i]))r=n[i++],o=n[i++];else if(s=0;s--){let r=i[s](n);r instanceof ee?n=r:Array.isArray(r)&&r.length==1&&r[0]instanceof ee?n=r[0]:n=Al(e,Nt(r),!1)}return n}function fc(n){let e=n.startState,t=e.facet(Sl),i=n;for(let s=t.length-1;s>=0;s--){let r=t[s](n);r&&Object.keys(r).length&&(i=Cl(i,as(e,r,n.changes.newLength),!0))}return i==n?n:ee.create(e,n.changes,n.selection,i.effects,i.annotations,i.scrollIntoView)}const uc=[];function Nt(n){return n==null?uc:Array.isArray(n)?n:[n]}var $=function(n){return n[n.Word=0]="Word",n[n.Space=1]="Space",n[n.Other=2]="Other",n}($||($={}));const dc=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;let hs;try{hs=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function pc(n){if(hs)return hs.test(n);for(let e=0;e"€"&&(t.toUpperCase()!=t.toLowerCase()||dc.test(t)))return!0}return!1}function gc(n){return e=>{if(!/\S/.test(e))return $.Space;if(pc(e))return $.Word;for(let t=0;t-1)return $.Word;return $.Other}}class N{constructor(e,t,i,s,r,o){this.config=e,this.doc=t,this.selection=i,this.values=s,this.status=e.statusTemplate.slice(),this.computeSlot=r,o&&(o._state=this);for(let l=0;ls.set(a,l)),t=null),s.set(o.value.compartment,o.value.extension)):o.is(E.reconfigure)?(t=null,i=o.value):o.is(E.appendConfig)&&(t=null,i=Nt(i).concat(o.value));let r;t?r=e.startState.values.slice():(t=tn.resolve(i,s,this),r=new N(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(l,a)=>a.reconfigure(l,this),null).values),new N(t,e.newDoc,e.newSelection,r,(o,l)=>l.update(o,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:b.cursor(t.from+e.length)}))}changeByRange(e){let t=this.selection,i=e(t.ranges[0]),s=this.changes(i.changes),r=[i.range],o=Nt(i.effects);for(let l=1;lo.spec.fromJSON(l,a)))}}return N.create({doc:e.doc,selection:b.fromJSON(e.selection),extensions:t.extensions?s.concat([t.extensions]):s})}static create(e={}){let t=tn.resolve(e.extensions||[],new Map),i=e.doc instanceof I?e.doc:I.of((e.doc||"").split(t.staticFacet(N.lineSeparator)||ns)),s=e.selection?e.selection instanceof b?e.selection:b.single(e.selection.anchor,e.selection.head):b.single(0);return gl(s,i.length),t.staticFacet(bl)||(s=s.asSingle()),new N(t,i,s,t.dynamicSlots.map(()=>null),(r,o)=>o.create(r),null)}get tabSize(){return this.facet(N.tabSize)}get lineBreak(){return this.facet(N.lineSeparator)||` -`}get readOnly(){return this.facet(vl)}phrase(e,...t){for(let i of this.facet(N.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,s)=>{if(s=="$")return"$";let r=+(s||1);return!r||r>t.length?i:t[r-1]})),e}languageDataAt(e,t,i=-1){let s=[];for(let r of this.facet(yl))for(let o of r(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&s.push(o[e]);return s}charCategorizer(e){return gc(this.languageDataAt("wordChars",e).join(""))}wordAt(e){let{text:t,from:i,length:s}=this.doc.lineAt(e),r=this.charCategorizer(e),o=e-i,l=e-i;for(;o>0;){let a=ce(t,o,!1);if(r(t.slice(a,o))!=$.Word)break;o=a}for(;ln.length?n[0]:4});N.lineSeparator=xl;N.readOnly=vl;N.phrases=D.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(s=>n[s]==e[s])}});N.languageData=yl;N.changeFilter=wl;N.transactionFilter=kl;N.transactionExtender=Sl;An.reconfigure=E.define();function Mt(n,e,t={}){let i={};for(let s of n)for(let r of Object.keys(s)){let o=s[r],l=i[r];if(l===void 0)i[r]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,r))i[r]=t[r](l,o);else throw new Error("Config merge conflict for field "+r)}for(let s in e)i[s]===void 0&&(i[s]=e[s]);return i}class wt{eq(e){return this==e}range(e,t=e){return cs.create(e,t,this)}}wt.prototype.startSide=wt.prototype.endSide=0;wt.prototype.point=!1;wt.prototype.mapMode=he.TrackDel;let cs=class Ml{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new Ml(e,t,i)}};function fs(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}class Qs{constructor(e,t,i,s){this.from=e,this.to=t,this.value=i,this.maxPoint=s}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,s=0){let r=i?this.to:this.from;for(let o=s,l=r.length;;){if(o==l)return o;let a=o+l>>1,h=r[a]-e||(i?this.value[a].endSide:this.value[a].startSide)-t;if(a==o)return h>=0?o:l;h>=0?l=a:o=a+1}}between(e,t,i,s){for(let r=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,r);rd||u==d&&h.startSide>0&&h.endSide<=0)continue;(d-u||h.endSide-h.startSide)<0||(o<0&&(o=u),h.point&&(l=Math.max(l,d-u)),i.push(h),s.push(u-o),r.push(d-o))}return{mapped:i.length?new Qs(s,r,i,l):null,pos:o}}}class K{constructor(e,t,i,s){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=s}static create(e,t,i,s){return new K(e,t,i,s)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:s=0,filterTo:r=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(fs)),this.isEmpty)return t.length?K.of(t):this;let l=new Dl(this,null,-1).goto(0),a=0,h=[],c=new kt;for(;l.value||a=0){let f=t[a++];c.addInner(f.from,f.to,f.value)||h.push(f)}else l.rangeIndex==1&&l.chunkIndexthis.chunkEnd(l.chunkIndex)||rl.to||r=r&&e<=r+o.length&&o.between(r,e-r,t-r,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return ci.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return ci.from(e).goto(t)}static compare(e,t,i,s,r=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),a=Br(o,l,i),h=new Qt(o,a,r),c=new Qt(l,a,r);i.iterGaps((f,u,d)=>Pr(h,f,c,u,d,s)),i.empty&&i.length==0&&Pr(h,0,c,0,0,s)}static eq(e,t,i=0,s){s==null&&(s=1e9-1);let r=e.filter(c=>!c.isEmpty&&t.indexOf(c)<0),o=t.filter(c=>!c.isEmpty&&e.indexOf(c)<0);if(r.length!=o.length)return!1;if(!r.length)return!0;let l=Br(r,o),a=new Qt(r,l,0).goto(i),h=new Qt(o,l,0).goto(i);for(;;){if(a.to!=h.to||!us(a.active,h.active)||a.point&&(!h.point||!a.point.eq(h.point)))return!1;if(a.to>s)return!0;a.next(),h.next()}}static spans(e,t,i,s,r=-1){let o=new Qt(e,null,r).goto(t),l=t,a=o.openStart;for(;;){let h=Math.min(o.to,i);if(o.point){let c=o.activeForPoint(o.to),f=o.pointFroml&&(s.span(l,h,o.active,a),a=o.openEnd(h));if(o.to>i)return a+(o.point&&o.to>i?1:0);l=o.to,o.next()}}static of(e,t=!1){let i=new kt;for(let s of e instanceof cs?[e]:t?mc(e):e)i.add(s.from,s.to,s.value);return i.finish()}}K.empty=new K([],[],null,-1);function mc(n){if(n.length>1)for(let e=n[0],t=1;t0)return n.slice().sort(fs);e=i}return n}K.empty.nextLayer=K.empty;class kt{finishChunk(e){this.chunks.push(new Qs(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,i){this.addInner(e,t,i)||(this.nextLayer||(this.nextLayer=new kt)).add(e,t,i)}addInner(e,t,i){let s=e-this.lastTo||i.startSide-this.last.endSide;if(s<=0&&(e-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return s<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=i,this.lastFrom=e,this.lastTo=t,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let i=t.value.length-1;return this.last=t.value[i],this.lastFrom=t.from[i]+e,this.lastTo=t.to[i]+e,!0}finish(){return this.finishInner(K.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=K.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}}function Br(n,e,t){let i=new Map;for(let r of n)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=i&&s.push(new Dl(o,t,i,r));return s.length==1?s[0]:new ci(s)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let i of this.heap)i.goto(e,t);for(let i=this.heap.length>>1;i>=0;i--)Hn(this.heap,i);return this.next(),this}forward(e,t){for(let i of this.heap)i.forward(e,t);for(let i=this.heap.length>>1;i>=0;i--)Hn(this.heap,i);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),Hn(this.heap,0)}}}function Hn(n,e){for(let t=n[e];;){let i=(e<<1)+1;if(i>=n.length)break;let s=n[i];if(i+1=0&&(s=n[i+1],i++),t.compare(s)<0)break;n[i]=t,n[e]=s,e=i}}class Qt{constructor(e,t,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=ci.from(e,t,i)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){Di(this.active,e),Di(this.activeTo,e),Di(this.activeRank,e),this.minActive=Lr(this.active,this.activeTo)}addActive(e){let t=0,{value:i,to:s,rank:r}=this.cursor;for(;t-1&&(this.activeTo[s]-this.cursor.from||this.active[s].endSide-this.cursor.startSide)<0){if(this.activeTo[s]>e){this.to=this.activeTo[s],this.endSide=this.active[s].endSide;break}this.removeActive(s),i&&Di(i,s)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let r=this.cursor.value;if(!r.point)this.addActive(i),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&i[s]=0&&!(this.activeRank[i]e||this.activeTo[i]==e&&this.active[i].endSide>=this.point.endSide)&&t.push(this.active[i]);return t.reverse()}openEnd(e){let t=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>e;i--)t++;return t}}function Pr(n,e,t,i,s,r){n.goto(e),t.goto(i);let o=i+s,l=i,a=i-e;for(;;){let h=n.to+a-t.to||n.endSide-t.endSide,c=h<0?n.to+a:t.to,f=Math.min(c,o);if(n.point||t.point?n.point&&t.point&&(n.point==t.point||n.point.eq(t.point))&&us(n.activeForPoint(n.to),t.activeForPoint(t.to))||r.comparePoint(l,f,n.point,t.point):f>l&&!us(n.active,t.active)&&r.compareRange(l,f,n.active,t.active),c>o)break;l=c,h<=0&&n.next(),h>=0&&t.next()}}function us(n,e){if(n.length!=e.length)return!1;for(let t=0;t=e;i--)n[i+1]=n[i];n[e]=t}function Lr(n,e){let t=-1,i=1e9;for(let s=0;s=e)return s;if(s==n.length)break;r+=n.charCodeAt(s)==9?t-r%t:1,s=ce(n,s)}return i===!0?-1:n.length}const ps="ͼ",Rr=typeof Symbol>"u"?"__"+ps:Symbol.for(ps),gs=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),Er=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{};class lt{constructor(e,t){this.rules=[];let{finish:i}=t||{};function s(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function r(o,l,a,h){let c=[],f=/^@(\w+)\b/.exec(o[0]),u=f&&f[1]=="keyframes";if(f&&l==null)return a.push(o[0]+";");for(let d in l){let p=l[d];if(/&/.test(d))r(d.split(/,\s*/).map(g=>o.map(m=>g.replace(/&/,m))).reduce((g,m)=>g.concat(m)),p,a);else if(p&&typeof p=="object"){if(!f)throw new RangeError("The value of a property ("+d+") should be a primitive value.");r(s(d),p,c,u)}else p!=null&&c.push(d.replace(/_.*/,"").replace(/[A-Z]/g,g=>"-"+g.toLowerCase())+": "+p+";")}(c.length||u)&&a.push((i&&!f&&!h?o.map(i):o).join(", ")+" {"+c.join(" ")+"}")}for(let o in e)r(s(o),e[o],this.rules)}getRules(){return this.rules.join(` -`)}static newName(){let e=Er[Rr]||1;return Er[Rr]=e+1,ps+e.toString(36)}static mount(e,t,i){let s=e[gs],r=i&&i.nonce;s?r&&s.setNonce(r):s=new yc(e,r),s.mount(Array.isArray(t)?t:[t])}}let Ir=new Map;class yc{constructor(e,t){let i=e.ownerDocument||e,s=i.defaultView;if(!e.head&&e.adoptedStyleSheets&&s.CSSStyleSheet){let r=Ir.get(i);if(r)return e.adoptedStyleSheets=[r.sheet,...e.adoptedStyleSheets],e[gs]=r;this.sheet=new s.CSSStyleSheet,e.adoptedStyleSheets=[this.sheet,...e.adoptedStyleSheets],Ir.set(i,this)}else{this.styleTag=i.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);let r=e.head||e;r.insertBefore(this.styleTag,r.firstChild)}this.modules=[],e[gs]=this}mount(e){let t=this.sheet,i=0,s=0;for(let r=0;r-1&&(this.modules.splice(l,1),s--,l=-1),l==-1){if(this.modules.splice(s++,0,o),t)for(let a=0;a",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},bc=typeof navigator<"u"&&/Mac/.test(navigator.platform),xc=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(var se=0;se<10;se++)at[48+se]=at[96+se]=String(se);for(var se=1;se<=24;se++)at[se+111]="F"+se;for(var se=65;se<=90;se++)at[se]=String.fromCharCode(se+32),fi[se]=String.fromCharCode(se);for(var Wn in at)fi.hasOwnProperty(Wn)||(fi[Wn]=at[Wn]);function wc(n){var e=bc&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||xc&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?fi:at)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function sn(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function ms(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function kc(n){let e=n.activeElement;for(;e&&e.shadowRoot;)e=e.shadowRoot.activeElement;return e}function _i(n,e){if(!e.anchorNode)return!1;try{return ms(n,e.anchorNode)}catch{return!1}}function Vt(n){return n.nodeType==3?St(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function rn(n,e,t,i){return t?Nr(n,e,t,i,-1)||Nr(n,e,t,i,1):!1}function on(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function Nr(n,e,t,i,s){for(;;){if(n==t&&e==i)return!0;if(e==(s<0?0:ht(n))){if(n.nodeName=="DIV")return!1;let r=n.parentNode;if(!r||r.nodeType!=1)return!1;e=on(n)+(s<0?0:1),n=r}else if(n.nodeType==1){if(n=n.childNodes[e+(s<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=s<0?ht(n):0}else return!1}}function ht(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function Mn(n,e){let t=e?n.left:n.right;return{left:t,right:t,top:n.top,bottom:n.bottom}}function Sc(n){return{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function vc(n,e,t,i,s,r,o,l){let a=n.ownerDocument,h=a.defaultView||window;for(let c=n,f=!1;c&&!f;)if(c.nodeType==1){let u,d=c==a.body;if(d)u=Sc(h);else{if(/^(fixed|sticky)$/.test(getComputedStyle(c).position)&&(f=!0),c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.assignedSlot||c.parentNode;continue}let m=c.getBoundingClientRect();u={left:m.left,right:m.left+c.clientWidth,top:m.top,bottom:m.top+c.clientHeight}}let p=0,g=0;if(s=="nearest")e.top0&&e.bottom>u.bottom+g&&(g=e.bottom-u.bottom+g+o)):e.bottom>u.bottom&&(g=e.bottom-u.bottom+o,t<0&&e.top-g0&&e.right>u.right+p&&(p=e.right-u.right+p+r)):e.right>u.right&&(p=e.right-u.right+r,t<0&&e.leftt.clientHeight||t.scrollWidth>t.clientWidth)return t;t=t.assignedSlot||t.parentNode}else if(t.nodeType==11)t=t.host;else break;return null}class Ac{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:i}=e;this.set(t,Math.min(e.anchorOffset,t?ht(t):0),i,Math.min(e.focusOffset,i?ht(i):0))}set(e,t,i,s){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=s}}let Bt=null;function Ol(n){if(n.setActive)return n.setActive();if(Bt)return n.focus(Bt);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(Bt==null?{get preventScroll(){return Bt={preventScroll:!0},!0}}:void 0),!Bt){Bt=!1;for(let t=0;tMath.max(1,n.scrollHeight-n.clientHeight-4)}class de{constructor(e,t,i=!0){this.node=e,this.offset=t,this.precise=i}static before(e,t){return new de(e.parentNode,on(e),t)}static after(e,t){return new de(e.parentNode,on(e)+1,t)}}const Zs=[];class V{constructor(){this.parent=null,this.dom=null,this.flags=2}get overrideDOMText(){return null}get posAtStart(){return this.parent?this.parent.posBefore(this):0}get posAtEnd(){return this.posAtStart+this.length}posBefore(e){let t=this.posAtStart;for(let i of this.children){if(i==e)return t;t+=i.length+i.breakAfter}throw new RangeError("Invalid child in posBefore")}posAfter(e){return this.posBefore(e)+e.length}sync(e,t){if(this.flags&2){let i=this.dom,s=null,r;for(let o of this.children){if(o.flags&7){if(!o.dom&&(r=s?s.nextSibling:i.firstChild)){let l=V.get(r);(!l||!l.parent&&l.canReuseDOM(o))&&o.reuseDOM(r)}o.sync(e,t),o.flags&=-8}if(r=s?s.nextSibling:i.firstChild,t&&!t.written&&t.node==i&&r!=o.dom&&(t.written=!0),o.dom.parentNode==i)for(;r&&r!=o.dom;)r=Hr(r);else i.insertBefore(o.dom,r);s=o.dom}for(r=s?s.nextSibling:i.firstChild,r&&t&&t.node==i&&(t.written=!0);r;)r=Hr(r)}else if(this.flags&1)for(let i of this.children)i.flags&7&&(i.sync(e,t),i.flags&=-8)}reuseDOM(e){}localPosFromDOM(e,t){let i;if(e==this.dom)i=this.dom.childNodes[t];else{let s=ht(e)==0?0:t==0?-1:1;for(;;){let r=e.parentNode;if(r==this.dom)break;s==0&&r.firstChild!=r.lastChild&&(e==r.firstChild?s=-1:s=1),e=r}s<0?i=e:i=e.nextSibling}if(i==this.dom.firstChild)return 0;for(;i&&!V.get(i);)i=i.nextSibling;if(!i)return this.length;for(let s=0,r=0;;s++){let o=this.children[s];if(o.dom==i)return r;r+=o.length+o.breakAfter}}domBoundsAround(e,t,i=0){let s=-1,r=-1,o=-1,l=-1;for(let a=0,h=i,c=i;at)return f.domBoundsAround(e,t,h);if(u>=e&&s==-1&&(s=a,r=h),h>t&&f.dom.parentNode==this.dom){o=a,l=c;break}c=u,h=u+f.breakAfter}return{from:r,to:l<0?i+this.length:l,startDOM:(s?this.children[s-1].dom.nextSibling:null)||this.dom.firstChild,endDOM:o=0?this.children[o].dom:null}}markDirty(e=!1){this.flags|=2,this.markParentsDirty(e)}markParentsDirty(e){for(let t=this.parent;t;t=t.parent){if(e&&(t.flags|=2),t.flags&1)return;t.flags|=1,e=!1}}setParent(e){this.parent!=e&&(this.parent=e,this.flags&7&&this.markParentsDirty(!0))}setDOM(e){this.dom&&(this.dom.cmView=null),this.dom=e,e.cmView=this}get rootView(){for(let e=this;;){let t=e.parent;if(!t)return e;e=t}}replaceChildren(e,t,i=Zs){this.markDirty();for(let s=e;sthis.pos||e==this.pos&&(t>0||this.i==0||this.children[this.i-1].breakAfter))return this.off=e-this.pos,this;let i=this.children[--this.i];this.pos-=i.length+i.breakAfter}}}function Ll(n,e,t,i,s,r,o,l,a){let{children:h}=n,c=h.length?h[e]:null,f=r.length?r[r.length-1]:null,u=f?f.breakAfter:o;if(!(e==i&&c&&!o&&!u&&r.length<2&&c.merge(t,s,r.length?f:null,t==0,l,a))){if(i0&&(!o&&r.length&&c.merge(t,c.length,r[0],!1,l,0)?c.breakAfter=r.shift().breakAfter:(tr)&&this.lineBreak(),s=o}return this.findPointBefore(i,t),this}readTextNode(e){let t=e.nodeValue;for(let i of this.points)i.node==e&&(i.pos=this.text.length+Math.min(i.offset,t.length));for(let i=0,s=this.lineSeparator?null:/\r\n?|\n/g;;){let r=-1,o=1,l;if(this.lineSeparator?(r=t.indexOf(this.lineSeparator,i),o=this.lineSeparator.length):(l=s.exec(t))&&(r=l.index,o=l[0].length),this.append(t.slice(i,r<0?t.length:r)),r<0)break;if(this.lineBreak(),o>1)for(let a of this.points)a.node==e&&a.pos>this.text.length&&(a.pos-=o-1);i=r+o}}readNode(e){if(e.cmIgnore)return;let t=V.get(e),i=t&&t.overrideDOMText;if(i!=null){this.findPointInside(e,i.length);for(let s=i.iter();!s.next().done;)s.lineBreak?this.lineBreak():this.append(s.value)}else e.nodeType==3?this.readTextNode(e):e.nodeName=="BR"?e.nextSibling&&this.lineBreak():e.nodeType==1&&this.readRange(e.firstChild,null)}findPointBefore(e,t){for(let i of this.points)i.node==e&&e.childNodes[i.offset]==t&&(i.pos=this.text.length)}findPointInside(e,t){for(let i of this.points)(e.nodeType==3?i.node==e:e.contains(i.node))&&(i.pos=this.text.length+Math.min(t,i.offset))}}function Wr(n){return n.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(n.nodeName)}class Vr{constructor(e,t){this.node=e,this.offset=t,this.pos=-1}}let De=typeof navigator<"u"?navigator:{userAgent:"",vendor:"",platform:""},ys=typeof document<"u"?document:{documentElement:{style:{}}};const bs=/Edge\/(\d+)/.exec(De.userAgent),Il=/MSIE \d/.test(De.userAgent),xs=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(De.userAgent),Dn=!!(Il||xs||bs),zr=!Dn&&/gecko\/(\d+)/i.test(De.userAgent),Vn=!Dn&&/Chrome\/(\d+)/.exec(De.userAgent),qr="webkitFontSmoothing"in ys.documentElement.style,Nl=!Dn&&/Apple Computer/.test(De.vendor),Kr=Nl&&(/Mobile\/\w+/.test(De.userAgent)||De.maxTouchPoints>2);var A={mac:Kr||/Mac/.test(De.platform),windows:/Win/.test(De.platform),linux:/Linux|X11/.test(De.platform),ie:Dn,ie_version:Il?ys.documentMode||6:xs?+xs[1]:bs?+bs[1]:0,gecko:zr,gecko_version:zr?+(/Firefox\/(\d+)/.exec(De.userAgent)||[0,0])[1]:0,chrome:!!Vn,chrome_version:Vn?+Vn[1]:0,ios:Kr,android:/Android\b/.test(De.userAgent),webkit:qr,safari:Nl,webkit_version:qr?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0,tabSize:ys.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};const Oc=256;class _e extends V{constructor(e){super(),this.text=e}get length(){return this.text.length}createDOM(e){this.setDOM(e||document.createTextNode(this.text))}sync(e,t){this.dom||this.createDOM(),this.dom.nodeValue!=this.text&&(t&&t.node==this.dom&&(t.written=!0),this.dom.nodeValue=this.text)}reuseDOM(e){e.nodeType==3&&this.createDOM(e)}merge(e,t,i){return this.flags&8||i&&(!(i instanceof _e)||this.length-(t-e)+i.length>Oc||i.flags&8)?!1:(this.text=this.text.slice(0,e)+(i?i.text:"")+this.text.slice(t),this.markDirty(),!0)}split(e){let t=new _e(this.text.slice(e));return this.text=this.text.slice(0,e),this.markDirty(),t.flags|=this.flags&8,t}localPosFromDOM(e,t){return e==this.dom?t:t?this.text.length:0}domAtPos(e){return new de(this.dom,e)}domBoundsAround(e,t,i){return{from:i,to:i+this.length,startDOM:this.dom,endDOM:this.dom.nextSibling}}coordsAt(e,t){return Tc(this.dom,e,t)}}class Qe extends V{constructor(e,t=[],i=0){super(),this.mark=e,this.children=t,this.length=i;for(let s of t)s.setParent(this)}setAttrs(e){if(Tl(e),this.mark.class&&(e.className=this.mark.class),this.mark.attrs)for(let t in this.mark.attrs)e.setAttribute(t,this.mark.attrs[t]);return e}canReuseDOM(e){return super.canReuseDOM(e)&&!((this.flags|e.flags)&8)}reuseDOM(e){e.nodeName==this.mark.tagName.toUpperCase()&&(this.setDOM(e),this.flags|=6)}sync(e,t){this.dom?this.flags&4&&this.setAttrs(this.dom):this.setDOM(this.setAttrs(document.createElement(this.mark.tagName))),super.sync(e,t)}merge(e,t,i,s,r,o){return i&&(!(i instanceof Qe&&i.mark.eq(this.mark))||e&&r<=0||te&&t.push(i=e&&(s=r),i=a,r++}let o=this.length-e;return this.length=e,s>-1&&(this.children.length=s,this.markDirty()),new Qe(this.mark,t,o)}domAtPos(e){return Fl(this,e)}coordsAt(e,t){return Wl(this,e,t)}}function Tc(n,e,t){let i=n.nodeValue.length;e>i&&(e=i);let s=e,r=e,o=0;e==0&&t<0||e==i&&t>=0?A.chrome||A.gecko||(e?(s--,o=1):r=0)?0:l.length-1];return A.safari&&!o&&a.width==0&&(a=Array.prototype.find.call(l,h=>h.width)||a),o?Mn(a,o<0):a||null}class mt extends V{static create(e,t,i){return new mt(e,t,i)}constructor(e,t,i){super(),this.widget=e,this.length=t,this.side=i,this.prevWidget=null}split(e){let t=mt.create(this.widget,this.length-e,this.side);return this.length-=e,t}sync(e){(!this.dom||!this.widget.updateDOM(this.dom,e))&&(this.dom&&this.prevWidget&&this.prevWidget.destroy(this.dom),this.prevWidget=null,this.setDOM(this.widget.toDOM(e)),this.dom.contentEditable="false")}getSide(){return this.side}merge(e,t,i,s,r,o){return i&&(!(i instanceof mt)||!this.widget.compare(i.widget)||e>0&&r<=0||t0)?de.before(this.dom):de.after(this.dom,e==this.length)}domBoundsAround(){return null}coordsAt(e,t){let i=this.widget.coordsAt(this.dom,e,t);if(i)return i;let s=this.dom.getClientRects(),r=null;if(!s.length)return null;let o=this.side?this.side<0:e>0;for(let l=o?s.length-1:0;r=s[l],!(e>0?l==0:l==s.length-1||r.top0?de.before(this.dom):de.after(this.dom)}localPosFromDOM(){return 0}domBoundsAround(){return null}coordsAt(e){return this.dom.getBoundingClientRect()}get overrideDOMText(){return I.empty}get isHidden(){return!0}}_e.prototype.children=mt.prototype.children=zt.prototype.children=Zs;function Fl(n,e){let t=n.dom,{children:i}=n,s=0;for(let r=0;sr&&e0;r--){let o=i[r-1];if(o.dom.parentNode==t)return o.domAtPos(o.length)}for(let r=s;r0&&e instanceof Qe&&s.length&&(i=s[s.length-1])instanceof Qe&&i.mark.eq(e.mark)?Hl(i,e.children[0],t-1):(s.push(e),e.setParent(n)),n.length+=e.length}function Wl(n,e,t){let i=null,s=-1,r=null,o=-1;function l(h,c){for(let f=0,u=0;f=c&&(d.children.length?l(d,c-u):(!r||r.isHidden&&t>0)&&(p>c||u==p&&d.getSide()>0)?(r=d,o=c-u):(u-1?1:0)!=s.length-(t&&s.indexOf(t)>-1?1:0))return!1;for(let r of i)if(r!=t&&(s.indexOf(r)==-1||n[r]!==e[r]))return!1;return!0}function ks(n,e,t){let i=!1;if(e)for(let s in e)t&&s in t||(i=!0,s=="style"?n.style.cssText="":n.removeAttribute(s));if(t)for(let s in t)e&&e[s]==t[s]||(i=!0,s=="style"?n.style.cssText=t[s]:n.setAttribute(s,t[s]));return i}function Pc(n){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new ct(e,t,t,i,e.widget||null,!1)}static replace(e){let t=!!e.block,i,s;if(e.isBlockGap)i=-5e8,s=4e8;else{let{start:r,end:o}=Vl(e,t);i=(r?t?-3e8:-1:5e8)-1,s=(o?t?2e8:1:-6e8)+1}return new ct(e,i,s,t,e.widget||null,!0)}static line(e){return new Si(e)}static set(e,t=!1){return K.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}}B.none=K.empty;class ki extends B{constructor(e){let{start:t,end:i}=Vl(e);super(t?-1:5e8,i?1:-6e8,null,e),this.tagName=e.tagName||"span",this.class=e.class||"",this.attrs=e.attributes||null}eq(e){var t,i;return this==e||e instanceof ki&&this.tagName==e.tagName&&(this.class||((t=this.attrs)===null||t===void 0?void 0:t.class))==(e.class||((i=e.attrs)===null||i===void 0?void 0:i.class))&&er(this.attrs,e.attrs,"class")}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}}ki.prototype.point=!1;class Si extends B{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof Si&&this.spec.class==e.spec.class&&er(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}}Si.prototype.mapMode=he.TrackBefore;Si.prototype.point=!0;class ct extends B{constructor(e,t,i,s,r,o){super(t,i,r,e),this.block=s,this.isReplace=o,this.mapMode=s?t<=0?he.TrackBefore:he.TrackAfter:he.TrackDel}get type(){return this.startSide=5||this.widget.lineBreaks>0)}eq(e){return e instanceof ct&&Lc(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}}ct.prototype.point=!0;function Vl(n,e=!1){let{inclusiveStart:t,inclusiveEnd:i}=n;return t==null&&(t=n.inclusive),i==null&&(i=n.inclusive),{start:t??e,end:i??e}}function Lc(n,e){return n==e||!!(n&&e&&n.compare(e))}function Ss(n,e,t,i=0){let s=t.length-1;s>=0&&t[s]+i>=n?t[s]=Math.max(t[s],e):t.push(n,e)}class ue extends V{constructor(){super(...arguments),this.children=[],this.length=0,this.prevAttrs=void 0,this.attrs=null,this.breakAfter=0}merge(e,t,i,s,r,o){if(i){if(!(i instanceof ue))return!1;this.dom||i.transferDOM(this)}return s&&this.setDeco(i?i.attrs:null),Rl(this,e,t,i?i.children:[],r,o),!0}split(e){let t=new ue;if(t.breakAfter=this.breakAfter,this.length==0)return t;let{i,off:s}=this.childPos(e);s&&(t.append(this.children[i].split(s),0),this.children[i].merge(s,this.children[i].length,null,!1,0,0),i++);for(let r=i;r0&&this.children[i-1].length==0;)this.children[--i].destroy();return this.children.length=i,this.markDirty(),this.length=e,t}transferDOM(e){this.dom&&(this.markDirty(),e.setDOM(this.dom),e.prevAttrs=this.prevAttrs===void 0?this.attrs:this.prevAttrs,this.prevAttrs=void 0,this.dom=null)}setDeco(e){er(this.attrs,e)||(this.dom&&(this.prevAttrs=this.attrs,this.markDirty()),this.attrs=e)}append(e,t){Hl(this,e,t)}addLineDeco(e){let t=e.spec.attributes,i=e.spec.class;t&&(this.attrs=ws(t,this.attrs||{})),i&&(this.attrs=ws({class:i},this.attrs||{}))}domAtPos(e){return Fl(this,e)}reuseDOM(e){e.nodeName=="DIV"&&(this.setDOM(e),this.flags|=6)}sync(e,t){var i;this.dom?this.flags&4&&(Tl(this.dom),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0):(this.setDOM(document.createElement("div")),this.dom.className="cm-line",this.prevAttrs=this.attrs?null:void 0),this.prevAttrs!==void 0&&(ks(this.dom,this.prevAttrs,this.attrs),this.dom.classList.add("cm-line"),this.prevAttrs=void 0),super.sync(e,t);let s=this.dom.lastChild;for(;s&&V.get(s)instanceof Qe;)s=s.lastChild;if(!s||!this.length||s.nodeName!="BR"&&((i=V.get(s))===null||i===void 0?void 0:i.isEditable)==!1&&(!A.ios||!this.children.some(r=>r instanceof _e))){let r=document.createElement("BR");r.cmIgnore=!0,this.dom.appendChild(r)}}measureTextSize(){if(this.children.length==0||this.length>20)return null;let e=0,t;for(let i of this.children){if(!(i instanceof _e)||/[^ -~]/.test(i.text))return null;let s=Vt(i.dom);if(s.length!=1)return null;e+=s[0].width,t=s[0].height}return e?{lineHeight:this.dom.getBoundingClientRect().height,charWidth:e/this.length,textHeight:t}:null}coordsAt(e,t){let i=Wl(this,e,t);if(!this.children.length&&i&&this.parent){let{heightOracle:s}=this.parent.view.viewState,r=i.bottom-i.top;if(Math.abs(r-s.lineHeight)<2&&s.textHeight=t){if(r instanceof ue)return r;if(o>t)break}s=o+r.breakAfter}return null}}class xt extends V{constructor(e,t,i){super(),this.widget=e,this.length=t,this.type=i,this.breakAfter=0,this.prevWidget=null}merge(e,t,i,s,r,o){return i&&(!(i instanceof xt)||!this.widget.compare(i.widget)||e>0&&r<=0||t0;){if(this.textOff==this.text.length){let{value:r,lineBreak:o,done:l}=this.cursor.next(this.skip);if(this.skip=0,l)throw new Error("Ran out of text content when drawing inline views");if(o){this.posCovered()||this.getLine(),this.content.length?this.content[this.content.length-1].breakAfter=1:this.breakAtStart=1,this.flushBuffer(),this.curLine=null,this.atCursorPos=!0,e--;continue}else this.text=r,this.textOff=0}let s=Math.min(this.text.length-this.textOff,e,512);this.flushBuffer(t.slice(t.length-i)),this.getLine().append(Ti(new _e(this.text.slice(this.textOff,this.textOff+s)),t),i),this.atCursorPos=!0,this.textOff+=s,e-=s,i=0}}span(e,t,i,s){this.buildText(t-e,i,s),this.pos=t,this.openStart<0&&(this.openStart=s)}point(e,t,i,s,r,o){if(this.disallowBlockEffectsFor[o]&&i instanceof ct){if(i.block)throw new RangeError("Block decorations may not be specified via plugins");if(t>this.doc.lineAt(this.pos).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}let l=t-e;if(i instanceof ct)if(i.block){let{type:a}=i;a==_.WidgetAfter&&!this.posCovered()&&this.getLine(),this.addBlockWidget(new xt(i.widget||new jr("div"),l,a))}else{let a=mt.create(i.widget||new jr("span"),l,l?0:i.startSide),h=this.atCursorPos&&!a.isEditable&&r<=s.length&&(e0),c=!a.isEditable&&(es.length||i.startSide<=0),f=this.getLine();this.pendingBuffer==2&&!h&&!a.isEditable&&(this.pendingBuffer=0),this.flushBuffer(s),h&&(f.append(Ti(new zt(1),s),r),r=s.length+Math.max(0,r-s.length)),f.append(Ti(a,s),r),this.atCursorPos=c,this.pendingBuffer=c?es.length?1:2:0,this.pendingBuffer&&(this.bufferMarks=s.slice())}else this.doc.lineAt(this.pos).from==this.pos&&this.getLine().addLineDeco(i);l&&(this.textOff+l<=this.text.length?this.textOff+=l:(this.skip+=l-(this.text.length-this.textOff),this.text="",this.textOff=0),this.pos=t),this.openStart<0&&(this.openStart=r)}static build(e,t,i,s,r){let o=new ai(e,t,i,r);return o.openEnd=K.spans(s,t,i,o),o.openStart<0&&(o.openStart=o.openEnd),o.finish(o.openEnd),o}}function Ti(n,e){for(let t of e)n=new Qe(t,[n],n.length);return n}class jr extends Dt{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}}const zl=D.define(),ql=D.define(),Kl=D.define(),$l=D.define(),vs=D.define(),jl=D.define(),Ul=D.define(),Gl=D.define({combine:n=>n.some(e=>e)}),Jl=D.define({combine:n=>n.some(e=>e)});class ln{constructor(e,t="nearest",i="nearest",s=5,r=5){this.range=e,this.y=t,this.x=i,this.yMargin=s,this.xMargin=r}map(e){return e.empty?this:new ln(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin)}}const Ur=E.define({map:(n,e)=>n.map(e)});function Ie(n,e,t){let i=n.facet($l);i.length?i[0](e):window.onerror?window.onerror(String(e),t,void 0,void 0,e):t?console.error(t+":",e):console.error(e)}const On=D.define({combine:n=>n.length?n[0]:!0});let Rc=0;const ii=D.define();class ge{constructor(e,t,i,s){this.id=e,this.create=t,this.domEventHandlers=i,this.extension=s(this)}static define(e,t){const{eventHandlers:i,provide:s,decorations:r}=t||{};return new ge(Rc++,e,i,o=>{let l=[ii.of(o)];return r&&l.push(ui.of(a=>{let h=a.plugin(o);return h?r(h):B.none})),s&&l.push(s(o)),l})}static fromClass(e,t){return ge.define(i=>new e(i),t)}}class zn{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(i){if(Ie(t.state,i,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.create(e)}catch(t){Ie(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(i){Ie(e.state,i,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}}const _l=D.define(),tr=D.define(),ui=D.define(),ir=D.define(),Xl=D.define();function Gr(n,e,t){let i=n.state.facet(Xl);if(!i.length)return i;let s=i.map(o=>o instanceof Function?o(n):o),r=[];return K.spans(s,e,t,{point(){},span(o,l,a,h){let c=r;for(let f=a.length-1;f>=0;f--,h--){let u=a[f].spec.bidiIsolate,d;if(u!=null)if(h>0&&c.length&&(d=c[c.length-1]).to==o&&d.direction==u)d.to=l,c=d.inner;else{let p={from:o,to:l,direction:u,inner:[]};c.push(p),c=p.inner}}}}),r}const Yl=D.define();function Ql(n){let e=0,t=0,i=0,s=0;for(let r of n.state.facet(Yl)){let o=r(n);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(i=Math.max(i,o.top)),o.bottom!=null&&(s=Math.max(s,o.bottom)))}return{left:e,right:t,top:i,bottom:s}}const ni=D.define();class Le{constructor(e,t,i,s){this.fromA=e,this.toA=t,this.fromB=i,this.toB=s}join(e){return new Le(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,i=this;for(;t>0;t--){let s=e[t-1];if(!(s.fromA>i.toA)){if(s.toAc)break;r+=2}if(!a)return i;new Le(a.fromA,a.toA,a.fromB,a.toB).addToSet(i),o=a.toA,l=a.toB}}}class an{constructor(e,t,i){this.view=e,this.state=t,this.transactions=i,this.flags=0,this.startState=e.state,this.changes=Z.empty(this.startState.doc.length);for(let r of i)this.changes=this.changes.compose(r.changes);let s=[];this.changes.iterChangedRanges((r,o,l,a)=>s.push(new Le(r,o,l,a))),this.changedRanges=s}static create(e,t,i){return new an(e,t,i)}get viewportChanged(){return(this.flags&4)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&10)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}}var J=function(n){return n[n.LTR=0]="LTR",n[n.RTL=1]="RTL",n}(J||(J={}));const di=J.LTR,Zl=J.RTL;function ea(n){let e=[];for(let t=0;t=t){if(l.level==i)return o;(r<0||(s!=0?s<0?l.fromt:e[r].level>l.level))&&(r=o)}}if(r<0)throw new RangeError("Index out of range");return r}}function ta(n,e){if(n.length!=e.length)return!1;for(let t=0;t=0;g-=3)if(He[g+1]==-d){let m=He[g+2],y=m&2?s:m&4?m&1?r:s:0;y&&(F[f]=F[He[g]]=y),l=g;break}}else{if(He.length==189)break;He[l++]=f,He[l++]=u,He[l++]=a}else if((p=F[f])==2||p==1){let g=p==s;a=g?0:1;for(let m=l-3;m>=0;m-=3){let y=He[m+2];if(y&2)break;if(g)He[m+2]|=2;else{if(y&4)break;He[m+2]|=4}}}}}function Vc(n,e,t,i){for(let s=0,r=i;s<=t.length;s++){let o=s?t[s-1].to:n,l=sa;)p==m&&(p=t[--g].from,m=g?t[g-1].to:n),F[--p]=d;a=c}else r=h,a++}}}function As(n,e,t,i,s,r,o){let l=i%2?2:1;if(i%2==s%2)for(let a=e,h=0;aa&&o.push(new st(a,g.from,d));let m=g.direction==di!=!(d%2);Ms(n,m?i+1:i,s,g.inner,g.from,g.to,o),a=g.to}p=g.to}else{if(p==t||(c?F[p]!=l:F[p]==l))break;p++}u?As(n,a,p,i+1,s,u,o):ae;){let c=!0,f=!1;if(!h||a>r[h-1].to){let g=F[a-1];g!=l&&(c=!1,f=g==16)}let u=!c&&l==1?[]:null,d=c?i:i+1,p=a;e:for(;;)if(h&&p==r[h-1].to){if(f)break e;let g=r[--h];if(!c)for(let m=g.from,y=h;;){if(m==e)break e;if(y&&r[y-1].to==m)m=r[--y].from;else{if(F[m-1]==l)break e;break}}if(u)u.push(g);else{g.toF.length;)F[F.length]=256;let i=[],s=e==di?0:1;return Ms(n,s,s,t,0,n.length,i),i}function ia(n){return[new st(0,n,0)]}let na="";function qc(n,e,t,i,s){var r;let o=i.head-n.from,l=-1;if(o==0){if(!s||!n.length)return null;e[0].level!=t&&(o=e[0].side(!1,t),l=0)}else if(o==n.length){if(s)return null;let u=e[e.length-1];u.level!=t&&(o=u.side(!0,t),l=e.length-1)}l<0&&(l=st.find(e,o,(r=i.bidiLevel)!==null&&r!==void 0?r:-1,i.assoc));let a=e[l];o==a.side(s,t)&&(a=e[l+=s?1:-1],o=a.side(!s,t));let h=s==(a.dir==t),c=ce(n.text,o,h);if(na=n.text.slice(Math.min(o,c),Math.max(o,c)),c!=a.side(s,t))return b.cursor(c+n.from,h?-1:1,a.level);let f=l==(s?e.length-1:0)?null:e[l+(s?1:-1)];return!f&&a.level!=t?b.cursor(s?n.to:n.from,s?-1:1,t):f&&f.level0&&t.length&&(t.every(({fromA:l,toA:a})=>athis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0);let i=this.view.inputState.composing<0?null:$c(this.view,e.changes);if(this.hasComposition){this.markedForComposition.clear();let{from:l,to:a}=this.hasComposition;t=new Le(l,a,e.changes.mapPos(l,-1),e.changes.mapPos(a,1)).addToSet(t.slice())}this.hasComposition=i?{from:i.range.fromB,to:i.range.toB}:null,(A.ie||A.chrome)&&!i&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let s=this.decorations,r=this.updateDeco(),o=Gc(s,r,e.changes);return t=Le.extendWithRanges(t,o),!(this.flags&7)&&t.length==0?!1:(this.updateInner(t,e.startState.doc.length,i),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t,i){this.view.viewState.mustMeasureContent=!0,this.updateChildren(e,t,i);let{observer:s}=this.view;s.ignore(()=>{this.dom.style.height=this.view.viewState.contentHeight+"px",this.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let o=A.chrome||A.ios?{node:s.selectionRange.focusNode,written:!1}:void 0;this.sync(this.view,o),this.flags&=-8,o&&(o.written||s.selectionRange.focusNode!=o.node)&&(this.forceSelection=!0),this.dom.style.height=""}),this.markedForComposition.forEach(o=>o.flags&=-9);let r=[];if(this.view.viewport.from||this.view.viewport.to=0?s[o]:null;if(!l)break;let{fromA:a,toA:h,fromB:c,toB:f}=l,u,d,p,g;if(i&&i.range.fromBc){let x=ai.build(this.view.state.doc,c,i.range.fromB,this.decorations,this.dynamicDecorationMap),w=ai.build(this.view.state.doc,i.range.toB,f,this.decorations,this.dynamicDecorationMap);d=x.breakAtStart,p=x.openStart,g=w.openEnd;let k=this.compositionView(i);w.breakAtStart?k.breakAfter=1:w.content.length&&k.merge(k.length,k.length,w.content[0],!1,w.openStart,0)&&(k.breakAfter=w.content[0].breakAfter,w.content.shift()),x.content.length&&k.merge(0,0,x.content[x.content.length-1],!0,0,x.openEnd)&&x.content.pop(),u=x.content.concat(k).concat(w.content)}else({content:u,breakAtStart:d,openStart:p,openEnd:g}=ai.build(this.view.state.doc,c,f,this.decorations,this.dynamicDecorationMap));let{i:m,off:y}=r.findPos(h,1),{i:S,off:M}=r.findPos(a,-1);Ll(this,S,M,m,y,u,d,p,g)}i&&this.fixCompositionDOM(i)}compositionView(e){let t=new _e(e.text.nodeValue);t.flags|=8;for(let{deco:s}of e.marks)t=new Qe(s,[t],t.length);let i=new ue;return i.append(t,0),i}fixCompositionDOM(e){let t=(r,o)=>{o.flags|=8,this.markedForComposition.add(o);let l=V.get(r);l!=o&&(l&&(l.dom=null),o.setDOM(r))},i=this.childPos(e.range.fromB,1),s=this.children[i.i];t(e.line,s);for(let r=e.marks.length-1;r>=-1;r--)i=s.childPos(i.off,1),s=s.children[i.i],t(r>=0?e.marks[r].node:e.text,s)}updateSelection(e=!1,t=!1){(e||!this.view.observer.selectionRange.focusNode)&&this.view.observer.readSelectionRange();let i=this.view.root.activeElement,s=i==this.dom,r=!s&&_i(this.dom,this.view.observer.selectionRange)&&!(i&&this.dom.contains(i));if(!(s||t||r))return;let o=this.forceSelection;this.forceSelection=!1;let l=this.view.state.selection.main,a=this.domAtPos(l.anchor),h=l.empty?a:this.domAtPos(l.head);if(A.gecko&&l.empty&&!this.hasComposition&&Kc(a)){let f=document.createTextNode("");this.view.observer.ignore(()=>a.node.insertBefore(f,a.node.childNodes[a.offset]||null)),a=h=new de(f,0),o=!0}let c=this.view.observer.selectionRange;(o||!c.focusNode||!rn(a.node,a.offset,c.anchorNode,c.anchorOffset)||!rn(h.node,h.offset,c.focusNode,c.focusOffset))&&(this.view.observer.ignore(()=>{A.android&&A.chrome&&this.dom.contains(c.focusNode)&&Jc(c.focusNode,this.dom)&&(this.dom.blur(),this.dom.focus({preventScroll:!0}));let f=sn(this.view.root);if(f)if(l.empty){if(A.gecko){let u=jc(a.node,a.offset);if(u&&u!=3){let d=ra(a.node,a.offset,u==1?1:-1);d&&(a=new de(d,u==1?0:d.nodeValue.length))}}f.collapse(a.node,a.offset),l.bidiLevel!=null&&c.caretBidiLevel!=null&&(c.caretBidiLevel=l.bidiLevel)}else if(f.extend){f.collapse(a.node,a.offset);try{f.extend(h.node,h.offset)}catch{}}else{let u=document.createRange();l.anchor>l.head&&([a,h]=[h,a]),u.setEnd(h.node,h.offset),u.setStart(a.node,a.offset),f.removeAllRanges(),f.addRange(u)}r&&this.view.root.activeElement==this.dom&&(this.dom.blur(),i&&i.focus())}),this.view.observer.setSelectionRange(a,h)),this.impreciseAnchor=a.precise?null:new de(c.anchorNode,c.anchorOffset),this.impreciseHead=h.precise?null:new de(c.focusNode,c.focusOffset)}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,i=sn(e.root),{anchorNode:s,anchorOffset:r}=e.observer.selectionRange;if(!i||!t.empty||!t.assoc||!i.modify)return;let o=ue.find(this,t.head);if(!o)return;let l=o.posAtStart;if(t.head==l||t.head==l+o.length)return;let a=this.coordsAt(t.head,-1),h=this.coordsAt(t.head,1);if(!a||!h||a.bottom>h.top)return;let c=this.domAtPos(t.head+t.assoc);i.collapse(c.node,c.offset),i.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let f=e.observer.selectionRange;e.docView.posFromDOM(f.anchorNode,f.anchorOffset)!=t.from&&i.collapse(s,r)}nearest(e){for(let t=e;t;){let i=V.get(t);if(i&&i.rootView==this)return i;t=t.parentNode}return null}posFromDOM(e,t){let i=this.nearest(e);if(!i)throw new RangeError("Trying to find position for a DOM position outside of the document");return i.localPosFromDOM(e,t)+i.posAtStart}domAtPos(e){let{i:t,off:i}=this.childCursor().findPos(e,-1);for(;to||e==o&&r.type!=_.WidgetBefore&&r.type!=_.WidgetAfter&&(!s||t==2||this.children[s-1].breakAfter||this.children[s-1].type==_.WidgetBefore&&t>-2))return r.coordsAt(e-o,t);i=o}}coordsForChar(e){let{i:t,off:i}=this.childPos(e,1),s=this.children[t];if(!(s instanceof ue))return null;for(;s.children.length;){let{i:l,off:a}=s.childPos(i,1);for(;;l++){if(l==s.children.length)return null;if((s=s.children[l]).length)break}i=a}if(!(s instanceof _e))return null;let r=ce(s.text,i);if(r==i)return null;let o=St(s.dom,i,r).getClientRects();return!o.length||o[0].top>=o[0].bottom?null:o[0]}measureVisibleLineHeights(e){let t=[],{from:i,to:s}=e,r=this.view.contentDOM.clientWidth,o=r>Math.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,l=-1,a=this.view.textDirection==J.LTR;for(let h=0,c=0;cs)break;if(h>=i){let d=f.dom.getBoundingClientRect();if(t.push(d.height),o){let p=f.dom.lastChild,g=p?Vt(p):[];if(g.length){let m=g[g.length-1],y=a?m.right-d.left:d.right-m.left;y>l&&(l=y,this.minWidth=r,this.minWidthFrom=h,this.minWidthTo=u)}}}h=u+f.breakAfter}return t}textDirectionAt(e){let{i:t}=this.childPos(e,1);return getComputedStyle(this.children[t].dom).direction=="rtl"?J.RTL:J.LTR}measureTextSize(){for(let r of this.children)if(r instanceof ue){let o=r.measureTextSize();if(o)return o}let e=document.createElement("div"),t,i,s;return e.className="cm-line",e.style.width="99999px",e.style.position="absolute",e.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.dom.appendChild(e);let r=Vt(e.firstChild)[0];t=e.getBoundingClientRect().height,i=r?r.width/27:7,s=r?r.height:t,e.remove()}),{lineHeight:t,charWidth:i,textHeight:s}}childCursor(e=this.length){let t=this.children.length;return t&&(e-=this.children[--t].length),new Pl(this.children,e,t)}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let i=0,s=0;;s++){let r=s==t.viewports.length?null:t.viewports[s],o=r?r.from-1:this.length;if(o>i){let l=t.lineBlockAt(o).bottom-t.lineBlockAt(i).top;e.push(B.replace({widget:new _r(l),block:!0,inclusive:!0,isBlockGap:!0}).range(i,o))}if(!r)break;i=r.to+1}return B.set(e)}updateDeco(){let e=this.view.state.facet(ui).map((t,i)=>(this.dynamicDecorationMap[i]=typeof t=="function")?t(this.view):t);for(let t=e.length;tt.anchor?-1:1),s;if(!i)return;!t.empty&&(s=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(i={left:Math.min(i.left,s.left),top:Math.min(i.top,s.top),right:Math.max(i.right,s.right),bottom:Math.max(i.bottom,s.bottom)});let r=Ql(this.view),o={left:i.left-r.left,top:i.top-r.top,right:i.right+r.right,bottom:i.bottom+r.bottom};vc(this.view.scrollDOM,o,t.head-1)return null;a+=d.text.length}if(h=h.parentNode,!h)return null;let c=V.get(h);if(c){r=c.posAtStart+a,o=r+l;break}}}return{from:r,to:o,node:i}}function $c(n,e){let t=sa(n,e.newLength-e.length);if(!t)return null;let{from:i,to:s,node:r}=t,o=e.mapPos(i,-1),l=e.mapPos(s,1),a=r.nodeValue;if(/[\n\r]/.test(a))return null;if(l-o!=a.length){let u=e.mapPos(i,1),d=e.mapPos(s,-1);if(d-u==a.length)o=u,l=d;else if(n.state.doc.sliceString(l-a.length,l)==a)o=l-a.length;else if(n.state.doc.sliceString(o,o+a.length)==a)l=o+a.length;else return null}let{main:h}=n.state.selection;if(n.state.doc.sliceString(o,l)!=a||o>h.head||l0)i=i.childNodes[s-1],s=ht(i);else break}if(t>=0)for(let i=n,s=e;;){if(i.nodeType==3)return i;if(i.nodeType==1&&s=0)i=i.childNodes[s],s=0;else break}return null}function jc(n,e){return n.nodeType!=1?0:(e&&n.childNodes[e-1].contentEditable=="false"?1:0)|(e0;){let h=ce(s.text,o,!1);if(i(s.text.slice(h,o))!=a)break;o=h}for(;ln?e.left-n:Math.max(0,n-e.right)}function Yc(n,e){return e.top>n?e.top-n:Math.max(0,n-e.bottom)}function qn(n,e){return n.tope.top+1}function Xr(n,e){return en.bottom?{top:n.top,left:n.left,right:n.right,bottom:e}:n}function Ds(n,e,t){let i,s,r,o,l=!1,a,h,c,f;for(let p=n.firstChild;p;p=p.nextSibling){let g=Vt(p);for(let m=0;mM||o==M&&r>S){i=p,s=y,r=S,o=M;let x=M?t0?m0)}S==0?t>y.bottom&&(!c||c.bottomy.top)&&(h=p,f=y):c&&qn(c,y)?c=Yr(c,y.bottom):f&&qn(f,y)&&(f=Xr(f,y.top))}}if(c&&c.bottom>=t?(i=a,s=c):f&&f.top<=t&&(i=h,s=f),!i)return{node:n,offset:0};let u=Math.max(s.left,Math.min(s.right,e));if(i.nodeType==3)return Qr(i,u,t);if(l&&i.contentEditable!="false")return Ds(i,u,t);let d=Array.prototype.indexOf.call(n.childNodes,i)+(e>=(s.left+s.right)/2?1:0);return{node:n,offset:d}}function Qr(n,e,t){let i=n.nodeValue.length,s=-1,r=1e9,o=0;for(let l=0;lt?c.top-t:t-c.bottom)-1;if(c.left-1<=e&&c.right+1>=e&&f=(c.left+c.right)/2,d=u;if((A.chrome||A.gecko)&&St(n,l).getBoundingClientRect().left==c.right&&(d=!u),f<=0)return{node:n,offset:l+(d?1:0)};s=l+(d?1:0),r=f}}}return{node:n,offset:s>-1?s:o>0?n.nodeValue.length:0}}function oa(n,e,t,i=-1){var s,r;let o=n.contentDOM.getBoundingClientRect(),l=o.top+n.viewState.paddingTop,a,{docHeight:h}=n.viewState,{x:c,y:f}=e,u=f-l;if(u<0)return 0;if(u>h)return n.state.doc.length;for(let x=n.viewState.heightOracle.textHeight/2,w=!1;a=n.elementAtHeight(u),a.type!=_.Text;)for(;u=i>0?a.bottom+x:a.top-x,!(u>=0&&u<=h);){if(w)return t?null:0;w=!0,i=-i}f=l+u;let d=a.from;if(dn.viewport.to)return n.viewport.to==n.state.doc.length?n.state.doc.length:t?null:Zr(n,o,a,c,f);let p=n.dom.ownerDocument,g=n.root.elementFromPoint?n.root:p,m=g.elementFromPoint(c,f);m&&!n.contentDOM.contains(m)&&(m=null),m||(c=Math.max(o.left+1,Math.min(o.right-1,c)),m=g.elementFromPoint(c,f),m&&!n.contentDOM.contains(m)&&(m=null));let y,S=-1;if(m&&((s=n.docView.nearest(m))===null||s===void 0?void 0:s.isEditable)!=!1){if(p.caretPositionFromPoint){let x=p.caretPositionFromPoint(c,f);x&&({offsetNode:y,offset:S}=x)}else if(p.caretRangeFromPoint){let x=p.caretRangeFromPoint(c,f);x&&({startContainer:y,startOffset:S}=x,(!n.contentDOM.contains(y)||A.safari&&Qc(y,S,c)||A.chrome&&Zc(y,S,c))&&(y=void 0))}}if(!y||!n.docView.dom.contains(y)){let x=ue.find(n.docView,d);if(!x)return u>a.top+a.height/2?a.to:a.from;({node:y,offset:S}=Ds(x.dom,c,f))}let M=n.docView.nearest(y);if(!M)return null;if(M.isWidget&&((r=M.dom)===null||r===void 0?void 0:r.nodeType)==1){let x=M.dom.getBoundingClientRect();return e.yn.defaultLineHeight*1.5){let l=n.viewState.heightOracle.textHeight,a=Math.floor((s-t.top-(n.defaultLineHeight-l)*.5)/l);r+=a*n.viewState.heightOracle.lineLength}let o=n.state.sliceDoc(t.from,t.to);return t.from+ds(o,r,n.state.tabSize)}function Qc(n,e,t){let i;if(n.nodeType!=3||e!=(i=n.nodeValue.length))return!1;for(let s=n.nextSibling;s;s=s.nextSibling)if(s.nodeType!=1||s.nodeName!="BR")return!1;return St(n,i-1,i).getBoundingClientRect().left>t}function Zc(n,e,t){if(e!=0)return!1;for(let s=n;;){let r=s.parentNode;if(!r||r.nodeType!=1||r.firstChild!=s)return!1;if(r.classList.contains("cm-line"))break;s=r}let i=n.nodeType==1?n.getBoundingClientRect():St(n,0,Math.max(n.nodeValue.length,1)).getBoundingClientRect();return t-i.left>5}function Os(n,e){let t=n.lineBlockAt(e);if(Array.isArray(t.type)){for(let i of t.type)if(i.to>e||i.to==e&&(i.to==t.to||i.type==_.Text))return i}return t}function ef(n,e,t,i){let s=Os(n,e.head),r=!i||s.type!=_.Text||!(n.lineWrapping||s.widgetLineBreaks)?null:n.coordsAtPos(e.assoc<0&&e.head>s.from?e.head-1:e.head);if(r){let o=n.dom.getBoundingClientRect(),l=n.textDirectionAt(s.from),a=n.posAtCoords({x:t==(l==J.LTR)?o.right-1:o.left+1,y:(r.top+r.bottom)/2});if(a!=null)return b.cursor(a,t?-1:1)}return b.cursor(t?s.to:s.from,t?-1:1)}function eo(n,e,t,i){let s=n.state.doc.lineAt(e.head),r=n.bidiSpans(s),o=n.textDirectionAt(s.from);for(let l=e,a=null;;){let h=qc(s,r,o,l,t),c=na;if(!h){if(s.number==(t?n.state.doc.lines:1))return l;c=` -`,s=n.state.doc.line(s.number+(t?1:-1)),r=n.bidiSpans(s),h=b.cursor(t?s.from:s.to)}if(a){if(!a(c))return l}else{if(!i)return h;a=i(c)}l=h}}function tf(n,e,t){let i=n.state.charCategorizer(e),s=i(t);return r=>{let o=i(r);return s==$.Space&&(s=o),s==o}}function nf(n,e,t,i){let s=e.head,r=t?1:-1;if(s==(t?n.state.doc.length:0))return b.cursor(s,e.assoc);let o=e.goalColumn,l,a=n.contentDOM.getBoundingClientRect(),h=n.coordsAtPos(s),c=n.documentTop;if(h)o==null&&(o=h.left-a.left),l=r<0?h.top:h.bottom;else{let d=n.viewState.lineBlockAt(s);o==null&&(o=Math.min(a.right-a.left,n.defaultCharacterWidth*(s-d.from))),l=(r<0?d.top:d.bottom)+c}let f=a.left+o,u=i??n.viewState.heightOracle.textHeight>>1;for(let d=0;;d+=10){let p=l+(u+d)*r,g=oa(n,{x:f,y:p},!1,r);if(pa.bottom||(r<0?gs))return b.cursor(g,e.assoc,void 0,o)}}function Xi(n,e,t){for(;;){let i=0;for(let s of n)s.between(e-1,e+1,(r,o,l)=>{if(e>r&&es(n)),t.from,e.head>t.from?-1:1);return i==t.from?t:b.cursor(i,i{this.ignoreDuringComposition(s)||s.type=="keydown"&&this.keydown(e,s)||(this.mustFlushObserver(s)&&e.observer.forceFlush(),this.runCustomHandlers(s.type,e,s)?s.preventDefault():i(e,s))};for(let i in X){let s=X[i];e.contentDOM.addEventListener(i,r=>{to(e,r)&&t(s,r)},Ts[i]),this.registeredEvents.push(i)}e.scrollDOM.addEventListener("mousedown",i=>{if(i.target==e.scrollDOM&&i.clientY>e.contentDOM.getBoundingClientRect().bottom&&(t(X.mousedown,i),!i.defaultPrevented&&i.button==2)){let s=e.contentDOM.style.minHeight;e.contentDOM.style.minHeight="100%",setTimeout(()=>e.contentDOM.style.minHeight=s,200)}}),e.scrollDOM.addEventListener("drop",i=>{i.target==e.scrollDOM&&i.clientY>e.contentDOM.getBoundingClientRect().bottom&&t(X.drop,i)}),A.chrome&&A.chrome_version==102&&e.scrollDOM.addEventListener("wheel",()=>{this.chromeScrollHack<0?e.contentDOM.style.pointerEvents="none":window.clearTimeout(this.chromeScrollHack),this.chromeScrollHack=setTimeout(()=>{this.chromeScrollHack=-1,e.contentDOM.style.pointerEvents=""},100)},{passive:!0}),this.notifiedFocused=e.hasFocus,A.safari&&e.contentDOM.addEventListener("input",()=>null),A.gecko&&bf(e.contentDOM.ownerDocument)}ensureHandlers(e,t){var i;let s;this.customHandlers=[];for(let r of t)if(s=(i=r.update(e).spec)===null||i===void 0?void 0:i.domEventHandlers){this.customHandlers.push({plugin:r.value,handlers:s});for(let o in s)this.registeredEvents.indexOf(o)<0&&o!="scroll"&&(this.registeredEvents.push(o),e.contentDOM.addEventListener(o,l=>{to(e,l)&&this.runCustomHandlers(o,e,l)&&l.preventDefault()}))}}runCustomHandlers(e,t,i){for(let s of this.customHandlers){let r=s.handlers[e];if(r)try{if(r.call(s.plugin,i,t)||i.defaultPrevented)return!0}catch(o){Ie(t.state,o)}}return!1}runScrollHandlers(e,t){this.lastScrollTop=e.scrollDOM.scrollTop,this.lastScrollLeft=e.scrollDOM.scrollLeft;for(let i of this.customHandlers){let s=i.handlers.scroll;if(s)try{s.call(i.plugin,t,e)}catch(r){Ie(e.state,r)}}}keydown(e,t){if(this.lastKeyCode=t.keyCode,this.lastKeyTime=Date.now(),t.keyCode==9&&Date.now()s.keyCode==t.keyCode))&&!t.ctrlKey||rf.indexOf(t.key)>-1&&t.ctrlKey&&!t.shiftKey)?(this.pendingIOSKey=i||t,setTimeout(()=>this.flushIOSKey(e),250),!0):!1}flushIOSKey(e){let t=this.pendingIOSKey;return t?(this.pendingIOSKey=void 0,Ft(e.contentDOM,t.key,t.keyCode)):!1}ignoreDuringComposition(e){return/^key/.test(e.type)?this.composing>0?!0:A.safari&&!A.ios&&this.compositionPendingKey&&Date.now()-this.compositionEndedAt<100?(this.compositionPendingKey=!1,!0):!1:!1}mustFlushObserver(e){return e.type=="keydown"&&e.keyCode!=229}startMouseSelection(e){this.mouseSelection&&this.mouseSelection.destroy(),this.mouseSelection=e}update(e){this.mouseSelection&&this.mouseSelection.update(e),e.transactions.length&&(this.lastKeyCode=this.lastSelectionTime=0)}destroy(){this.mouseSelection&&this.mouseSelection.destroy()}}const la=[{key:"Backspace",keyCode:8,inputType:"deleteContentBackward"},{key:"Enter",keyCode:13,inputType:"insertParagraph"},{key:"Enter",keyCode:13,inputType:"insertLineBreak"},{key:"Delete",keyCode:46,inputType:"deleteContentForward"}],rf="dthko",aa=[16,17,18,20,91,92,224,225],Bi=6;function Pi(n){return Math.max(0,n)*.7+8}function of(n,e){return Math.max(Math.abs(n.clientX-e.clientX),Math.abs(n.clientY-e.clientY))}class lf{constructor(e,t,i,s){this.view=e,this.startEvent=t,this.style=i,this.mustSelect=s,this.scrollSpeed={x:0,y:0},this.scrolling=-1,this.lastEvent=t,this.scrollParent=Cc(e.contentDOM),this.atoms=e.state.facet(ir).map(o=>o(e));let r=e.contentDOM.ownerDocument;r.addEventListener("mousemove",this.move=this.move.bind(this)),r.addEventListener("mouseup",this.up=this.up.bind(this)),this.extend=t.shiftKey,this.multiple=e.state.facet(N.allowMultipleSelections)&&af(e,t),this.dragging=cf(e,t)&&ua(t)==1?null:!1}start(e){this.dragging===!1&&(e.preventDefault(),this.select(e))}move(e){var t;if(e.buttons==0)return this.destroy();if(this.dragging||this.dragging==null&&of(this.startEvent,e)<10)return;this.select(this.lastEvent=e);let i=0,s=0,r=((t=this.scrollParent)===null||t===void 0?void 0:t.getBoundingClientRect())||{left:0,top:0,right:this.view.win.innerWidth,bottom:this.view.win.innerHeight},o=Ql(this.view);e.clientX-o.left<=r.left+Bi?i=-Pi(r.left-e.clientX):e.clientX+o.right>=r.right-Bi&&(i=Pi(e.clientX-r.right)),e.clientY-o.top<=r.top+Bi?s=-Pi(r.top-e.clientY):e.clientY+o.bottom>=r.bottom-Bi&&(s=Pi(e.clientY-r.bottom)),this.setScrollSpeed(i,s)}up(e){this.dragging==null&&this.select(this.lastEvent),this.dragging||e.preventDefault(),this.destroy()}destroy(){this.setScrollSpeed(0,0);let e=this.view.contentDOM.ownerDocument;e.removeEventListener("mousemove",this.move),e.removeEventListener("mouseup",this.up),this.view.inputState.mouseSelection=null}setScrollSpeed(e,t){this.scrollSpeed={x:e,y:t},e||t?this.scrolling<0&&(this.scrolling=setInterval(()=>this.scroll(),50)):this.scrolling>-1&&(clearInterval(this.scrolling),this.scrolling=-1)}scroll(){this.scrollParent?(this.scrollParent.scrollLeft+=this.scrollSpeed.x,this.scrollParent.scrollTop+=this.scrollSpeed.y):this.view.win.scrollBy(this.scrollSpeed.x,this.scrollSpeed.y),this.dragging===!1&&this.select(this.lastEvent)}skipAtoms(e){let t=null;for(let i=0;ithis.select(this.lastEvent),20)}}function af(n,e){let t=n.state.facet(zl);return t.length?t[0](e):A.mac?e.metaKey:e.ctrlKey}function hf(n,e){let t=n.state.facet(ql);return t.length?t[0](e):A.mac?!e.altKey:!e.ctrlKey}function cf(n,e){let{main:t}=n.state.selection;if(t.empty)return!1;let i=sn(n.root);if(!i||i.rangeCount==0)return!0;let s=i.getRangeAt(0).getClientRects();for(let r=0;r=e.clientX&&o.top<=e.clientY&&o.bottom>=e.clientY)return!0}return!1}function to(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target,i;t!=n.contentDOM;t=t.parentNode)if(!t||t.nodeType==11||(i=V.get(t))&&i.ignoreEvent(e))return!1;return!0}const X=Object.create(null),Ts=Object.create(null),ha=A.ie&&A.ie_version<15||A.ios&&A.webkit_version<604;function ff(n){let e=n.dom.parentNode;if(!e)return;let t=e.appendChild(document.createElement("textarea"));t.style.cssText="position: fixed; left: -10000px; top: 10px",t.focus(),setTimeout(()=>{n.focus(),t.remove(),ca(n,t.value)},50)}function ca(n,e){let{state:t}=n,i,s=1,r=t.toText(e),o=r.lines==t.selection.ranges.length;if(Bs!=null&&t.selection.ranges.every(a=>a.empty)&&Bs==r.toString()){let a=-1;i=t.changeByRange(h=>{let c=t.doc.lineAt(h.from);if(c.from==a)return{range:h};a=c.from;let f=t.toText((o?r.line(s++).text:e)+t.lineBreak);return{changes:{from:c.from,insert:f},range:b.cursor(h.from+f.length)}})}else o?i=t.changeByRange(a=>{let h=r.line(s++);return{changes:{from:a.from,to:a.to,insert:h.text},range:b.cursor(a.from+h.length)}}):i=t.replaceSelection(r);n.dispatch(i,{userEvent:"input.paste",scrollIntoView:!0})}X.keydown=(n,e)=>{n.inputState.setSelectionOrigin("select"),e.keyCode==27&&(n.inputState.lastEscPress=Date.now())};X.touchstart=(n,e)=>{n.inputState.lastTouchTime=Date.now(),n.inputState.setSelectionOrigin("select.pointer")};X.touchmove=n=>{n.inputState.setSelectionOrigin("select.pointer")};Ts.touchstart=Ts.touchmove={passive:!0};X.mousedown=(n,e)=>{if(n.observer.flush(),n.inputState.lastTouchTime>Date.now()-2e3)return;let t=null;for(let i of n.state.facet(Kl))if(t=i(n,e),t)break;if(!t&&e.button==0&&(t=pf(n,e)),t){let i=!n.hasFocus;n.inputState.startMouseSelection(new lf(n,e,t,i)),i&&n.observer.ignore(()=>Ol(n.contentDOM)),n.inputState.mouseSelection&&n.inputState.mouseSelection.start(e)}};function io(n,e,t,i){if(i==1)return b.cursor(e,t);if(i==2)return _c(n.state,e,t);{let s=ue.find(n.docView,e),r=n.state.doc.lineAt(s?s.posAtEnd:e),o=s?s.posAtStart:r.from,l=s?s.posAtEnd:r.to;return ln>=e.top&&n<=e.bottom,no=(n,e,t)=>fa(e,t)&&n>=t.left&&n<=t.right;function uf(n,e,t,i){let s=ue.find(n.docView,e);if(!s)return 1;let r=e-s.posAtStart;if(r==0)return 1;if(r==s.length)return-1;let o=s.coordsAt(r,-1);if(o&&no(t,i,o))return-1;let l=s.coordsAt(r,1);return l&&no(t,i,l)?1:o&&fa(i,o)?-1:1}function so(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1);return{pos:t,bias:uf(n,t,e.clientX,e.clientY)}}const df=A.ie&&A.ie_version<=11;let ro=null,oo=0,lo=0;function ua(n){if(!df)return n.detail;let e=ro,t=lo;return ro=n,lo=Date.now(),oo=!e||t>Date.now()-400&&Math.abs(e.clientX-n.clientX)<2&&Math.abs(e.clientY-n.clientY)<2?(oo+1)%3:1}function pf(n,e){let t=so(n,e),i=ua(e),s=n.state.selection;return{update(r){r.docChanged&&(t.pos=r.changes.mapPos(t.pos),s=s.map(r.changes))},get(r,o,l){let a=so(n,r),h,c=io(n,a.pos,a.bias,i);if(t.pos!=a.pos&&!o){let f=io(n,t.pos,t.bias,i),u=Math.min(f.from,c.from),d=Math.max(f.to,c.to);c=u1&&(h=gf(s,a.pos))?h:l?s.addRange(c):b.create([c])}}}function gf(n,e){for(let t=0;t=e)return b.create(n.ranges.slice(0,t).concat(n.ranges.slice(t+1)),n.mainIndex==t?0:n.mainIndex-(n.mainIndex>t?1:0))}return null}X.dragstart=(n,e)=>{let{selection:{main:t}}=n.state,{mouseSelection:i}=n.inputState;i&&(i.dragging=t),e.dataTransfer&&(e.dataTransfer.setData("Text",n.state.sliceDoc(t.from,t.to)),e.dataTransfer.effectAllowed="copyMove")};function ao(n,e,t,i){if(!t)return;let s=n.posAtCoords({x:e.clientX,y:e.clientY},!1);e.preventDefault();let{mouseSelection:r}=n.inputState,o=i&&r&&r.dragging&&hf(n,e)?{from:r.dragging.from,to:r.dragging.to}:null,l={from:s,insert:t},a=n.state.changes(o?[o,l]:l);n.focus(),n.dispatch({changes:a,selection:{anchor:a.mapPos(s,-1),head:a.mapPos(s,1)},userEvent:o?"move.drop":"input.drop"})}X.drop=(n,e)=>{if(!e.dataTransfer)return;if(n.state.readOnly)return e.preventDefault();let t=e.dataTransfer.files;if(t&&t.length){e.preventDefault();let i=Array(t.length),s=0,r=()=>{++s==t.length&&ao(n,e,i.filter(o=>o!=null).join(n.state.lineBreak),!1)};for(let o=0;o{/[\x00-\x08\x0e-\x1f]{2}/.test(l.result)||(i[o]=l.result),r()},l.readAsText(t[o])}}else ao(n,e,e.dataTransfer.getData("Text"),!0)};X.paste=(n,e)=>{if(n.state.readOnly)return e.preventDefault();n.observer.flush();let t=ha?null:e.clipboardData;t?(ca(n,t.getData("text/plain")||t.getData("text/uri-text")),e.preventDefault()):ff(n)};function mf(n,e){let t=n.dom.parentNode;if(!t)return;let i=t.appendChild(document.createElement("textarea"));i.style.cssText="position: fixed; left: -10000px; top: 10px",i.value=e,i.focus(),i.selectionEnd=e.length,i.selectionStart=0,setTimeout(()=>{i.remove(),n.focus()},50)}function yf(n){let e=[],t=[],i=!1;for(let s of n.selection.ranges)s.empty||(e.push(n.sliceDoc(s.from,s.to)),t.push(s));if(!e.length){let s=-1;for(let{from:r}of n.selection.ranges){let o=n.doc.lineAt(r);o.number>s&&(e.push(o.text),t.push({from:o.from,to:Math.min(n.doc.length,o.to+1)})),s=o.number}i=!0}return{text:e.join(n.lineBreak),ranges:t,linewise:i}}let Bs=null;X.copy=X.cut=(n,e)=>{let{text:t,ranges:i,linewise:s}=yf(n.state);if(!t&&!s)return;Bs=s?t:null;let r=ha?null:e.clipboardData;r?(e.preventDefault(),r.clearData(),r.setData("text/plain",t)):mf(n,t),e.type=="cut"&&!n.state.readOnly&&n.dispatch({changes:i,scrollIntoView:!0,userEvent:"delete.cut"})};const da=Ze.define();function pa(n,e){let t=[];for(let i of n.facet(Ul)){let s=i(n,e);s&&t.push(s)}return t?n.update({effects:t,annotations:da.of(!0)}):null}function ga(n){setTimeout(()=>{let e=n.hasFocus;if(e!=n.inputState.notifiedFocused){let t=pa(n.state,e);t?n.dispatch(t):n.update([])}},10)}X.focus=n=>{n.inputState.lastFocusTime=Date.now(),!n.scrollDOM.scrollTop&&(n.inputState.lastScrollTop||n.inputState.lastScrollLeft)&&(n.scrollDOM.scrollTop=n.inputState.lastScrollTop,n.scrollDOM.scrollLeft=n.inputState.lastScrollLeft),ga(n)};X.blur=n=>{n.observer.clearSelectionRange(),ga(n)};X.compositionstart=X.compositionupdate=n=>{n.inputState.compositionFirstChange==null&&(n.inputState.compositionFirstChange=!0),n.inputState.composing<0&&(n.inputState.composing=0)};X.compositionend=n=>{n.inputState.composing=-1,n.inputState.compositionEndedAt=Date.now(),n.inputState.compositionPendingKey=!0,n.inputState.compositionPendingChange=n.observer.pendingRecords().length>0,n.inputState.compositionFirstChange=null,A.chrome&&A.android?n.observer.flushSoon():n.inputState.compositionPendingChange?Promise.resolve().then(()=>n.observer.flush()):setTimeout(()=>{n.inputState.composing<0&&n.docView.hasComposition&&n.update([])},50)};X.contextmenu=n=>{n.inputState.lastContextMenu=Date.now()};X.beforeinput=(n,e)=>{var t;let i;if(A.chrome&&A.android&&(i=la.find(s=>s.inputType==e.inputType))&&(n.observer.delayAndroidKey(i.key,i.keyCode),i.key=="Backspace"||i.key=="Delete")){let s=((t=window.visualViewport)===null||t===void 0?void 0:t.height)||0;setTimeout(()=>{var r;(((r=window.visualViewport)===null||r===void 0?void 0:r.height)||0)>s+10&&n.hasFocus&&(n.contentDOM.blur(),n.focus())},100)}};const ho=new Set;function bf(n){ho.has(n)||(ho.add(n),n.addEventListener("copy",()=>{}),n.addEventListener("cut",()=>{}))}const co=["pre-wrap","normal","pre-line","break-spaces"];class xf{constructor(e){this.lineWrapping=e,this.doc=I.empty,this.heightSamples={},this.lineHeight=14,this.charWidth=7,this.textHeight=14,this.lineLength=30,this.heightChanged=!1}heightForGap(e,t){let i=this.doc.lineAt(t).number-this.doc.lineAt(e).number+1;return this.lineWrapping&&(i+=Math.max(0,Math.ceil((t-e-i*this.lineLength*.5)/this.lineLength))),this.lineHeight*i}heightForLine(e){return this.lineWrapping?(1+Math.max(0,Math.ceil((e-this.lineLength)/(this.lineLength-5))))*this.lineHeight:this.lineHeight}setDoc(e){return this.doc=e,this}mustRefreshForWrapping(e){return co.indexOf(e)>-1!=this.lineWrapping}mustRefreshForHeights(e){let t=!1;for(let i=0;i-1,a=Math.round(t)!=Math.round(this.lineHeight)||this.lineWrapping!=l;if(this.lineWrapping=l,this.lineHeight=t,this.charWidth=i,this.textHeight=s,this.lineLength=r,a){this.heightSamples={};for(let h=0;h0}set outdated(e){this.flags=(e?2:0)|this.flags&-3}setHeight(e,t){this.height!=t&&(Math.abs(this.height-t)>Yi&&(e.heightChanged=!0),this.height=t)}replace(e,t,i){return pe.of(i)}decomposeLeft(e,t){t.push(this)}decomposeRight(e,t){t.push(this)}applyChanges(e,t,i,s){let r=this,o=i.doc;for(let l=s.length-1;l>=0;l--){let{fromA:a,toA:h,fromB:c,toB:f}=s[l],u=r.lineAt(a,q.ByPosNoHeight,i.setDoc(t),0,0),d=u.to>=h?u:r.lineAt(h,q.ByPosNoHeight,i,0,0);for(f+=d.to-h,h=d.to;l>0&&u.from<=s[l-1].toA;)a=s[l-1].fromA,c=s[l-1].fromB,l--,ar*2){let l=e[t-1];l.break?e.splice(--t,1,l.left,null,l.right):e.splice(--t,1,l.left,l.right),i+=1+l.break,s-=l.size}else if(r>s*2){let l=e[i];l.break?e.splice(i,1,l.left,null,l.right):e.splice(i,1,l.left,l.right),i+=2+l.break,r-=l.size}else break;else if(s=r&&o(this.blockAt(0,i,s,r))}updateHeight(e,t=0,i=!1,s){return s&&s.from<=t&&s.more&&this.setHeight(e,s.heights[s.index++]),this.outdated=!1,this}toString(){return`block(${this.length})`}}class Ae extends ma{constructor(e,t){super(e,t,null),this.collapsed=0,this.widgetHeight=0,this.breaks=0}blockAt(e,t,i,s){return new $e(s,this.length,i,this.height,this.breaks)}replace(e,t,i){let s=i[0];return i.length==1&&(s instanceof Ae||s instanceof ie&&s.flags&4)&&Math.abs(this.length-s.length)<10?(s instanceof ie?s=new Ae(s.length,this.height):s.height=this.height,this.outdated||(s.outdated=!1),s):pe.of(i)}updateHeight(e,t=0,i=!1,s){return s&&s.from<=t&&s.more?this.setHeight(e,s.heights[s.index++]):(i||this.outdated)&&this.setHeight(e,Math.max(this.widgetHeight,e.heightForLine(this.length-this.collapsed))+this.breaks*e.lineHeight),this.outdated=!1,this}toString(){return`line(${this.length}${this.collapsed?-this.collapsed:""}${this.widgetHeight?":"+this.widgetHeight:""})`}}class ie extends pe{constructor(e){super(e,0)}heightMetrics(e,t){let i=e.doc.lineAt(t).number,s=e.doc.lineAt(t+this.length).number,r=s-i+1,o,l=0;if(e.lineWrapping){let a=Math.min(this.height,e.lineHeight*r);o=a/r,this.length>r+1&&(l=(this.height-a)/(this.length-r-1))}else o=this.height/r;return{firstLine:i,lastLine:s,perLine:o,perChar:l}}blockAt(e,t,i,s){let{firstLine:r,lastLine:o,perLine:l,perChar:a}=this.heightMetrics(t,s);if(t.lineWrapping){let h=s+Math.round(Math.max(0,Math.min(1,(e-i)/this.height))*this.length),c=t.doc.lineAt(h),f=l+c.length*a,u=Math.max(i,e-f/2);return new $e(c.from,c.length,u,f,0)}else{let h=Math.max(0,Math.min(o-r,Math.floor((e-i)/l))),{from:c,length:f}=t.doc.line(r+h);return new $e(c,f,i+l*h,l,0)}}lineAt(e,t,i,s,r){if(t==q.ByHeight)return this.blockAt(e,i,s,r);if(t==q.ByPosNoHeight){let{from:d,to:p}=i.doc.lineAt(e);return new $e(d,p-d,0,0,0)}let{firstLine:o,perLine:l,perChar:a}=this.heightMetrics(i,r),h=i.doc.lineAt(e),c=l+h.length*a,f=h.number-o,u=s+l*f+a*(h.from-r-f);return new $e(h.from,h.length,Math.max(s,Math.min(u,s+this.height-c)),c,0)}forEachLine(e,t,i,s,r,o){e=Math.max(e,r),t=Math.min(t,r+this.length);let{firstLine:l,perLine:a,perChar:h}=this.heightMetrics(i,r);for(let c=e,f=s;c<=t;){let u=i.doc.lineAt(c);if(c==e){let p=u.number-l;f+=a*p+h*(e-r-p)}let d=a+h*u.length;o(new $e(u.from,u.length,f,d,0)),f+=d,c=u.to+1}}replace(e,t,i){let s=this.length-t;if(s>0){let r=i[i.length-1];r instanceof ie?i[i.length-1]=new ie(r.length+s):i.push(null,new ie(s-1))}if(e>0){let r=i[0];r instanceof ie?i[0]=new ie(e+r.length):i.unshift(new ie(e-1),null)}return pe.of(i)}decomposeLeft(e,t){t.push(new ie(e-1),null)}decomposeRight(e,t){t.push(null,new ie(this.length-e-1))}updateHeight(e,t=0,i=!1,s){let r=t+this.length;if(s&&s.from<=t+this.length&&s.more){let o=[],l=Math.max(t,s.from),a=-1;for(s.from>t&&o.push(new ie(s.from-t-1).updateHeight(e,t));l<=r&&s.more;){let c=e.doc.lineAt(l).length;o.length&&o.push(null);let f=s.heights[s.index++];a==-1?a=f:Math.abs(f-a)>=Yi&&(a=-2);let u=new Ae(c,f);u.outdated=!1,o.push(u),l+=c+1}l<=r&&o.push(null,new ie(r-l).updateHeight(e,l));let h=pe.of(o);return(a<0||Math.abs(h.height-this.height)>=Yi||Math.abs(a-this.heightMetrics(e,t).perLine)>=Yi)&&(e.heightChanged=!0),h}else(i||this.outdated)&&(this.setHeight(e,e.heightForGap(t,t+this.length)),this.outdated=!1);return this}toString(){return`gap(${this.length})`}}class kf extends pe{constructor(e,t,i){super(e.length+t+i.length,e.height+i.height,t|(e.outdated||i.outdated?2:0)),this.left=e,this.right=i,this.size=e.size+i.size}get break(){return this.flags&1}blockAt(e,t,i,s){let r=i+this.left.height;return el))return h;let c=t==q.ByPosNoHeight?q.ByPosNoHeight:q.ByPos;return a?h.join(this.right.lineAt(l,c,i,o,l)):this.left.lineAt(l,c,i,s,r).join(h)}forEachLine(e,t,i,s,r,o){let l=s+this.left.height,a=r+this.left.length+this.break;if(this.break)e=a&&this.right.forEachLine(e,t,i,l,a,o);else{let h=this.lineAt(a,q.ByPos,i,s,r);e=e&&h.from<=t&&o(h),t>h.to&&this.right.forEachLine(h.to+1,t,i,l,a,o)}}replace(e,t,i){let s=this.left.length+this.break;if(tthis.left.length)return this.balanced(this.left,this.right.replace(e-s,t-s,i));let r=[];e>0&&this.decomposeLeft(e,r);let o=r.length;for(let l of i)r.push(l);if(e>0&&fo(r,o-1),t=i&&t.push(null)),e>i&&this.right.decomposeLeft(e-i,t)}decomposeRight(e,t){let i=this.left.length,s=i+this.break;if(e>=s)return this.right.decomposeRight(e-s,t);e2*t.size||t.size>2*e.size?pe.of(this.break?[e,null,t]:[e,t]):(this.left=e,this.right=t,this.height=e.height+t.height,this.outdated=e.outdated||t.outdated,this.size=e.size+t.size,this.length=e.length+this.break+t.length,this)}updateHeight(e,t=0,i=!1,s){let{left:r,right:o}=this,l=t+r.length+this.break,a=null;return s&&s.from<=t+r.length&&s.more?a=r=r.updateHeight(e,t,i,s):r.updateHeight(e,t,i),s&&s.from<=l+o.length&&s.more?a=o=o.updateHeight(e,l,i,s):o.updateHeight(e,l,i),a?this.balanced(r,o):(this.height=this.left.height+this.right.height,this.outdated=!1,this)}toString(){return this.left+(this.break?" ":"-")+this.right}}function fo(n,e){let t,i;n[e]==null&&(t=n[e-1])instanceof ie&&(i=n[e+1])instanceof ie&&n.splice(e-1,3,new ie(t.length+1+i.length))}const Sf=5;class nr{constructor(e,t){this.pos=e,this.oracle=t,this.nodes=[],this.lineStart=-1,this.lineEnd=-1,this.covering=null,this.writtenTo=e}get isCovered(){return this.covering&&this.nodes[this.nodes.length-1]==this.covering}span(e,t){if(this.lineStart>-1){let i=Math.min(t,this.lineEnd),s=this.nodes[this.nodes.length-1];s instanceof Ae?s.length+=i-this.pos:(i>this.pos||!this.isCovered)&&this.nodes.push(new Ae(i-this.pos,-1)),this.writtenTo=i,t>i&&(this.nodes.push(null),this.writtenTo++,this.lineStart=-1)}this.pos=t}point(e,t,i){if(e=Sf)&&this.addLineDeco(s,r,o)}else t>e&&this.span(e,t);this.lineEnd>-1&&this.lineEnd-1)return;let{from:e,to:t}=this.oracle.doc.lineAt(this.pos);this.lineStart=e,this.lineEnd=t,this.writtenToe&&this.nodes.push(new Ae(this.pos-e,-1)),this.writtenTo=this.pos}blankContent(e,t){let i=new ie(t-e);return this.oracle.doc.lineAt(e).to==t&&(i.flags|=4),i}ensureLine(){this.enterLine();let e=this.nodes.length?this.nodes[this.nodes.length-1]:null;if(e instanceof Ae)return e;let t=new Ae(0,-1);return this.nodes.push(t),t}addBlock(e){var t;this.enterLine();let i=(t=e.deco)===null||t===void 0?void 0:t.type;i==_.WidgetAfter&&!this.isCovered&&this.ensureLine(),this.nodes.push(e),this.writtenTo=this.pos=this.pos+e.length,i!=_.WidgetBefore&&(this.covering=e)}addLineDeco(e,t,i){let s=this.ensureLine();s.length+=i,s.collapsed+=i,s.widgetHeight=Math.max(s.widgetHeight,e),s.breaks+=t,this.writtenTo=this.pos=this.pos+i}finish(e){let t=this.nodes.length==0?null:this.nodes[this.nodes.length-1];this.lineStart>-1&&!(t instanceof Ae)&&!this.isCovered?this.nodes.push(new Ae(0,-1)):(this.writtenToc.clientHeight||c.scrollWidth>c.clientWidth)&&f.overflow!="visible"){let u=c.getBoundingClientRect();r=Math.max(r,u.left),o=Math.min(o,u.right),l=Math.max(l,u.top),a=h==n.parentNode?u.bottom:Math.min(a,u.bottom)}h=f.position=="absolute"||f.position=="fixed"?c.offsetParent:c.parentNode}else if(h.nodeType==11)h=h.host;else break;return{left:r-t.left,right:Math.max(r,o)-t.left,top:l-(t.top+e),bottom:Math.max(l,a)-(t.top+e)}}function Mf(n,e){let t=n.getBoundingClientRect();return{left:0,right:t.right-t.left,top:e,bottom:t.bottom-(t.top+e)}}class $n{constructor(e,t,i){this.from=e,this.to=t,this.size=i}static same(e,t){if(e.length!=t.length)return!1;for(let i=0;itypeof i!="function"&&i.class=="cm-lineWrapping");this.heightOracle=new xf(t),this.stateDeco=e.facet(ui).filter(i=>typeof i!="function"),this.heightMap=pe.empty().applyChanges(this.stateDeco,I.empty,this.heightOracle.setDoc(e.doc),[new Le(0,0,0,e.doc.length)]),this.viewport=this.getViewport(0,null),this.updateViewportLines(),this.updateForViewport(),this.lineGaps=this.ensureLineGaps([]),this.lineGapDeco=B.set(this.lineGaps.map(i=>i.draw(!1))),this.computeVisibleRanges()}updateForViewport(){let e=[this.viewport],{main:t}=this.state.selection;for(let i=0;i<=1;i++){let s=i?t.head:t.anchor;if(!e.some(({from:r,to:o})=>s>=r&&s<=o)){let{from:r,to:o}=this.lineBlockAt(s);e.push(new Li(r,o))}}this.viewports=e.sort((i,s)=>i.from-s.from),this.scaler=this.heightMap.height<=7e6?po:new Bf(this.heightOracle,this.heightMap,this.viewports)}updateViewportLines(){this.viewportLines=[],this.heightMap.forEachLine(this.viewport.from,this.viewport.to,this.heightOracle.setDoc(this.state.doc),0,0,e=>{this.viewportLines.push(this.scaler.scale==1?e:si(e,this.scaler))})}update(e,t=null){this.state=e.state;let i=this.stateDeco;this.stateDeco=this.state.facet(ui).filter(c=>typeof c!="function");let s=e.changedRanges,r=Le.extendWithRanges(s,vf(i,this.stateDeco,e?e.changes:Z.empty(this.state.doc.length))),o=this.heightMap.height,l=this.scrolledToBottom?null:this.scrollAnchorAt(this.scrollTop);this.heightMap=this.heightMap.applyChanges(this.stateDeco,e.startState.doc,this.heightOracle.setDoc(this.state.doc),r),this.heightMap.height!=o&&(e.flags|=2),l?(this.scrollAnchorPos=e.changes.mapPos(l.from,-1),this.scrollAnchorHeight=l.top):(this.scrollAnchorPos=-1,this.scrollAnchorHeight=this.heightMap.height);let a=r.length?this.mapViewport(this.viewport,e.changes):this.viewport;(t&&(t.range.heada.to)||!this.viewportIsAppropriate(a))&&(a=this.getViewport(0,t));let h=!e.changes.empty||e.flags&2||a.from!=this.viewport.from||a.to!=this.viewport.to;this.viewport=a,this.updateForViewport(),h&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(this.mapLineGaps(this.lineGaps,e.changes))),e.flags|=this.computeVisibleRanges(),t&&(this.scrollTarget=t),!this.mustEnforceCursorAssoc&&e.selectionSet&&e.view.lineWrapping&&e.state.selection.main.empty&&e.state.selection.main.assoc&&!e.state.facet(Jl)&&(this.mustEnforceCursorAssoc=!0)}measure(e){let t=e.contentDOM,i=window.getComputedStyle(t),s=this.heightOracle,r=i.whiteSpace;this.defaultTextDirection=i.direction=="rtl"?J.RTL:J.LTR;let o=this.heightOracle.mustRefreshForWrapping(r),l=t.getBoundingClientRect(),a=o||this.mustMeasureContent||this.contentDOMHeight!=l.height;this.contentDOMHeight=l.height,this.mustMeasureContent=!1;let h=0,c=0,f=parseInt(i.paddingTop)||0,u=parseInt(i.paddingBottom)||0;(this.paddingTop!=f||this.paddingBottom!=u)&&(this.paddingTop=f,this.paddingBottom=u,h|=10),this.editorWidth!=e.scrollDOM.clientWidth&&(s.lineWrapping&&(a=!0),this.editorWidth=e.scrollDOM.clientWidth,h|=8),this.scrollTop!=e.scrollDOM.scrollTop&&(this.scrollAnchorHeight=-1,this.scrollTop=e.scrollDOM.scrollTop),this.scrolledToBottom=Bl(e.scrollDOM);let d=(this.printing?Mf:Af)(t,this.paddingTop),p=d.top-this.pixelViewport.top,g=d.bottom-this.pixelViewport.bottom;this.pixelViewport=d;let m=this.pixelViewport.bottom>this.pixelViewport.top&&this.pixelViewport.right>this.pixelViewport.left;if(m!=this.inView&&(this.inView=m,m&&(a=!0)),!this.inView&&!this.scrollTarget)return 0;let y=l.width;if((this.contentDOMWidth!=y||this.editorHeight!=e.scrollDOM.clientHeight)&&(this.contentDOMWidth=l.width,this.editorHeight=e.scrollDOM.clientHeight,h|=8),a){let M=e.docView.measureVisibleLineHeights(this.viewport);if(s.mustRefreshForHeights(M)&&(o=!0),o||s.lineWrapping&&Math.abs(y-this.contentDOMWidth)>s.charWidth){let{lineHeight:x,charWidth:w,textHeight:k}=e.docView.measureTextSize();o=x>0&&s.refresh(r,x,w,k,y/w,M),o&&(e.docView.minWidth=0,h|=8)}p>0&&g>0?c=Math.max(p,g):p<0&&g<0&&(c=Math.min(p,g)),s.heightChanged=!1;for(let x of this.viewports){let w=x.from==this.viewport.from?M:e.docView.measureVisibleLineHeights(x);this.heightMap=(o?pe.empty().applyChanges(this.stateDeco,I.empty,this.heightOracle,[new Le(0,0,0,e.state.doc.length)]):this.heightMap).updateHeight(s,0,o,new wf(x.from,w))}s.heightChanged&&(h|=2)}let S=!this.viewportIsAppropriate(this.viewport,c)||this.scrollTarget&&(this.scrollTarget.range.headthis.viewport.to);return S&&(this.viewport=this.getViewport(c,this.scrollTarget)),this.updateForViewport(),(h&2||S)&&this.updateViewportLines(),(this.lineGaps.length||this.viewport.to-this.viewport.from>4e3)&&this.updateLineGaps(this.ensureLineGaps(o?[]:this.lineGaps,e)),h|=this.computeVisibleRanges(),this.mustEnforceCursorAssoc&&(this.mustEnforceCursorAssoc=!1,e.docView.enforceCursorAssoc()),h}get visibleTop(){return this.scaler.fromDOM(this.pixelViewport.top)}get visibleBottom(){return this.scaler.fromDOM(this.pixelViewport.bottom)}getViewport(e,t){let i=.5-Math.max(-.5,Math.min(.5,e/1e3/2)),s=this.heightMap,r=this.heightOracle,{visibleTop:o,visibleBottom:l}=this,a=new Li(s.lineAt(o-i*1e3,q.ByHeight,r,0,0).from,s.lineAt(l+(1-i)*1e3,q.ByHeight,r,0,0).to);if(t){let{head:h}=t.range;if(ha.to){let c=Math.min(this.editorHeight,this.pixelViewport.bottom-this.pixelViewport.top),f=s.lineAt(h,q.ByPos,r,0,0),u;t.y=="center"?u=(f.top+f.bottom)/2-c/2:t.y=="start"||t.y=="nearest"&&h=l+Math.max(10,Math.min(i,250)))&&s>o-2*1e3&&r>1,o=s<<1;if(this.defaultTextDirection!=J.LTR&&!i)return[];let l=[],a=(h,c,f,u)=>{if(c-hh&&mm.from>=f.from&&m.to<=f.to&&Math.abs(m.from-h)m.fromy));if(!g){if(cm.from<=c&&m.to>=c)){let m=t.moveToLineBoundary(b.cursor(c),!1,!0).head;m>h&&(c=m)}g=new $n(h,c,this.gapSize(f,h,c,u))}l.push(g)};for(let h of this.viewportLines){if(h.lengthh.from&&a(h.from,u,h,c),dt.draw(this.heightOracle.lineWrapping))))}computeVisibleRanges(){let e=this.stateDeco;this.lineGaps.length&&(e=e.concat(this.lineGapDeco));let t=[];K.spans(e,this.viewport.from,this.viewport.to,{span(s,r){t.push({from:s,to:r})},point(){}},20);let i=t.length!=this.visibleRanges.length||this.visibleRanges.some((s,r)=>s.from!=t[r].from||s.to!=t[r].to);return this.visibleRanges=t,i?4:0}lineBlockAt(e){return e>=this.viewport.from&&e<=this.viewport.to&&this.viewportLines.find(t=>t.from<=e&&t.to>=e)||si(this.heightMap.lineAt(e,q.ByPos,this.heightOracle,0,0),this.scaler)}lineBlockAtHeight(e){return si(this.heightMap.lineAt(this.scaler.fromDOM(e),q.ByHeight,this.heightOracle,0,0),this.scaler)}scrollAnchorAt(e){let t=this.lineBlockAtHeight(e+8);return t.from>=this.viewport.from||this.viewportLines[0].top-e>200?t:this.viewportLines[0]}elementAtHeight(e){return si(this.heightMap.blockAt(this.scaler.fromDOM(e),this.heightOracle,0,0),this.scaler)}get docHeight(){return this.scaler.toDOM(this.heightMap.height)}get contentHeight(){return this.docHeight+this.paddingTop+this.paddingBottom}}class Li{constructor(e,t){this.from=e,this.to=t}}function Of(n,e,t){let i=[],s=n,r=0;return K.spans(t,n,e,{span(){},point(o,l){o>s&&(i.push({from:s,to:o}),r+=o-s),s=l}},20),s=1)return e[e.length-1].to;let i=Math.floor(n*t);for(let s=0;;s++){let{from:r,to:o}=e[s],l=o-r;if(i<=l)return r+i;i-=l}}function Ei(n,e){let t=0;for(let{from:i,to:s}of n.ranges){if(e<=s){t+=e-i;break}t+=s-i}return t/n.total}function Tf(n,e){for(let t of n)if(e(t))return t}const po={toDOM(n){return n},fromDOM(n){return n},scale:1};class Bf{constructor(e,t,i){let s=0,r=0,o=0;this.viewports=i.map(({from:l,to:a})=>{let h=t.lineAt(l,q.ByPos,e,0,0).top,c=t.lineAt(a,q.ByPos,e,0,0).bottom;return s+=c-h,{from:l,to:a,top:h,bottom:c,domTop:0,domBottom:0}}),this.scale=(7e6-s)/(t.height-s);for(let l of this.viewports)l.domTop=o+(l.top-r)*this.scale,o=l.domBottom=l.domTop+(l.bottom-l.top),r=l.bottom}toDOM(e){for(let t=0,i=0,s=0;;t++){let r=tsi(s,e)):n._content)}const Ii=D.define({combine:n=>n.join(" ")}),Ps=D.define({combine:n=>n.indexOf(!0)>-1}),Ls=lt.newName(),ya=lt.newName(),ba=lt.newName(),xa={"&light":"."+ya,"&dark":"."+ba};function Rs(n,e,t){return new lt(e,{finish(i){return/&/.test(i)?i.replace(/&\w*/,s=>{if(s=="&")return n;if(!t||!t[s])throw new RangeError(`Unsupported selector: ${s}`);return t[s]}):n+" "+i}})}const Pf=Rs("."+Ls,{"&":{position:"relative !important",boxSizing:"border-box","&.cm-focused":{outline:"1px dotted #212121"},display:"flex !important",flexDirection:"column"},".cm-scroller":{display:"flex !important",alignItems:"flex-start !important",fontFamily:"monospace",lineHeight:1.4,height:"100%",overflowX:"auto",position:"relative",zIndex:0},".cm-content":{margin:0,flexGrow:2,flexShrink:0,display:"block",whiteSpace:"pre",wordWrap:"normal",boxSizing:"border-box",padding:"4px 0",outline:"none","&[contenteditable=true]":{WebkitUserModify:"read-write-plaintext-only"}},".cm-lineWrapping":{whiteSpace_fallback:"pre-wrap",whiteSpace:"break-spaces",wordBreak:"break-word",overflowWrap:"anywhere",flexShrink:1},"&light .cm-content":{caretColor:"black"},"&dark .cm-content":{caretColor:"white"},".cm-line":{display:"block",padding:"0 2px 0 6px"},".cm-layer":{position:"absolute",left:0,top:0,contain:"size style","& > *":{position:"absolute"}},"&light .cm-selectionBackground":{background:"#d9d9d9"},"&dark .cm-selectionBackground":{background:"#222"},"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#d7d4f0"},"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":{background:"#233"},".cm-cursorLayer":{pointerEvents:"none"},"&.cm-focused > .cm-scroller > .cm-cursorLayer":{animation:"steps(1) cm-blink 1.2s infinite"},"@keyframes cm-blink":{"0%":{},"50%":{opacity:0},"100%":{}},"@keyframes cm-blink2":{"0%":{},"50%":{opacity:0},"100%":{}},".cm-cursor, .cm-dropCursor":{borderLeft:"1.2px solid black",marginLeft:"-0.6px",pointerEvents:"none"},".cm-cursor":{display:"none"},"&dark .cm-cursor":{borderLeftColor:"#444"},".cm-dropCursor":{position:"absolute"},"&.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor":{display:"block"},"&light .cm-activeLine":{backgroundColor:"#cceeff44"},"&dark .cm-activeLine":{backgroundColor:"#99eeff33"},"&light .cm-specialChar":{color:"red"},"&dark .cm-specialChar":{color:"#f78"},".cm-gutters":{flexShrink:0,display:"flex",height:"100%",boxSizing:"border-box",insetInlineStart:0,zIndex:200},"&light .cm-gutters":{backgroundColor:"#f5f5f5",color:"#6c6c6c",borderRight:"1px solid #ddd"},"&dark .cm-gutters":{backgroundColor:"#333338",color:"#ccc"},".cm-gutter":{display:"flex !important",flexDirection:"column",flexShrink:0,boxSizing:"border-box",minHeight:"100%",overflow:"hidden"},".cm-gutterElement":{boxSizing:"border-box"},".cm-lineNumbers .cm-gutterElement":{padding:"0 3px 0 5px",minWidth:"20px",textAlign:"right",whiteSpace:"nowrap"},"&light .cm-activeLineGutter":{backgroundColor:"#e2f2ff"},"&dark .cm-activeLineGutter":{backgroundColor:"#222227"},".cm-panels":{boxSizing:"border-box",position:"sticky",left:0,right:0},"&light .cm-panels":{backgroundColor:"#f5f5f5",color:"black"},"&light .cm-panels-top":{borderBottom:"1px solid #ddd"},"&light .cm-panels-bottom":{borderTop:"1px solid #ddd"},"&dark .cm-panels":{backgroundColor:"#333338",color:"white"},".cm-tab":{display:"inline-block",overflow:"hidden",verticalAlign:"bottom"},".cm-widgetBuffer":{verticalAlign:"text-top",height:"1em",width:0,display:"inline"},".cm-placeholder":{color:"#888",display:"inline-block",verticalAlign:"top"},".cm-highlightSpace:before":{content:"attr(data-display)",position:"absolute",pointerEvents:"none",color:"#888"},".cm-highlightTab":{backgroundImage:`url('data:image/svg+xml,')`,backgroundSize:"auto 100%",backgroundPosition:"right 90%",backgroundRepeat:"no-repeat"},".cm-trailingSpace":{backgroundColor:"#ff332255"},".cm-button":{verticalAlign:"middle",color:"inherit",fontSize:"70%",padding:".2em 1em",borderRadius:"1px"},"&light .cm-button":{backgroundImage:"linear-gradient(#eff1f5, #d9d9df)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#b4b4b4, #d0d3d6)"}},"&dark .cm-button":{backgroundImage:"linear-gradient(#393939, #111)",border:"1px solid #888","&:active":{backgroundImage:"linear-gradient(#111, #333)"}},".cm-textfield":{verticalAlign:"middle",color:"inherit",fontSize:"70%",border:"1px solid silver",padding:".2em .5em"},"&light .cm-textfield":{backgroundColor:"white"},"&dark .cm-textfield":{border:"1px solid #555",backgroundColor:"inherit"}},xa);class Lf{constructor(e,t,i,s){this.typeOver=s,this.bounds=null,this.text="";let{impreciseHead:r,impreciseAnchor:o}=e.docView;if(e.state.readOnly&&t>-1)this.newSel=null;else if(t>-1&&(this.bounds=e.docView.domBoundsAround(t,i,0))){let l=r||o?[]:If(e),a=new El(l,e.state);a.readRange(this.bounds.startDOM,this.bounds.endDOM),this.text=a.text,this.newSel=Nf(l,this.bounds.from)}else{let l=e.observer.selectionRange,a=r&&r.node==l.focusNode&&r.offset==l.focusOffset||!ms(e.contentDOM,l.focusNode)?e.state.selection.main.head:e.docView.posFromDOM(l.focusNode,l.focusOffset),h=o&&o.node==l.anchorNode&&o.offset==l.anchorOffset||!ms(e.contentDOM,l.anchorNode)?e.state.selection.main.anchor:e.docView.posFromDOM(l.anchorNode,l.anchorOffset);this.newSel=b.single(h,a)}}}function wa(n,e){let t,{newSel:i}=e,s=n.state.selection.main,r=n.inputState.lastKeyTime>Date.now()-100?n.inputState.lastKeyCode:-1;if(e.bounds){let{from:o,to:l}=e.bounds,a=s.from,h=null;(r===8||A.android&&e.text.length=s.from&&t.to<=s.to&&(t.from!=s.from||t.to!=s.to)&&s.to-s.from-(t.to-t.from)<=4?t={from:s.from,to:s.to,insert:n.state.doc.slice(s.from,t.from).append(t.insert).append(n.state.doc.slice(t.to,s.to))}:(A.mac||A.android)&&t&&t.from==t.to&&t.from==s.head-1&&/^\. ?$/.test(t.insert.toString())&&n.contentDOM.getAttribute("autocorrect")=="off"?(i&&t.insert.length==2&&(i=b.single(i.main.anchor-1,i.main.head-1)),t={from:s.from,to:s.to,insert:I.of([" "])}):A.chrome&&t&&t.from==t.to&&t.from==s.head&&t.insert.toString()==` - `&&n.lineWrapping&&(i&&(i=b.single(i.main.anchor-1,i.main.head-1)),t={from:s.from,to:s.to,insert:I.of([" "])}),t){if(A.ios&&n.inputState.flushIOSKey(n)||A.android&&(t.from==s.from&&t.to==s.to&&t.insert.length==1&&t.insert.lines==2&&Ft(n.contentDOM,"Enter",13)||(t.from==s.from-1&&t.to==s.to&&t.insert.length==0||r==8&&t.insert.lengths.head)&&Ft(n.contentDOM,"Backspace",8)||t.from==s.from&&t.to==s.to+1&&t.insert.length==0&&Ft(n.contentDOM,"Delete",46)))return!0;let o=t.insert.toString();n.inputState.composing>=0&&n.inputState.composing++;let l,a=()=>l||(l=Rf(n,t,i));return n.state.facet(jl).some(h=>h(n,t.from,t.to,o,a))||n.dispatch(a()),!0}else if(i&&!i.main.eq(s)){let o=!1,l="select";return n.inputState.lastSelectionTime>Date.now()-50&&(n.inputState.lastSelectionOrigin=="select"&&(o=!0),l=n.inputState.lastSelectionOrigin),n.dispatch({selection:i,scrollIntoView:o,userEvent:l}),!0}else return!1}function Rf(n,e,t){let i,s=n.state,r=s.selection.main;if(e.from>=r.from&&e.to<=r.to&&e.to-e.from>=(r.to-r.from)/3&&(!t||t.main.empty&&t.main.from==e.from+e.insert.length)&&n.inputState.composing<0){let l=r.frome.to?s.sliceDoc(e.to,r.to):"";i=s.replaceSelection(n.state.toText(l+e.insert.sliceString(0,void 0,n.state.lineBreak)+a))}else{let l=s.changes(e),a=t&&t.main.to<=l.newLength?t.main:void 0;if(s.selection.ranges.length>1&&n.inputState.composing>=0&&e.to<=r.to&&e.to>=r.to-10){let h=n.state.sliceDoc(e.from,e.to),c=sa(n,e.insert.length-(e.to-e.from))||n.state.doc.lineAt(r.head),f=r.to-e.to,u=r.to-r.from;i=s.changeByRange(d=>{if(d.from==r.from&&d.to==r.to)return{changes:l,range:a||d.map(l)};let p=d.to-f,g=p-h.length;if(d.to-d.from!=u||n.state.sliceDoc(g,p)!=h||c&&d.to>=c.from&&d.from<=c.to)return{range:d};let m=s.changes({from:g,to:p,insert:e.insert}),y=d.to-r.to;return{changes:m,range:a?b.range(Math.max(0,a.anchor+y),Math.max(0,a.head+y)):d.map(m)}})}else i={changes:l,selection:a&&s.selection.replaceRange(a)}}let o="input.type";return(n.composing||n.inputState.compositionPendingChange&&n.inputState.compositionEndedAt>Date.now()-50)&&(n.inputState.compositionPendingChange=!1,o+=".compose",n.inputState.compositionFirstChange&&(o+=".start",n.inputState.compositionFirstChange=!1)),s.update(i,{userEvent:o,scrollIntoView:!0})}function Ef(n,e,t,i){let s=Math.min(n.length,e.length),r=0;for(;r0&&l>0&&n.charCodeAt(o-1)==e.charCodeAt(l-1);)o--,l--;if(i=="end"){let a=Math.max(0,r-Math.min(o,l));t-=o+a-r}if(o=o?r-t:0;r-=a,l=r+(l-o),o=r}else if(l=l?r-t:0;r-=a,o=r+(o-l),l=r}return{from:r,toA:o,toB:l}}function If(n){let e=[];if(n.root.activeElement!=n.contentDOM)return e;let{anchorNode:t,anchorOffset:i,focusNode:s,focusOffset:r}=n.observer.selectionRange;return t&&(e.push(new Vr(t,i)),(s!=t||r!=i)&&e.push(new Vr(s,r))),e}function Nf(n,e){if(n.length==0)return null;let t=n[0].pos,i=n.length==2?n[1].pos:t;return t>-1&&i>-1?b.single(t+e,i+e):null}const Ff={childList:!0,characterData:!0,subtree:!0,attributes:!0,characterDataOldValue:!0},jn=A.ie&&A.ie_version<=11;class Hf{constructor(e){this.view=e,this.active=!1,this.selectionRange=new Ac,this.selectionChanged=!1,this.delayedFlush=-1,this.resizeTimeout=-1,this.queue=[],this.delayedAndroidKey=null,this.flushingAndroidKey=-1,this.lastChange=0,this.scrollTargets=[],this.intersection=null,this.resizeScroll=null,this.resizeContent=null,this.intersecting=!1,this.gapIntersection=null,this.gaps=[],this.parentCheck=-1,this.dom=e.contentDOM,this.observer=new MutationObserver(t=>{for(let i of t)this.queue.push(i);(A.ie&&A.ie_version<=11||A.ios&&e.composing)&&t.some(i=>i.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():this.flush()}),jn&&(this.onCharData=t=>{this.queue.push({target:t.target,type:"characterData",oldValue:t.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this),this.onResize=this.onResize.bind(this),this.onPrint=this.onPrint.bind(this),this.onScroll=this.onScroll.bind(this),typeof ResizeObserver=="function"&&(this.resizeScroll=new ResizeObserver(()=>{var t;((t=this.view.docView)===null||t===void 0?void 0:t.lastUpdate)this.view.requestMeasure()),this.resizeContent.observe(e.contentDOM)),this.addWindowListeners(this.win=e.win),this.start(),typeof IntersectionObserver=="function"&&(this.intersection=new IntersectionObserver(t=>{this.parentCheck<0&&(this.parentCheck=setTimeout(this.listenForScroll.bind(this),1e3)),t.length>0&&t[t.length-1].intersectionRatio>0!=this.intersecting&&(this.intersecting=!this.intersecting,this.intersecting!=this.view.inView&&this.onScrollChanged(document.createEvent("Event")))},{threshold:[0,.001]}),this.intersection.observe(this.dom),this.gapIntersection=new IntersectionObserver(t=>{t.length>0&&t[t.length-1].intersectionRatio>0&&this.onScrollChanged(document.createEvent("Event"))},{})),this.listenForScroll(),this.readSelectionRange()}onScrollChanged(e){this.view.inputState.runScrollHandlers(this.view,e),this.intersecting&&this.view.measure()}onScroll(e){this.intersecting&&this.flush(!1),this.onScrollChanged(e)}onResize(){this.resizeTimeout<0&&(this.resizeTimeout=setTimeout(()=>{this.resizeTimeout=-1,this.view.requestMeasure()},50))}onPrint(){this.view.viewState.printing=!0,this.view.measure(),setTimeout(()=>{this.view.viewState.printing=!1,this.view.requestMeasure()},500)}updateGaps(e){if(this.gapIntersection&&(e.length!=this.gaps.length||this.gaps.some((t,i)=>t!=e[i]))){this.gapIntersection.disconnect();for(let t of e)this.gapIntersection.observe(t);this.gaps=e}}onSelectionChange(e){let t=this.selectionChanged;if(!this.readSelectionRange()||this.delayedAndroidKey)return;let{view:i}=this,s=this.selectionRange;if(i.state.facet(On)?i.root.activeElement!=this.dom:!_i(i.dom,s))return;let r=s.anchorNode&&i.docView.nearest(s.anchorNode);if(r&&r.ignoreEvent(e)){t||(this.selectionChanged=!1);return}(A.ie&&A.ie_version<=11||A.android&&A.chrome)&&!i.state.selection.main.empty&&s.focusNode&&rn(s.focusNode,s.focusOffset,s.anchorNode,s.anchorOffset)?this.flushSoon():this.flush(!1)}readSelectionRange(){let{view:e}=this,t=A.safari&&e.root.nodeType==11&&kc(this.dom.ownerDocument)==this.dom&&Wf(this.view)||sn(e.root);if(!t||this.selectionRange.eq(t))return!1;let i=_i(this.dom,t);return i&&!this.selectionChanged&&e.inputState.lastFocusTime>Date.now()-200&&e.inputState.lastTouchTime{let r=this.delayedAndroidKey;r&&(this.clearDelayedAndroidKey(),this.view.inputState.lastKeyCode=r.keyCode,this.view.inputState.lastKeyTime=Date.now(),!this.flush()&&r.force&&Ft(this.dom,r.key,r.keyCode))};this.flushingAndroidKey=this.view.win.requestAnimationFrame(s)}(!this.delayedAndroidKey||e=="Enter")&&(this.delayedAndroidKey={key:e,keyCode:t,force:this.lastChange{this.delayedFlush=-1,this.flush()}))}forceFlush(){this.delayedFlush>=0&&(this.view.win.cancelAnimationFrame(this.delayedFlush),this.delayedFlush=-1),this.flush()}pendingRecords(){for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}processRecords(){let e=this.pendingRecords();e.length&&(this.queue=[]);let t=-1,i=-1,s=!1;for(let r of e){let o=this.readMutation(r);o&&(o.typeOver&&(s=!0),t==-1?{from:t,to:i}=o:(t=Math.min(o.from,t),i=Math.max(o.to,i)))}return{from:t,to:i,typeOver:s}}readChange(){let{from:e,to:t,typeOver:i}=this.processRecords(),s=this.selectionChanged&&_i(this.dom,this.selectionRange);return e<0&&!s?null:(e>-1&&(this.lastChange=Date.now()),this.view.inputState.lastFocusTime=0,this.selectionChanged=!1,new Lf(this.view,e,t,i))}flush(e=!0){if(this.delayedFlush>=0||this.delayedAndroidKey)return!1;e&&this.readSelectionRange();let t=this.readChange();if(!t)return!1;let i=this.view.state,s=wa(this.view,t);return this.view.state==i&&this.view.update([]),s}readMutation(e){let t=this.view.docView.nearest(e.target);if(!t||t.ignoreMutation(e))return null;if(t.markDirty(e.type=="attributes"),e.type=="attributes"&&(t.flags|=4),e.type=="childList"){let i=go(t,e.previousSibling||e.target.previousSibling,-1),s=go(t,e.nextSibling||e.target.nextSibling,1);return{from:i?t.posAfter(i):t.posAtStart,to:s?t.posBefore(s):t.posAtEnd,typeOver:!1}}else return e.type=="characterData"?{from:t.posAtStart,to:t.posAtEnd,typeOver:e.target.nodeValue==e.oldValue}:null}setWindow(e){e!=this.win&&(this.removeWindowListeners(this.win),this.win=e,this.addWindowListeners(this.win))}addWindowListeners(e){e.addEventListener("resize",this.onResize),e.addEventListener("beforeprint",this.onPrint),e.addEventListener("scroll",this.onScroll),e.document.addEventListener("selectionchange",this.onSelectionChange)}removeWindowListeners(e){e.removeEventListener("scroll",this.onScroll),e.removeEventListener("resize",this.onResize),e.removeEventListener("beforeprint",this.onPrint),e.document.removeEventListener("selectionchange",this.onSelectionChange)}destroy(){var e,t,i,s;this.stop(),(e=this.intersection)===null||e===void 0||e.disconnect(),(t=this.gapIntersection)===null||t===void 0||t.disconnect(),(i=this.resizeScroll)===null||i===void 0||i.disconnect(),(s=this.resizeContent)===null||s===void 0||s.disconnect();for(let r of this.scrollTargets)r.removeEventListener("scroll",this.onScroll);this.removeWindowListeners(this.win),clearTimeout(this.parentCheck),clearTimeout(this.resizeTimeout),this.win.cancelAnimationFrame(this.delayedFlush),this.win.cancelAnimationFrame(this.flushingAndroidKey)}}function go(n,e,t){for(;e;){let i=V.get(e);if(i&&i.parent==n)return i;let s=e.parentNode;e=s!=n.dom?s:t>0?e.nextSibling:e.previousSibling}return null}function Wf(n){let e=null;function t(a){a.preventDefault(),a.stopImmediatePropagation(),e=a.getTargetRanges()[0]}if(n.contentDOM.addEventListener("beforeinput",t,!0),n.dom.ownerDocument.execCommand("indent"),n.contentDOM.removeEventListener("beforeinput",t,!0),!e)return null;let i=e.startContainer,s=e.startOffset,r=e.endContainer,o=e.endOffset,l=n.docView.domAtPos(n.state.selection.main.anchor);return rn(l.node,l.offset,r,o)&&([i,s,r,o]=[r,o,i,s]),{anchorNode:i,anchorOffset:s,focusNode:r,focusOffset:o}}class O{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return this.inputState.composing>0}get compositionStarted(){return this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(e={}){this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.contentDOM=document.createElement("div"),this.scrollDOM=document.createElement("div"),this.scrollDOM.tabIndex=-1,this.scrollDOM.className="cm-scroller",this.scrollDOM.appendChild(this.contentDOM),this.announceDOM=document.createElement("div"),this.announceDOM.style.cssText="position: fixed; top: -10000px",this.announceDOM.setAttribute("aria-live","polite"),this.dom=document.createElement("div"),this.dom.appendChild(this.announceDOM),this.dom.appendChild(this.scrollDOM);let{dispatch:t}=e;this.dispatchTransactions=e.dispatchTransactions||t&&(i=>i.forEach(s=>t(s,this)))||(i=>this.update(i)),this.dispatch=this.dispatch.bind(this),this._root=e.root||Mc(e.parent)||document,this.viewState=new uo(e.state||N.create(e)),this.plugins=this.state.facet(ii).map(i=>new zn(i));for(let i of this.plugins)i.update(this);this.observer=new Hf(this),this.inputState=new sf(this),this.inputState.ensureHandlers(this,this.plugins),this.docView=new Jr(this),this.mountStyles(),this.updateAttrs(),this.updateState=0,this.requestMeasure(),e.parent&&e.parent.appendChild(this.dom)}dispatch(...e){let t=e.length==1&&e[0]instanceof ee?e:e.length==1&&Array.isArray(e[0])?e[0]:[this.state.update(...e)];this.dispatchTransactions(t,this)}update(e){if(this.updateState!=0)throw new Error("Calls to EditorView.update are not allowed while an update is in progress");let t=!1,i=!1,s,r=this.state;for(let u of e){if(u.startState!=r)throw new RangeError("Trying to update state with a transaction that doesn't start from the previous state.");r=u.state}if(this.destroyed){this.viewState.state=r;return}let o=this.hasFocus,l=0,a=null;e.some(u=>u.annotation(da))?(this.inputState.notifiedFocused=o,l=1):o!=this.inputState.notifiedFocused&&(this.inputState.notifiedFocused=o,a=pa(r,o),a||(l=1));let h=this.observer.delayedAndroidKey,c=null;if(h?(this.observer.clearDelayedAndroidKey(),c=this.observer.readChange(),(c&&!this.state.doc.eq(r.doc)||!this.state.selection.eq(r.selection))&&(c=null)):this.observer.clear(),r.facet(N.phrases)!=this.state.facet(N.phrases))return this.setState(r);s=an.create(this,r,e),s.flags|=l;let f=this.viewState.scrollTarget;try{this.updateState=2;for(let u of e){if(f&&(f=f.map(u.changes)),u.scrollIntoView){let{main:d}=u.state.selection;f=new ln(d.empty?d:b.cursor(d.head,d.head>d.anchor?-1:1))}for(let d of u.effects)d.is(Ur)&&(f=d.value)}this.viewState.update(s,f),this.bidiCache=hn.update(this.bidiCache,s.changes),s.empty||(this.updatePlugins(s),this.inputState.update(s)),t=this.docView.update(s),this.state.facet(ni)!=this.styleModules&&this.mountStyles(),i=this.updateAttrs(),this.showAnnouncements(e),this.docView.updateSelection(t,e.some(u=>u.isUserEvent("select.pointer")))}finally{this.updateState=0}if(s.startState.facet(Ii)!=s.state.facet(Ii)&&(this.viewState.mustMeasureContent=!0),(t||i||f||this.viewState.mustEnforceCursorAssoc||this.viewState.mustMeasureContent)&&this.requestMeasure(),!s.empty)for(let u of this.state.facet(vs))u(s);(a||c)&&Promise.resolve().then(()=>{a&&this.state==a.startState&&this.dispatch(a),c&&!wa(this,c)&&h.force&&Ft(this.contentDOM,h.key,h.keyCode)})}setState(e){if(this.updateState!=0)throw new Error("Calls to EditorView.setState are not allowed while an update is in progress");if(this.destroyed){this.viewState.state=e;return}this.updateState=2;let t=this.hasFocus;try{for(let i of this.plugins)i.destroy(this);this.viewState=new uo(e),this.plugins=e.facet(ii).map(i=>new zn(i)),this.pluginMap.clear();for(let i of this.plugins)i.update(this);this.docView=new Jr(this),this.inputState.ensureHandlers(this,this.plugins),this.mountStyles(),this.updateAttrs(),this.bidiCache=[]}finally{this.updateState=0}t&&this.focus(),this.requestMeasure()}updatePlugins(e){let t=e.startState.facet(ii),i=e.state.facet(ii);if(t!=i){let s=[];for(let r of i){let o=t.indexOf(r);if(o<0)s.push(new zn(r));else{let l=this.plugins[o];l.mustUpdate=e,s.push(l)}}for(let r of this.plugins)r.mustUpdate!=e&&r.destroy(this);this.plugins=s,this.pluginMap.clear(),this.inputState.ensureHandlers(this,this.plugins)}else for(let s of this.plugins)s.mustUpdate=e;for(let s=0;s-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.observer.delayedAndroidKey){this.measureScheduled=-1,this.requestMeasure();return}this.measureScheduled=0,e&&this.observer.forceFlush();let t=null,i=this.scrollDOM,{scrollTop:s}=i,{scrollAnchorPos:r,scrollAnchorHeight:o}=this.viewState;s!=this.viewState.scrollTop&&(o=-1),this.viewState.scrollAnchorHeight=-1;try{for(let l=0;;l++){if(o<0)if(Bl(i))r=-1,o=this.viewState.heightMap.height;else{let d=this.viewState.scrollAnchorAt(s);r=d.from,o=d.top}this.updateState=1;let a=this.viewState.measure(this);if(!a&&!this.measureRequests.length&&this.viewState.scrollTarget==null)break;if(l>5){console.warn(this.measureRequests.length?"Measure loop restarted more than 5 times":"Viewport failed to stabilize");break}let h=[];a&4||([this.measureRequests,h]=[h,this.measureRequests]);let c=h.map(d=>{try{return d.read(this)}catch(p){return Ie(this.state,p),mo}}),f=an.create(this,this.state,[]),u=!1;f.flags|=a,t?t.flags|=a:t=f,this.updateState=2,f.empty||(this.updatePlugins(f),this.inputState.update(f),this.updateAttrs(),u=this.docView.update(f));for(let d=0;d1||p<-1){s=i.scrollTop=s+p,o=-1;continue}}break}}}finally{this.updateState=0,this.measureScheduled=-1}if(t&&!t.empty)for(let l of this.state.facet(vs))l(t)}get themeClasses(){return Ls+" "+(this.state.facet(Ps)?ba:ya)+" "+this.state.facet(Ii)}updateAttrs(){let e=yo(this,_l,{class:"cm-editor"+(this.hasFocus?" cm-focused ":" ")+this.themeClasses}),t={spellcheck:"false",autocorrect:"off",autocapitalize:"off",translate:"no",contenteditable:this.state.facet(On)?"true":"false",class:"cm-content",style:`${A.tabSize}: ${this.state.tabSize}`,role:"textbox","aria-multiline":"true"};this.state.readOnly&&(t["aria-readonly"]="true"),yo(this,tr,t);let i=this.observer.ignore(()=>{let s=ks(this.contentDOM,this.contentAttrs,t),r=ks(this.dom,this.editorAttrs,e);return s||r});return this.editorAttrs=e,this.contentAttrs=t,i}showAnnouncements(e){let t=!0;for(let i of e)for(let s of i.effects)if(s.is(O.announce)){t&&(this.announceDOM.textContent=""),t=!1;let r=this.announceDOM.appendChild(document.createElement("div"));r.textContent=s.value}}mountStyles(){this.styleModules=this.state.facet(ni);let e=this.state.facet(O.cspNonce);lt.mount(this.root,this.styleModules.concat(Pf).reverse(),e?{nonce:e}:void 0)}readMeasured(){if(this.updateState==2)throw new Error("Reading the editor layout isn't allowed during an update");this.updateState==0&&this.measureScheduled>-1&&this.measure(!1)}requestMeasure(e){if(this.measureScheduled<0&&(this.measureScheduled=this.win.requestAnimationFrame(()=>this.measure())),e){if(this.measureRequests.indexOf(e)>-1)return;if(e.key!=null){for(let t=0;ti.spec==e)||null),t&&t.update(this).value}get documentTop(){return this.contentDOM.getBoundingClientRect().top+this.viewState.paddingTop}get documentPadding(){return{top:this.viewState.paddingTop,bottom:this.viewState.paddingBottom}}elementAtHeight(e){return this.readMeasured(),this.viewState.elementAtHeight(e)}lineBlockAtHeight(e){return this.readMeasured(),this.viewState.lineBlockAtHeight(e)}get viewportLineBlocks(){return this.viewState.viewportLines}lineBlockAt(e){return this.viewState.lineBlockAt(e)}get contentHeight(){return this.viewState.contentHeight}moveByChar(e,t,i){return Kn(this,e,eo(this,e,t,i))}moveByGroup(e,t){return Kn(this,e,eo(this,e,t,i=>tf(this,e.head,i)))}moveToLineBoundary(e,t,i=!0){return ef(this,e,t,i)}moveVertically(e,t,i){return Kn(this,e,nf(this,e,t,i))}domAtPos(e){return this.docView.domAtPos(e)}posAtDOM(e,t=0){return this.docView.posFromDOM(e,t)}posAtCoords(e,t=!0){return this.readMeasured(),oa(this,e,t)}coordsAtPos(e,t=1){this.readMeasured();let i=this.docView.coordsAt(e,t);if(!i||i.left==i.right)return i;let s=this.state.doc.lineAt(e),r=this.bidiSpans(s),o=r[st.find(r,e-s.from,-1,t)];return Mn(i,o.dir==J.LTR==t>0)}coordsForChar(e){return this.readMeasured(),this.docView.coordsForChar(e)}get defaultCharacterWidth(){return this.viewState.heightOracle.charWidth}get defaultLineHeight(){return this.viewState.heightOracle.lineHeight}get textDirection(){return this.viewState.defaultTextDirection}textDirectionAt(e){return!this.state.facet(Gl)||ethis.viewport.to?this.textDirection:(this.readMeasured(),this.docView.textDirectionAt(e))}get lineWrapping(){return this.viewState.heightOracle.lineWrapping}bidiSpans(e){if(e.length>Vf)return ia(e.length);let t=this.textDirectionAt(e.from),i;for(let r of this.bidiCache)if(r.from==e.from&&r.dir==t&&(r.fresh||ta(r.isolates,i=Gr(this,e.from,e.to))))return r.order;i||(i=Gr(this,e.from,e.to));let s=zc(e.text,t,i);return this.bidiCache.push(new hn(e.from,e.to,t,i,!0,s)),s}get hasFocus(){var e;return(this.dom.ownerDocument.hasFocus()||A.safari&&((e=this.inputState)===null||e===void 0?void 0:e.lastContextMenu)>Date.now()-3e4)&&this.root.activeElement==this.contentDOM}focus(){this.observer.ignore(()=>{Ol(this.contentDOM),this.docView.updateSelection()})}setRoot(e){this._root!=e&&(this._root=e,this.observer.setWindow((e.nodeType==9?e:e.ownerDocument).defaultView||window),this.mountStyles())}destroy(){for(let e of this.plugins)e.destroy(this);this.plugins=[],this.inputState.destroy(),this.dom.remove(),this.observer.destroy(),this.measureScheduled>-1&&this.win.cancelAnimationFrame(this.measureScheduled),this.destroyed=!0}static scrollIntoView(e,t={}){return Ur.of(new ln(typeof e=="number"?b.cursor(e):e,t.y,t.x,t.yMargin,t.xMargin))}static domEventHandlers(e){return ge.define(()=>({}),{eventHandlers:e})}static theme(e,t){let i=lt.newName(),s=[Ii.of(i),ni.of(Rs(`.${i}`,e))];return t&&t.dark&&s.push(Ps.of(!0)),s}static baseTheme(e){return At.lowest(ni.of(Rs("."+Ls,e,xa)))}static findFromDOM(e){var t;let i=e.querySelector(".cm-content"),s=i&&V.get(i)||V.get(e);return((t=s==null?void 0:s.rootView)===null||t===void 0?void 0:t.view)||null}}O.styleModule=ni;O.inputHandler=jl;O.focusChangeEffect=Ul;O.perLineTextDirection=Gl;O.exceptionSink=$l;O.updateListener=vs;O.editable=On;O.mouseSelectionStyle=Kl;O.dragMovesSelection=ql;O.clickAddsSelectionRange=zl;O.decorations=ui;O.atomicRanges=ir;O.bidiIsolatedRanges=Xl;O.scrollMargins=Yl;O.darkTheme=Ps;O.cspNonce=D.define({combine:n=>n.length?n[0]:""});O.contentAttributes=tr;O.editorAttributes=_l;O.lineWrapping=O.contentAttributes.of({class:"cm-lineWrapping"});O.announce=E.define();const Vf=4096,mo={};class hn{constructor(e,t,i,s,r,o){this.from=e,this.to=t,this.dir=i,this.isolates=s,this.fresh=r,this.order=o}static update(e,t){if(t.empty&&!e.some(r=>r.fresh))return e;let i=[],s=e.length?e[e.length-1].dir:J.LTR;for(let r=Math.max(0,e.length-10);r=0;s--){let r=i[s],o=typeof r=="function"?r(n):r;o&&ws(o,t)}return t}const zf=A.mac?"mac":A.windows?"win":A.linux?"linux":"key";function qf(n,e){const t=n.split(/-(?!$)/);let i=t[t.length-1];i=="Space"&&(i=" ");let s,r,o,l;for(let a=0;ai.concat(s),[]))),t}function $f(n,e,t){return Sa(ka(n.state),e,n,t)}let it=null;const jf=4e3;function Uf(n,e=zf){let t=Object.create(null),i=Object.create(null),s=(o,l)=>{let a=i[o];if(a==null)i[o]=l;else if(a!=l)throw new Error("Key binding "+o+" is used both as a regular binding and as a multi-stroke prefix")},r=(o,l,a,h,c)=>{var f,u;let d=t[o]||(t[o]=Object.create(null)),p=l.split(/ (?!$)/).map(y=>qf(y,e));for(let y=1;y{let x=it={view:M,prefix:S,scope:o};return setTimeout(()=>{it==x&&(it=null)},jf),!0}]})}let g=p.join(" ");s(g,!1);let m=d[g]||(d[g]={preventDefault:!1,stopPropagation:!1,run:((u=(f=d._any)===null||f===void 0?void 0:f.run)===null||u===void 0?void 0:u.slice())||[]});a&&m.run.push(a),h&&(m.preventDefault=!0),c&&(m.stopPropagation=!0)};for(let o of n){let l=o.scope?o.scope.split(" "):["editor"];if(o.any)for(let h of l){let c=t[h]||(t[h]=Object.create(null));c._any||(c._any={preventDefault:!1,stopPropagation:!1,run:[]});for(let f in c)c[f].run.push(o.any)}let a=o[e]||o.key;if(a)for(let h of l)r(h,a,o.run,o.preventDefault,o.stopPropagation),o.shift&&r(h,"Shift-"+a,o.shift,o.preventDefault,o.stopPropagation)}return t}function Sa(n,e,t,i){let s=wc(e),r=ne(s,0),o=Oe(r)==s.length&&s!=" ",l="",a=!1,h=!1,c=!1;it&&it.view==t&&it.scope==i&&(l=it.prefix+" ",aa.indexOf(e.keyCode)<0&&(h=!0,it=null));let f=new Set,u=m=>{if(m){for(let y of m.run)if(!f.has(y)&&(f.add(y),y(t,e)))return m.stopPropagation&&(c=!0),!0;m.preventDefault&&(m.stopPropagation&&(c=!0),h=!0)}return!1},d=n[i],p,g;return d&&(u(d[l+Ni(s,e,!o)])?a=!0:o&&(e.altKey||e.metaKey||e.ctrlKey)&&!(A.windows&&e.ctrlKey&&e.altKey)&&(p=at[e.keyCode])&&p!=s?(u(d[l+Ni(p,e,!0)])||e.shiftKey&&(g=fi[e.keyCode])!=s&&g!=p&&u(d[l+Ni(g,e,!1)]))&&(a=!0):o&&e.shiftKey&&u(d[l+Ni(s,e,!0)])&&(a=!0),!a&&u(d._any)&&(a=!0)),h&&(a=!0),a&&c&&e.stopPropagation(),a}class vi{constructor(e,t,i,s,r){this.className=e,this.left=t,this.top=i,this.width=s,this.height=r}draw(){let e=document.createElement("div");return e.className=this.className,this.adjust(e),e}update(e,t){return t.className!=this.className?!1:(this.adjust(e),!0)}adjust(e){e.style.left=this.left+"px",e.style.top=this.top+"px",this.width!=null&&(e.style.width=this.width+"px"),e.style.height=this.height+"px"}eq(e){return this.left==e.left&&this.top==e.top&&this.width==e.width&&this.height==e.height&&this.className==e.className}static forRange(e,t,i){if(i.empty){let s=e.coordsAtPos(i.head,i.assoc||1);if(!s)return[];let r=va(e);return[new vi(t,s.left-r.left,s.top-r.top,null,s.bottom-s.top)]}else return Gf(e,t,i)}}function va(n){let e=n.scrollDOM.getBoundingClientRect();return{left:(n.textDirection==J.LTR?e.left:e.right-n.scrollDOM.clientWidth)-n.scrollDOM.scrollLeft,top:e.top-n.scrollDOM.scrollTop}}function xo(n,e,t){let i=b.cursor(e);return{from:Math.max(t.from,n.moveToLineBoundary(i,!1,!0).from),to:Math.min(t.to,n.moveToLineBoundary(i,!0,!0).from),type:_.Text}}function Gf(n,e,t){if(t.to<=n.viewport.from||t.from>=n.viewport.to)return[];let i=Math.max(t.from,n.viewport.from),s=Math.min(t.to,n.viewport.to),r=n.textDirection==J.LTR,o=n.contentDOM,l=o.getBoundingClientRect(),a=va(n),h=o.querySelector(".cm-line"),c=h&&window.getComputedStyle(h),f=l.left+(c?parseInt(c.paddingLeft)+Math.min(0,parseInt(c.textIndent)):0),u=l.right-(c?parseInt(c.paddingRight):0),d=Os(n,i),p=Os(n,s),g=d.type==_.Text?d:null,m=p.type==_.Text?p:null;if(g&&(n.lineWrapping||d.widgetLineBreaks)&&(g=xo(n,i,g)),m&&(n.lineWrapping||p.widgetLineBreaks)&&(m=xo(n,s,m)),g&&m&&g.from==m.from)return S(M(t.from,t.to,g));{let w=g?M(t.from,null,g):x(d,!1),k=m?M(null,t.to,m):x(p,!0),T=[];return(g||d).to<(m||p).from-(g&&m?1:0)||d.widgetLineBreaks>1&&w.bottom+n.defaultLineHeight/2Y&&xe.from=oe)break;Q>te&&z(Math.max(U,te),w==null&&U<=Y,Math.min(Q,oe),k==null&&Q>=re,we.dir)}if(te=Fe.to+1,te>=oe)break}return R.length==0&&z(Y,w==null,re,k==null,n.textDirection),{top:H,bottom:P,horizontal:R}}function x(w,k){let T=l.top+(k?w.top:w.bottom);return{top:T,bottom:T,horizontal:[]}}}function Jf(n,e){return n.constructor==e.constructor&&n.eq(e)}class _f{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(Qi)!=e.state.facet(Qi)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&e.view.requestMeasure(this.measureReq)}setOrder(e){let t=0,i=e.facet(Qi);for(;t!Jf(t,this.drawn[i]))){let t=this.dom.firstChild,i=0;for(let s of e)s.update&&t&&s.constructor&&this.drawn[i].constructor&&s.update(t,this.drawn[i])?(t=t.nextSibling,i++):this.dom.insertBefore(s.draw(),t);for(;t;){let s=t.nextSibling;t.remove(),t=s}this.drawn=e}}destroy(){this.layer.destroy&&this.layer.destroy(this.dom,this.view),this.dom.remove()}}const Qi=D.define();function Ca(n){return[ge.define(e=>new _f(e,n)),Qi.of(n)]}const Aa=!A.ios,pi=D.define({combine(n){return Mt(n,{cursorBlinkRate:1200,drawRangeCursor:!0},{cursorBlinkRate:(e,t)=>Math.min(e,t),drawRangeCursor:(e,t)=>e||t})}});function zg(n={}){return[pi.of(n),Xf,Yf,Qf,Jl.of(!0)]}function Ma(n){return n.startState.facet(pi)!=n.state.facet(pi)}const Xf=Ca({above:!0,markers(n){let{state:e}=n,t=e.facet(pi),i=[];for(let s of e.selection.ranges){let r=s==e.selection.main;if(s.empty?!r||Aa:t.drawRangeCursor){let o=r?"cm-cursor cm-cursor-primary":"cm-cursor cm-cursor-secondary",l=s.empty?s:b.cursor(s.head,s.head>s.anchor?-1:1);for(let a of vi.forRange(n,o,l))i.push(a)}}return i},update(n,e){n.transactions.some(i=>i.selection)&&(e.style.animationName=e.style.animationName=="cm-blink"?"cm-blink2":"cm-blink");let t=Ma(n);return t&&wo(n.state,e),n.docChanged||n.selectionSet||t},mount(n,e){wo(e.state,n)},class:"cm-cursorLayer"});function wo(n,e){e.style.animationDuration=n.facet(pi).cursorBlinkRate+"ms"}const Yf=Ca({above:!1,markers(n){return n.state.selection.ranges.map(e=>e.empty?[]:vi.forRange(n,"cm-selectionBackground",e)).reduce((e,t)=>e.concat(t))},update(n,e){return n.docChanged||n.selectionSet||n.viewportChanged||Ma(n)},class:"cm-selectionLayer"}),Da={".cm-line":{"& ::selection":{backgroundColor:"transparent !important"},"&::selection":{backgroundColor:"transparent !important"}}};Aa&&(Da[".cm-line"].caretColor="transparent !important");const Qf=At.highest(O.theme(Da)),Oa=E.define({map(n,e){return n==null?null:e.mapPos(n)}}),ri=be.define({create(){return null},update(n,e){return n!=null&&(n=e.changes.mapPos(n)),e.effects.reduce((t,i)=>i.is(Oa)?i.value:t,n)}}),Zf=ge.fromClass(class{constructor(n){this.view=n,this.cursor=null,this.measureReq={read:this.readPos.bind(this),write:this.drawCursor.bind(this)}}update(n){var e;let t=n.state.field(ri);t==null?this.cursor!=null&&((e=this.cursor)===null||e===void 0||e.remove(),this.cursor=null):(this.cursor||(this.cursor=this.view.scrollDOM.appendChild(document.createElement("div")),this.cursor.className="cm-dropCursor"),(n.startState.field(ri)!=t||n.docChanged||n.geometryChanged)&&this.view.requestMeasure(this.measureReq))}readPos(){let n=this.view.state.field(ri),e=n!=null&&this.view.coordsAtPos(n);if(!e)return null;let t=this.view.scrollDOM.getBoundingClientRect();return{left:e.left-t.left+this.view.scrollDOM.scrollLeft,top:e.top-t.top+this.view.scrollDOM.scrollTop,height:e.bottom-e.top}}drawCursor(n){this.cursor&&(n?(this.cursor.style.left=n.left+"px",this.cursor.style.top=n.top+"px",this.cursor.style.height=n.height+"px"):this.cursor.style.left="-100000px")}destroy(){this.cursor&&this.cursor.remove()}setDropPos(n){this.view.state.field(ri)!=n&&this.view.dispatch({effects:Oa.of(n)})}},{eventHandlers:{dragover(n){this.setDropPos(this.view.posAtCoords({x:n.clientX,y:n.clientY}))},dragleave(n){(n.target==this.view.contentDOM||!this.view.contentDOM.contains(n.relatedTarget))&&this.setDropPos(null)},dragend(){this.setDropPos(null)},drop(){this.setDropPos(null)}}});function qg(){return[ri,Zf]}function ko(n,e,t,i,s){e.lastIndex=0;for(let r=n.iterRange(t,i),o=t,l;!r.next().done;o+=r.value.length)if(!r.lineBreak)for(;l=e.exec(r.value);)s(o+l.index,l)}function eu(n,e){let t=n.visibleRanges;if(t.length==1&&t[0].from==n.viewport.from&&t[0].to==n.viewport.to)return t;let i=[];for(let{from:s,to:r}of t)s=Math.max(n.state.doc.lineAt(s).from,s-e),r=Math.min(n.state.doc.lineAt(r).to,r+e),i.length&&i[i.length-1].to>=s?i[i.length-1].to=r:i.push({from:s,to:r});return i}class tu{constructor(e){const{regexp:t,decoration:i,decorate:s,boundary:r,maxLength:o=1e3}=e;if(!t.global)throw new RangeError("The regular expression given to MatchDecorator should have its 'g' flag set");if(this.regexp=t,s)this.addMatch=(l,a,h,c)=>s(c,h,h+l[0].length,l,a);else if(typeof i=="function")this.addMatch=(l,a,h,c)=>{let f=i(l,a,h);f&&c(h,h+l[0].length,f)};else if(i)this.addMatch=(l,a,h,c)=>c(h,h+l[0].length,i);else throw new RangeError("Either 'decorate' or 'decoration' should be provided to MatchDecorator");this.boundary=r,this.maxLength=o}createDeco(e){let t=new kt,i=t.add.bind(t);for(let{from:s,to:r}of eu(e,this.maxLength))ko(e.state.doc,this.regexp,s,r,(o,l)=>this.addMatch(l,e,o,i));return t.finish()}updateDeco(e,t){let i=1e9,s=-1;return e.docChanged&&e.changes.iterChanges((r,o,l,a)=>{a>e.view.viewport.from&&l1e3?this.createDeco(e.view):s>-1?this.updateRange(e.view,t.map(e.changes),i,s):t}updateRange(e,t,i,s){for(let r of e.visibleRanges){let o=Math.max(r.from,i),l=Math.min(r.to,s);if(l>o){let a=e.state.doc.lineAt(o),h=a.toa.from;o--)if(this.boundary.test(a.text[o-1-a.from])){c=o;break}for(;lu.push(y.range(g,m));if(a==h)for(this.regexp.lastIndex=c-a.from;(d=this.regexp.exec(a.text))&&d.indexthis.addMatch(m,e,g,p));t=t.update({filterFrom:c,filterTo:f,filter:(g,m)=>gf,add:u})}}return t}}const Es=/x/.unicode!=null?"gu":"g",iu=new RegExp(`[\0-\b ---Ÿ­؜​‎‏\u2028\u2029‭‮⁦⁧⁩\uFEFF-]`,Es),nu={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"};let Un=null;function su(){var n;if(Un==null&&typeof document<"u"&&document.body){let e=document.body.style;Un=((n=e.tabSize)!==null&&n!==void 0?n:e.MozTabSize)!=null}return Un||!1}const Zi=D.define({combine(n){let e=Mt(n,{render:null,specialChars:iu,addSpecialChars:null});return(e.replaceTabs=!su())&&(e.specialChars=new RegExp(" |"+e.specialChars.source,Es)),e.addSpecialChars&&(e.specialChars=new RegExp(e.specialChars.source+"|"+e.addSpecialChars.source,Es)),e}});function Kg(n={}){return[Zi.of(n),ru()]}let So=null;function ru(){return So||(So=ge.fromClass(class{constructor(n){this.view=n,this.decorations=B.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(n.state.facet(Zi)),this.decorations=this.decorator.createDeco(n)}makeDecorator(n){return new tu({regexp:n.specialChars,decoration:(e,t,i)=>{let{doc:s}=t.state,r=ne(e[0],0);if(r==9){let o=s.lineAt(i),l=t.state.tabSize,a=Gt(o.text,l,i-o.from);return B.replace({widget:new hu((l-a%l)*this.view.defaultCharacterWidth)})}return this.decorationCache[r]||(this.decorationCache[r]=B.replace({widget:new au(n,r)}))},boundary:n.replaceTabs?void 0:/[^]/})}update(n){let e=n.state.facet(Zi);n.startState.facet(Zi)!=e?(this.decorator=this.makeDecorator(e),this.decorations=this.decorator.createDeco(n.view)):this.decorations=this.decorator.updateDeco(n,this.decorations)}},{decorations:n=>n.decorations}))}const ou="•";function lu(n){return n>=32?ou:n==10?"␤":String.fromCharCode(9216+n)}class au extends Dt{constructor(e,t){super(),this.options=e,this.code=t}eq(e){return e.code==this.code}toDOM(e){let t=lu(this.code),i=e.state.phrase("Control character")+" "+(nu[this.code]||"0x"+this.code.toString(16)),s=this.options.render&&this.options.render(this.code,i,t);if(s)return s;let r=document.createElement("span");return r.textContent=t,r.title=i,r.setAttribute("aria-label",i),r.className="cm-specialChar",r}ignoreEvent(){return!1}}class hu extends Dt{constructor(e){super(),this.width=e}eq(e){return e.width==this.width}toDOM(){let e=document.createElement("span");return e.textContent=" ",e.className="cm-tab",e.style.width=this.width+"px",e}ignoreEvent(){return!1}}class cu extends Dt{constructor(e){super(),this.content=e}toDOM(){let e=document.createElement("span");return e.className="cm-placeholder",e.style.pointerEvents="none",e.appendChild(typeof this.content=="string"?document.createTextNode(this.content):this.content),typeof this.content=="string"?e.setAttribute("aria-label","placeholder "+this.content):e.setAttribute("aria-hidden","true"),e}coordsAt(e){let t=e.firstChild?Vt(e.firstChild):[];if(!t.length)return null;let i=window.getComputedStyle(e.parentNode),s=Mn(t[0],i.direction!="rtl"),r=parseInt(i.lineHeight);return s.bottom-s.top>r*1.5?{left:s.left,right:s.right,top:s.top,bottom:s.top+r}:s}ignoreEvent(){return!1}}function $g(n){return ge.fromClass(class{constructor(e){this.view=e,this.placeholder=n?B.set([B.widget({widget:new cu(n),side:1}).range(0)]):B.none}get decorations(){return this.view.state.doc.length?B.none:this.placeholder}},{decorations:e=>e.decorations})}const Is=2e3;function fu(n,e,t){let i=Math.min(e.line,t.line),s=Math.max(e.line,t.line),r=[];if(e.off>Is||t.off>Is||e.col<0||t.col<0){let o=Math.min(e.off,t.off),l=Math.max(e.off,t.off);for(let a=i;a<=s;a++){let h=n.doc.line(a);h.length<=l&&r.push(b.range(h.from+o,h.to+l))}}else{let o=Math.min(e.col,t.col),l=Math.max(e.col,t.col);for(let a=i;a<=s;a++){let h=n.doc.line(a),c=ds(h.text,o,n.tabSize,!0);if(c<0)r.push(b.cursor(h.to));else{let f=ds(h.text,l,n.tabSize);r.push(b.range(h.from+c,h.from+f))}}}return r}function uu(n,e){let t=n.coordsAtPos(n.viewport.from);return t?Math.round(Math.abs((t.left-e)/n.defaultCharacterWidth)):-1}function vo(n,e){let t=n.posAtCoords({x:e.clientX,y:e.clientY},!1),i=n.state.doc.lineAt(t),s=t-i.from,r=s>Is?-1:s==i.length?uu(n,e.clientX):Gt(i.text,n.state.tabSize,t-i.from);return{line:i.number,col:r,off:s}}function du(n,e){let t=vo(n,e),i=n.state.selection;return t?{update(s){if(s.docChanged){let r=s.changes.mapPos(s.startState.doc.line(t.line).from),o=s.state.doc.lineAt(r);t={line:o.number,col:t.col,off:Math.min(t.off,o.length)},i=i.map(s.changes)}},get(s,r,o){let l=vo(n,s);if(!l)return i;let a=fu(n.state,t,l);return a.length?o?b.create(a.concat(i.ranges)):b.create(a):i}}:null}function jg(n){let e=(n==null?void 0:n.eventFilter)||(t=>t.altKey&&t.button==0);return O.mouseSelectionStyle.of((t,i)=>e(i)?du(t,i):null)}const Fi="-10000px";class pu{constructor(e,t,i){this.facet=t,this.createTooltipView=i,this.input=e.state.facet(t),this.tooltips=this.input.filter(s=>s),this.tooltipViews=this.tooltips.map(i)}update(e){var t;let i=e.state.facet(this.facet),s=i.filter(o=>o);if(i===this.input){for(let o of this.tooltipViews)o.update&&o.update(e);return!1}let r=[];for(let o=0;o{var e,t,i;return{position:A.ios?"absolute":((e=n.find(s=>s.position))===null||e===void 0?void 0:e.position)||"fixed",parent:((t=n.find(s=>s.parent))===null||t===void 0?void 0:t.parent)||null,tooltipSpace:((i=n.find(s=>s.tooltipSpace))===null||i===void 0?void 0:i.tooltipSpace)||gu}}}),Co=new WeakMap,Ta=ge.fromClass(class{constructor(n){this.view=n,this.inView=!0,this.lastTransaction=0,this.measureTimeout=-1;let e=n.state.facet(Gn);this.position=e.position,this.parent=e.parent,this.classes=n.themeClasses,this.createContainer(),this.measureReq={read:this.readMeasure.bind(this),write:this.writeMeasure.bind(this),key:this},this.manager=new pu(n,Ba,t=>this.createTooltip(t)),this.intersectionObserver=typeof IntersectionObserver=="function"?new IntersectionObserver(t=>{Date.now()>this.lastTransaction-50&&t.length>0&&t[t.length-1].intersectionRatio<1&&this.measureSoon()},{threshold:[1]}):null,this.observeIntersection(),n.win.addEventListener("resize",this.measureSoon=this.measureSoon.bind(this)),this.maybeMeasure()}createContainer(){this.parent?(this.container=document.createElement("div"),this.container.style.position="relative",this.container.className=this.view.themeClasses,this.parent.appendChild(this.container)):this.container=this.view.dom}observeIntersection(){if(this.intersectionObserver){this.intersectionObserver.disconnect();for(let n of this.manager.tooltipViews)this.intersectionObserver.observe(n.dom)}}measureSoon(){this.measureTimeout<0&&(this.measureTimeout=setTimeout(()=>{this.measureTimeout=-1,this.maybeMeasure()},50))}update(n){n.transactions.length&&(this.lastTransaction=Date.now());let e=this.manager.update(n);e&&this.observeIntersection();let t=e||n.geometryChanged,i=n.state.facet(Gn);if(i.position!=this.position){this.position=i.position;for(let s of this.manager.tooltipViews)s.dom.style.position=this.position;t=!0}if(i.parent!=this.parent){this.parent&&this.container.remove(),this.parent=i.parent,this.createContainer();for(let s of this.manager.tooltipViews)this.container.appendChild(s.dom);t=!0}else this.parent&&this.view.themeClasses!=this.classes&&(this.classes=this.container.className=this.view.themeClasses);t&&this.maybeMeasure()}createTooltip(n){let e=n.create(this.view);if(e.dom.classList.add("cm-tooltip"),n.arrow&&!e.dom.querySelector(".cm-tooltip > .cm-tooltip-arrow")){let t=document.createElement("div");t.className="cm-tooltip-arrow",e.dom.appendChild(t)}return e.dom.style.position=this.position,e.dom.style.top=Fi,this.container.appendChild(e.dom),e.mount&&e.mount(this.view),e}destroy(){var n,e;this.view.win.removeEventListener("resize",this.measureSoon);for(let t of this.manager.tooltipViews)t.dom.remove(),(n=t.destroy)===null||n===void 0||n.call(t);(e=this.intersectionObserver)===null||e===void 0||e.disconnect(),clearTimeout(this.measureTimeout)}readMeasure(){let n=this.view.dom.getBoundingClientRect();return{editor:n,parent:this.parent?this.container.getBoundingClientRect():n,pos:this.manager.tooltips.map((e,t)=>{let i=this.manager.tooltipViews[t];return i.getCoords?i.getCoords(e.pos):this.view.coordsAtPos(e.pos)}),size:this.manager.tooltipViews.map(({dom:e})=>e.getBoundingClientRect()),space:this.view.state.facet(Gn).tooltipSpace(this.view)}}writeMeasure(n){var e;let{editor:t,space:i}=n,s=[];for(let r=0;r=Math.min(t.bottom,i.bottom)||h.rightMath.min(t.right,i.right)+.1){a.style.top=Fi;continue}let f=o.arrow?l.dom.querySelector(".cm-tooltip-arrow"):null,u=f?7:0,d=c.right-c.left,p=(e=Co.get(l))!==null&&e!==void 0?e:c.bottom-c.top,g=l.offset||yu,m=this.view.textDirection==J.LTR,y=c.width>i.right-i.left?m?i.left:i.right-c.width:m?Math.min(h.left-(f?14:0)+g.x,i.right-d):Math.max(i.left,h.left-d+(f?14:0)-g.x),S=!!o.above;!o.strictSide&&(S?h.top-(c.bottom-c.top)-g.yi.bottom)&&S==i.bottom-h.bottom>h.top-i.top&&(S=!S);let M=(S?h.top-i.top:i.bottom-h.bottom)-u;if(My&&k.topx&&(x=S?k.top-p-2-u:k.bottom+u+2);this.position=="absolute"?(a.style.top=x-n.parent.top+"px",a.style.left=y-n.parent.left+"px"):(a.style.top=x+"px",a.style.left=y+"px"),f&&(f.style.left=`${h.left+(m?g.x:-g.x)-(y+14-7)}px`),l.overlap!==!0&&s.push({left:y,top:x,right:w,bottom:x+p}),a.classList.toggle("cm-tooltip-above",S),a.classList.toggle("cm-tooltip-below",!S),l.positioned&&l.positioned(n.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let n of this.manager.tooltipViews)n.dom.style.top=Fi}},{eventHandlers:{scroll(){this.maybeMeasure()}}}),mu=O.baseTheme({".cm-tooltip":{zIndex:100,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:`${7*2}px`,position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),yu={x:0,y:0},Ba=D.define({enables:[Ta,mu]});function Pa(n,e){let t=n.plugin(Ta);if(!t)return null;let i=t.manager.tooltips.indexOf(e);return i<0?null:t.manager.tooltipViews[i]}const Ao=D.define({combine(n){let e,t;for(let i of n)e=e||i.topContainer,t=t||i.bottomContainer;return{topContainer:e,bottomContainer:t}}});function cn(n,e){let t=n.plugin(La),i=t?t.specs.indexOf(e):-1;return i>-1?t.panels[i]:null}const La=ge.fromClass(class{constructor(n){this.input=n.state.facet(fn),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(n));let e=n.state.facet(Ao);this.top=new Hi(n,!0,e.topContainer),this.bottom=new Hi(n,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(n){let e=n.state.facet(Ao);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new Hi(n.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new Hi(n.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=n.state.facet(fn);if(t!=this.input){let i=t.filter(a=>a),s=[],r=[],o=[],l=[];for(let a of i){let h=this.specs.indexOf(a),c;h<0?(c=a(n.view),l.push(c)):(c=this.panels[h],c.update&&c.update(n)),s.push(c),(c.top?r:o).push(c)}this.specs=i,this.panels=s,this.top.sync(r),this.bottom.sync(o);for(let a of l)a.dom.classList.add("cm-panel"),a.mount&&a.mount()}else for(let i of this.panels)i.update&&i.update(n)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:n=>O.scrollMargins.of(e=>{let t=e.plugin(n);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})});class Hi{constructor(e,t,i){this.view=e,this.top=t,this.container=i,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom",this.dom.style[this.top?"top":"bottom"]="0";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=Mo(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=Mo(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}}function Mo(n){let e=n.nextSibling;return n.remove(),e}const fn=D.define({enables:La});class vt extends wt{compare(e){return this==e||this.constructor==e.constructor&&this.eq(e)}eq(e){return!1}destroy(e){}}vt.prototype.elementClass="";vt.prototype.toDOM=void 0;vt.prototype.mapMode=he.TrackBefore;vt.prototype.startSide=vt.prototype.endSide=-1;vt.prototype.point=!0;const bu=D.define(),xu=new class extends vt{constructor(){super(...arguments),this.elementClass="cm-activeLineGutter"}},wu=bu.compute(["selection"],n=>{let e=[],t=-1;for(let i of n.selection.ranges){let s=n.doc.lineAt(i.head).from;s>t&&(t=s,e.push(xu.range(s)))}return K.of(e)});function Ug(){return wu}const ku=1024;let Su=0;class Te{constructor(e,t){this.from=e,this.to=t}}class L{constructor(e={}){this.id=Su++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")})}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=me.match(e)),t=>{let i=e(t);return i===void 0?null:[this,i]}}}L.closedBy=new L({deserialize:n=>n.split(" ")});L.openedBy=new L({deserialize:n=>n.split(" ")});L.group=new L({deserialize:n=>n.split(" ")});L.contextHash=new L({perNode:!0});L.lookAhead=new L({perNode:!0});L.mounted=new L({perNode:!0});class vu{constructor(e,t,i){this.tree=e,this.overlay=t,this.parser=i}}const Cu=Object.create(null);class me{constructor(e,t,i,s=0){this.name=e,this.props=t,this.id=i,this.flags=s}static define(e){let t=e.props&&e.props.length?Object.create(null):Cu,i=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),s=new me(e.name||"",t,e.id,i);if(e.props){for(let r of e.props)if(Array.isArray(r)||(r=r(s)),r){if(r[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[r[0].id]=r[1]}}return s}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(L.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let i in e)for(let s of i.split(" "))t[s]=e[i];return i=>{for(let s=i.prop(L.group),r=-1;r<(s?s.length:0);r++){let o=t[r<0?i.name:s[r]];if(o)return o}}}}me.none=new me("",Object.create(null),0,8);class rr{constructor(e){this.types=e;for(let t=0;t0;for(let a=this.cursor(o|j.IncludeAnonymous);;){let h=!1;if(a.from<=r&&a.to>=s&&(!l&&a.type.isAnonymous||t(a)!==!1)){if(a.firstChild())continue;h=!0}for(;h&&i&&(l||!a.type.isAnonymous)&&i(a),!a.nextSibling();){if(!a.parent())return;h=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:ar(me.none,this.children,this.positions,0,this.children.length,0,this.length,(t,i,s)=>new W(this.type,t,i,s,this.propValues),e.makeTree||((t,i,s)=>new W(me.none,t,i,s)))}static build(e){return Mu(e)}}W.empty=new W(me.none,[],[],0);class or{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new or(this.buffer,this.index)}}class Ot{constructor(e,t,i){this.buffer=e,this.length=t,this.set=i}get type(){return me.none}toString(){let e=[];for(let t=0;t0));a=o[a+3]);return l}slice(e,t,i){let s=this.buffer,r=new Uint16Array(t-e),o=0;for(let l=e,a=0;l=e&&te;case 1:return t<=e&&i>e;case 2:return i>e;case 4:return!0}}function Ea(n,e){let t=n.childBefore(e);for(;t;){let i=t.lastChild;if(!i||i.to!=t.to)break;i.type.isError&&i.from==i.to?(n=t,t=i.prevSibling):t=i}return n}function qt(n,e,t,i){for(var s;n.from==n.to||(t<1?n.from>=e:n.from>e)||(t>-1?n.to<=e:n.to0?l.length:-1;e!=h;e+=t){let c=l[e],f=a[e]+o.from;if(Ra(s,i,f,f+c.length)){if(c instanceof Ot){if(r&j.ExcludeBuffers)continue;let u=c.findChild(0,c.buffer.length,t,i-f,s);if(u>-1)return new je(new Au(o,c,e,f),null,u)}else if(r&j.IncludeAnonymous||!c.type.isAnonymous||lr(c)){let u;if(!(r&j.IgnoreMounts)&&c.props&&(u=c.prop(L.mounted))&&!u.overlay)return new Re(u.tree,f,e,o);let d=new Re(c,f,e,o);return r&j.IncludeAnonymous||!d.type.isAnonymous?d:d.nextChild(t<0?c.children.length-1:0,t,i,s)}}}if(r&j.IncludeAnonymous||!o.type.isAnonymous||(o.index>=0?e=o.index+t:e=t<0?-1:o._parent._tree.children.length,o=o._parent,!o))return null}}get firstChild(){return this.nextChild(0,1,0,4)}get lastChild(){return this.nextChild(this._tree.children.length-1,-1,0,4)}childAfter(e){return this.nextChild(0,1,e,2)}childBefore(e){return this.nextChild(this._tree.children.length-1,-1,e,-2)}enter(e,t,i=0){let s;if(!(i&j.IgnoreOverlays)&&(s=this._tree.prop(L.mounted))&&s.overlay){let r=e-this.from;for(let{from:o,to:l}of s.overlay)if((t>0?o<=r:o=r:l>r))return new Re(s.tree,s.overlay[0].from+this.from,-1,this)}return this.nextChild(0,1,e,t,i)}nextSignificantParent(){let e=this;for(;e.type.isAnonymous&&e._parent;)e=e._parent;return e}get parent(){return this._parent?this._parent.nextSignificantParent():null}get nextSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index+1,1,0,4):null}get prevSibling(){return this._parent&&this.index>=0?this._parent.nextChild(this.index-1,-1,0,4):null}cursor(e=0){return new gi(this,e)}get tree(){return this._tree}toTree(){return this._tree}resolve(e,t=0){return qt(this,e,t,!1)}resolveInner(e,t=0){return qt(this,e,t,!0)}enterUnfinishedNodesBefore(e){return Ea(this,e)}getChild(e,t=null,i=null){let s=un(this,e,t,i);return s.length?s[0]:null}getChildren(e,t=null,i=null){return un(this,e,t,i)}toString(){return this._tree.toString()}get node(){return this}matchContext(e){return dn(this,e)}}function un(n,e,t,i){let s=n.cursor(),r=[];if(!s.firstChild())return r;if(t!=null){for(;!s.type.is(t);)if(!s.nextSibling())return r}for(;;){if(i!=null&&s.type.is(i))return r;if(s.type.is(e)&&r.push(s.node),!s.nextSibling())return i==null?r:[]}}function dn(n,e,t=e.length-1){for(let i=n.parent;t>=0;i=i.parent){if(!i)return!1;if(!i.type.isAnonymous){if(e[t]&&e[t]!=i.name)return!1;t--}}return!0}class Au{constructor(e,t,i,s){this.parent=e,this.buffer=t,this.index=i,this.start=s}}class je{get name(){return this.type.name}get from(){return this.context.start+this.context.buffer.buffer[this.index+1]}get to(){return this.context.start+this.context.buffer.buffer[this.index+2]}constructor(e,t,i){this.context=e,this._parent=t,this.index=i,this.type=e.buffer.set.types[e.buffer.buffer[i]]}child(e,t,i){let{buffer:s}=this.context,r=s.findChild(this.index+4,s.buffer[this.index+3],e,t-this.context.start,i);return r<0?null:new je(this.context,this,r)}get firstChild(){return this.child(1,0,4)}get lastChild(){return this.child(-1,0,4)}childAfter(e){return this.child(1,e,2)}childBefore(e){return this.child(-1,e,-2)}enter(e,t,i=0){if(i&j.ExcludeBuffers)return null;let{buffer:s}=this.context,r=s.findChild(this.index+4,s.buffer[this.index+3],t>0?1:-1,e-this.context.start,t);return r<0?null:new je(this.context,this,r)}get parent(){return this._parent||this.context.parent.nextSignificantParent()}externalSibling(e){return this._parent?null:this.context.parent.nextChild(this.context.index+e,e,0,4)}get nextSibling(){let{buffer:e}=this.context,t=e.buffer[this.index+3];return t<(this._parent?e.buffer[this._parent.index+3]:e.buffer.length)?new je(this.context,this._parent,t):this.externalSibling(1)}get prevSibling(){let{buffer:e}=this.context,t=this._parent?this._parent.index+4:0;return this.index==t?this.externalSibling(-1):new je(this.context,this._parent,e.findChild(t,this.index,-1,0,4))}cursor(e=0){return new gi(this,e)}get tree(){return null}toTree(){let e=[],t=[],{buffer:i}=this.context,s=this.index+4,r=i.buffer[this.index+3];if(r>s){let o=i.buffer[this.index+1];e.push(i.slice(s,r,o)),t.push(0)}return new W(this.type,e,t,this.to-this.from)}resolve(e,t=0){return qt(this,e,t,!1)}resolveInner(e,t=0){return qt(this,e,t,!0)}enterUnfinishedNodesBefore(e){return Ea(this,e)}toString(){return this.context.buffer.childString(this.index)}getChild(e,t=null,i=null){let s=un(this,e,t,i);return s.length?s[0]:null}getChildren(e,t=null,i=null){return un(this,e,t,i)}get node(){return this}matchContext(e){return dn(this,e)}}class gi{get name(){return this.type.name}constructor(e,t=0){if(this.mode=t,this.buffer=null,this.stack=[],this.index=0,this.bufferNode=null,e instanceof Re)this.yieldNode(e);else{this._tree=e.context.parent,this.buffer=e.context;for(let i=e._parent;i;i=i._parent)this.stack.unshift(i.index);this.bufferNode=e,this.yieldBuf(e.index)}}yieldNode(e){return e?(this._tree=e,this.type=e.type,this.from=e.from,this.to=e.to,!0):!1}yieldBuf(e,t){this.index=e;let{start:i,buffer:s}=this.buffer;return this.type=t||s.set.types[s.buffer[e]],this.from=i+s.buffer[e+1],this.to=i+s.buffer[e+2],!0}yield(e){return e?e instanceof Re?(this.buffer=null,this.yieldNode(e)):(this.buffer=e.context,this.yieldBuf(e.index,e.type)):!1}toString(){return this.buffer?this.buffer.buffer.childString(this.index):this._tree.toString()}enterChild(e,t,i){if(!this.buffer)return this.yield(this._tree.nextChild(e<0?this._tree._tree.children.length-1:0,e,t,i,this.mode));let{buffer:s}=this.buffer,r=s.findChild(this.index+4,s.buffer[this.index+3],e,t-this.buffer.start,i);return r<0?!1:(this.stack.push(this.index),this.yieldBuf(r))}firstChild(){return this.enterChild(1,0,4)}lastChild(){return this.enterChild(-1,0,4)}childAfter(e){return this.enterChild(1,e,2)}childBefore(e){return this.enterChild(-1,e,-2)}enter(e,t,i=this.mode){return this.buffer?i&j.ExcludeBuffers?!1:this.enterChild(1,e,t):this.yield(this._tree.enter(e,t,i))}parent(){if(!this.buffer)return this.yieldNode(this.mode&j.IncludeAnonymous?this._tree._parent:this._tree.parent);if(this.stack.length)return this.yieldBuf(this.stack.pop());let e=this.mode&j.IncludeAnonymous?this.buffer.parent:this.buffer.parent.nextSignificantParent();return this.buffer=null,this.yieldNode(e)}sibling(e){if(!this.buffer)return this._tree._parent?this.yield(this._tree.index<0?null:this._tree._parent.nextChild(this._tree.index+e,e,0,4,this.mode)):!1;let{buffer:t}=this.buffer,i=this.stack.length-1;if(e<0){let s=i<0?0:this.stack[i]+4;if(this.index!=s)return this.yieldBuf(t.findChild(s,this.index,-1,0,4))}else{let s=t.buffer[this.index+3];if(s<(i<0?t.buffer.length:t.buffer[this.stack[i]+3]))return this.yieldBuf(s)}return i<0?this.yield(this.buffer.parent.nextChild(this.buffer.index+e,e,0,4,this.mode)):!1}nextSibling(){return this.sibling(1)}prevSibling(){return this.sibling(-1)}atLastNode(e){let t,i,{buffer:s}=this;if(s){if(e>0){if(this.index-1)for(let r=t+e,o=e<0?-1:i._tree.children.length;r!=o;r+=e){let l=i._tree.children[r];if(this.mode&j.IncludeAnonymous||l instanceof Ot||!l.type.isAnonymous||lr(l))return!1}return!0}move(e,t){if(t&&this.enterChild(e,0,4))return!0;for(;;){if(this.sibling(e))return!0;if(this.atLastNode(e)||!this.parent())return!1}}next(e=!0){return this.move(1,e)}prev(e=!0){return this.move(-1,e)}moveTo(e,t=0){for(;(this.from==this.to||(t<1?this.from>=e:this.from>e)||(t>-1?this.to<=e:this.to=0;){for(let o=e;o;o=o._parent)if(o.index==s){if(s==this.index)return o;t=o,i=r+1;break e}s=this.stack[--r]}for(let s=i;s=0;r--){if(r<0)return dn(this.node,e,s);let o=i[t.buffer[this.stack[r]]];if(!o.isAnonymous){if(e[s]&&e[s]!=o.name)return!1;s--}}return!0}}function lr(n){return n.children.some(e=>e instanceof Ot||!e.type.isAnonymous||lr(e))}function Mu(n){var e;let{buffer:t,nodeSet:i,maxBufferLength:s=ku,reused:r=[],minRepeatType:o=i.types.length}=n,l=Array.isArray(t)?new or(t,t.length):t,a=i.types,h=0,c=0;function f(x,w,k,T,H){let{id:P,start:R,end:z,size:Y}=l,re=c;for(;Y<0;)if(l.next(),Y==-1){let we=r[P];k.push(we),T.push(R-x);return}else if(Y==-3){h=P;return}else if(Y==-4){c=P;return}else throw new RangeError(`Unrecognized record size: ${Y}`);let xe=a[P],te,oe,Fe=R-x;if(z-R<=s&&(oe=g(l.pos-w,H))){let we=new Uint16Array(oe.size-oe.skip),U=l.pos-oe.size,Q=we.length;for(;l.pos>U;)Q=m(oe.start,we,Q);te=new Ot(we,z-oe.start,i),Fe=oe.start-x}else{let we=l.pos-Y;l.next();let U=[],Q=[],ut=P>=o?P:-1,Tt=0,Mi=z;for(;l.pos>we;)ut>=0&&l.id==ut&&l.size>=0?(l.end<=Mi-s&&(d(U,Q,R,Tt,l.end,Mi,ut,re),Tt=U.length,Mi=l.end),l.next()):f(R,we,U,Q,ut);if(ut>=0&&Tt>0&&Tt-1&&Tt>0){let Cr=u(xe);te=ar(xe,U,Q,0,U.length,0,z-R,Cr,Cr)}else te=p(xe,U,Q,z-R,re-z)}k.push(te),T.push(Fe)}function u(x){return(w,k,T)=>{let H=0,P=w.length-1,R,z;if(P>=0&&(R=w[P])instanceof W){if(!P&&R.type==x&&R.length==T)return R;(z=R.prop(L.lookAhead))&&(H=k[P]+R.length+z)}return p(x,w,k,T,H)}}function d(x,w,k,T,H,P,R,z){let Y=[],re=[];for(;x.length>T;)Y.push(x.pop()),re.push(w.pop()+k-H);x.push(p(i.types[R],Y,re,P-H,z-P)),w.push(H-k)}function p(x,w,k,T,H=0,P){if(h){let R=[L.contextHash,h];P=P?[R].concat(P):[R]}if(H>25){let R=[L.lookAhead,H];P=P?[R].concat(P):[R]}return new W(x,w,k,T,P)}function g(x,w){let k=l.fork(),T=0,H=0,P=0,R=k.end-s,z={size:0,start:0,skip:0};e:for(let Y=k.pos-x;k.pos>Y;){let re=k.size;if(k.id==w&&re>=0){z.size=T,z.start=H,z.skip=P,P+=4,T+=4,k.next();continue}let xe=k.pos-re;if(re<0||xe=o?4:0,oe=k.start;for(k.next();k.pos>xe;){if(k.size<0)if(k.size==-3)te+=4;else break e;else k.id>=o&&(te+=4);k.next()}H=oe,T+=re,P+=te}return(w<0||T==x)&&(z.size=T,z.start=H,z.skip=P),z.size>4?z:void 0}function m(x,w,k){let{id:T,start:H,end:P,size:R}=l;if(l.next(),R>=0&&T4){let Y=l.pos-(R-4);for(;l.pos>Y;)k=m(x,w,k)}w[--k]=z,w[--k]=P-x,w[--k]=H-x,w[--k]=T}else R==-3?h=T:R==-4&&(c=T);return k}let y=[],S=[];for(;l.pos>0;)f(n.start||0,n.bufferStart||0,y,S,-1);let M=(e=n.length)!==null&&e!==void 0?e:y.length?S[0]+y[0].length:0;return new W(a[n.topID],y.reverse(),S.reverse(),M)}const Oo=new WeakMap;function en(n,e){if(!n.isAnonymous||e instanceof Ot||e.type!=n)return 1;let t=Oo.get(e);if(t==null){t=1;for(let i of e.children){if(i.type!=n||!(i instanceof W)){t=1;break}t+=en(n,i)}Oo.set(e,t)}return t}function ar(n,e,t,i,s,r,o,l,a){let h=0;for(let p=i;p=c)break;k+=T}if(M==x+1){if(k>c){let T=p[x];d(T.children,T.positions,0,T.children.length,g[x]+S);continue}f.push(p[x])}else{let T=g[M-1]+p[M-1].length-w;f.push(ar(n,p,g,x,M,w,T,null,a))}u.push(w+S-r)}}return d(e,t,i,s,0),(l||a)(f,u,o)}class Gg{constructor(){this.map=new WeakMap}setBuffer(e,t,i){let s=this.map.get(e);s||this.map.set(e,s=new Map),s.set(t,i)}getBuffer(e,t){let i=this.map.get(e);return i&&i.get(t)}set(e,t){e instanceof je?this.setBuffer(e.context.buffer,e.index,t):e instanceof Re&&this.map.set(e.tree,t)}get(e){return e instanceof je?this.getBuffer(e.context.buffer,e.index):e instanceof Re?this.map.get(e.tree):void 0}cursorSet(e,t){e.buffer?this.setBuffer(e.buffer.buffer,e.index,t):this.map.set(e.tree,t)}cursorGet(e){return e.buffer?this.getBuffer(e.buffer.buffer,e.index):this.map.get(e.tree)}}class Ye{constructor(e,t,i,s,r=!1,o=!1){this.from=e,this.to=t,this.tree=i,this.offset=s,this.open=(r?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],i=!1){let s=[new Ye(0,e.length,e,0,!1,i)];for(let r of t)r.to>e.length&&s.push(r);return s}static applyChanges(e,t,i=128){if(!t.length)return e;let s=[],r=1,o=e.length?e[0]:null;for(let l=0,a=0,h=0;;l++){let c=l=i)for(;o&&o.from=u.from||f<=u.to||h){let d=Math.max(u.from,a)-h,p=Math.min(u.to,f)-h;u=d>=p?null:new Ye(d,p,u.tree,u.offset+h,l>0,!!c)}if(u&&s.push(u),o.to>f)break;o=rnew Te(s.from,s.to)):[new Te(0,0)]:[new Te(0,e.length)],this.createParse(e,t||[],i)}parse(e,t,i){let s=this.startParse(e,t,i);for(;;){let r=s.advance();if(r)return r}}}class Du{constructor(e){this.string=e}get length(){return this.string.length}chunk(e){return this.string.slice(e)}get lineChunks(){return!1}read(e,t){return this.string.slice(e,t)}}function Jg(n){return(e,t,i,s)=>new Tu(e,n,t,i,s)}class To{constructor(e,t,i,s,r){if(this.parser=e,this.parse=t,this.overlay=i,this.target=s,this.ranges=r,!r.length||r.some(o=>o.from>=o.to))throw new RangeError("Invalid inner parse ranges given: "+JSON.stringify(r))}}class Ou{constructor(e,t,i,s,r,o,l){this.parser=e,this.predicate=t,this.mounts=i,this.index=s,this.start=r,this.target=o,this.prev=l,this.depth=0,this.ranges=[]}}const Ns=new L({perNode:!0});class Tu{constructor(e,t,i,s,r){this.nest=t,this.input=i,this.fragments=s,this.ranges=r,this.inner=[],this.innerDone=0,this.baseTree=null,this.stoppedAt=null,this.baseParse=e}advance(){if(this.baseParse){let i=this.baseParse.advance();if(!i)return null;if(this.baseParse=null,this.baseTree=i,this.startInner(),this.stoppedAt!=null)for(let s of this.inner)s.parse.stopAt(this.stoppedAt)}if(this.innerDone==this.inner.length){let i=this.baseTree;return this.stoppedAt!=null&&(i=new W(i.type,i.children,i.positions,i.length,i.propValues.concat([[Ns,this.stoppedAt]]))),i}let e=this.inner[this.innerDone],t=e.parse.advance();if(t){this.innerDone++;let i=Object.assign(Object.create(null),e.target.props);i[L.mounted.id]=new vu(t,e.overlay,e.parser),e.target.props=i}return null}get parsedPos(){if(this.baseParse)return 0;let e=this.input.length;for(let t=this.innerDone;tc.frag.from<=s.from&&c.frag.to>=s.to&&c.mount.overlay);if(h)for(let c of h.mount.overlay){let f=c.from+h.pos,u=c.to+h.pos;f>=s.from&&u<=s.to&&!t.ranges.some(d=>d.fromf)&&t.ranges.push({from:f,to:u})}}l=!1}else if(i&&(o=Bu(i.ranges,s.from,s.to)))l=o!=2;else if(!s.type.isAnonymous&&s.fromnew Te(f.from-s.from,f.to-s.from)):null,s.tree,c)),r.overlay?c.length&&(i={ranges:c,depth:0,prev:i}):l=!1}}else t&&(a=t.predicate(s))&&(a===!0&&(a=new Te(s.from,s.to)),a.fromnew Te(c.from-t.start,c.to-t.start)),t.target,h)),t=t.prev}i&&!--i.depth&&(i=i.prev)}}}}function Bu(n,e,t){for(let i of n){if(i.from>=t)break;if(i.to>e)return i.from<=e&&i.to>=t?2:1}return 0}function Bo(n,e,t,i,s,r){if(e=e.to);i++);let o=s.children[i],l=o.buffer;function a(h,c,f,u,d){let p=h;for(;l[p+2]+r<=e.from;)p=l[p+3];let g=[],m=[];Bo(o,h,p,g,m,u);let y=l[p+1],S=l[p+2],M=y+r==e.from&&S+r==e.to&&l[p]==e.type.id;return g.push(M?e.toTree():a(p+4,l[p+3],o.set.types[l[p]],y,S-y)),m.push(y-u),Bo(o,l[p+3],c,g,m,u),new W(f,g,m,d)}s.children[i]=a(0,l.length,me.none,0,o.length);for(let h=0;h<=t;h++)n.childAfter(e.from)}class Po{constructor(e,t){this.offset=t,this.done=!1,this.cursor=e.cursor(j.IncludeAnonymous|j.IgnoreMounts)}moveTo(e){let{cursor:t}=this,i=e-this.offset;for(;!this.done&&t.from=e&&t.enter(i,1,j.IgnoreOverlays|j.ExcludeBuffers)||t.next(!1)||(this.done=!0)}hasNode(e){if(this.moveTo(e.from),!this.done&&this.cursor.from+this.offset==e.from&&this.cursor.tree)for(let t=this.cursor.tree;;){if(t==e.tree)return!0;if(t.children.length&&t.positions[0]==0&&t.children[0]instanceof W)t=t.children[0];else break}return!1}}class Lu{constructor(e){var t;if(this.fragments=e,this.curTo=0,this.fragI=0,e.length){let i=this.curFrag=e[0];this.curTo=(t=i.tree.prop(Ns))!==null&&t!==void 0?t:i.to,this.inner=new Po(i.tree,-i.offset)}else this.curFrag=this.inner=null}hasNode(e){for(;this.curFrag&&e.from>=this.curTo;)this.nextFrag();return this.curFrag&&this.curFrag.from<=e.from&&this.curTo>=e.to&&this.inner.hasNode(e)}nextFrag(){var e;if(this.fragI++,this.fragI==this.fragments.length)this.curFrag=this.inner=null;else{let t=this.curFrag=this.fragments[this.fragI];this.curTo=(e=t.tree.prop(Ns))!==null&&e!==void 0?e:t.to,this.inner=new Po(t.tree,-t.offset)}}findMounts(e,t){var i;let s=[];if(this.inner){this.inner.cursor.moveTo(e,1);for(let r=this.inner.cursor.node;r;r=r.parent){let o=(i=r.tree)===null||i===void 0?void 0:i.prop(L.mounted);if(o&&o.parser==t)for(let l=this.fragI;l=r.to)break;a.tree==this.curFrag.tree&&s.push({frag:a,pos:r.from-a.offset,mount:o})}}}return s}}function Lo(n,e){let t=null,i=e;for(let s=1,r=0;s=l)break;a.to<=o||(t||(i=t=e.slice()),a.froml&&t.splice(r+1,0,new Te(l,a.to))):a.to>l?t[r--]=new Te(l,a.to):t.splice(r--,1))}}return i}function Ru(n,e,t,i){let s=0,r=0,o=!1,l=!1,a=-1e9,h=[];for(;;){let c=s==n.length?1e9:o?n[s].to:n[s].from,f=r==e.length?1e9:l?e[r].to:e[r].from;if(o!=l){let u=Math.max(a,t),d=Math.min(c,f,i);unew Te(u.from+i,u.to+i)),f=Ru(e,c,a,h);for(let u=0,d=a;;u++){let p=u==f.length,g=p?h:f[u].from;if(g>d&&t.push(new Ye(d,g,s.tree,-o,r.from>=d||r.openStart,r.to<=g||r.openEnd)),p)break;d=f[u].to}}else t.push(new Ye(a,h,s.tree,-o,r.from>=o||r.openStart,r.to<=l||r.openEnd))}return t}let Eu=0;class qe{constructor(e,t,i){this.set=e,this.base=t,this.modified=i,this.id=Eu++}static define(e){if(e!=null&&e.base)throw new Error("Can not derive from a modified tag");let t=new qe([],null,[]);if(t.set.push(t),e)for(let i of e.set)t.set.push(i);return t}static defineModifier(){let e=new pn;return t=>t.modified.indexOf(e)>-1?t:pn.get(t.base||t,t.modified.concat(e).sort((i,s)=>i.id-s.id))}}let Iu=0;class pn{constructor(){this.instances=[],this.id=Iu++}static get(e,t){if(!t.length)return e;let i=t[0].instances.find(l=>l.base==e&&Nu(t,l.modified));if(i)return i;let s=[],r=new qe(s,e,t);for(let l of t)l.instances.push(r);let o=Fu(t);for(let l of e.set)if(!l.modified.length)for(let a of o)s.push(pn.get(l,a));return r}}function Nu(n,e){return n.length==e.length&&n.every((t,i)=>t==e[i])}function Fu(n){let e=[[]];for(let t=0;ti.length-t.length)}function Hu(n){let e=Object.create(null);for(let t in n){let i=n[t];Array.isArray(i)||(i=[i]);for(let s of t.split(" "))if(s){let r=[],o=2,l=s;for(let f=0;;){if(l=="..."&&f>0&&f+3==s.length){o=1;break}let u=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(l);if(!u)throw new RangeError("Invalid path: "+s);if(r.push(u[0]=="*"?"":u[0][0]=='"'?JSON.parse(u[0]):u[0]),f+=u[0].length,f==s.length)break;let d=s[f++];if(f==s.length&&d=="!"){o=0;break}if(d!="/")throw new RangeError("Invalid path: "+s);l=s.slice(f)}let a=r.length-1,h=r[a];if(!h)throw new RangeError("Invalid path: "+s);let c=new gn(i,o,a>0?r.slice(0,a):null);e[h]=c.sort(e[h])}}return Na.add(e)}const Na=new L;class gn{constructor(e,t,i,s){this.tags=e,this.mode=t,this.context=i,this.next=s}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(e){return!e||e.depth{let o=s;for(let l of r)for(let a of l.set){let h=t[a.id];if(h){o=o?o+" "+h:h;break}}return o},scope:i}}function Wu(n,e){let t=null;for(let i of n){let s=i.style(e);s&&(t=t?t+" "+s:s)}return t}function Vu(n,e,t,i=0,s=n.length){let r=new zu(i,Array.isArray(e)?e:[e],t);r.highlightRange(n.cursor(),i,s,"",r.highlighters),r.flush(s)}class zu{constructor(e,t,i){this.at=e,this.highlighters=t,this.span=i,this.class=""}startSpan(e,t){t!=this.class&&(this.flush(e),e>this.at&&(this.at=e),this.class=t)}flush(e){e>this.at&&this.class&&this.span(this.at,e,this.class)}highlightRange(e,t,i,s,r){let{type:o,from:l,to:a}=e;if(l>=i||a<=t)return;o.isTop&&(r=this.highlighters.filter(d=>!d.scope||d.scope(o)));let h=s,c=qu(e)||gn.empty,f=Wu(r,c.tags);if(f&&(h&&(h+=" "),h+=f,c.mode==1&&(s+=(s?" ":"")+f)),this.startSpan(Math.max(t,l),h),c.opaque)return;let u=e.tree&&e.tree.prop(L.mounted);if(u&&u.overlay){let d=e.node.enter(u.overlay[0].from+l,1),p=this.highlighters.filter(m=>!m.scope||m.scope(u.tree.type)),g=e.firstChild();for(let m=0,y=l;;m++){let S=m=M||!e.nextSibling())););if(!S||M>i)break;y=S.to+l,y>t&&(this.highlightRange(d.cursor(),Math.max(t,S.from+l),Math.min(i,y),"",p),this.startSpan(Math.min(i,y),h))}g&&e.parent()}else if(e.firstChild()){u&&(s="");do if(!(e.to<=t)){if(e.from>=i)break;this.highlightRange(e,t,i,s,r),this.startSpan(Math.min(i,e.to),h)}while(e.nextSibling());e.parent()}}}function qu(n){let e=n.type.prop(Na);for(;e&&e.context&&!n.matchContext(e.context);)e=e.next;return e||null}const v=qe.define,Vi=v(),et=v(),Eo=v(et),Io=v(et),tt=v(),zi=v(tt),Jn=v(tt),ze=v(),dt=v(ze),We=v(),Ve=v(),Fs=v(),Zt=v(Fs),qi=v(),C={comment:Vi,lineComment:v(Vi),blockComment:v(Vi),docComment:v(Vi),name:et,variableName:v(et),typeName:Eo,tagName:v(Eo),propertyName:Io,attributeName:v(Io),className:v(et),labelName:v(et),namespace:v(et),macroName:v(et),literal:tt,string:zi,docString:v(zi),character:v(zi),attributeValue:v(zi),number:Jn,integer:v(Jn),float:v(Jn),bool:v(tt),regexp:v(tt),escape:v(tt),color:v(tt),url:v(tt),keyword:We,self:v(We),null:v(We),atom:v(We),unit:v(We),modifier:v(We),operatorKeyword:v(We),controlKeyword:v(We),definitionKeyword:v(We),moduleKeyword:v(We),operator:Ve,derefOperator:v(Ve),arithmeticOperator:v(Ve),logicOperator:v(Ve),bitwiseOperator:v(Ve),compareOperator:v(Ve),updateOperator:v(Ve),definitionOperator:v(Ve),typeOperator:v(Ve),controlOperator:v(Ve),punctuation:Fs,separator:v(Fs),bracket:Zt,angleBracket:v(Zt),squareBracket:v(Zt),paren:v(Zt),brace:v(Zt),content:ze,heading:dt,heading1:v(dt),heading2:v(dt),heading3:v(dt),heading4:v(dt),heading5:v(dt),heading6:v(dt),contentSeparator:v(ze),list:v(ze),quote:v(ze),emphasis:v(ze),strong:v(ze),link:v(ze),monospace:v(ze),strikethrough:v(ze),inserted:v(),deleted:v(),changed:v(),invalid:v(),meta:qi,documentMeta:v(qi),annotation:v(qi),processingInstruction:v(qi),definition:qe.defineModifier(),constant:qe.defineModifier(),function:qe.defineModifier(),standard:qe.defineModifier(),local:qe.defineModifier(),special:qe.defineModifier()};Fa([{tag:C.link,class:"tok-link"},{tag:C.heading,class:"tok-heading"},{tag:C.emphasis,class:"tok-emphasis"},{tag:C.strong,class:"tok-strong"},{tag:C.keyword,class:"tok-keyword"},{tag:C.atom,class:"tok-atom"},{tag:C.bool,class:"tok-bool"},{tag:C.url,class:"tok-url"},{tag:C.labelName,class:"tok-labelName"},{tag:C.inserted,class:"tok-inserted"},{tag:C.deleted,class:"tok-deleted"},{tag:C.literal,class:"tok-literal"},{tag:C.string,class:"tok-string"},{tag:C.number,class:"tok-number"},{tag:[C.regexp,C.escape,C.special(C.string)],class:"tok-string2"},{tag:C.variableName,class:"tok-variableName"},{tag:C.local(C.variableName),class:"tok-variableName tok-local"},{tag:C.definition(C.variableName),class:"tok-variableName tok-definition"},{tag:C.special(C.variableName),class:"tok-variableName2"},{tag:C.definition(C.propertyName),class:"tok-propertyName tok-definition"},{tag:C.typeName,class:"tok-typeName"},{tag:C.namespace,class:"tok-namespace"},{tag:C.className,class:"tok-className"},{tag:C.macroName,class:"tok-macroName"},{tag:C.propertyName,class:"tok-propertyName"},{tag:C.operator,class:"tok-operator"},{tag:C.comment,class:"tok-comment"},{tag:C.meta,class:"tok-meta"},{tag:C.invalid,class:"tok-invalid"},{tag:C.punctuation,class:"tok-punctuation"}]);var _n;const yt=new L;function Ha(n){return D.define({combine:n?e=>e.concat(n):void 0})}const Ku=new L;class Be{constructor(e,t,i=[],s=""){this.data=e,this.name=s,N.prototype.hasOwnProperty("tree")||Object.defineProperty(N.prototype,"tree",{get(){return ye(this)}}),this.parser=t,this.extension=[jt.of(this),N.languageData.of((r,o,l)=>{let a=No(r,o,l),h=a.type.prop(yt);if(!h)return[];let c=r.facet(h),f=a.type.prop(Ku);if(f){let u=a.resolve(o-a.from,l);for(let d of f)if(d.test(u,r)){let p=r.facet(d.facet);return d.type=="replace"?p:p.concat(c)}}return c})].concat(i)}isActiveAt(e,t,i=-1){return No(e,t,i).type.prop(yt)==this.data}findRegions(e){let t=e.facet(jt);if((t==null?void 0:t.data)==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let i=[],s=(r,o)=>{if(r.prop(yt)==this.data){i.push({from:o,to:o+r.length});return}let l=r.prop(L.mounted);if(l){if(l.tree.prop(yt)==this.data){if(l.overlay)for(let a of l.overlay)i.push({from:a.from+o,to:a.to+o});else i.push({from:o,to:o+r.length});return}else if(l.overlay){let a=i.length;if(s(l.tree,l.overlay[0].from+o),i.length>a)return}}for(let a=0;ai.isTop?t:void 0)]}),e.name)}configure(e,t){return new Hs(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}}function ye(n){let e=n.field(Be.state,!1);return e?e.tree:W.empty}class $u{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let i=this.cursorPos-this.string.length;return e=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-i,t-i)}}let ei=null;class Kt{constructor(e,t,i=[],s,r,o,l,a){this.parser=e,this.state=t,this.fragments=i,this.tree=s,this.treeLen=r,this.viewport=o,this.skipped=l,this.scheduleOn=a,this.parse=null,this.tempSkipped=[]}static create(e,t,i){return new Kt(e,t,[],W.empty,0,i,[],null)}startParse(){return this.parser.startParse(new $u(this.state.doc),this.fragments)}work(e,t){return t!=null&&t>=this.state.doc.length&&(t=void 0),this.tree!=W.empty&&this.isDone(t??this.state.doc.length)?(this.takeTree(),!0):this.withContext(()=>{var i;if(typeof e=="number"){let s=Date.now()+e;e=()=>Date.now()>s}for(this.parse||(this.parse=this.startParse()),t!=null&&(this.parse.stoppedAt==null||this.parse.stoppedAt>t)&&t=this.treeLen&&((this.parse.stoppedAt==null||this.parse.stoppedAt>e)&&this.parse.stopAt(e),this.withContext(()=>{for(;!(t=this.parse.advance()););}),this.treeLen=e,this.tree=t,this.fragments=this.withoutTempSkipped(Ye.addTree(this.tree,this.fragments,!0)),this.parse=null)}withContext(e){let t=ei;ei=this;try{return e()}finally{ei=t}}withoutTempSkipped(e){for(let t;t=this.tempSkipped.pop();)e=Fo(e,t.from,t.to);return e}changes(e,t){let{fragments:i,tree:s,treeLen:r,viewport:o,skipped:l}=this;if(this.takeTree(),!e.empty){let a=[];if(e.iterChangedRanges((h,c,f,u)=>a.push({fromA:h,toA:c,fromB:f,toB:u})),i=Ye.applyChanges(i,a),s=W.empty,r=0,o={from:e.mapPos(o.from,-1),to:e.mapPos(o.to,1)},this.skipped.length){l=[];for(let h of this.skipped){let c=e.mapPos(h.from,1),f=e.mapPos(h.to,-1);ce.from&&(this.fragments=Fo(this.fragments,s,r),this.skipped.splice(i--,1))}return this.skipped.length>=t?!1:(this.reset(),!0)}reset(){this.parse&&(this.takeTree(),this.parse=null)}skipUntilInView(e,t){this.skipped.push({from:e,to:t})}static getSkippingParser(e){return new class extends Ia{createParse(t,i,s){let r=s[0].from,o=s[s.length-1].to;return{parsedPos:r,advance(){let a=ei;if(a){for(let h of s)a.tempSkipped.push(h);e&&(a.scheduleOn=a.scheduleOn?Promise.all([a.scheduleOn,e]):e)}return this.parsedPos=o,new W(me.none,[],[],o-r)},stoppedAt:null,stopAt(){}}}}}isDone(e){e=Math.min(e,this.state.doc.length);let t=this.fragments;return this.treeLen>=e&&t.length&&t[0].from==0&&t[0].to>=e}static get(){return ei}}function Fo(n,e,t){return Ye.applyChanges(n,[{fromA:e,toA:t,fromB:e,toB:t}])}class $t{constructor(e){this.context=e,this.tree=e.tree}apply(e){if(!e.docChanged&&this.tree==this.context.tree)return this;let t=this.context.changes(e.changes,e.state),i=this.context.treeLen==e.startState.doc.length?void 0:Math.max(e.changes.mapPos(this.context.treeLen),t.viewport.to);return t.work(20,i)||t.takeTree(),new $t(t)}static init(e){let t=Math.min(3e3,e.doc.length),i=Kt.create(e.facet(jt).parser,e,{from:0,to:t});return i.work(20,t)||i.takeTree(),new $t(i)}}Be.state=be.define({create:$t.init,update(n,e){for(let t of e.effects)if(t.is(Be.setState))return t.value;return e.startState.facet(jt)!=e.state.facet(jt)?$t.init(e.state):n.apply(e)}});let Wa=n=>{let e=setTimeout(()=>n(),500);return()=>clearTimeout(e)};typeof requestIdleCallback<"u"&&(Wa=n=>{let e=-1,t=setTimeout(()=>{e=requestIdleCallback(n,{timeout:500-100})},100);return()=>e<0?clearTimeout(t):cancelIdleCallback(e)});const Xn=typeof navigator<"u"&&(!((_n=navigator.scheduling)===null||_n===void 0)&&_n.isInputPending)?()=>navigator.scheduling.isInputPending():null,ju=ge.fromClass(class{constructor(e){this.view=e,this.working=null,this.workScheduled=0,this.chunkEnd=-1,this.chunkBudget=-1,this.work=this.work.bind(this),this.scheduleWork()}update(e){let t=this.view.state.field(Be.state).context;(t.updateViewport(e.view.viewport)||this.view.viewport.to>t.treeLen)&&this.scheduleWork(),e.docChanged&&(this.view.hasFocus&&(this.chunkBudget+=50),this.scheduleWork()),this.checkAsyncSchedule(t)}scheduleWork(){if(this.working)return;let{state:e}=this.view,t=e.field(Be.state);(t.tree!=t.context.tree||!t.context.isDone(e.doc.length))&&(this.working=Wa(this.work))}work(e){this.working=null;let t=Date.now();if(this.chunkEnds+1e3,a=r.context.work(()=>Xn&&Xn()||Date.now()>o,s+(l?0:1e5));this.chunkBudget-=Date.now()-t,(a||this.chunkBudget<=0)&&(r.context.takeTree(),this.view.dispatch({effects:Be.setState.of(new $t(r.context))})),this.chunkBudget>0&&!(a&&!l)&&this.scheduleWork(),this.checkAsyncSchedule(r.context)}checkAsyncSchedule(e){e.scheduleOn&&(this.workScheduled++,e.scheduleOn.then(()=>this.scheduleWork()).catch(t=>Ie(this.view.state,t)).then(()=>this.workScheduled--),e.scheduleOn=null)}destroy(){this.working&&this.working()}isWorking(){return!!(this.working||this.workScheduled>0)}},{eventHandlers:{focus(){this.scheduleWork()}}}),jt=D.define({combine(n){return n.length?n[0]:null},enables:n=>[Be.state,ju,O.contentAttributes.compute([n],e=>{let t=e.facet(n);return t&&t.name?{"data-language":t.name}:{}})]});class Xg{constructor(e,t=[]){this.language=e,this.support=t,this.extension=[e,t]}}const Va=D.define(),Tn=D.define({combine:n=>{if(!n.length)return" ";let e=n[0];if(!e||/\S/.test(e)||Array.from(e).some(t=>t!=e[0]))throw new Error("Invalid indent unit: "+JSON.stringify(n[0]));return e}});function Ct(n){let e=n.facet(Tn);return e.charCodeAt(0)==9?n.tabSize*e.length:e.length}function mn(n,e){let t="",i=n.tabSize,s=n.facet(Tn)[0];if(s==" "){for(;e>=i;)t+=" ",e-=i;s=" "}for(let r=0;r=e?Gu(n,t,e):null}class Bn{constructor(e,t={}){this.state=e,this.options=t,this.unit=Ct(e)}lineAt(e,t=1){let i=this.state.doc.lineAt(e),{simulateBreak:s,simulateDoubleBreak:r}=this.options;return s!=null&&s>=i.from&&s<=i.to?r&&s==e?{text:"",from:e}:(t<0?s-1&&(r+=o-this.countColumn(i,i.search(/\S|$/))),r}countColumn(e,t=e.length){return Gt(e,this.state.tabSize,t)}lineIndent(e,t=1){let{text:i,from:s}=this.lineAt(e,t),r=this.options.overrideIndentation;if(r){let o=r(s);if(o>-1)return o}return this.countColumn(i,i.search(/\S|$/))}get simulatedBreak(){return this.options.simulateBreak||null}}const Uu=new L;function Gu(n,e,t){return qa(e.resolveInner(t).enterUnfinishedNodesBefore(t),t,n)}function Ju(n){return n.pos==n.options.simulateBreak&&n.options.simulateDoubleBreak}function _u(n){let e=n.type.prop(Uu);if(e)return e;let t=n.firstChild,i;if(t&&(i=t.type.prop(L.closedBy))){let s=n.lastChild,r=s&&i.indexOf(s.name)>-1;return o=>Ka(o,!0,1,void 0,r&&!Ju(o)?s.from:void 0)}return n.parent==null?Xu:null}function qa(n,e,t){for(;n;n=n.parent){let i=_u(n);if(i)return i(hr.create(t,e,n))}return null}function Xu(){return 0}class hr extends Bn{constructor(e,t,i){super(e.state,e.options),this.base=e,this.pos=t,this.node=i}static create(e,t,i){return new hr(e,t,i)}get textAfter(){return this.textAfterPos(this.pos)}get baseIndent(){return this.baseIndentFor(this.node)}baseIndentFor(e){let t=this.state.doc.lineAt(e.from);for(;;){let i=e.resolve(t.from);for(;i.parent&&i.parent.from==i.from;)i=i.parent;if(Yu(i,e))break;t=this.state.doc.lineAt(i.from)}return this.lineIndent(t.from)}continue(){let e=this.node.parent;return e?qa(e,this.pos,this.base):0}}function Yu(n,e){for(let t=e;t;t=t.parent)if(n==t)return!0;return!1}function Qu(n){let e=n.node,t=e.childAfter(e.from),i=e.lastChild;if(!t)return null;let s=n.options.simulateBreak,r=n.state.doc.lineAt(t.from),o=s==null||s<=r.from?r.to:Math.min(r.to,s);for(let l=t.to;;){let a=e.childAfter(l);if(!a||a==i)return null;if(!a.type.isSkipped)return a.fromKa(i,e,t,n)}function Ka(n,e,t,i,s){let r=n.textAfter,o=r.match(/^\s*/)[0].length,l=i&&r.slice(o,o+i.length)==i||s==n.pos+o,a=e?Qu(n):null;return a?l?n.column(a.from):n.column(a.to):n.baseIndent+(l?0:n.unit*t)}const Qg=n=>n.baseIndent;function Zg({except:n,units:e=1}={}){return t=>{let i=n&&n.test(t.textAfter);return t.baseIndent+(i?0:e*t.unit)}}const em=new L;function tm(n){let e=n.firstChild,t=n.lastChild;return e&&e.tol.prop(yt)==o.data:o?l=>l==o:void 0,this.style=Fa(e.map(l=>({tag:l.tag,class:l.class||s(Object.assign({},l,{tag:null}))})),{all:r}).style,this.module=i?new lt(i):null,this.themeType=t.themeType}static define(e,t){return new Pn(e,t||{})}}const Ws=D.define(),$a=D.define({combine(n){return n.length?[n[0]]:null}});function Yn(n){let e=n.facet(Ws);return e.length?e:n.facet($a)}function im(n,e){let t=[ed],i;return n instanceof Pn&&(n.module&&t.push(O.styleModule.of(n.module)),i=n.themeType),e!=null&&e.fallback?t.push($a.of(n)):i?t.push(Ws.computeN([O.darkTheme],s=>s.facet(O.darkTheme)==(i=="dark")?[n]:[])):t.push(Ws.of(n)),t}class Zu{constructor(e){this.markCache=Object.create(null),this.tree=ye(e.state),this.decorations=this.buildDeco(e,Yn(e.state))}update(e){let t=ye(e.state),i=Yn(e.state),s=i!=Yn(e.startState);t.length{i.add(o,l,this.markCache[a]||(this.markCache[a]=B.mark({class:a})))},s,r);return i.finish()}}const ed=At.high(ge.fromClass(Zu,{decorations:n=>n.decorations})),nm=Pn.define([{tag:C.meta,color:"#404740"},{tag:C.link,textDecoration:"underline"},{tag:C.heading,textDecoration:"underline",fontWeight:"bold"},{tag:C.emphasis,fontStyle:"italic"},{tag:C.strong,fontWeight:"bold"},{tag:C.strikethrough,textDecoration:"line-through"},{tag:C.keyword,color:"#708"},{tag:[C.atom,C.bool,C.url,C.contentSeparator,C.labelName],color:"#219"},{tag:[C.literal,C.inserted],color:"#164"},{tag:[C.string,C.deleted],color:"#a11"},{tag:[C.regexp,C.escape,C.special(C.string)],color:"#e40"},{tag:C.definition(C.variableName),color:"#00f"},{tag:C.local(C.variableName),color:"#30a"},{tag:[C.typeName,C.namespace],color:"#085"},{tag:C.className,color:"#167"},{tag:[C.special(C.variableName),C.macroName],color:"#256"},{tag:C.definition(C.propertyName),color:"#00c"},{tag:C.comment,color:"#940"},{tag:C.invalid,color:"#f00"}]),td=O.baseTheme({"&.cm-focused .cm-matchingBracket":{backgroundColor:"#328c8252"},"&.cm-focused .cm-nonmatchingBracket":{backgroundColor:"#bb555544"}}),ja=1e4,Ua="()[]{}",Ga=D.define({combine(n){return Mt(n,{afterCursor:!0,brackets:Ua,maxScanDistance:ja,renderMatch:sd})}}),id=B.mark({class:"cm-matchingBracket"}),nd=B.mark({class:"cm-nonmatchingBracket"});function sd(n){let e=[],t=n.matched?id:nd;return e.push(t.range(n.start.from,n.start.to)),n.end&&e.push(t.range(n.end.from,n.end.to)),e}const rd=be.define({create(){return B.none},update(n,e){if(!e.docChanged&&!e.selection)return n;let t=[],i=e.state.facet(Ga);for(let s of e.state.selection.ranges){if(!s.empty)continue;let r=Ue(e.state,s.head,-1,i)||s.head>0&&Ue(e.state,s.head-1,1,i)||i.afterCursor&&(Ue(e.state,s.head,1,i)||s.headO.decorations.from(n)}),od=[rd,td];function sm(n={}){return[Ga.of(n),od]}const ld=new L;function Vs(n,e,t){let i=n.prop(e<0?L.openedBy:L.closedBy);if(i)return i;if(n.name.length==1){let s=t.indexOf(n.name);if(s>-1&&s%2==(e<0?1:0))return[t[s+e]]}return null}function zs(n){let e=n.type.prop(ld);return e?e(n.node):n}function Ue(n,e,t,i={}){let s=i.maxScanDistance||ja,r=i.brackets||Ua,o=ye(n),l=o.resolveInner(e,t);for(let a=l;a;a=a.parent){let h=Vs(a.type,t,r);if(h&&a.from0?e>=c.from&&ec.from&&e<=c.to))return ad(n,e,t,a,c,h,r)}}return hd(n,e,t,o,l.type,s,r)}function ad(n,e,t,i,s,r,o){let l=i.parent,a={from:s.from,to:s.to},h=0,c=l==null?void 0:l.cursor();if(c&&(t<0?c.childBefore(i.from):c.childAfter(i.to)))do if(t<0?c.to<=i.from:c.from>=i.to){if(h==0&&r.indexOf(c.type.name)>-1&&c.from0)return null;let h={from:t<0?e-1:e,to:t>0?e+1:e},c=n.doc.iterRange(e,t>0?n.doc.length:0),f=0;for(let u=0;!c.next().done&&u<=r;){let d=c.value;t<0&&(u+=d.length);let p=e+u*t;for(let g=t>0?0:d.length-1,m=t>0?d.length:-1;g!=m;g+=t){let y=o.indexOf(d[g]);if(!(y<0||i.resolveInner(p+g,1).type!=s))if(y%2==0==t>0)f++;else{if(f==1)return{start:h,end:{from:p+g,to:p+g+1},matched:y>>1==a>>1};f--}}t>0&&(u+=d.length)}return c.done?{start:h,matched:!1}:null}function Ho(n,e,t,i=0,s=0){e==null&&(e=n.search(/[^\s\u00a0]/),e==-1&&(e=n.length));let r=s;for(let o=i;o=this.string.length}sol(){return this.pos==0}peek(){return this.string.charAt(this.pos)||void 0}next(){if(this.post}eatSpace(){let e=this.pos;for(;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e}skipToEnd(){this.pos=this.string.length}skipTo(e){let t=this.string.indexOf(e,this.pos);if(t>-1)return this.pos=t,!0}backUp(e){this.pos-=e}column(){return this.lastColumnPosi?o.toLowerCase():o,r=this.string.substr(this.pos,e.length);return s(r)==s(e)?(t!==!1&&(this.pos+=e.length),!0):null}else{let s=this.string.slice(this.pos).match(e);return s&&s.index>0?null:(s&&t!==!1&&(this.pos+=s[0].length),s)}}current(){return this.string.slice(this.start,this.pos)}}function cd(n){return{name:n.name||"",token:n.token,blankLine:n.blankLine||(()=>{}),startState:n.startState||(()=>!0),copyState:n.copyState||fd,indent:n.indent||(()=>null),languageData:n.languageData||{},tokenTable:n.tokenTable||fr}}function fd(n){if(typeof n!="object")return n;let e={};for(let t in n){let i=n[t];e[t]=i instanceof Array?i.slice():i}return e}const Wo=new WeakMap;class _a extends Be{constructor(e){let t=Ha(e.languageData),i=cd(e),s,r=new class extends Ia{createParse(o,l,a){return new dd(s,o,l,a)}};super(t,r,[Va.of((o,l)=>this.getIndent(o,l))],e.name),this.topNode=md(t),s=this,this.streamParser=i,this.stateAfter=new L({perNode:!0}),this.tokenTable=e.tokenTable?new Za(i.tokenTable):gd}static define(e){return new _a(e)}getIndent(e,t){let i=ye(e.state),s=i.resolve(t);for(;s&&s.type!=this.topNode;)s=s.parent;if(!s)return null;let r,{overrideIndentation:o}=e.options;o&&(r=Wo.get(e.state),r!=null&&r1e4)return null;for(;a=i&&t+e.length<=s&&e.prop(n.stateAfter);if(r)return{state:n.streamParser.copyState(r),pos:t+e.length};for(let o=e.children.length-1;o>=0;o--){let l=e.children[o],a=t+e.positions[o],h=l instanceof W&&a=e.length)return e;!s&&e.type==n.topNode&&(s=!0);for(let r=e.children.length-1;r>=0;r--){let o=e.positions[r],l=e.children[r],a;if(ot&&cr(n,s.tree,0-s.offset,t,o),a;if(l&&(a=Xa(n,s.tree,t+s.offset,l.pos+s.offset,!1)))return{state:l.state,tree:a}}return{state:n.streamParser.startState(i?Ct(i):4),tree:W.empty}}class dd{constructor(e,t,i,s){this.lang=e,this.input=t,this.fragments=i,this.ranges=s,this.stoppedAt=null,this.chunks=[],this.chunkPos=[],this.chunk=[],this.chunkReused=void 0,this.rangeIndex=0,this.to=s[s.length-1].to;let r=Kt.get(),o=s[0].from,{state:l,tree:a}=ud(e,i,o,r==null?void 0:r.state);this.state=l,this.parsedPos=this.chunkStart=o+a.length;for(let h=0;h=t?this.finish():e&&this.parsedPos>=e.viewport.to?(e.skipUntilInView(this.parsedPos,t),this.finish()):null}stopAt(e){this.stoppedAt=e}lineAfter(e){let t=this.input.chunk(e);if(this.input.lineChunks)t==` -`&&(t="");else{let i=t.indexOf(` -`);i>-1&&(t=t.slice(0,i))}return e+t.length<=this.to?t:t.slice(0,this.to-e)}nextLine(){let e=this.parsedPos,t=this.lineAfter(e),i=e+t.length;for(let s=this.rangeIndex;;){let r=this.ranges[s].to;if(r>=i||(t=t.slice(0,r-(i-t.length)),s++,s==this.ranges.length))break;let o=this.ranges[s].from,l=this.lineAfter(o);t+=l,i=o+l.length}return{line:t,end:i}}skipGapsTo(e,t,i){for(;;){let s=this.ranges[this.rangeIndex].to,r=e+t;if(i>0?s>r:s>=r)break;let o=this.ranges[++this.rangeIndex].from;t+=o-s}return t}moveRangeIndex(){for(;this.ranges[this.rangeIndex].to1){r=this.skipGapsTo(t,r,1),t+=r;let o=this.chunk.length;r=this.skipGapsTo(i,r,-1),i+=r,s+=this.chunk.length-o}return this.chunk.push(e,t,i,s),r}parseLine(e){let{line:t,end:i}=this.nextLine(),s=0,{streamParser:r}=this.lang,o=new Ja(t,e?e.state.tabSize:4,e?Ct(e.state):2);if(o.eol())r.blankLine(this.state,o.indentUnit);else for(;!o.eol();){let l=Ya(r.token,o,this.state);if(l&&(s=this.emitToken(this.lang.tokenTable.resolve(l),this.parsedPos+o.start,this.parsedPos+o.pos,4,s)),o.start>1e4)break}this.parsedPos=i,this.moveRangeIndex(),this.parsedPose.start)return s}throw new Error("Stream parser failed to advance stream.")}const fr=Object.create(null),mi=[me.none],pd=new rr(mi),Vo=[],Qa=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])Qa[n]=eh(fr,e);class Za{constructor(e){this.extra=e,this.table=Object.assign(Object.create(null),Qa)}resolve(e){return e?this.table[e]||(this.table[e]=eh(this.extra,e)):0}}const gd=new Za(fr);function Qn(n,e){Vo.indexOf(n)>-1||(Vo.push(n),console.warn(e))}function eh(n,e){let t=null;for(let r of e.split(".")){let o=n[r]||C[r];o?typeof o=="function"?t?t=o(t):Qn(r,`Modifier ${r} used at start of tag`):t?Qn(r,`Tag ${r} used as modifier`):t=o:Qn(r,`Unknown highlighting tag ${r}`)}if(!t)return 0;let i=e.replace(/ /g,"_"),s=me.define({id:mi.length,name:i,props:[Hu({[i]:t})]});return mi.push(s),s.id}function md(n){let e=me.define({id:mi.length,name:"Document",props:[yt.add(()=>n)],top:!0});return mi.push(e),e}const yd=n=>{let{state:e}=n,t=e.doc.lineAt(e.selection.main.from),i=dr(n.state,t.from);return i.line?bd(n):i.block?wd(n):!1};function ur(n,e){return({state:t,dispatch:i})=>{if(t.readOnly)return!1;let s=n(e,t);return s?(i(t.update(s)),!0):!1}}const bd=ur(vd,0),xd=ur(th,0),wd=ur((n,e)=>th(n,e,Sd(e)),0);function dr(n,e){let t=n.languageDataAt("commentTokens",e);return t.length?t[0]:{}}const ti=50;function kd(n,{open:e,close:t},i,s){let r=n.sliceDoc(i-ti,i),o=n.sliceDoc(s,s+ti),l=/\s*$/.exec(r)[0].length,a=/^\s*/.exec(o)[0].length,h=r.length-l;if(r.slice(h-e.length,h)==e&&o.slice(a,a+t.length)==t)return{open:{pos:i-l,margin:l&&1},close:{pos:s+a,margin:a&&1}};let c,f;s-i<=2*ti?c=f=n.sliceDoc(i,s):(c=n.sliceDoc(i,i+ti),f=n.sliceDoc(s-ti,s));let u=/^\s*/.exec(c)[0].length,d=/\s*$/.exec(f)[0].length,p=f.length-d-t.length;return c.slice(u,u+e.length)==e&&f.slice(p,p+t.length)==t?{open:{pos:i+u+e.length,margin:/\s/.test(c.charAt(u+e.length))?1:0},close:{pos:s-d-t.length,margin:/\s/.test(f.charAt(p-1))?1:0}}:null}function Sd(n){let e=[];for(let t of n.selection.ranges){let i=n.doc.lineAt(t.from),s=t.to<=i.to?i:n.doc.lineAt(t.to),r=e.length-1;r>=0&&e[r].to>i.from?e[r].to=s.to:e.push({from:i.from+/^\s*/.exec(i.text)[0].length,to:s.to})}return e}function th(n,e,t=e.selection.ranges){let i=t.map(r=>dr(e,r.from).block);if(!i.every(r=>r))return null;let s=t.map((r,o)=>kd(e,i[o],r.from,r.to));if(n!=2&&!s.every(r=>r))return{changes:e.changes(t.map((r,o)=>s[o]?[]:[{from:r.from,insert:i[o].open+" "},{from:r.to,insert:" "+i[o].close}]))};if(n!=1&&s.some(r=>r)){let r=[];for(let o=0,l;os&&(r==o||o>f.from)){s=f.from;let u=/^\s*/.exec(f.text)[0].length,d=u==f.length,p=f.text.slice(u,u+h.length)==h?u:-1;ur.comment<0&&(!r.empty||r.single))){let r=[];for(let{line:l,token:a,indent:h,empty:c,single:f}of i)(f||!c)&&r.push({from:l.from+h,insert:a+" "});let o=e.changes(r);return{changes:o,selection:e.selection.map(o,1)}}else if(n!=1&&i.some(r=>r.comment>=0)){let r=[];for(let{line:o,comment:l,token:a}of i)if(l>=0){let h=o.from+l,c=h+a.length;o.text[c-o.from]==" "&&c++,r.push({from:h,to:c})}return{changes:r}}return null}const qs=Ze.define(),Cd=Ze.define(),Ad=D.define(),ih=D.define({combine(n){return Mt(n,{minDepth:100,newGroupDelay:500,joinToEvent:(e,t)=>t},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(e,t)=>(i,s)=>e(i,s)||t(i,s)})}});function Md(n){let e=0;return n.iterChangedRanges((t,i)=>e=i),e}const nh=be.define({create(){return Ge.empty},update(n,e){let t=e.state.facet(ih),i=e.annotation(qs);if(i){let a=e.docChanged?b.single(Md(e.changes)):void 0,h=Se.fromTransaction(e,a),c=i.side,f=c==0?n.undone:n.done;return h?f=yn(f,f.length,t.minDepth,h):f=oh(f,e.startState.selection),new Ge(c==0?i.rest:f,c==0?f:i.rest)}let s=e.annotation(Cd);if((s=="full"||s=="before")&&(n=n.isolate()),e.annotation(ee.addToHistory)===!1)return e.changes.empty?n:n.addMapping(e.changes.desc);let r=Se.fromTransaction(e),o=e.annotation(ee.time),l=e.annotation(ee.userEvent);return r?n=n.addChanges(r,o,l,t,e):e.selection&&(n=n.addSelection(e.startState.selection,o,l,t.newGroupDelay)),(s=="full"||s=="after")&&(n=n.isolate()),n},toJSON(n){return{done:n.done.map(e=>e.toJSON()),undone:n.undone.map(e=>e.toJSON())}},fromJSON(n){return new Ge(n.done.map(Se.fromJSON),n.undone.map(Se.fromJSON))}});function rm(n={}){return[nh,ih.of(n),O.domEventHandlers({beforeinput(e,t){let i=e.inputType=="historyUndo"?sh:e.inputType=="historyRedo"?Ks:null;return i?(e.preventDefault(),i(t)):!1}})]}function Ln(n,e){return function({state:t,dispatch:i}){if(!e&&t.readOnly)return!1;let s=t.field(nh,!1);if(!s)return!1;let r=s.pop(n,t,e);return r?(i(r),!0):!1}}const sh=Ln(0,!1),Ks=Ln(1,!1),Dd=Ln(0,!0),Od=Ln(1,!0);class Se{constructor(e,t,i,s,r){this.changes=e,this.effects=t,this.mapped=i,this.startSelection=s,this.selectionsAfter=r}setSelAfter(e){return new Se(this.changes,this.effects,this.mapped,this.startSelection,e)}toJSON(){var e,t,i;return{changes:(e=this.changes)===null||e===void 0?void 0:e.toJSON(),mapped:(t=this.mapped)===null||t===void 0?void 0:t.toJSON(),startSelection:(i=this.startSelection)===null||i===void 0?void 0:i.toJSON(),selectionsAfter:this.selectionsAfter.map(s=>s.toJSON())}}static fromJSON(e){return new Se(e.changes&&Z.fromJSON(e.changes),[],e.mapped&&Je.fromJSON(e.mapped),e.startSelection&&b.fromJSON(e.startSelection),e.selectionsAfter.map(b.fromJSON))}static fromTransaction(e,t){let i=Pe;for(let s of e.startState.facet(Ad)){let r=s(e);r.length&&(i=i.concat(r))}return!i.length&&e.changes.empty?null:new Se(e.changes.invert(e.startState.doc),i,void 0,t||e.startState.selection,Pe)}static selection(e){return new Se(void 0,Pe,void 0,void 0,e)}}function yn(n,e,t,i){let s=e+1>t+20?e-t-1:0,r=n.slice(s,e);return r.push(i),r}function Td(n,e){let t=[],i=!1;return n.iterChangedRanges((s,r)=>t.push(s,r)),e.iterChangedRanges((s,r,o,l)=>{for(let a=0;a=h&&o<=c&&(i=!0)}}),i}function Bd(n,e){return n.ranges.length==e.ranges.length&&n.ranges.filter((t,i)=>t.empty!=e.ranges[i].empty).length===0}function rh(n,e){return n.length?e.length?n.concat(e):n:e}const Pe=[],Pd=200;function oh(n,e){if(n.length){let t=n[n.length-1],i=t.selectionsAfter.slice(Math.max(0,t.selectionsAfter.length-Pd));return i.length&&i[i.length-1].eq(e)?n:(i.push(e),yn(n,n.length-1,1e9,t.setSelAfter(i)))}else return[Se.selection([e])]}function Ld(n){let e=n[n.length-1],t=n.slice();return t[n.length-1]=e.setSelAfter(e.selectionsAfter.slice(0,e.selectionsAfter.length-1)),t}function Zn(n,e){if(!n.length)return n;let t=n.length,i=Pe;for(;t;){let s=Rd(n[t-1],e,i);if(s.changes&&!s.changes.empty||s.effects.length){let r=n.slice(0,t);return r[t-1]=s,r}else e=s.mapped,t--,i=s.selectionsAfter}return i.length?[Se.selection(i)]:Pe}function Rd(n,e,t){let i=rh(n.selectionsAfter.length?n.selectionsAfter.map(l=>l.map(e)):Pe,t);if(!n.changes)return Se.selection(i);let s=n.changes.map(e),r=e.mapDesc(n.changes,!0),o=n.mapped?n.mapped.composeDesc(r):r;return new Se(s,E.mapEffects(n.effects,e),o,n.startSelection.map(r),i)}const Ed=/^(input\.type|delete)($|\.)/;class Ge{constructor(e,t,i=0,s=void 0){this.done=e,this.undone=t,this.prevTime=i,this.prevUserEvent=s}isolate(){return this.prevTime?new Ge(this.done,this.undone):this}addChanges(e,t,i,s,r){let o=this.done,l=o[o.length-1];return l&&l.changes&&!l.changes.empty&&e.changes&&(!i||Ed.test(i))&&(!l.selectionsAfter.length&&t-this.prevTime0&&t-this.prevTimet.empty?n.moveByChar(t,e):Rn(t,e))}function fe(n){return n.textDirectionAt(n.state.selection.main.head)==J.LTR}const ah=n=>lh(n,!fe(n)),hh=n=>lh(n,fe(n));function ch(n,e){return Ne(n,t=>t.empty?n.moveByGroup(t,e):Rn(t,e))}const Id=n=>ch(n,!fe(n)),Nd=n=>ch(n,fe(n));function Fd(n,e,t){if(e.type.prop(t))return!0;let i=e.to-e.from;return i&&(i>2||/[^\s,.;:]/.test(n.sliceDoc(e.from,e.to)))||e.firstChild}function En(n,e,t){let i=ye(n).resolveInner(e.head),s=t?L.closedBy:L.openedBy;for(let a=e.head;;){let h=t?i.childAfter(a):i.childBefore(a);if(!h)break;Fd(n,h,s)?i=h:a=t?h.to:h.from}let r=i.type.prop(s),o,l;return r&&(o=t?Ue(n,i.from,1):Ue(n,i.to,-1))&&o.matched?l=t?o.end.to:o.end.from:l=t?i.to:i.from,b.cursor(l,t?-1:1)}const Hd=n=>Ne(n,e=>En(n.state,e,!fe(n))),Wd=n=>Ne(n,e=>En(n.state,e,fe(n)));function fh(n,e){return Ne(n,t=>{if(!t.empty)return Rn(t,e);let i=n.moveVertically(t,e);return i.head!=t.head?i:n.moveToLineBoundary(t,e)})}const uh=n=>fh(n,!1),dh=n=>fh(n,!0);function ph(n){let e=n.scrollDOM.clientHeighto.empty?n.moveVertically(o,e,t.height):Rn(o,e));if(s.eq(i.selection))return!1;let r;if(t.selfScroll){let o=n.coordsAtPos(i.selection.main.head),l=n.scrollDOM.getBoundingClientRect(),a=l.top+t.marginTop,h=l.bottom-t.marginBottom;o&&o.top>a&&o.bottomgh(n,!1),$s=n=>gh(n,!0);function ft(n,e,t){let i=n.lineBlockAt(e.head),s=n.moveToLineBoundary(e,t);if(s.head==e.head&&s.head!=(t?i.to:i.from)&&(s=n.moveToLineBoundary(e,t,!1)),!t&&s.head==i.from&&i.length){let r=/^\s*/.exec(n.state.sliceDoc(i.from,Math.min(i.from+100,i.to)))[0].length;r&&e.head!=i.from+r&&(s=b.cursor(i.from+r))}return s}const Vd=n=>Ne(n,e=>ft(n,e,!0)),zd=n=>Ne(n,e=>ft(n,e,!1)),qd=n=>Ne(n,e=>ft(n,e,!fe(n))),Kd=n=>Ne(n,e=>ft(n,e,fe(n))),$d=n=>Ne(n,e=>b.cursor(n.lineBlockAt(e.head).from,1)),jd=n=>Ne(n,e=>b.cursor(n.lineBlockAt(e.head).to,-1));function Ud(n,e,t){let i=!1,s=Jt(n.selection,r=>{let o=Ue(n,r.head,-1)||Ue(n,r.head,1)||r.head>0&&Ue(n,r.head-1,1)||r.headUd(n,e,!1);function Ee(n,e){let t=Jt(n.state.selection,i=>{let s=e(i);return b.range(i.anchor,s.head,s.goalColumn,s.bidiLevel||void 0)});return t.eq(n.state.selection)?!1:(n.dispatch(Xe(n.state,t)),!0)}function mh(n,e){return Ee(n,t=>n.moveByChar(t,e))}const yh=n=>mh(n,!fe(n)),bh=n=>mh(n,fe(n));function xh(n,e){return Ee(n,t=>n.moveByGroup(t,e))}const Jd=n=>xh(n,!fe(n)),_d=n=>xh(n,fe(n)),Xd=n=>Ee(n,e=>En(n.state,e,!fe(n))),Yd=n=>Ee(n,e=>En(n.state,e,fe(n)));function wh(n,e){return Ee(n,t=>n.moveVertically(t,e))}const kh=n=>wh(n,!1),Sh=n=>wh(n,!0);function vh(n,e){return Ee(n,t=>n.moveVertically(t,e,ph(n).height))}const qo=n=>vh(n,!1),Ko=n=>vh(n,!0),Qd=n=>Ee(n,e=>ft(n,e,!0)),Zd=n=>Ee(n,e=>ft(n,e,!1)),ep=n=>Ee(n,e=>ft(n,e,!fe(n))),tp=n=>Ee(n,e=>ft(n,e,fe(n))),ip=n=>Ee(n,e=>b.cursor(n.lineBlockAt(e.head).from)),np=n=>Ee(n,e=>b.cursor(n.lineBlockAt(e.head).to)),$o=({state:n,dispatch:e})=>(e(Xe(n,{anchor:0})),!0),jo=({state:n,dispatch:e})=>(e(Xe(n,{anchor:n.doc.length})),!0),Uo=({state:n,dispatch:e})=>(e(Xe(n,{anchor:n.selection.main.anchor,head:0})),!0),Go=({state:n,dispatch:e})=>(e(Xe(n,{anchor:n.selection.main.anchor,head:n.doc.length})),!0),sp=({state:n,dispatch:e})=>(e(n.update({selection:{anchor:0,head:n.doc.length},userEvent:"select"})),!0),rp=({state:n,dispatch:e})=>{let t=Nn(n).map(({from:i,to:s})=>b.range(i,Math.min(s+1,n.doc.length)));return e(n.update({selection:b.create(t),userEvent:"select"})),!0},op=({state:n,dispatch:e})=>{let t=Jt(n.selection,i=>{var s;let r=ye(n).resolveInner(i.head,1);for(;!(r.from=i.to||r.to>i.to&&r.from<=i.from||!(!((s=r.parent)===null||s===void 0)&&s.parent));)r=r.parent;return b.range(r.to,r.from)});return e(Xe(n,t)),!0},lp=({state:n,dispatch:e})=>{let t=n.selection,i=null;return t.ranges.length>1?i=b.create([t.main]):t.main.empty||(i=b.create([b.cursor(t.main.head)])),i?(e(Xe(n,i)),!0):!1};function In(n,e){if(n.state.readOnly)return!1;let t="delete.selection",{state:i}=n,s=i.changeByRange(r=>{let{from:o,to:l}=r;if(o==l){let a=e(o);ao&&(t="delete.forward",a=Ki(n,a,!0)),o=Math.min(o,a),l=Math.max(l,a)}else o=Ki(n,o,!1),l=Ki(n,l,!0);return o==l?{range:r}:{changes:{from:o,to:l},range:b.cursor(o)}});return s.changes.empty?!1:(n.dispatch(i.update(s,{scrollIntoView:!0,userEvent:t,effects:t=="delete.selection"?O.announce.of(i.phrase("Selection deleted")):void 0})),!0)}function Ki(n,e,t){if(n instanceof O)for(let i of n.state.facet(O.atomicRanges).map(s=>s(n)))i.between(e,e,(s,r)=>{se&&(e=t?r:s)});return e}const Ch=(n,e)=>In(n,t=>{let{state:i}=n,s=i.doc.lineAt(t),r,o;if(!e&&t>s.from&&tCh(n,!1),Ah=n=>Ch(n,!0),Mh=(n,e)=>In(n,t=>{let i=t,{state:s}=n,r=s.doc.lineAt(i),o=s.charCategorizer(i);for(let l=null;;){if(i==(e?r.to:r.from)){i==t&&r.number!=(e?s.doc.lines:1)&&(i+=e?1:-1);break}let a=ce(r.text,i-r.from,e)+r.from,h=r.text.slice(Math.min(i,a)-r.from,Math.max(i,a)-r.from),c=o(h);if(l!=null&&c!=l)break;(h!=" "||i!=t)&&(l=c),i=a}return i}),Dh=n=>Mh(n,!1),ap=n=>Mh(n,!0),Oh=n=>In(n,e=>{let t=n.lineBlockAt(e).to;return eIn(n,e=>{let t=n.lineBlockAt(e).from;return e>t?t:Math.max(0,e-1)}),cp=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=n.changeByRange(i=>({changes:{from:i.from,to:i.to,insert:I.of(["",""])},range:b.cursor(i.from)}));return e(n.update(t,{scrollIntoView:!0,userEvent:"input"})),!0},fp=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=n.changeByRange(i=>{if(!i.empty||i.from==0||i.from==n.doc.length)return{range:i};let s=i.from,r=n.doc.lineAt(s),o=s==r.from?s-1:ce(r.text,s-r.from,!1)+r.from,l=s==r.to?s+1:ce(r.text,s-r.from,!0)+r.from;return{changes:{from:o,to:l,insert:n.doc.slice(s,l).append(n.doc.slice(o,s))},range:b.cursor(l)}});return t.changes.empty?!1:(e(n.update(t,{scrollIntoView:!0,userEvent:"move.character"})),!0)};function Nn(n){let e=[],t=-1;for(let i of n.selection.ranges){let s=n.doc.lineAt(i.from),r=n.doc.lineAt(i.to);if(!i.empty&&i.to==r.from&&(r=n.doc.lineAt(i.to-1)),t>=s.number){let o=e[e.length-1];o.to=r.to,o.ranges.push(i)}else e.push({from:s.from,to:r.to,ranges:[i]});t=r.number+1}return e}function Th(n,e,t){if(n.readOnly)return!1;let i=[],s=[];for(let r of Nn(n)){if(t?r.to==n.doc.length:r.from==0)continue;let o=n.doc.lineAt(t?r.to+1:r.from-1),l=o.length+1;if(t){i.push({from:r.to,to:o.to},{from:r.from,insert:o.text+n.lineBreak});for(let a of r.ranges)s.push(b.range(Math.min(n.doc.length,a.anchor+l),Math.min(n.doc.length,a.head+l)))}else{i.push({from:o.from,to:r.from},{from:r.to,insert:n.lineBreak+o.text});for(let a of r.ranges)s.push(b.range(a.anchor-l,a.head-l))}}return i.length?(e(n.update({changes:i,scrollIntoView:!0,selection:b.create(s,n.selection.mainIndex),userEvent:"move.line"})),!0):!1}const up=({state:n,dispatch:e})=>Th(n,e,!1),dp=({state:n,dispatch:e})=>Th(n,e,!0);function Bh(n,e,t){if(n.readOnly)return!1;let i=[];for(let s of Nn(n))t?i.push({from:s.from,insert:n.doc.slice(s.from,s.to)+n.lineBreak}):i.push({from:s.to,insert:n.lineBreak+n.doc.slice(s.from,s.to)});return e(n.update({changes:i,scrollIntoView:!0,userEvent:"input.copyline"})),!0}const pp=({state:n,dispatch:e})=>Bh(n,e,!1),gp=({state:n,dispatch:e})=>Bh(n,e,!0),mp=n=>{if(n.state.readOnly)return!1;let{state:e}=n,t=e.changes(Nn(e).map(({from:s,to:r})=>(s>0?s--:rn.moveVertically(s,!0)).map(t);return n.dispatch({changes:t,selection:i,scrollIntoView:!0,userEvent:"delete.line"}),!0};function yp(n,e){if(/\(\)|\[\]|\{\}/.test(n.sliceDoc(e-1,e+1)))return{from:e,to:e};let t=ye(n).resolveInner(e),i=t.childBefore(e),s=t.childAfter(e),r;return i&&s&&i.to<=e&&s.from>=e&&(r=i.type.prop(L.closedBy))&&r.indexOf(s.name)>-1&&n.doc.lineAt(i.to).from==n.doc.lineAt(s.from).from&&!/\S/.test(n.sliceDoc(i.to,s.from))?{from:i.to,to:s.from}:null}const bp=Ph(!1),xp=Ph(!0);function Ph(n){return({state:e,dispatch:t})=>{if(e.readOnly)return!1;let i=e.changeByRange(s=>{let{from:r,to:o}=s,l=e.doc.lineAt(r),a=!n&&r==o&&yp(e,r);n&&(r=o=(o<=l.to?l:e.doc.lineAt(o)).to);let h=new Bn(e,{simulateBreak:r,simulateDoubleBreak:!!a}),c=za(h,r);for(c==null&&(c=Gt(/^\s*/.exec(e.doc.lineAt(r).text)[0],e.tabSize));ol.from&&r{let s=[];for(let o=i.from;o<=i.to;){let l=n.doc.lineAt(o);l.number>t&&(i.empty||i.to>l.from)&&(e(l,s,i),t=l.number),o=l.to+1}let r=n.changes(s);return{changes:s,range:b.range(r.mapPos(i.anchor,1),r.mapPos(i.head,1))}})}const wp=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let t=Object.create(null),i=new Bn(n,{overrideIndentation:r=>{let o=t[r];return o??-1}}),s=pr(n,(r,o,l)=>{let a=za(i,r.from);if(a==null)return;/\S/.test(r.text)||(a=0);let h=/^\s*/.exec(r.text)[0],c=mn(n,a);(h!=c||l.fromn.readOnly?!1:(e(n.update(pr(n,(t,i)=>{i.push({from:t.from,insert:n.facet(Tn)})}),{userEvent:"input.indent"})),!0),Sp=({state:n,dispatch:e})=>n.readOnly?!1:(e(n.update(pr(n,(t,i)=>{let s=/^\s*/.exec(t.text)[0];if(!s)return;let r=Gt(s,n.tabSize),o=0,l=mn(n,Math.max(0,r-Ct(n)));for(;o({mac:n.key,run:n.run,shift:n.shift}))),lm=[{key:"Alt-ArrowLeft",mac:"Ctrl-ArrowLeft",run:Hd,shift:Xd},{key:"Alt-ArrowRight",mac:"Ctrl-ArrowRight",run:Wd,shift:Yd},{key:"Alt-ArrowUp",run:up},{key:"Shift-Alt-ArrowUp",run:pp},{key:"Alt-ArrowDown",run:dp},{key:"Shift-Alt-ArrowDown",run:gp},{key:"Escape",run:lp},{key:"Mod-Enter",run:xp},{key:"Alt-l",mac:"Ctrl-l",run:rp},{key:"Mod-i",run:op,preventDefault:!0},{key:"Mod-[",run:Sp},{key:"Mod-]",run:kp},{key:"Mod-Alt-\\",run:wp},{key:"Shift-Mod-k",run:mp},{key:"Shift-Mod-\\",run:Gd},{key:"Mod-/",run:yd},{key:"Alt-A",run:xd}].concat(Cp);function le(){var n=arguments[0];typeof n=="string"&&(n=document.createElement(n));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var s=t[i];typeof s=="string"?n.setAttribute(i,s):s!=null&&(n[i]=s)}e++}for(;en.normalize("NFKD"):n=>n;class Ut{constructor(e,t,i=0,s=e.length,r,o){this.test=o,this.value={from:0,to:0},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(i,s),this.bufferStart=i,this.normalize=r?l=>r(Jo(l)):Jo,this.query=this.normalize(t)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return ne(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let t=_s(e),i=this.bufferStart+this.bufferPos;this.bufferPos+=Oe(e);let s=this.normalize(t);for(let r=0,o=i;;r++){let l=s.charCodeAt(r),a=this.match(l,o);if(r==s.length-1){if(a)return this.value=a,this;break}o==i&&rthis.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let t=this.matchPos<=this.to&&this.re.exec(this.curLine);if(t){let i=this.curLineStart+t.index,s=i+t[0].length;if(this.matchPos=bn(this.text,s+(i==s?1:0)),i==this.curLineStart+this.curLine.length&&this.nextLine(),(ithis.value.to)&&(!this.test||this.test(i,s,t)))return this.value={from:i,to:s,match:t},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length=i||s.to<=t){let l=new Ht(t,e.sliceString(t,i));return es.set(e,l),l}if(s.from==t&&s.to==i)return s;let{text:r,from:o}=s;return o>t&&(r=e.sliceString(t,o)+r,o=t),s.to=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,t=this.re.exec(this.flat.text);if(t&&!t[0]&&t.index==e&&(this.re.lastIndex=e+1,t=this.re.exec(this.flat.text)),t){let i=this.flat.from+t.index,s=i+t[0].length;if((this.flat.to>=this.to||t.index+t[0].length<=this.flat.text.length-10)&&(!this.test||this.test(i,s,t)))return this.value={from:i,to:s,match:t},this.matchPos=bn(this.text,s+(i==s?1:0)),this}if(this.flat.to==this.to)return this.done=!0,this;this.flat=Ht.get(this.text,this.flat.from,this.chunkEnd(this.flat.from+this.flat.text.length*2))}}}typeof Symbol<"u"&&(Eh.prototype[Symbol.iterator]=Ih.prototype[Symbol.iterator]=function(){return this});function Ap(n){try{return new RegExp(n,gr),!0}catch{return!1}}function bn(n,e){if(e>=n.length)return e;let t=n.lineAt(e),i;for(;e=56320&&i<57344;)e++;return e}function Us(n){let e=le("input",{class:"cm-textfield",name:"line"}),t=le("form",{class:"cm-gotoLine",onkeydown:s=>{s.keyCode==27?(s.preventDefault(),n.dispatch({effects:xn.of(!1)}),n.focus()):s.keyCode==13&&(s.preventDefault(),i())},onsubmit:s=>{s.preventDefault(),i()}},le("label",n.state.phrase("Go to line"),": ",e)," ",le("button",{class:"cm-button",type:"submit"},n.state.phrase("go")));function i(){let s=/^([+-])?(\d+)?(:\d+)?(%)?$/.exec(e.value);if(!s)return;let{state:r}=n,o=r.doc.lineAt(r.selection.main.head),[,l,a,h,c]=s,f=h?+h.slice(1):0,u=a?+a:o.number;if(a&&c){let g=u/100;l&&(g=g*(l=="-"?-1:1)+o.number/r.doc.lines),u=Math.round(r.doc.lines*g)}else a&&l&&(u=u*(l=="-"?-1:1)+o.number);let d=r.doc.line(Math.max(1,Math.min(r.doc.lines,u))),p=b.cursor(d.from+Math.max(0,Math.min(f,d.length)));n.dispatch({effects:[xn.of(!1),O.scrollIntoView(p.from,{y:"center"})],selection:p}),n.focus()}return{dom:t}}const xn=E.define(),_o=be.define({create(){return!0},update(n,e){for(let t of e.effects)t.is(xn)&&(n=t.value);return n},provide:n=>fn.from(n,e=>e?Us:null)}),Mp=n=>{let e=cn(n,Us);if(!e){let t=[xn.of(!0)];n.state.field(_o,!1)==null&&t.push(E.appendConfig.of([_o,Dp])),n.dispatch({effects:t}),e=cn(n,Us)}return e&&e.dom.querySelector("input").focus(),!0},Dp=O.baseTheme({".cm-panel.cm-gotoLine":{padding:"2px 6px 4px","& label":{fontSize:"80%"}}}),Op={highlightWordAroundCursor:!1,minSelectionLength:1,maxMatches:100,wholeWords:!1},Nh=D.define({combine(n){return Mt(n,Op,{highlightWordAroundCursor:(e,t)=>e||t,minSelectionLength:Math.min,maxMatches:Math.min})}});function am(n){let e=[Rp,Lp];return n&&e.push(Nh.of(n)),e}const Tp=B.mark({class:"cm-selectionMatch"}),Bp=B.mark({class:"cm-selectionMatch cm-selectionMatch-main"});function Xo(n,e,t,i){return(t==0||n(e.sliceDoc(t-1,t))!=$.Word)&&(i==e.doc.length||n(e.sliceDoc(i,i+1))!=$.Word)}function Pp(n,e,t,i){return n(e.sliceDoc(t,t+1))==$.Word&&n(e.sliceDoc(i-1,i))==$.Word}const Lp=ge.fromClass(class{constructor(n){this.decorations=this.getDeco(n)}update(n){(n.selectionSet||n.docChanged||n.viewportChanged)&&(this.decorations=this.getDeco(n.view))}getDeco(n){let e=n.state.facet(Nh),{state:t}=n,i=t.selection;if(i.ranges.length>1)return B.none;let s=i.main,r,o=null;if(s.empty){if(!e.highlightWordAroundCursor)return B.none;let a=t.wordAt(s.head);if(!a)return B.none;o=t.charCategorizer(s.head),r=t.sliceDoc(a.from,a.to)}else{let a=s.to-s.from;if(a200)return B.none;if(e.wholeWords){if(r=t.sliceDoc(s.from,s.to),o=t.charCategorizer(s.head),!(Xo(o,t,s.from,s.to)&&Pp(o,t,s.from,s.to)))return B.none}else if(r=t.sliceDoc(s.from,s.to).trim(),!r)return B.none}let l=[];for(let a of n.visibleRanges){let h=new Ut(t.doc,r,a.from,a.to);for(;!h.next().done;){let{from:c,to:f}=h.value;if((!o||Xo(o,t,c,f))&&(s.empty&&c<=s.from&&f>=s.to?l.push(Bp.range(c,f)):(c>=s.to||f<=s.from)&&l.push(Tp.range(c,f)),l.length>e.maxMatches))return B.none}}return B.set(l)}},{decorations:n=>n.decorations}),Rp=O.baseTheme({".cm-selectionMatch":{backgroundColor:"#99ff7780"},".cm-searchMatch .cm-selectionMatch":{backgroundColor:"transparent"}}),Ep=({state:n,dispatch:e})=>{let{selection:t}=n,i=b.create(t.ranges.map(s=>n.wordAt(s.head)||b.cursor(s.head)),t.mainIndex);return i.eq(t)?!1:(e(n.update({selection:i})),!0)};function Ip(n,e){let{main:t,ranges:i}=n.selection,s=n.wordAt(t.head),r=s&&s.from==t.from&&s.to==t.to;for(let o=!1,l=new Ut(n.doc,e,i[i.length-1].to);;)if(l.next(),l.done){if(o)return null;l=new Ut(n.doc,e,0,Math.max(0,i[i.length-1].from-1)),o=!0}else{if(o&&i.some(a=>a.from==l.value.from))continue;if(r){let a=n.wordAt(l.value.from);if(!a||a.from!=l.value.from||a.to!=l.value.to)continue}return l.value}}const Np=({state:n,dispatch:e})=>{let{ranges:t}=n.selection;if(t.some(r=>r.from===r.to))return Ep({state:n,dispatch:e});let i=n.sliceDoc(t[0].from,t[0].to);if(n.selection.ranges.some(r=>n.sliceDoc(r.from,r.to)!=i))return!1;let s=Ip(n,i);return s?(e(n.update({selection:n.selection.addRange(b.range(s.from,s.to),!1),effects:O.scrollIntoView(s.to)})),!0):!1},_t=D.define({combine(n){return Mt(n,{top:!1,caseSensitive:!1,literal:!1,regexp:!1,wholeWord:!1,createPanel:e=>new Gp(e),scrollToMatch:e=>O.scrollIntoView(e)})}});class Fh{constructor(e){this.search=e.search,this.caseSensitive=!!e.caseSensitive,this.literal=!!e.literal,this.regexp=!!e.regexp,this.replace=e.replace||"",this.valid=!!this.search&&(!this.regexp||Ap(this.search)),this.unquoted=this.unquote(this.search),this.wholeWord=!!e.wholeWord}unquote(e){return this.literal?e:e.replace(/\\([nrt\\])/g,(t,i)=>i=="n"?` -`:i=="r"?"\r":i=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord}create(){return this.regexp?new Vp(this):new Hp(this)}getCursor(e,t=0,i){let s=e.doc?e:N.create({doc:e});return i==null&&(i=s.doc.length),this.regexp?Lt(this,s,t,i):Pt(this,s,t,i)}}class Hh{constructor(e){this.spec=e}}function Pt(n,e,t,i){return new Ut(e.doc,n.unquoted,t,i,n.caseSensitive?void 0:s=>s.toLowerCase(),n.wholeWord?Fp(e.doc,e.charCategorizer(e.selection.main.head)):void 0)}function Fp(n,e){return(t,i,s,r)=>((r>t||r+s.length=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Pt(this.spec,e,Math.max(0,t-this.spec.unquoted.length),Math.min(i+this.spec.unquoted.length,e.doc.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}function Lt(n,e,t,i){return new Eh(e.doc,n.search,{ignoreCase:!n.caseSensitive,test:n.wholeWord?Wp(e.charCategorizer(e.selection.main.head)):void 0},t,i)}function wn(n,e){return n.slice(ce(n,e,!1),e)}function kn(n,e){return n.slice(e,ce(n,e))}function Wp(n){return(e,t,i)=>!i[0].length||(n(wn(i.input,i.index))!=$.Word||n(kn(i.input,i.index))!=$.Word)&&(n(kn(i.input,i.index+i[0].length))!=$.Word||n(wn(i.input,i.index+i[0].length))!=$.Word)}class Vp extends Hh{nextMatch(e,t,i){let s=Lt(this.spec,e,i,e.doc.length).next();return s.done&&(s=Lt(this.spec,e,0,t).next()),s.done?null:s.value}prevMatchInRange(e,t,i){for(let s=1;;s++){let r=Math.max(t,i-s*1e4),o=Lt(this.spec,e,r,i),l=null;for(;!o.next().done;)l=o.value;if(l&&(r==t||l.from>r+10))return l;if(r==t)return null}}prevMatch(e,t,i){return this.prevMatchInRange(e,0,t)||this.prevMatchInRange(e,i,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace.replace(/\$([$&\d+])/g,(t,i)=>i=="$"?"$":i=="&"?e.match[0]:i!="0"&&+i=t)return null;s.push(i.value)}return s}highlight(e,t,i,s){let r=Lt(this.spec,e,Math.max(0,t-250),Math.min(i+250,e.doc.length));for(;!r.next().done;)s(r.value.from,r.value.to)}}const yi=E.define(),mr=E.define(),rt=be.define({create(n){return new ts(Gs(n).create(),null)},update(n,e){for(let t of e.effects)t.is(yi)?n=new ts(t.value.create(),n.panel):t.is(mr)&&(n=new ts(n.query,t.value?yr:null));return n},provide:n=>fn.from(n,e=>e.panel)});class ts{constructor(e,t){this.query=e,this.panel=t}}const zp=B.mark({class:"cm-searchMatch"}),qp=B.mark({class:"cm-searchMatch cm-searchMatch-selected"}),Kp=ge.fromClass(class{constructor(n){this.view=n,this.decorations=this.highlight(n.state.field(rt))}update(n){let e=n.state.field(rt);(e!=n.startState.field(rt)||n.docChanged||n.selectionSet||n.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:n,panel:e}){if(!e||!n.spec.valid)return B.none;let{view:t}=this,i=new kt;for(let s=0,r=t.visibleRanges,o=r.length;sr[s+1].from-2*250;)a=r[++s].to;n.highlight(t.state,l,a,(h,c)=>{let f=t.state.selection.ranges.some(u=>u.from==h&&u.to==c);i.add(h,c,f?qp:zp)})}return i.finish()}},{decorations:n=>n.decorations});function Ci(n){return e=>{let t=e.state.field(rt,!1);return t&&t.query.spec.valid?n(e,t):zh(e)}}const Sn=Ci((n,{query:e})=>{let{to:t}=n.state.selection.main,i=e.nextMatch(n.state,t,t);if(!i)return!1;let s=b.single(i.from,i.to),r=n.state.facet(_t);return n.dispatch({selection:s,effects:[br(n,i),r.scrollToMatch(s.main,n)],userEvent:"select.search"}),Vh(n),!0}),vn=Ci((n,{query:e})=>{let{state:t}=n,{from:i}=t.selection.main,s=e.prevMatch(t,i,i);if(!s)return!1;let r=b.single(s.from,s.to),o=n.state.facet(_t);return n.dispatch({selection:r,effects:[br(n,s),o.scrollToMatch(r.main,n)],userEvent:"select.search"}),Vh(n),!0}),$p=Ci((n,{query:e})=>{let t=e.matchAll(n.state,1e3);return!t||!t.length?!1:(n.dispatch({selection:b.create(t.map(i=>b.range(i.from,i.to))),userEvent:"select.search.matches"}),!0)}),jp=({state:n,dispatch:e})=>{let t=n.selection;if(t.ranges.length>1||t.main.empty)return!1;let{from:i,to:s}=t.main,r=[],o=0;for(let l=new Ut(n.doc,n.sliceDoc(i,s));!l.next().done;){if(r.length>1e3)return!1;l.value.from==i&&(o=r.length),r.push(b.range(l.value.from,l.value.to))}return e(n.update({selection:b.create(r,o),userEvent:"select.search.matches"})),!0},Yo=Ci((n,{query:e})=>{let{state:t}=n,{from:i,to:s}=t.selection.main;if(t.readOnly)return!1;let r=e.nextMatch(t,i,i);if(!r)return!1;let o=[],l,a,h=[];if(r.from==i&&r.to==s&&(a=t.toText(e.getReplacement(r)),o.push({from:r.from,to:r.to,insert:a}),r=e.nextMatch(t,r.from,r.to),h.push(O.announce.of(t.phrase("replaced match on line $",t.doc.lineAt(i).number)+"."))),r){let c=o.length==0||o[0].from>=r.to?0:r.to-r.from-a.length;l=b.single(r.from-c,r.to-c),h.push(br(n,r)),h.push(t.facet(_t).scrollToMatch(l.main,n))}return n.dispatch({changes:o,selection:l,effects:h,userEvent:"input.replace"}),!0}),Up=Ci((n,{query:e})=>{if(n.state.readOnly)return!1;let t=e.matchAll(n.state,1e9).map(s=>{let{from:r,to:o}=s;return{from:r,to:o,insert:e.getReplacement(s)}});if(!t.length)return!1;let i=n.state.phrase("replaced $ matches",t.length)+".";return n.dispatch({changes:t,effects:O.announce.of(i),userEvent:"input.replace.all"}),!0});function yr(n){return n.state.facet(_t).createPanel(n)}function Gs(n,e){var t,i,s,r,o;let l=n.selection.main,a=l.empty||l.to>l.from+100?"":n.sliceDoc(l.from,l.to);if(e&&!a)return e;let h=n.facet(_t);return new Fh({search:((t=e==null?void 0:e.literal)!==null&&t!==void 0?t:h.literal)?a:a.replace(/\n/g,"\\n"),caseSensitive:(i=e==null?void 0:e.caseSensitive)!==null&&i!==void 0?i:h.caseSensitive,literal:(s=e==null?void 0:e.literal)!==null&&s!==void 0?s:h.literal,regexp:(r=e==null?void 0:e.regexp)!==null&&r!==void 0?r:h.regexp,wholeWord:(o=e==null?void 0:e.wholeWord)!==null&&o!==void 0?o:h.wholeWord})}function Wh(n){let e=cn(n,yr);return e&&e.dom.querySelector("[main-field]")}function Vh(n){let e=Wh(n);e&&e==n.root.activeElement&&e.select()}const zh=n=>{let e=n.state.field(rt,!1);if(e&&e.panel){let t=Wh(n);if(t&&t!=n.root.activeElement){let i=Gs(n.state,e.query.spec);i.valid&&n.dispatch({effects:yi.of(i)}),t.focus(),t.select()}}else n.dispatch({effects:[mr.of(!0),e?yi.of(Gs(n.state,e.query.spec)):E.appendConfig.of(_p)]});return!0},qh=n=>{let e=n.state.field(rt,!1);if(!e||!e.panel)return!1;let t=cn(n,yr);return t&&t.dom.contains(n.root.activeElement)&&n.focus(),n.dispatch({effects:mr.of(!1)}),!0},hm=[{key:"Mod-f",run:zh,scope:"editor search-panel"},{key:"F3",run:Sn,shift:vn,scope:"editor search-panel",preventDefault:!0},{key:"Mod-g",run:Sn,shift:vn,scope:"editor search-panel",preventDefault:!0},{key:"Escape",run:qh,scope:"editor search-panel"},{key:"Mod-Shift-l",run:jp},{key:"Alt-g",run:Mp},{key:"Mod-d",run:Np,preventDefault:!0}];class Gp{constructor(e){this.view=e;let t=this.query=e.state.field(rt).query.spec;this.commit=this.commit.bind(this),this.searchField=le("input",{value:t.search,placeholder:Ce(e,"Find"),"aria-label":Ce(e,"Find"),class:"cm-textfield",name:"search",form:"","main-field":"true",onchange:this.commit,onkeyup:this.commit}),this.replaceField=le("input",{value:t.replace,placeholder:Ce(e,"Replace"),"aria-label":Ce(e,"Replace"),class:"cm-textfield",name:"replace",form:"",onchange:this.commit,onkeyup:this.commit}),this.caseField=le("input",{type:"checkbox",name:"case",form:"",checked:t.caseSensitive,onchange:this.commit}),this.reField=le("input",{type:"checkbox",name:"re",form:"",checked:t.regexp,onchange:this.commit}),this.wordField=le("input",{type:"checkbox",name:"word",form:"",checked:t.wholeWord,onchange:this.commit});function i(s,r,o){return le("button",{class:"cm-button",name:s,onclick:r,type:"button"},o)}this.dom=le("div",{onkeydown:s=>this.keydown(s),class:"cm-search"},[this.searchField,i("next",()=>Sn(e),[Ce(e,"next")]),i("prev",()=>vn(e),[Ce(e,"previous")]),i("select",()=>$p(e),[Ce(e,"all")]),le("label",null,[this.caseField,Ce(e,"match case")]),le("label",null,[this.reField,Ce(e,"regexp")]),le("label",null,[this.wordField,Ce(e,"by word")]),...e.state.readOnly?[]:[le("br"),this.replaceField,i("replace",()=>Yo(e),[Ce(e,"replace")]),i("replaceAll",()=>Up(e),[Ce(e,"replace all")])],le("button",{name:"close",onclick:()=>qh(e),"aria-label":Ce(e,"close"),type:"button"},["×"])])}commit(){let e=new Fh({search:this.searchField.value,caseSensitive:this.caseField.checked,regexp:this.reField.checked,wholeWord:this.wordField.checked,replace:this.replaceField.value});e.eq(this.query)||(this.query=e,this.view.dispatch({effects:yi.of(e)}))}keydown(e){$f(this.view,e,"search-panel")?e.preventDefault():e.keyCode==13&&e.target==this.searchField?(e.preventDefault(),(e.shiftKey?vn:Sn)(this.view)):e.keyCode==13&&e.target==this.replaceField&&(e.preventDefault(),Yo(this.view))}update(e){for(let t of e.transactions)for(let i of t.effects)i.is(yi)&&!i.value.eq(this.query)&&this.setQuery(i.value)}setQuery(e){this.query=e,this.searchField.value=e.search,this.replaceField.value=e.replace,this.caseField.checked=e.caseSensitive,this.reField.checked=e.regexp,this.wordField.checked=e.wholeWord}mount(){this.searchField.select()}get pos(){return 80}get top(){return this.view.state.facet(_t).top}}function Ce(n,e){return n.state.phrase(e)}const $i=30,ji=/[\s\.,:;?!]/;function br(n,{from:e,to:t}){let i=n.state.doc.lineAt(e),s=n.state.doc.lineAt(t).to,r=Math.max(i.from,e-$i),o=Math.min(s,t+$i),l=n.state.sliceDoc(r,o);if(r!=i.from){for(let a=0;a<$i;a++)if(!ji.test(l[a+1])&&ji.test(l[a])){l=l.slice(a);break}}if(o!=s){for(let a=l.length-1;a>l.length-$i;a--)if(!ji.test(l[a-1])&&ji.test(l[a])){l=l.slice(0,a);break}}return O.announce.of(`${n.state.phrase("current match")}. ${l} ${n.state.phrase("on line")} ${i.number}.`)}const Jp=O.baseTheme({".cm-panel.cm-search":{padding:"2px 6px 4px",position:"relative","& [name=close]":{position:"absolute",top:"0",right:"4px",backgroundColor:"inherit",border:"none",font:"inherit",padding:0,margin:0},"& input, & button, & label":{margin:".2em .6em .2em 0"},"& input[type=checkbox]":{marginRight:".2em"},"& label":{fontSize:"80%",whiteSpace:"pre"}},"&light .cm-searchMatch":{backgroundColor:"#ffff0054"},"&dark .cm-searchMatch":{backgroundColor:"#00ffff8a"},"&light .cm-searchMatch-selected":{backgroundColor:"#ff6a0054"},"&dark .cm-searchMatch-selected":{backgroundColor:"#ff00ff8a"}}),_p=[rt,At.low(Kp),Jp];class Kh{constructor(e,t,i){this.state=e,this.pos=t,this.explicit=i,this.abortListeners=[]}tokenBefore(e){let t=ye(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),i=Math.max(t.from,this.pos-250),s=t.text.slice(i-t.from,this.pos-t.from),r=s.search($h(e,!1));return r<0?null:{from:i+r,to:this.pos,text:s.slice(r)}}get aborted(){return this.abortListeners==null}addEventListener(e,t){e=="abort"&&this.abortListeners&&this.abortListeners.push(t)}}function Qo(n){let e=Object.keys(n).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function Xp(n){let e=Object.create(null),t=Object.create(null);for(let{label:s}of n){e[s[0]]=!0;for(let r=1;rtypeof s=="string"?{label:s}:s),[t,i]=e.every(s=>/^\w+$/.test(s.label))?[/\w*$/,/\w+$/]:Xp(e);return s=>{let r=s.matchBefore(i);return r||s.explicit?{from:r?r.from:s.pos,options:e,validFor:t}:null}}function cm(n,e){return t=>{for(let i=ye(t.state).resolveInner(t.pos,-1);i;i=i.parent){if(n.indexOf(i.name)>-1)return null;if(i.type.isTop)break}return e(t)}}class Zo{constructor(e,t,i,s){this.completion=e,this.source=t,this.match=i,this.score=s}}function ot(n){return n.selection.main.from}function $h(n,e){var t;let{source:i}=n,s=e&&i[0]!="^",r=i[i.length-1]!="$";return!s&&!r?n:new RegExp(`${s?"^":""}(?:${i})${r?"$":""}`,(t=n.flags)!==null&&t!==void 0?t:n.ignoreCase?"i":"")}const jh=Ze.define();function Qp(n,e,t,i){let{main:s}=n.selection,r=t-s.from,o=i-s.from;return Object.assign(Object.assign({},n.changeByRange(l=>l!=s&&t!=i&&n.sliceDoc(l.from+r,l.from+o)!=n.sliceDoc(t,i)?{range:l}:{changes:{from:l.from+r,to:i==s.from?l.to:l.from+o,insert:e},range:b.cursor(l.from+r+e.length)})),{userEvent:"input.complete"})}const el=new WeakMap;function Zp(n){if(!Array.isArray(n))return n;let e=el.get(n);return e||el.set(n,e=Yp(n)),e}const xr=E.define(),bi=E.define();class eg{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t=48&&w<=57||w>=97&&w<=122?2:w>=65&&w<=90?1:0:(k=_s(w))!=k.toLowerCase()?1:k!=k.toUpperCase()?2:0;(!S||T==1&&m||x==0&&T!=0)&&(t[f]==w||i[f]==w&&(u=!0)?o[f++]=S:o.length&&(y=!1)),x=T,S+=Oe(w)}return f==a&&o[0]==0&&y?this.result(-100+(u?-200:0),o,e):d==a&&p==0?this.ret(-200-e.length+(g==e.length?0:-100),[0,g]):l>-1?this.ret(-700-e.length,[l,l+this.pattern.length]):d==a?this.ret(-200+-700-e.length,[p,g]):f==a?this.result(-100+(u?-200:0)+-700+(y?0:-1100),o,e):t.length==2?!1:this.result((s[0]?-700:0)+-200+-1100,s,e)}result(e,t,i){let s=[],r=0;for(let o of t){let l=o+(this.astral?Oe(ne(i,o)):1);r&&s[r-1]==o?s[r-1]=l:(s[r++]=o,s[r++]=l)}return this.ret(e-i.length,s)}}const ve=D.define({combine(n){return Mt(n,{activateOnTyping:!0,selectOnOpen:!0,override:null,closeOnBlur:!0,maxRenderedOptions:100,defaultKeymap:!0,tooltipClass:()=>"",optionClass:()=>"",aboveCursor:!1,icons:!0,addToOptions:[],positionInfo:tg,compareCompletions:(e,t)=>e.label.localeCompare(t.label),interactionDelay:75},{defaultKeymap:(e,t)=>e&&t,closeOnBlur:(e,t)=>e&&t,icons:(e,t)=>e&&t,tooltipClass:(e,t)=>i=>tl(e(i),t(i)),optionClass:(e,t)=>i=>tl(e(i),t(i)),addToOptions:(e,t)=>e.concat(t)})}});function tl(n,e){return n?e?n+" "+e:n:e}function tg(n,e,t,i,s){let r=n.textDirection==J.RTL,o=r,l=!1,a="top",h,c,f=e.left-s.left,u=s.right-e.right,d=i.right-i.left,p=i.bottom-i.top;if(o&&f=p||g>e.top?h=t.bottom-e.top:(a="bottom",h=e.bottom-t.top)}return{style:`${a}: ${h}px; max-width: ${c}px`,class:"cm-completionInfo-"+(l?r?"left-narrow":"right-narrow":o?"left":"right")}}function ig(n){let e=n.addToOptions.slice();return n.icons&&e.push({render(t){let i=document.createElement("div");return i.classList.add("cm-completionIcon"),t.type&&i.classList.add(...t.type.split(/\s+/g).map(s=>"cm-completionIcon-"+s)),i.setAttribute("aria-hidden","true"),i},position:20}),e.push({render(t,i,s){let r=document.createElement("span");r.className="cm-completionLabel";let o=t.displayLabel||t.label,l=0;for(let a=0;al&&r.appendChild(document.createTextNode(o.slice(l,h)));let f=r.appendChild(document.createElement("span"));f.appendChild(document.createTextNode(o.slice(h,c))),f.className="cm-completionMatchedText",l=c}return lt.position-i.position).map(t=>t.render)}function il(n,e,t){if(n<=t)return{from:0,to:n};if(e<0&&(e=0),e<=n>>1){let s=Math.floor(e/t);return{from:s*t,to:(s+1)*t}}let i=Math.floor((n-e)/t);return{from:n-(i+1)*t,to:n-i*t}}class ng{constructor(e,t,i){this.view=e,this.stateField=t,this.applyCompletion=i,this.info=null,this.infoDestroy=null,this.placeInfoReq={read:()=>this.measureInfo(),write:a=>this.placeInfo(a),key:this},this.space=null,this.currentClass="";let s=e.state.field(t),{options:r,selected:o}=s.open,l=e.state.facet(ve);this.optionContent=ig(l),this.optionClass=l.optionClass,this.tooltipClass=l.tooltipClass,this.range=il(r.length,o,l.maxRenderedOptions),this.dom=document.createElement("div"),this.dom.className="cm-tooltip-autocomplete",this.updateTooltipClass(e.state),this.dom.addEventListener("mousedown",a=>{for(let h=a.target,c;h&&h!=this.dom;h=h.parentNode)if(h.nodeName=="LI"&&(c=/-(\d+)$/.exec(h.id))&&+c[1]{let h=e.state.field(this.stateField,!1);h&&h.tooltip&&e.state.facet(ve).closeOnBlur&&a.relatedTarget!=e.contentDOM&&e.dispatch({effects:bi.of(null)})}),this.list=this.dom.appendChild(this.createListBox(r,s.id,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})}mount(){this.updateSel()}update(e){var t,i,s;let r=e.state.field(this.stateField),o=e.startState.field(this.stateField);this.updateTooltipClass(e.state),r!=o&&(this.updateSel(),((t=r.open)===null||t===void 0?void 0:t.disabled)!=((i=o.open)===null||i===void 0?void 0:i.disabled)&&this.dom.classList.toggle("cm-tooltip-autocomplete-disabled",!!(!((s=r.open)===null||s===void 0)&&s.disabled)))}updateTooltipClass(e){let t=this.tooltipClass(e);if(t!=this.currentClass){for(let i of this.currentClass.split(" "))i&&this.dom.classList.remove(i);for(let i of t.split(" "))i&&this.dom.classList.add(i);this.currentClass=t}}positioned(e){this.space=e,this.info&&this.view.requestMeasure(this.placeInfoReq)}updateSel(){let e=this.view.state.field(this.stateField),t=e.open;if((t.selected>-1&&t.selected=this.range.to)&&(this.range=il(t.options.length,t.selected,this.view.state.facet(ve).maxRenderedOptions),this.list.remove(),this.list=this.dom.appendChild(this.createListBox(t.options,e.id,this.range)),this.list.addEventListener("scroll",()=>{this.info&&this.view.requestMeasure(this.placeInfoReq)})),this.updateSelectedOption(t.selected)){this.destroyInfo();let{completion:i}=t.options[t.selected],{info:s}=i;if(!s)return;let r=typeof s=="string"?document.createTextNode(s):s(i);if(!r)return;"then"in r?r.then(o=>{o&&this.view.state.field(this.stateField,!1)==e&&this.addInfoPane(o,i)}).catch(o=>Ie(this.view.state,o,"completion info")):this.addInfoPane(r,i)}}addInfoPane(e,t){this.destroyInfo();let i=this.info=document.createElement("div");if(i.className="cm-tooltip cm-completionInfo",e.nodeType!=null)i.appendChild(e),this.infoDestroy=null;else{let{dom:s,destroy:r}=e;i.appendChild(s),this.infoDestroy=r||null}this.dom.appendChild(i),this.view.requestMeasure(this.placeInfoReq)}updateSelectedOption(e){let t=null;for(let i=this.list.firstChild,s=this.range.from;i;i=i.nextSibling,s++)i.nodeName!="LI"||!i.id?s--:s==e?i.hasAttribute("aria-selected")||(i.setAttribute("aria-selected","true"),t=i):i.hasAttribute("aria-selected")&&i.removeAttribute("aria-selected");return t&&rg(this.list,t),t}measureInfo(){let e=this.dom.querySelector("[aria-selected]");if(!e||!this.info)return null;let t=this.dom.getBoundingClientRect(),i=this.info.getBoundingClientRect(),s=e.getBoundingClientRect(),r=this.space;if(!r){let o=this.dom.ownerDocument.defaultView||window;r={left:0,top:0,right:o.innerWidth,bottom:o.innerHeight}}return s.top>Math.min(r.bottom,t.bottom)-10||s.bottomi.from||i.from==0))if(r=u,typeof h!="string"&&h.header)s.appendChild(h.header(h));else{let d=s.appendChild(document.createElement("completion-section"));d.textContent=u}}const c=s.appendChild(document.createElement("li"));c.id=t+"-"+o,c.setAttribute("role","option");let f=this.optionClass(l);f&&(c.className=f);for(let u of this.optionContent){let d=u(l,this.view.state,a);d&&c.appendChild(d)}}return i.from&&s.classList.add("cm-completionListIncompleteTop"),i.tonew ng(t,n,e)}function rg(n,e){let t=n.getBoundingClientRect(),i=e.getBoundingClientRect();i.topt.bottom&&(n.scrollTop+=i.bottom-t.bottom)}function nl(n){return(n.boost||0)*100+(n.apply?10:0)+(n.info?5:0)+(n.type?1:0)}function og(n,e){let t=[],i=null,s=a=>{t.push(a);let{section:h}=a.completion;if(h){i||(i=[]);let c=typeof h=="string"?h:h.name;i.some(f=>f.name==c)||i.push(typeof h=="string"?{name:c}:h)}};for(let a of n)if(a.hasResult()){let h=a.result.getMatch;if(a.result.filter===!1)for(let c of a.result.options)s(new Zo(c,a.source,h?h(c):[],1e9-t.length));else{let c=new eg(e.sliceDoc(a.from,a.to));for(let f of a.result.options)if(c.match(f.label)){let u=f.displayLabel?h?h(f,c.matched):[]:c.matched;s(new Zo(f,a.source,u,c.score+(f.boost||0)))}}}if(i){let a=Object.create(null),h=0,c=(f,u)=>{var d,p;return((d=f.rank)!==null&&d!==void 0?d:1e9)-((p=u.rank)!==null&&p!==void 0?p:1e9)||(f.namec.score-h.score||l(h.completion,c.completion))){let h=a.completion;!o||o.label!=h.label||o.detail!=h.detail||o.type!=null&&h.type!=null&&o.type!=h.type||o.apply!=h.apply||o.boost!=h.boost?r.push(a):nl(a.completion)>nl(o)&&(r[r.length-1]=a),o=a.completion}return r}class Et{constructor(e,t,i,s,r,o){this.options=e,this.attrs=t,this.tooltip=i,this.timestamp=s,this.selected=r,this.disabled=o}setSelected(e,t){return e==this.selected||e>=this.options.length?this:new Et(this.options,sl(t,e),this.tooltip,this.timestamp,e,this.disabled)}static build(e,t,i,s,r){let o=og(e,t);if(!o.length)return s&&e.some(a=>a.state==1)?new Et(s.options,s.attrs,s.tooltip,s.timestamp,s.selected,!0):null;let l=t.facet(ve).selectOnOpen?0:-1;if(s&&s.selected!=l&&s.selected!=-1){let a=s.options[s.selected].completion;for(let h=0;hh.hasResult()?Math.min(a,h.from):a,1e8),create:sg(Me,Jh),above:r.aboveCursor},s?s.timestamp:Date.now(),l,!1)}map(e){return new Et(this.options,this.attrs,Object.assign(Object.assign({},this.tooltip),{pos:e.mapPos(this.tooltip.pos)}),this.timestamp,this.selected,this.disabled)}}class Cn{constructor(e,t,i){this.active=e,this.id=t,this.open=i}static start(){return new Cn(hg,"cm-ac-"+Math.floor(Math.random()*2e6).toString(36),null)}update(e){let{state:t}=e,i=t.facet(ve),r=(i.override||t.languageDataAt("autocomplete",ot(t)).map(Zp)).map(l=>(this.active.find(h=>h.source==l)||new ke(l,this.active.some(h=>h.state!=0)?1:0)).update(e,i));r.length==this.active.length&&r.every((l,a)=>l==this.active[a])&&(r=this.active);let o=this.open;o&&e.docChanged&&(o=o.map(e.changes)),e.selection||r.some(l=>l.hasResult()&&e.changes.touchesRange(l.from,l.to))||!lg(r,this.active)?o=Et.build(r,t,this.id,o,i):o&&o.disabled&&!r.some(l=>l.state==1)&&(o=null),!o&&r.every(l=>l.state!=1)&&r.some(l=>l.hasResult())&&(r=r.map(l=>l.hasResult()?new ke(l.source,0):l));for(let l of e.effects)l.is(Gh)&&(o=o&&o.setSelected(l.value,this.id));return r==this.active&&o==this.open?this:new Cn(r,this.id,o)}get tooltip(){return this.open?this.open.tooltip:null}get attrs(){return this.open?this.open.attrs:ag}}function lg(n,e){if(n==e)return!0;for(let t=0,i=0;;){for(;t-1&&(t["aria-activedescendant"]=n+"-"+e),t}const hg=[];function Js(n){return n.isUserEvent("input.type")?"input":n.isUserEvent("delete.backward")?"delete":null}class ke{constructor(e,t,i=-1){this.source=e,this.state=t,this.explicitPos=i}hasResult(){return!1}update(e,t){let i=Js(e),s=this;i?s=s.handleUserEvent(e,i,t):e.docChanged?s=s.handleChange(e):e.selection&&s.state!=0&&(s=new ke(s.source,0));for(let r of e.effects)if(r.is(xr))s=new ke(s.source,1,r.value?ot(e.state):-1);else if(r.is(bi))s=new ke(s.source,0);else if(r.is(Uh))for(let o of r.value)o.source==s.source&&(s=o);return s}handleUserEvent(e,t,i){return t=="delete"||!i.activateOnTyping?this.map(e.changes):new ke(this.source,1)}handleChange(e){return e.changes.touchesRange(ot(e.startState))?new ke(this.source,0):this.map(e.changes)}map(e){return e.empty||this.explicitPos<0?this:new ke(this.source,this.state,e.mapPos(this.explicitPos))}}class Wt extends ke{constructor(e,t,i,s,r){super(e,2,t),this.result=i,this.from=s,this.to=r}hasResult(){return!0}handleUserEvent(e,t,i){var s;let r=e.changes.mapPos(this.from),o=e.changes.mapPos(this.to,1),l=ot(e.state);if((this.explicitPos<0?l<=r:lo||t=="delete"&&ot(e.startState)==this.from)return new ke(this.source,t=="input"&&i.activateOnTyping?1:0);let a=this.explicitPos<0?-1:e.changes.mapPos(this.explicitPos),h;return cg(this.result.validFor,e.state,r,o)?new Wt(this.source,a,this.result,r,o):this.result.update&&(h=this.result.update(this.result,r,o,new Kh(e.state,l,a>=0)))?new Wt(this.source,a,h,h.from,(s=h.to)!==null&&s!==void 0?s:ot(e.state)):new ke(this.source,1,a)}handleChange(e){return e.changes.touchesRange(this.from,this.to)?new ke(this.source,0):this.map(e.changes)}map(e){return e.empty?this:new Wt(this.source,this.explicitPos<0?-1:e.mapPos(this.explicitPos),this.result,e.mapPos(this.from),e.mapPos(this.to,1))}}function cg(n,e,t,i){if(!n)return!1;let s=e.sliceDoc(t,i);return typeof n=="function"?n(s,t,i,e):$h(n,!0).test(s)}const Uh=E.define({map(n,e){return n.map(t=>t.map(e))}}),Gh=E.define(),Me=be.define({create(){return Cn.start()},update(n,e){return n.update(e)},provide:n=>[Ba.from(n,e=>e.tooltip),O.contentAttributes.from(n,e=>e.attrs)]});function Jh(n,e){const t=e.completion.apply||e.completion.label;let i=n.state.field(Me).active.find(s=>s.source==e.source);return i instanceof Wt?(typeof t=="string"?n.dispatch(Object.assign(Object.assign({},Qp(n.state,t,i.from,i.to)),{annotations:jh.of(e.completion)})):t(n,e.completion,i.from,i.to),!0):!1}function Ui(n,e="option"){return t=>{let i=t.state.field(Me,!1);if(!i||!i.open||i.open.disabled||Date.now()-i.open.timestamp-1?i.open.selected+s*(n?1:-1):n?0:o-1;return l<0?l=e=="page"?0:o-1:l>=o&&(l=e=="page"?o-1:0),t.dispatch({effects:Gh.of(l)}),!0}}const fg=n=>{let e=n.state.field(Me,!1);return n.state.readOnly||!e||!e.open||e.open.selected<0||e.open.disabled||Date.now()-e.open.timestampn.state.field(Me,!1)?(n.dispatch({effects:xr.of(!0)}),!0):!1,dg=n=>{let e=n.state.field(Me,!1);return!e||!e.active.some(t=>t.state!=0)?!1:(n.dispatch({effects:bi.of(null)}),!0)};class pg{constructor(e,t){this.active=e,this.context=t,this.time=Date.now(),this.updates=[],this.done=void 0}}const rl=50,gg=50,mg=1e3,yg=ge.fromClass(class{constructor(n){this.view=n,this.debounceUpdate=-1,this.running=[],this.debounceAccept=-1,this.composing=0;for(let e of n.state.field(Me).active)e.state==1&&this.startQuery(e)}update(n){let e=n.state.field(Me);if(!n.selectionSet&&!n.docChanged&&n.startState.field(Me)==e)return;let t=n.transactions.some(i=>(i.selection||i.docChanged)&&!Js(i));for(let i=0;igg&&Date.now()-s.time>mg){for(let r of s.context.abortListeners)try{r()}catch(o){Ie(this.view.state,o)}s.context.abortListeners=null,this.running.splice(i--,1)}else s.updates.push(...n.transactions)}if(this.debounceUpdate>-1&&clearTimeout(this.debounceUpdate),this.debounceUpdate=e.active.some(i=>i.state==1&&!this.running.some(s=>s.active.source==i.source))?setTimeout(()=>this.startUpdate(),rl):-1,this.composing!=0)for(let i of n.transactions)Js(i)=="input"?this.composing=2:this.composing==2&&i.selection&&(this.composing=3)}startUpdate(){this.debounceUpdate=-1;let{state:n}=this.view,e=n.field(Me);for(let t of e.active)t.state==1&&!this.running.some(i=>i.active.source==t.source)&&this.startQuery(t)}startQuery(n){let{state:e}=this.view,t=ot(e),i=new Kh(e,t,n.explicitPos==t),s=new pg(n,i);this.running.push(s),Promise.resolve(n.source(i)).then(r=>{s.context.aborted||(s.done=r||null,this.scheduleAccept())},r=>{this.view.dispatch({effects:bi.of(null)}),Ie(this.view.state,r)})}scheduleAccept(){this.running.every(n=>n.done!==void 0)?this.accept():this.debounceAccept<0&&(this.debounceAccept=setTimeout(()=>this.accept(),rl))}accept(){var n;this.debounceAccept>-1&&clearTimeout(this.debounceAccept),this.debounceAccept=-1;let e=[],t=this.view.state.facet(ve);for(let i=0;io.source==s.active.source);if(r&&r.state==1)if(s.done==null){let o=new ke(s.active.source,0);for(let l of s.updates)o=o.update(l,t);o.state!=1&&e.push(o)}else this.startQuery(r)}e.length&&this.view.dispatch({effects:Uh.of(e)})}},{eventHandlers:{blur(n){let e=this.view.state.field(Me,!1);if(e&&e.tooltip&&this.view.state.facet(ve).closeOnBlur){let t=e.open&&Pa(this.view,e.open.tooltip);(!t||!t.dom.contains(n.relatedTarget))&&this.view.dispatch({effects:bi.of(null)})}},compositionstart(){this.composing=1},compositionend(){this.composing==3&&setTimeout(()=>this.view.dispatch({effects:xr.of(!1)}),20),this.composing=0}}}),_h=O.baseTheme({".cm-tooltip.cm-tooltip-autocomplete":{"& > ul":{fontFamily:"monospace",whiteSpace:"nowrap",overflow:"hidden auto",maxWidth_fallback:"700px",maxWidth:"min(700px, 95vw)",minWidth:"250px",maxHeight:"10em",height:"100%",listStyle:"none",margin:0,padding:0,"& > li, & > completion-section":{padding:"1px 3px",lineHeight:1.2},"& > li":{overflowX:"hidden",textOverflow:"ellipsis",cursor:"pointer"},"& > completion-section":{display:"list-item",borderBottom:"1px solid silver",paddingLeft:"0.5em",opacity:.7}}},"&light .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#17c",color:"white"},"&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#777"},"&dark .cm-tooltip-autocomplete ul li[aria-selected]":{background:"#347",color:"white"},"&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]":{background:"#444"},".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after":{content:'"···"',opacity:.5,display:"block",textAlign:"center"},".cm-tooltip.cm-completionInfo":{position:"absolute",padding:"3px 9px",width:"max-content",maxWidth:"400px",boxSizing:"border-box"},".cm-completionInfo.cm-completionInfo-left":{right:"100%"},".cm-completionInfo.cm-completionInfo-right":{left:"100%"},".cm-completionInfo.cm-completionInfo-left-narrow":{right:"30px"},".cm-completionInfo.cm-completionInfo-right-narrow":{left:"30px"},"&light .cm-snippetField":{backgroundColor:"#00000022"},"&dark .cm-snippetField":{backgroundColor:"#ffffff22"},".cm-snippetFieldPosition":{verticalAlign:"text-top",width:0,height:"1.15em",display:"inline-block",margin:"0 -0.7px -.7em",borderLeft:"1.4px dotted #888"},".cm-completionMatchedText":{textDecoration:"underline"},".cm-completionDetail":{marginLeft:"0.5em",fontStyle:"italic"},".cm-completionIcon":{fontSize:"90%",width:".8em",display:"inline-block",textAlign:"center",paddingRight:".6em",opacity:"0.6",boxSizing:"content-box"},".cm-completionIcon-function, .cm-completionIcon-method":{"&:after":{content:"'ƒ'"}},".cm-completionIcon-class":{"&:after":{content:"'○'"}},".cm-completionIcon-interface":{"&:after":{content:"'◌'"}},".cm-completionIcon-variable":{"&:after":{content:"'𝑥'"}},".cm-completionIcon-constant":{"&:after":{content:"'𝐶'"}},".cm-completionIcon-type":{"&:after":{content:"'𝑡'"}},".cm-completionIcon-enum":{"&:after":{content:"'∪'"}},".cm-completionIcon-property":{"&:after":{content:"'□'"}},".cm-completionIcon-keyword":{"&:after":{content:"'🔑︎'"}},".cm-completionIcon-namespace":{"&:after":{content:"'▢'"}},".cm-completionIcon-text":{"&:after":{content:"'abc'",fontSize:"50%",verticalAlign:"middle"}}});class bg{constructor(e,t,i,s){this.field=e,this.line=t,this.from=i,this.to=s}}class wr{constructor(e,t,i){this.field=e,this.from=t,this.to=i}map(e){let t=e.mapPos(this.from,-1,he.TrackDel),i=e.mapPos(this.to,1,he.TrackDel);return t==null||i==null?null:new wr(this.field,t,i)}}class kr{constructor(e,t){this.lines=e,this.fieldPositions=t}instantiate(e,t){let i=[],s=[t],r=e.doc.lineAt(t),o=/^\s*/.exec(r.text)[0];for(let a of this.lines){if(i.length){let h=o,c=/^\t*/.exec(a)[0].length;for(let f=0;fnew wr(a.field,s[a.line]+a.from,s[a.line]+a.to));return{text:i,ranges:l}}static parse(e){let t=[],i=[],s=[],r;for(let o of e.split(/\r\n?|\n/)){for(;r=/[#$]\{(?:(\d+)(?::([^}]*))?|([^}]*))\}/.exec(o);){let l=r[1]?+r[1]:null,a=r[2]||r[3]||"",h=-1;for(let c=0;c=h&&f.field++}s.push(new bg(h,i.length,r.index,r.index+a.length)),o=o.slice(0,r.index)+a+o.slice(r.index+r[0].length)}for(let l;l=/\\([{}])/.exec(o);){o=o.slice(0,l.index)+l[1]+o.slice(l.index+l[0].length);for(let a of s)a.line==i.length&&a.from>l.index&&(a.from--,a.to--)}i.push(o)}return new kr(i,s)}}let xg=B.widget({widget:new class extends Dt{toDOM(){let n=document.createElement("span");return n.className="cm-snippetFieldPosition",n}ignoreEvent(){return!1}}}),wg=B.mark({class:"cm-snippetField"});class Xt{constructor(e,t){this.ranges=e,this.active=t,this.deco=B.set(e.map(i=>(i.from==i.to?xg:wg).range(i.from,i.to)))}map(e){let t=[];for(let i of this.ranges){let s=i.map(e);if(!s)return null;t.push(s)}return new Xt(t,this.active)}selectionInsideField(e){return e.ranges.every(t=>this.ranges.some(i=>i.field==this.active&&i.from<=t.from&&i.to>=t.to))}}const Ai=E.define({map(n,e){return n&&n.map(e)}}),kg=E.define(),xi=be.define({create(){return null},update(n,e){for(let t of e.effects){if(t.is(Ai))return t.value;if(t.is(kg)&&n)return new Xt(n.ranges,t.value)}return n&&e.docChanged&&(n=n.map(e.changes)),n&&e.selection&&!n.selectionInsideField(e.selection)&&(n=null),n},provide:n=>O.decorations.from(n,e=>e?e.deco:B.none)});function Sr(n,e){return b.create(n.filter(t=>t.field==e).map(t=>b.range(t.from,t.to)))}function Sg(n){let e=kr.parse(n);return(t,i,s,r)=>{let{text:o,ranges:l}=e.instantiate(t.state,s),a={changes:{from:s,to:r,insert:I.of(o)},scrollIntoView:!0,annotations:i?jh.of(i):void 0};if(l.length&&(a.selection=Sr(l,0)),l.length>1){let h=new Xt(l,0),c=a.effects=[Ai.of(h)];t.state.field(xi,!1)===void 0&&c.push(E.appendConfig.of([xi,Dg,Og,_h]))}t.dispatch(t.state.update(a))}}function Xh(n){return({state:e,dispatch:t})=>{let i=e.field(xi,!1);if(!i||n<0&&i.active==0)return!1;let s=i.active+n,r=n>0&&!i.ranges.some(o=>o.field==s+n);return t(e.update({selection:Sr(i.ranges,s),effects:Ai.of(r?null:new Xt(i.ranges,s))})),!0}}const vg=({state:n,dispatch:e})=>n.field(xi,!1)?(e(n.update({effects:Ai.of(null)})),!0):!1,Cg=Xh(1),Ag=Xh(-1),Mg=[{key:"Tab",run:Cg,shift:Ag},{key:"Escape",run:vg}],ol=D.define({combine(n){return n.length?n[0]:Mg}}),Dg=At.highest(sr.compute([ol],n=>n.facet(ol)));function fm(n,e){return Object.assign(Object.assign({},e),{apply:Sg(n)})}const Og=O.domEventHandlers({mousedown(n,e){let t=e.state.field(xi,!1),i;if(!t||(i=e.posAtCoords({x:n.clientX,y:n.clientY}))==null)return!1;let s=t.ranges.find(r=>r.from<=i&&r.to>=i);return!s||s.field==t.active?!1:(e.dispatch({selection:Sr(t.ranges,s.field),effects:Ai.of(t.ranges.some(r=>r.field>s.field)?new Xt(t.ranges,s.field):null)}),!0)}}),wi={brackets:["(","[","{","'",'"'],before:")]}:;>",stringPrefixes:[]},bt=E.define({map(n,e){let t=e.mapPos(n,-1,he.TrackAfter);return t??void 0}}),vr=new class extends wt{};vr.startSide=1;vr.endSide=-1;const Yh=be.define({create(){return K.empty},update(n,e){if(e.selection){let t=e.state.doc.lineAt(e.selection.main.head).from,i=e.startState.doc.lineAt(e.startState.selection.main.head).from;t!=e.changes.mapPos(i,-1)&&(n=K.empty)}n=n.map(e.changes);for(let t of e.effects)t.is(bt)&&(n=n.update({add:[vr.range(t.value,t.value+1)]}));return n}});function um(){return[Bg,Yh]}const is="()[]{}<>";function Qh(n){for(let e=0;e{if((Tg?n.composing:n.compositionStarted)||n.state.readOnly)return!1;let s=n.state.selection.main;if(i.length>2||i.length==2&&Oe(ne(i,0))==1||e!=s.from||t!=s.to)return!1;let r=Lg(n.state,i);return r?(n.dispatch(r),!0):!1}),Pg=({state:n,dispatch:e})=>{if(n.readOnly)return!1;let i=Zh(n,n.selection.main.head).brackets||wi.brackets,s=null,r=n.changeByRange(o=>{if(o.empty){let l=Rg(n.doc,o.head);for(let a of i)if(a==l&&Fn(n.doc,o.head)==Qh(ne(a,0)))return{changes:{from:o.head-a.length,to:o.head+a.length},range:b.cursor(o.head-a.length)}}return{range:s=o}});return s||e(n.update(r,{scrollIntoView:!0,userEvent:"delete.backward"})),!s},dm=[{key:"Backspace",run:Pg}];function Lg(n,e){let t=Zh(n,n.selection.main.head),i=t.brackets||wi.brackets;for(let s of i){let r=Qh(ne(s,0));if(e==s)return r==s?Ng(n,s,i.indexOf(s+s+s)>-1,t):Eg(n,s,r,t.before||wi.before);if(e==r&&ec(n,n.selection.main.from))return Ig(n,s,r)}return null}function ec(n,e){let t=!1;return n.field(Yh).between(0,n.doc.length,i=>{i==e&&(t=!0)}),t}function Fn(n,e){let t=n.sliceString(e,e+2);return t.slice(0,Oe(ne(t,0)))}function Rg(n,e){let t=n.sliceString(e-2,e);return Oe(ne(t,0))==t.length?t:t.slice(1)}function Eg(n,e,t,i){let s=null,r=n.changeByRange(o=>{if(!o.empty)return{changes:[{insert:e,from:o.from},{insert:t,from:o.to}],effects:bt.of(o.to+e.length),range:b.range(o.anchor+e.length,o.head+e.length)};let l=Fn(n.doc,o.head);return!l||/\s/.test(l)||i.indexOf(l)>-1?{changes:{insert:e+t,from:o.head},effects:bt.of(o.head+e.length),range:b.cursor(o.head+e.length)}:{range:s=o}});return s?null:n.update(r,{scrollIntoView:!0,userEvent:"input.type"})}function Ig(n,e,t){let i=null,s=n.changeByRange(r=>r.empty&&Fn(n.doc,r.head)==t?{changes:{from:r.head,to:r.head+t.length,insert:t},range:b.cursor(r.head+t.length)}:i={range:r});return i?null:n.update(s,{scrollIntoView:!0,userEvent:"input.type"})}function Ng(n,e,t,i){let s=i.stringPrefixes||wi.stringPrefixes,r=null,o=n.changeByRange(l=>{if(!l.empty)return{changes:[{insert:e,from:l.from},{insert:e,from:l.to}],effects:bt.of(l.to+e.length),range:b.range(l.anchor+e.length,l.head+e.length)};let a=l.head,h=Fn(n.doc,a),c;if(h==e){if(ll(n,a))return{changes:{insert:e+e,from:a},effects:bt.of(a+e.length),range:b.cursor(a+e.length)};if(ec(n,a)){let u=t&&n.sliceDoc(a,a+e.length*3)==e+e+e?e+e+e:e;return{changes:{from:a,to:a+u.length,insert:u},range:b.cursor(a+u.length)}}}else{if(t&&n.sliceDoc(a-2*e.length,a)==e+e&&(c=al(n,a-2*e.length,s))>-1&&ll(n,c))return{changes:{insert:e+e+e+e,from:a},effects:bt.of(a+e.length),range:b.cursor(a+e.length)};if(n.charCategorizer(a)(h)!=$.Word&&al(n,a,s)>-1&&!Fg(n,a,e,s))return{changes:{insert:e+e,from:a},effects:bt.of(a+e.length),range:b.cursor(a+e.length)}}return{range:r=l}});return r?null:n.update(o,{scrollIntoView:!0,userEvent:"input.type"})}function ll(n,e){let t=ye(n).resolveInner(e+1);return t.parent&&t.from==e}function Fg(n,e,t,i){let s=ye(n).resolveInner(e,-1),r=i.reduce((o,l)=>Math.max(o,l.length),0);for(let o=0;o<5;o++){let l=n.sliceDoc(s.from,Math.min(s.to,s.from+t.length+r)),a=l.indexOf(t);if(!a||a>-1&&i.indexOf(l.slice(0,a))>-1){let c=s.firstChild;for(;c&&c.from==s.from&&c.to-c.from>t.length+a;){if(n.sliceDoc(c.to-t.length,c.to)==t)return!1;c=c.firstChild}return!0}let h=s.to==e&&s.parent;if(!h)break;s=h}return!1}function al(n,e,t){let i=n.charCategorizer(e);if(i(n.sliceDoc(e-1,e))!=$.Word)return e;for(let s of t){let r=e-s.length;if(n.sliceDoc(r,e)==s&&i(n.sliceDoc(r-1,r))!=$.Word)return r}return-1}function pm(n={}){return[Me,ve.of(n),yg,Wg,_h]}const Hg=[{key:"Ctrl-Space",run:ug},{key:"Escape",run:dg},{key:"ArrowDown",run:Ui(!0)},{key:"ArrowUp",run:Ui(!1)},{key:"PageDown",run:Ui(!0,"page")},{key:"PageUp",run:Ui(!1,"page")},{key:"Enter",run:fg}],Wg=At.highest(sr.computeN([ve],n=>n.facet(ve).defaultKeymap?[Hg]:[]));export{Zg as A,em as B,An as C,ku as D,O as E,tm as F,Xg as G,ye as H,j as I,Gg as J,cm as K,Hs as L,Yp as M,rr as N,b as O,Ia as P,fm as Q,Qg as R,_a as S,W as T,Yg as U,Ku as V,Ha as W,ld as X,N as a,Kg as b,rm as c,zg as d,qg as e,sm as f,um as g,Ug as h,am as i,dm as j,sr as k,lm as l,hm as m,om as n,Hg as o,pm as p,$g as q,jg as r,im as s,nm as t,me as u,L as v,Hu as w,C as x,Jg as y,Uu as z}; diff --git a/ui/dist/assets/index-BWzVsQhl.js b/ui/dist/assets/index-BWzVsQhl.js new file mode 100644 index 0000000000..ae71e22412 --- /dev/null +++ b/ui/dist/assets/index-BWzVsQhl.js @@ -0,0 +1,86 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./docsList-DsIHfo2q.js","./expandInfo-CJ9aeAaN.js","./fieldsInfo-CiLcXgNq.js","./docsView-oyuvqZP8.js","./docsCreate-Ap21j9FE.js","./docsUpdate-DAnNYWFv.js","./docsListAuthMethods-CYETXzX7.js","./docsAuthWithPassword-NXiq7pZI.js","./docsAuthWithOAuth2-qI6si9uJ.js","./docsAuthWithOTP-75iz1Xmg.js","./docsAuthRefresh-Cq3xTY6M.js"])))=>i.map(i=>d[i]); +var e=Object.create,n=Object.defineProperty,r=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getPrototypeOf,o=Object.prototype.hasOwnProperty,s=(e,n)=>()=>(n||(e((n={exports:{}}).exports,n),e=null),n.exports),c=(e,a,s,c)=>{if(a&&typeof a==`object`||typeof a==`function`)for(var l=i(a),u=0,d=l.length,f;ua[e]).bind(null,f),enumerable:!(c=r(a,f))||c.enumerable});return e},l=(r,i,s)=>(s=r==null?{}:e(a(r)),c(i||!r||!r.__esModule||!o.call(r,`default`)?n(s,`default`,{value:r,enumerable:!0}):s,r));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))r(e);new MutationObserver(e=>{for(let n of e)if(n.type===`childList`)for(let e of n.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&r(e)}).observe(document,{childList:!0,subtree:!0});function n(e){let n={};return e.integrity&&(n.integrity=e.integrity),e.referrerPolicy&&(n.referrerPolicy=e.referrerPolicy),n.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,n}function r(e){if(e.ep)return;e.ep=!0;let r=n(e);fetch(e.href,r)}})();var u=`abcdefghijklmnopqrstuvwxyz0123456789`,d=store({hash:window.location.hash});window.addEventListener(`hashchange`,()=>{d.hash=window.location.hash}),Prism.languages.pbrule={string:Prism.languages.js.string,number:Prism.languages.js.number,function:Prism.languages.js.function,boolean:/\b(?:true|false)\b/i,constant:/\b(?:null)\b/i,comment:{pattern:/\/\/.*/,greedy:!0},italic:/_via_|\:\w+/,keyword:/&&|\|\||\??(?:!~|!=|>=|<=|=|~|>|>|<)(?=[@\w\s]|$)/};var f={isObject(e){return typeof e==`object`&&!!e&&e.constructor===Object},isEmpty(e){return e==null||e===``||Array.isArray(e)&&e.length===0||typeof e==`object`&&app.utils.isEmptyObject(e)},isEmptyObject(e){for(let n in e)return!1;return!0},toArray(e,n=!1){return Array.isArray(e)?e.slice():(n||!f.isEmpty(e))&&e!==void 0?[e]:[]},removeByValue(e,n){if(!Array.isArray(e)){console.warn(`[removeByValue] not an array:`,e);return}for(let r=e.length-1;r>=0;r--)if(e[r]==n){e.splice(r,1);break}},removeByKey(e,n,r){if(!Array.isArray(e)){console.warn(`[removeByKey] not an array:`,e);return}for(let i in e)if(e[i][n]==r){e.splice(i,1);break}},pushUnique(e,n){if(!Array.isArray(e)){console.warn(`[pushUnique] not an array:`,e);return}e.includes(n)||e.push(n)},mergeUnique(e,n){for(let r of n)app.utils.pushUnique(e,r);return e},pushOrReplaceObject(e,n,r=`id`){for(let i=e.length-1;i>=0;i--)if(e[i][r]==n[r]){e[i]=n;return}e.push(n)},filterDuplicatesByKey(e,n=`id`){e=Array.isArray(e)?e:[];let r={};for(let i of e)r[i[n]]=i;return Object.values(r)},filterRedactedProps(e,n=`******`){let r=JSON.parse(JSON.stringify(e||{}));for(let e in r)typeof r[e]==`object`&&r[e]!==null?r[e]=f.filterRedactedProps(r[e],n):r[e]===n&&delete r[e];return r},getByPath(e,n,r=null,i=`.`){let a=e||{},o=(n||``).split(i);for(let e of o){if(!f.isObject(a)&&!Array.isArray(a)||a[e]===void 0)return r;a=a[e]}return a},setByPath(e,n,r,i=`.`){if(typeof e!=`object`||!e){console.warn(`setByPath: data not an object or array.`);return}let a=e,o=n.split(i),s=o.pop();for(let e of o)(!f.isObject(a)&&!Array.isArray(a)||!f.isObject(a[e])&&!Array.isArray(a[e]))&&(a[e]={}),a=a[e];a[s]=r},deleteByPath(e,n,r=`.`){let i=e||{},a=(n||``).split(r),o=a.pop();for(let e of a)(!f.isObject(i)&&!Array.isArray(i)||!f.isObject(i[e])&&!Array.isArray(i[e]))&&(i[e]={}),i=i[e];Array.isArray(i)?i.splice(o,1):f.isObject(i)&&delete i[o],a.length>0&&(Array.isArray(i)&&!i.length||f.isObject(i)&&!Object.keys(i).length)&&(Array.isArray(e)&&e.length>0||f.isObject(e)&&Object.keys(e).length>0)&&f.deleteByPath(e,a.join(r),r)},emptyClone(e,n=[]){let r=JSON.parse(JSON.stringify(e));for(let e in r)n.includes(e)||(typeof r[e]==`string`?r[e]=``:typeof r[e]==`number`?r[e]=0:typeof r[e]==`boolean`?r[e]=!1:Array.isArray(r[e])?r[e]=[]:app.utils.isObject(r[e])&&(r[e]={}));return r},randomString(e=8,n=u){let r=``;for(let i=0;i`u`)return app.utils.randomString(e);let n=new Uint8Array(e);crypto.getRandomValues(n);let r=``;for(let i=0;i`,`>`).replaceAll(`"`,`"`).replaceAll(`'`,`'`):``},plainText(e){return e?(new DOMParser().parseFromString(e,`text/html`).body.textContent||``).trim():``},truncate(e,n=150,r=!0){if(e=``+e,e.length<=n)return e;if(e=e.slice(0,n),r){for(;e.endsWith(`.`);)e=e.slice(0,-1);e+=`...`}return e},truncateObject(e,n=150,r=!0){let i=Array.isArray(e)?[]:{};for(let a in e){let o=e[a];typeof o==`string`?o=app.utils.truncate(o,n,r):(Array.isArray(o)||app.utils.isObject(o))&&(o=app.utils.truncateObject(o,n,r)),i[a]=o}return i},displayValue(e,n=150,r=`N/A`){if(f.isEmpty(e))return r;if(typeof e!=`string`){if(typeof e==`boolean`)e=e?`True`:`False`;else if(Array.isArray(e)&&typeof e[0]!=`object`)e=e.map(e=>f.displayValue(e,n,r)).join(`, `);else try{e=JSON.stringify(e)||``}catch{e=``+e}}return e?f.truncate(e,n):r},splitNonEmpty(e,n=`,`){let r=(e||``).split(n),i=[];for(let e of r)e=e.trim(),f.isEmpty(e)||i.push(e);return i},joinNonEmpty(e,n=`, `){e||=[];let r=[];for(let n of e)n=typeof n==`string`?n.trim():n,f.isEmpty(n)||r.push(``+n);return r.join(n)},formattedFileSize(e){let n=e?Math.floor(Math.log(e)/Math.log(1024)):0;return(e/1024**n).toFixed(2)*1+` `+[`B`,`KB`,`MB`,`GB`,`TB`][n]},toRFC3339Datetime(e){if(!e)return``;let n;return n=e instanceof Date?e:typeof e==`string`?new Date(e.replace(` `,`T`)):new Date(e),n.toISOString().replace(`T`,` `)},toLocalDatetime(e){if(!e)return``;let n;n=e instanceof Date?e:typeof e==`string`?new Date(e.replace(` `,`T`)):new Date(e);let r=n.getFullYear();return isNaN(r)?``:`${r}-${(n.getMonth()+1).toString().padStart(2,`0`)}-${n.getDate().toString().padStart(2,`0`)} ${n.getHours().toString().padStart(2,`0`)}:${n.getMinutes().toString().padStart(2,`0`)}:${n.getSeconds().toString().padStart(2,`0`)}.${n.getMilliseconds().toString().padStart(3,`0`)}`},toDatetimeLocalInputValue(e){if(!e)return``;let n;n=e instanceof Date?e:typeof e==`string`?new Date(e.replaceAll(` `,`T`)):new Date(e);let r=n.getFullYear();return isNaN(r)?``:`${r}-${(n.getMonth()+1).toString().padStart(2,`0`)}-${n.getDate().toString().padStart(2,`0`)}T${n.getHours().toString().padStart(2,`0`)}:${n.getMinutes().toString().padStart(2,`0`)}:${n.getSeconds().toString().padStart(2,`0`)}`},async copyToClipboard(e){if(e=e==null?``:e instanceof Date?e.toISOString():typeof e==`object`?JSON.stringify(e):``+e,!(!e.length||!window.navigator?.clipboard))return window.navigator.clipboard.writeText(e).catch(e=>{console.warn(`Failed to copy.`,e)})},download(e,n){let r=document.createElement(`a`);r.setAttribute(`href`,e),r.setAttribute(`download`,n),r.setAttribute(`target`,`_blank`),r.setAttribute(`rel`,`noopener noreferrer`),r.click(),r=null},downloadJSON(e,n){n=n.endsWith(`.json`)?n:n+`.json`;let r=new Blob([JSON.stringify(e,null,2)],{type:`application/json`}),i=window.URL.createObjectURL(r);f.download(i,n)},downloadCSV(e,n){n=n.endsWith(`.csv`)?n:n+`.csv`;let r=e.map(e=>e.map(e=>`"`+(``+e).replaceAll(`"`,`""`)+`"`).join(`,`)).join(` +`),i=new Blob([r],{type:`text/csv;charset=utf-8;`}),a=window.URL.createObjectURL(i);f.download(a,n)},getApiExampleURL(){let e;if(app.pb.baseURL.startsWith(`http://`)||app.pb.baseURL.startsWith(`https://`))e=app.pb.baseURL;else{e=window.location.href;let n=e.indexOf(`/_/`);e=n>=0?e.substring(0,n):window.location.origin}return e.replace(`//localhost`,`//127.0.0.1`)},isActivePath(e,n=!0,r=``){r||=d.hash;let i;return i=RegExp(n?`^`+RegExp.escape(e)+`\\/?.*$`:`^`+RegExp.escape(e)+`\\/?(?:\\?.+)?$`),i.test(r)},getHashQueryParams(e=``){e||=d.hash;let n=``,r=e.indexOf(`?`);return r>-1&&(n=window.location.hash.substring(r+1)),Object.fromEntries(new URLSearchParams(n))},replaceHashQueryParams(e,n=null){e||={};let r=``,i=window.location.hash,a=i.indexOf(`?`);a>-1&&(r=i.substring(a+1),i=i.substring(0,a));let o=new URLSearchParams(r);for(let n in e){let r=e[n];f.isEmpty(r)?o.delete(n):o.set(n,r)}r=o.toString(),r!=``&&(i+=`?`+r);let s=window.location.href,c=s.indexOf(`#`);c>-1&&(s=s.substring(0,c));let l=s+i;return n===!1||(n===!0?window.history.pushState(null,``,l):window.history.replaceState(null,``,l)),l},getLocalHistory(e,n=null){try{let r=window.localStorage.getItem(e);if(r)return JSON.parse(r)||n}catch(n){console.log(`failed to load local history:`,e,n)}return n},saveLocalHistory(e,n){try{app.utils.isEmpty(n)?window.localStorage.removeItem(e):typeof n==`string`?window.localStorage.setItem(e,n):window.localStorage.setItem(e,JSON.stringify(n))}catch(n){console.log(`failed to save local history:`,e,n)}},generateThumb(e,n=100,r=100){return new Promise(i=>{let a=new FileReader;a.onload=function(a){let o=new Image;o.onload=function(){let a=document.createElement(`canvas`),s=a.getContext(`2d`),c=o.width,l=o.height;return a.width=n,a.height=r,s.drawImage(o,c>l?(c-l)/2:0,0,c>l?l:c,c>l?l:c,0,0,n,r),i(a.toDataURL(e.type))},o.src=a.target.result},a.readAsDataURL(e)})},normalizeSearchFilter(e,n=[]){if(e=(e||``).trim(),!e||!n.length)return e;for(let n of[`=`,`~`,`>`,`<`])if(e.includes(n))return e;return e=isNaN(e)&&e!=`true`&&e!=`false`?e.replace(/^[\"\'\`]|[\"\'\`]$/gm,``):e,n.map(n=>app.pb.filter(`${n}?~{:searchTerm}`,{searchTerm:e})).join(`||`)},logLevels:{[-4]:{label:`DEBUG`,class:``},0:{label:`INFO`,class:`success`},4:{label:`WARN`,class:`warning`},8:{label:`ERROR`,class:`danger`}},logDataFormatters:{execTime:function(e){return e?.data?.execTime===void 0?`N/A`:e.data.execTime+`ms`}},extendStore(e,n={},...r){let i=[];for(let a in n){let o=n[a];typeof e.__raw?.[a]==`function`||typeof o!=`function`||a.length>2&&a.startsWith(`on`)||r.includes(a)?e[a]=o:i.push(watch(o,n=>{e[a]=n}))}return i},cssTimeToMs(e){return e?(e=e.toLowerCase(),e.endsWith(`ms`)?Number(e.substring(0,e.length-2)):e.endsWith(`s`)?Number(e.substring(0,e.length-1)):Number(e)||0):0},isDarkEnoughForWhiteText(e){if(e=e?.startsWith(`#`)?e.substring(1):e,e?.length!=6)return!1;let n=parseInt(e.substring(0,2),16),r=parseInt(e.substring(2,4),16),i=parseInt(e.substring(4,6),16);return(n*299+r*587+i*114)/1e3<128},bulkSelectRange(e,n,r,i){let a=e.findIndex(e=>e.id==r.id);if(a==-1)return;let o=Object.keys(n),s=o[o.length-1],c=e.findIndex(e=>e.id==s);if(c==-1)return;let l=c,u=a;l>u&&([l,u]=[u,l]);for(let r=l;r<=u;r++)i?n[e[r].id]=e[r]:delete n[e[r].id]},imageExtensions:[`.jpg`,`.jpeg`,`.png`,`.svg`,`.gif`,`.jfif`,`.webp`,`.avif`],videoExtensions:[`.mp4`,`.avi`,`.mov`,`.3gp`,`.wmv`],audioExtensions:[`.aa`,`.aac`,`.m4v`,`.mp3`,`.ogg`,`.oga`,`.mogg`,`.amr`],documentExtensions:[`.pdf`,`.doc`,`.docx`,`.xls`,`.xlsx`,`.ppt`,`.pptx`,`.odp`,`.odt`,`.ods`,`.txt`],archiveExtensions:[`.zip`,`.gz`,`.tar`,`.rar`,`.7z`],hasExtension(e,n){return typeof n==`string`&&(n=n.toLowerCase(),!!e?.find(e=>n.endsWith(e)))},hasImageExtension(e){return app.utils.hasExtension(app.utils.imageExtensions,e)},hasVideoExtension(e){return app.utils.hasExtension(app.utils.videoExtensions,e)},hasAudioExtension(e){return app.utils.hasExtension(app.utils.audioExtensions,e)},hasDocumentExtension(e){return app.utils.hasExtension(app.utils.documentExtensions,e)},hasArchiveExtension(e){return app.utils.hasExtension(app.utils.archiveExtensions,e)},getFileType(e){return app.utils.hasImageExtension(e)?`image`:app.utils.hasVideoExtension(e)?`video`:app.utils.hasAudioExtension(e)?`audio`:app.utils.hasDocumentExtension(e)?`document`:app.utils.hasArchiveExtension(e)?`archive`:`file`},fileTypeIcons:{image:`ri-image-line`,video:`ri-movie-line`,audio:`ri-music-2-line`,document:`ri-file-line`,archive:`ri-file-zip-line`,file:`ri-file-line`},fallbackFieldIcon:`ri-puzzle-line`,fallbackCollectionIcon:`ri-puzzle-line`,fallbackProviderIcon:`ri-puzzle-line`,fallbackPresentableProps:[`title`,`name`,`slug`,`email`,`username`,`nickname`,`displayName`,`label`,`subject`,`topic`,`message`,`heading`,`headline`,`header`,`caption`,`key`,`identifier`,`id`],sortedCollections(e=[]){let n,r;function i(e,i){return n=e.name.startsWith(`_`),r=i.name.startsWith(`_`),n&&!r?1:!n&&r?-1:e.name>i.name?1:e.namee?.id&&!a.find(n=>n.id==e.id)),s=a.filter(e=>e?.id&&!i.find(n=>n.id==e.id)),c=a.filter(e=>{let n=app.utils.isObject(e)&&i.find(n=>n.id==e.id);if(!n)return!1;for(let r in n)if(JSON.stringify(e[r])!=JSON.stringify(n[r]))return!0;return!1});return!!(s.length||c.length||r&&o.length)},extractColumnsFromQuery(e){let n=`__PBGROUP__`;e=(e||``).replace(/\([\s\S]+?\)/gm,n).replace(/[\t\r\n]|(?:\s\s)+/g,` `);let r=e.match(/select\s+([\s\S]+)\s+from/)?.[1]?.split(`,`)||[],i=[];for(let e of r){let r=e.trim().split(` `).pop();r!=``&&r!=n&&i.push(r.replace(/[\'\"\`\[\]\s]/g,``))}return i},getAllCollectionIdentifiers(e,n=``){if(!e)return[];let r=[n+`id`],i=e.type==`auth`;if(e.type===`view`)for(let i of app.utils.extractColumnsFromQuery(e.viewQuery))app.utils.pushUnique(r,n+i);let a=e.fields||[];for(let e of a)if(!(e.type==`password`||i&&e.name==`tokenKey`)){if(app.fieldTypes[e.type]?.identifierExtractor){let i=app.utils.toArray(app.fieldTypes[e.type]?.identifierExtractor(e,n));for(let e of i)app.utils.pushUnique(r,e)}else app.utils.pushUnique(r,n+e.name)}return r},getDummyFieldsData(e,n=!1){let r=e?.fields||[],i={};for(let e of r)if(!e.hidden){if(app.fieldTypes[e.type]?.dummyData){let r=app.fieldTypes[e.type].dummyData(e,n);r!==void 0&&(i[e.name]=r)}else i[e.name]=`[[DATA]]`}return i},parseIndex(e){let n={unique:!1,optional:!1,schemaName:``,indexName:``,tableName:``,columns:[],where:``},r=/\s*create\s+(unique\s+)?\s*index\s*(if\s+not\s+exists\s+)?(\S*)\s+on\s+(\S*)\s*\(([\s\S]*?)\)(?:\s*where\s+([\s\S]*?))?\s*$/gi.exec((e||``).trim());if(r?.length!=7)return n;let i=/^[\"\'\`\[\{}]|[\"\'\`\]\}]$/g;n.unique=r[1]?.trim().toLowerCase()===`unique`,n.optional=!app.utils.isEmpty(r[2]?.trim());let a=(r[3]||``).split(`.`);a.length==2?(n.schemaName=a[0].replace(i,``),n.indexName=a[1].replace(i,``)):(n.schemaName=``,n.indexName=a[0].replace(i,``)),n.tableName=(r[4]||``).replace(i,``);let o=(r[5]||``).replace(/,(?=[^\(]*\))/gi,`{PB_TEMP}`).split(`,`);for(let e of o){e=e.trim().replaceAll(`{PB_TEMP}`,`,`);let r=/^([\s\S]+?)(?:\s+collate\s+([\w]+))?(?:\s+(asc|desc))?\s*$/gi.exec(e);if(r?.length!=4)continue;let a=r[1]?.trim()?.replace(i,``);a&&n.columns.push({name:a,collate:r[2]||``,sort:r[3]?.toUpperCase()||``})}return n.where=r[6]||``,n},buildIndex(e){let n=`CREATE `;e.unique&&(n+=`UNIQUE `),n+=`INDEX `,e.optional&&(n+=`IF NOT EXISTS `),e.schemaName&&(n+=`\`${e.schemaName}\`.`),n+=`\`${e.indexName||`idx_`+app.utils.randomString(10)}\` `,n+=`ON \`${e.tableName}\` (`;let r=e.columns.filter(e=>!!e?.name);return r.length>1&&(n+=` + `),n+=r.map(e=>{let n=``;return e.name.includes(`(`)||e.name.includes(` `)?n+=e.name:n+="`"+e.name+"`",e.collate&&(n+=` COLLATE `+e.collate),e.sort&&(n+=` `+e.sort.toUpperCase()),n}).join(`, + `),r.length>1&&(n+=` +`),n+=`)`,e.where&&(n+=` WHERE ${e.where}`),n},replaceIndexFields(e,n){let r=app.utils.parseIndex(e);return typeof n==`function`?Object.assign(r,n(r)||{}):Object.assign(r,n||{}),app.utils.buildIndex(r)},replaceIndexColumn(e,n,r){if(n===r)return e;let i=app.utils.parseIndex(e),a=!1;for(let e of i.columns)e.name===n&&(e.name=r,a=!0);return a?app.utils.buildIndex(i):e}};window.app=window.app||{},window.app.utils=f,window.app=window.app||{},window.app.utils=window.app.utils||{},window.app.utils.mimeTypes=[{ext:``,mimeType:`application/octet-stream`},{ext:`.xpm`,mimeType:`image/x-xpixmap`},{ext:`.7z`,mimeType:`application/x-7z-compressed`},{ext:`.zip`,mimeType:`application/zip`},{ext:`.xlsx`,mimeType:`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`},{ext:`.docx`,mimeType:`application/vnd.openxmlformats-officedocument.wordprocessingml.document`},{ext:`.pptx`,mimeType:`application/vnd.openxmlformats-officedocument.presentationml.presentation`},{ext:`.epub`,mimeType:`application/epub+zip`},{ext:`.apk`,mimeType:`application/vnd.android.package-archive`},{ext:`.jar`,mimeType:`application/jar`},{ext:`.odt`,mimeType:`application/vnd.oasis.opendocument.text`},{ext:`.ott`,mimeType:`application/vnd.oasis.opendocument.text-template`},{ext:`.ods`,mimeType:`application/vnd.oasis.opendocument.spreadsheet`},{ext:`.ots`,mimeType:`application/vnd.oasis.opendocument.spreadsheet-template`},{ext:`.odp`,mimeType:`application/vnd.oasis.opendocument.presentation`},{ext:`.otp`,mimeType:`application/vnd.oasis.opendocument.presentation-template`},{ext:`.odg`,mimeType:`application/vnd.oasis.opendocument.graphics`},{ext:`.otg`,mimeType:`application/vnd.oasis.opendocument.graphics-template`},{ext:`.odf`,mimeType:`application/vnd.oasis.opendocument.formula`},{ext:`.odc`,mimeType:`application/vnd.oasis.opendocument.chart`},{ext:`.sxc`,mimeType:`application/vnd.sun.xml.calc`},{ext:`.pdf`,mimeType:`application/pdf`},{ext:`.fdf`,mimeType:`application/vnd.fdf`},{ext:``,mimeType:`application/x-ole-storage`},{ext:`.msi`,mimeType:`application/x-ms-installer`},{ext:`.aaf`,mimeType:`application/octet-stream`},{ext:`.msg`,mimeType:`application/vnd.ms-outlook`},{ext:`.xls`,mimeType:`application/vnd.ms-excel`},{ext:`.pub`,mimeType:`application/vnd.ms-publisher`},{ext:`.ppt`,mimeType:`application/vnd.ms-powerpoint`},{ext:`.doc`,mimeType:`application/msword`},{ext:`.ps`,mimeType:`application/postscript`},{ext:`.psd`,mimeType:`image/vnd.adobe.photoshop`},{ext:`.p7s`,mimeType:`application/pkcs7-signature`},{ext:`.ogg`,mimeType:`application/ogg`},{ext:`.oga`,mimeType:`audio/ogg`},{ext:`.ogv`,mimeType:`video/ogg`},{ext:`.png`,mimeType:`image/png`},{ext:`.png`,mimeType:`image/vnd.mozilla.apng`},{ext:`.jpg`,mimeType:`image/jpeg`},{ext:`.jxl`,mimeType:`image/jxl`},{ext:`.jp2`,mimeType:`image/jp2`},{ext:`.jpf`,mimeType:`image/jpx`},{ext:`.jpm`,mimeType:`image/jpm`},{ext:`.jxs`,mimeType:`image/jxs`},{ext:`.gif`,mimeType:`image/gif`},{ext:`.webp`,mimeType:`image/webp`},{ext:`.exe`,mimeType:`application/vnd.microsoft.portable-executable`},{ext:``,mimeType:`application/x-elf`},{ext:``,mimeType:`application/x-object`},{ext:``,mimeType:`application/x-executable`},{ext:`.so`,mimeType:`application/x-sharedlib`},{ext:``,mimeType:`application/x-coredump`},{ext:`.a`,mimeType:`application/x-archive`},{ext:`.deb`,mimeType:`application/vnd.debian.binary-package`},{ext:`.tar`,mimeType:`application/x-tar`},{ext:`.xar`,mimeType:`application/x-xar`},{ext:`.bz2`,mimeType:`application/x-bzip2`},{ext:`.fits`,mimeType:`application/fits`},{ext:`.tiff`,mimeType:`image/tiff`},{ext:`.bmp`,mimeType:`image/bmp`},{ext:`.ico`,mimeType:`image/x-icon`},{ext:`.mp3`,mimeType:`audio/mpeg`},{ext:`.flac`,mimeType:`audio/flac`},{ext:`.midi`,mimeType:`audio/midi`},{ext:`.ape`,mimeType:`audio/ape`},{ext:`.mpc`,mimeType:`audio/musepack`},{ext:`.amr`,mimeType:`audio/amr`},{ext:`.wav`,mimeType:`audio/wav`},{ext:`.aiff`,mimeType:`audio/aiff`},{ext:`.au`,mimeType:`audio/basic`},{ext:`.mpeg`,mimeType:`video/mpeg`},{ext:`.mov`,mimeType:`video/quicktime`},{ext:`.mp4`,mimeType:`video/mp4`},{ext:`.avif`,mimeType:`image/avif`},{ext:`.3gp`,mimeType:`video/3gpp`},{ext:`.3g2`,mimeType:`video/3gpp2`},{ext:`.mp4`,mimeType:`audio/mp4`},{ext:`.mqv`,mimeType:`video/quicktime`},{ext:`.m4a`,mimeType:`audio/x-m4a`},{ext:`.m4v`,mimeType:`video/x-m4v`},{ext:`.heic`,mimeType:`image/heic`},{ext:`.heic`,mimeType:`image/heic-sequence`},{ext:`.heif`,mimeType:`image/heif`},{ext:`.heif`,mimeType:`image/heif-sequence`},{ext:`.mj2`,mimeType:`video/mj2`},{ext:`.dvb`,mimeType:`video/vnd.dvb.file`},{ext:`.webm`,mimeType:`video/webm`},{ext:`.avi`,mimeType:`video/x-msvideo`},{ext:`.flv`,mimeType:`video/x-flv`},{ext:`.mkv`,mimeType:`video/x-matroska`},{ext:`.asf`,mimeType:`video/x-ms-asf`},{ext:`.asf`,mimeType:`video/x-ms-wmv`},{ext:`.asf`,mimeType:`video/asf`},{ext:`.aac`,mimeType:`audio/aac`},{ext:`.voc`,mimeType:`audio/x-unknown`},{ext:`.m3u`,mimeType:`application/vnd.apple.mpegurl`},{ext:`.rmvb`,mimeType:`application/vnd.rn-realmedia-vbr`},{ext:`.gz`,mimeType:`application/gzip`},{ext:`.class`,mimeType:`application/x-java-applet`},{ext:`.swf`,mimeType:`application/x-shockwave-flash`},{ext:`.crx`,mimeType:`application/x-chrome-extension`},{ext:`.ttf`,mimeType:`font/ttf`},{ext:`.woff`,mimeType:`font/woff`},{ext:`.woff2`,mimeType:`font/woff2`},{ext:`.otf`,mimeType:`font/otf`},{ext:`.ttc`,mimeType:`font/collection`},{ext:`.eot`,mimeType:`application/vnd.ms-fontobject`},{ext:`.wasm`,mimeType:`application/wasm`},{ext:`.shx`,mimeType:`application/vnd.shx`},{ext:`.shp`,mimeType:`application/vnd.shp`},{ext:`.dbf`,mimeType:`application/x-dbf`},{ext:`.dcm`,mimeType:`application/dicom`},{ext:`.rar`,mimeType:`application/x-rar-compressed`},{ext:`.djvu`,mimeType:`image/vnd.djvu`},{ext:`.mobi`,mimeType:`application/x-mobipocket-ebook`},{ext:`.lit`,mimeType:`application/x-ms-reader`},{ext:`.bpg`,mimeType:`image/bpg`},{ext:`.cbor`,mimeType:`application/cbor`},{ext:`.sqlite`,mimeType:`application/vnd.sqlite3`},{ext:`.dwg`,mimeType:`image/vnd.dwg`},{ext:`.nes`,mimeType:`application/vnd.nintendo.snes.rom`},{ext:`.lnk`,mimeType:`application/x-ms-shortcut`},{ext:`.macho`,mimeType:`application/x-mach-binary`},{ext:`.qcp`,mimeType:`audio/qcelp`},{ext:`.icns`,mimeType:`image/x-icns`},{ext:`.hdr`,mimeType:`image/vnd.radiance`},{ext:`.mrc`,mimeType:`application/marc`},{ext:`.mdb`,mimeType:`application/x-msaccess`},{ext:`.accdb`,mimeType:`application/x-msaccess`},{ext:`.zst`,mimeType:`application/zstd`},{ext:`.cab`,mimeType:`application/vnd.ms-cab-compressed`},{ext:`.rpm`,mimeType:`application/x-rpm`},{ext:`.xz`,mimeType:`application/x-xz`},{ext:`.lz`,mimeType:`application/lzip`},{ext:`.torrent`,mimeType:`application/x-bittorrent`},{ext:`.cpio`,mimeType:`application/x-cpio`},{ext:``,mimeType:`application/tzif`},{ext:`.xcf`,mimeType:`image/x-xcf`},{ext:`.pat`,mimeType:`image/x-gimp-pat`},{ext:`.gbr`,mimeType:`image/x-gimp-gbr`},{ext:`.glb`,mimeType:`model/gltf-binary`},{ext:`.cab`,mimeType:`application/x-installshield`},{ext:`.jxr`,mimeType:`image/jxr`},{ext:`.parquet`,mimeType:`application/vnd.apache.parquet`},{ext:`.txt`,mimeType:`text/plain`},{ext:`.html`,mimeType:`text/html`},{ext:`.svg`,mimeType:`image/svg+xml`},{ext:`.xml`,mimeType:`text/xml`},{ext:`.rss`,mimeType:`application/rss+xml`},{ext:`.atom`,mimeType:`application/atom+xml`},{ext:`.x3d`,mimeType:`model/x3d+xml`},{ext:`.kml`,mimeType:`application/vnd.google-earth.kml+xml`},{ext:`.xlf`,mimeType:`application/x-xliff+xml`},{ext:`.dae`,mimeType:`model/vnd.collada+xml`},{ext:`.gml`,mimeType:`application/gml+xml`},{ext:`.gpx`,mimeType:`application/gpx+xml`},{ext:`.tcx`,mimeType:`application/vnd.garmin.tcx+xml`},{ext:`.amf`,mimeType:`application/x-amf`},{ext:`.3mf`,mimeType:`application/vnd.ms-package.3dmanufacturing-3dmodel+xml`},{ext:`.xfdf`,mimeType:`application/vnd.adobe.xfdf`},{ext:`.owl`,mimeType:`application/owl+xml`},{ext:`.php`,mimeType:`text/x-php`},{ext:`.js`,mimeType:`text/javascript`},{ext:`.lua`,mimeType:`text/x-lua`},{ext:`.pl`,mimeType:`text/x-perl`},{ext:`.py`,mimeType:`text/x-python`},{ext:`.json`,mimeType:`application/json`},{ext:`.geojson`,mimeType:`application/geo+json`},{ext:`.har`,mimeType:`application/json`},{ext:`.ndjson`,mimeType:`application/x-ndjson`},{ext:`.rtf`,mimeType:`text/rtf`},{ext:`.srt`,mimeType:`application/x-subrip`},{ext:`.tcl`,mimeType:`text/x-tcl`},{ext:`.csv`,mimeType:`text/csv`},{ext:`.tsv`,mimeType:`text/tab-separated-values`},{ext:`.vcf`,mimeType:`text/vcard`},{ext:`.ics`,mimeType:`text/calendar`},{ext:`.warc`,mimeType:`application/warc`},{ext:`.vtt`,mimeType:`text/vtt`},{ext:`.pbm`,mimeType:`image/x-portable-bitmap`},{ext:`.pgm`,mimeType:`image/x-portable-graymap`},{ext:`.ppm`,mimeType:`image/x-portable-pixmap`},{ext:`.eml`,mimeType:`message/rfc822`}];var p=new BroadcastChannel(`tabsSync`),m=`pbSettings`,h=`pbColorScheme`;window.app=window.app||{},window.app.store=store({_ready:!1,superuser:null,showHeader:!0,page:t.div({className:`page`},()=>{if(!app.store._ready)return t.span({className:`loader lg m-auto`,title:`Loading plugins...`})}),mainLogo:`./images/logo.svg`,headerLogo:`./images/logo_white.svg`,favicon:``,title:``,_mediaColorScheme:``,userColorScheme:window.localStorage.getItem(h)||``,get activeColorScheme(){return app.store.userColorScheme?app.store.userColorScheme:app.store._mediaColorScheme||`light`},errors:null,creditLinks:[{href:`https://pocketbase.io/docs`,icon:`ri-book-open-line`,label:`Docs`},{href:`https://github.com/pocketbase/pocketbase/releases`,icon:`ri-github-line`,label:`PocketBase v0.40.5`}],headerLinks:[{href:`#/collections`,icon:`ri-database-2-line`,label:`Collections`},{href:`#/logs`,icon:`ri-bar-chart-box-line`,label:`Logs`},{href:`#/settings`,icon:`ri-settings-3-line`,label:`Settings`}],settingsNavGroups:{System:[{href:`#/settings`,icon:`ri-home-gear-line`,label:`Application`},{href:`#/settings/mail`,icon:`ri-send-plane-2-line`,label:`Mail settings`},{href:`#/settings/storage`,icon:`ri-archive-drawer-line`,label:`Files storage`},{href:`#/settings/backups`,icon:`ri-archive-line`,label:`Backups`},{href:`#/settings/crons`,icon:`ri-time-line`,label:`Crons`}],Sync:[{href:`#/settings/export-collections`,icon:`ri-uninstall-line`,label:`Export collections`},{href:`#/settings/import-collections`,icon:`ri-install-line`,label:`Import collections`}],Debug:[{href:`#/settings/sql`,icon:`ri-terminal-box-line`,label:`SQL console`}]},predefinedAccentColors:[`#1055c9`,`#a3142a`,`#096d5c`,`#e6620a`,`#007d9c`,`#3f3da9`],settings:app.utils.getLocalHistory(m,{}),isLoadingSettings:!1,async loadSettings(){app.store.isLoadingSettings=!0;try{let e=await app.pb.settings.getAll({requestKey:`appStore.loadSettings`});app.store.settings=e,app.store.isLoadingSettings=!1}catch(e){e.isAbort||(app.store.isLoadingSettings=!1,app.checkApiError(e))}},collections:[],collectionScaffolds:{},isLoadingCollections:!1,_activeCollectionIdOrName:``,get activeCollection(){let e=app.store._activeCollectionIdOrName;return app.store.collections.find(n=>n.id==e||n.name==e)||app.store.collections[0]},set activeCollection(e){typeof e==`string`?app.store._activeCollectionIdOrName=e:app.store._activeCollectionIdOrName=e?.id},async silentlyReloadCollections(){try{let e=await app.pb.collections.getFullList({requestKey:`appStore.silentlyReloadCollections`});e=app.utils.sortedCollectionsByType(e),JSON.stringify(e)!=JSON.stringify(app.store.collections)&&(app.store.collections=e)}catch(e){e.isAbort||console.warn(`failed to reload app store collections:`,e)}},async loadCollections(e=null){app.store.isLoadingCollections=!0;try{let[n,r]=await Promise.all([app.pb.collections.getScaffolds({requestKey:`appStore.loadCollections.getScaffolds`}),app.pb.collections.getFullList({requestKey:`appStore.loadCollections.getFullList`})]);r=app.utils.sortedCollectionsByType(r),JSON.stringify(app.store.collections)!=JSON.stringify(r)&&(app.store.collections=r),app.store.collectionScaffolds=n,app.store._activeCollectionIdOrName=e||app.store._activeCollectionIdOrName||app.store.collections[0]?.id||``,app.store.isLoadingCollections=!1}catch(e){e.isAbort||(app.store.isLoadingCollections=!1,app.checkApiError(e))}},addOrUpdateCollection(e){let n=app.store.collections.findIndex(n=>n.id==e.id);n>=0?(app.store.activeCollection?.id==e.id&&(app.store._activeCollectionIdOrName=e.id),app.store.collections[n]=e):app.store.collections.push(e),app.store.collections=app.utils.sortedCollectionsByType(app.store.collections)},oauth2Providers:[],isLoadingOAuth2Providers:!1,async loadOAuth2Providers(){app.store.isLoadingOAuth2Providers=!0;try{app.store.oauth2Providers=await app.pb.collections.getAllOAuth2Providers(),app.store.isLoadingOAuth2Providers=!1}catch(e){e.isAbort||(app.checkApiError(e),app.store.isLoadingOAuth2Providers=!1)}}}),window.addEventListener(`hashchange`,()=>{app.store.title=``,app.store.errors=null}),watch(()=>{let e=app.utils.toArray(app.store.title),n=app.store.settings?.meta?.appName||``;n&&e.push(n),document.title=e.join(` - `)});var g;watch(()=>app.store.settings?.meta?.accentColor,e=>{g||(g=t.meta({name:`theme-color`}),document.head.appendChild(g)),e?(g?.setAttribute(`content`,e),document.documentElement.style.setProperty(`--accentColor`,e)):(g?.removeAttribute(`content`),document.documentElement.style.removeProperty(`--accentColor`))});var _;watch(()=>app.store.favicon,e=>{_||(_=t.link({rel:`icon`}),document.head.appendChild(_)),e?_.href=e:_.href=window.location.href.startsWith(`https://`)?`./images/favicon_prod.png`:`./images/favicon.png`});var v=window.matchMedia(`(prefers-color-scheme: dark)`);app.store._mediaColorScheme=v.matches?`dark`:`light`,v.addEventListener(`change`,({matches:e})=>{app.store._mediaColorScheme=e?`dark`:`light`}),watch(()=>app.store.userColorScheme,e=>{e?window.localStorage.setItem(h,e):window.localStorage.removeItem(h),p?.postMessage({colorScheme:e})});var ee;watch(()=>app.store.activeColorScheme,e=>{clearTimeout(ee),document.documentElement.style.setProperty(`--animationSpeed`,`0`),document.documentElement.setAttribute(`data-color-scheme`,e),ee=setTimeout(()=>{document.documentElement.style.removeProperty(`--animationSpeed`)},100)});function te(e,n){e.__errListener&&=(e.removeEventListener(`input`,e.__errListener),e.removeEventListener(`change`,e.__errListener),null),e.setCustomValidity&&(e.setCustomValidity(``),e._oldTitle?e.setAttribute(`title`,e._oldTitle):e.removeAttribute(`title`)),e.removeAttribute(`data-error`);let r=n.nextSibling;r&&r.classList?.contains(`generated-error`)&&r.getAttribute(`data-input-name`)==e.getAttribute(`name`)&&r.remove(),n.querySelector(`[data-error]`)||n.classList.remove(`error`)}watch(()=>JSON.stringify(app.store.errors)&&app.store.errors,e=>{let n=document.querySelectorAll(`[name]`);for(let r of n){if(r.classList.contains(`no-error`))continue;let n=r.closest(`.field-list`)||r.closest(`.fields`)||r.closest(`.field`);if(!n)continue;let i=r.getAttribute(`name`);te(r,n);let a=app.utils.getByPath(e,i),o=a?.message||``;if(!o&&!app.utils.isEmpty(a)){let e=[];for(let n in a)a[n]?.message&&e.push(`${n}: ${a[n]?.message}`);o=e.join(` +`)}o&&(n.classList.add(`error`),r.__errListener=function(){te(r,n),app.utils.deleteByPath(app.store.errors,i)},r.addEventListener(`input`,r.__errListener),r.addEventListener(`change`,r.__errListener),r.setAttribute(`data-error`,!0),r.setCustomValidity&&r.reportValidity&&r.classList.contains(`inline-error`)?(r.setCustomValidity(o),r.reportValidity(),r._oldTitle=r.title,r.title=o):n.after(t.div({"html-data-input-name":i,className:`field-help error generated-error`,textContent:o})))}}),p.onmessage=e=>{e.data?.collections&&JSON.stringify(app.store.collections)!=JSON.stringify(e.data.collections)&&(app.store.collections=e.data.collections),e.data?.settings&&JSON.stringify(app.store.settings)!=JSON.stringify(e.data.settings)&&(app.store.settings=e.data.settings),e.data?.colorScheme&&(app.store.userColorScheme=e.data.colorScheme)},watch(()=>JSON.stringify(app.store.collections),(e,n)=>{e&&e!=`[]`&&n&&n!=`[]`&&e!=n&&p?.postMessage({collections:JSON.parse(e)})}),watch(()=>JSON.stringify(app.store.settings),(e,n)=>{e&&e!=`{}`&&n&&n!=`{}`&&e!=n&&p?.postMessage({settings:JSON.parse(e)}),window.localStorage.setItem(m,e)});var y=class e extends Error{constructor(n){super(`ClientResponseError`),this.url=``,this.status=0,this.response={},this.isAbort=!1,this.originalError=null,Object.setPrototypeOf(this,e.prototype),typeof n==`object`&&n&&(this.originalError=n.originalError,this.url=typeof n.url==`string`?n.url:``,this.status=typeof n.status==`number`?n.status:0,this.isAbort=!!n.isAbort||n.name===`AbortError`||n.message===`Aborted`,this.response=n.response!==null&&typeof n.response==`object`?n.response:n.data!==null&&typeof n.data==`object`?n.data:{}),this.originalError||n instanceof e||(this.originalError=n),this.name=`ClientResponseError `+this.status,this.message=this.response?.message,this.message||=this.isAbort?`The request was aborted (most likely autocancelled; you can find more info in https://github.com/pocketbase/js-sdk#auto-cancellation).`:this.originalError?.cause?.message?.includes(`ECONNREFUSED ::1`)?`Failed to connect to the PocketBase server. Try changing the SDK URL from localhost to 127.0.0.1 (https://github.com/pocketbase/js-sdk/issues/21).`:`Something went wrong.`,this.cause=this.originalError}get data(){return this.response}toJSON(){return{...this}}},ne=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function re(e,n){let r={};if(typeof e!=`string`)return r;let i=Object.assign({},n||{}).decode||ae,a=0;for(;a0&&(!r.exp||r.exp-n>Date.now()/1e3))}oe=typeof atob!=`function`||x?e=>{let n=String(e).replace(/=+$/,``);if(n.length%4==1)throw Error(`'atob' failed: The string to be decoded is not correctly encoded.`);for(var r,i,a=0,o=0,s=``;i=n.charAt(o++);~i&&(r=a%4?64*r+i:i,a++%4)&&(s+=String.fromCharCode(255&r>>(-2*a&6))))i=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=`.indexOf(i);return s}:atob;var C=`pb_auth`,ce=class{constructor(){this.baseToken=``,this.baseModel=null,this._onChangeCallbacks=[]}get token(){return this.baseToken}get record(){return this.baseModel}get model(){return this.baseModel}get isValid(){return!se(this.token)}get isSuperuser(){let e=S(this.token);return e.type==`auth`&&(this.record?.collectionName==`_superusers`||!this.record?.collectionName&&e.collectionId==`pbc_3142635823`)}get isAdmin(){return console.warn(`Please replace pb.authStore.isAdmin with pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName`),this.isSuperuser}get isAuthRecord(){return console.warn(`Please replace pb.authStore.isAuthRecord with !pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName`),S(this.token).type==`auth`&&!this.isSuperuser}save(e,n){this.baseToken=e||``,this.baseModel=n||null,this.triggerChange()}clear(){this.baseToken=``,this.baseModel=null,this.triggerChange()}loadFromCookie(e,n=C){let r=re(e||``)[n]||``,i={};try{i=JSON.parse(r),(typeof i!=`object`||Array.isArray(i))&&(i={})}catch{}this.save(i.token||``,i.record||i.model||null)}exportToCookie(e,n=C){let r={secure:!0,sameSite:!0,httpOnly:!0,path:`/`},i=S(this.token);r.expires=i?.exp?new Date(1e3*i.exp):new Date(`1970-01-01`),e=Object.assign({},r,e);let a={token:this.token,record:this.record?JSON.parse(JSON.stringify(this.record)):null},o=ie(n,JSON.stringify(a),e),s=typeof Blob<`u`?new Blob([o]).size:o.length;if(a.record&&s>4096){a.record={id:a.record?.id,email:a.record?.email};let r=[`collectionId`,`collectionName`,`verified`];for(let e in this.record)r.includes(e)&&(a.record[e]=this.record[e]);o=ie(n,JSON.stringify(a),e)}return o}onChange(e,n=!1){return this._onChangeCallbacks.push(e),n&&e(this.token,this.record),()=>{for(let n=this._onChangeCallbacks.length-1;n>=0;n--)if(this._onChangeCallbacks[n]==e)return delete this._onChangeCallbacks[n],void this._onChangeCallbacks.splice(n,1)}}triggerChange(){for(let e of this._onChangeCallbacks)e&&e(this.token,this.record)}},w=class extends ce{constructor(e=`pocketbase_auth`){super(),this.storageFallback={},this.storageKey=e,this._bindStorageEvent()}get token(){return(this._storageGet(this.storageKey)||{}).token||``}get record(){let e=this._storageGet(this.storageKey)||{};return e.record||e.model||null}get model(){return this.record}save(e,n){this._storageSet(this.storageKey,{token:e,record:n}),super.save(e,n)}clear(){this._storageRemove(this.storageKey),super.clear()}_storageGet(e){if(typeof window<`u`&&window?.localStorage){let n=window.localStorage.getItem(e)||``;try{return JSON.parse(n)}catch{return n}}return this.storageFallback[e]}_storageSet(e,n){if(typeof window<`u`&&window?.localStorage){let r=n;typeof n!=`string`&&(r=JSON.stringify(n)),window.localStorage.setItem(e,r)}else this.storageFallback[e]=n}_storageRemove(e){typeof window<`u`&&window?.localStorage&&window.localStorage?.removeItem(e),delete this.storageFallback[e]}_bindStorageEvent(){typeof window<`u`&&window?.localStorage&&window.addEventListener&&window.addEventListener(`storage`,(e=>{if(e.key!=this.storageKey)return;let n=this._storageGet(this.storageKey)||{};super.save(n.token||``,n.record||n.model||null)}))}},T=class{constructor(e){this.client=e}},E=class extends T{async getAll(e){return e=Object.assign({method:`GET`},e),this.client.send(`/api/settings`,e)}async update(e,n){return n=Object.assign({method:`PATCH`,body:e},n),this.client.send(`/api/settings`,n)}async testS3(e=`storage`,n){return n=Object.assign({method:`POST`,body:{filesystem:e}},n),this.client.send(`/api/settings/test/s3`,n).then((()=>!0))}async testEmail(e,n,r,i){return i=Object.assign({method:`POST`,body:{email:n,template:r,collection:e}},i),this.client.send(`/api/settings/test/email`,i).then((()=>!0))}async generateAppleClientSecret(e,n,r,i,a,o){return o=Object.assign({method:`POST`,body:{clientId:e,teamId:n,keyId:r,privateKey:i,duration:a}},o),this.client.send(`/api/settings/apple/generate-client-secret`,o)}},D=[`requestKey`,`$cancelKey`,`$autoCancel`,`fetch`,`headers`,`body`,`query`,`params`,`cache`,`credentials`,`headers`,`integrity`,`keepalive`,`method`,`mode`,`redirect`,`referrer`,`referrerPolicy`,`signal`,`window`];function O(e){if(e){e.query=e.query||{};for(let n in e)D.includes(n)||(e.query[n]=e[n],delete e[n])}}function k(e){let n=[];for(let r in e){let i=encodeURIComponent(r),a=Array.isArray(e[r])?e[r]:[e[r]];for(let e of a)e=A(e),e!==null&&n.push(i+`=`+e)}return n.join(`&`)}function A(e){return e==null?null:e instanceof Date?encodeURIComponent(e.toISOString().replace(`T`,` `)):encodeURIComponent(typeof e==`object`?JSON.stringify(e):e)}var j=class extends T{constructor(){super(...arguments),this.clientId=``,this.eventSource=null,this.subscriptions={},this.lastSentSubscriptions=[],this.maxConnectTimeout=15e3,this.reconnectAttempts=0,this.maxReconnectAttempts=1/0,this.predefinedReconnectIntervals=[200,300,500,1e3,1200,1500,2e3],this.pendingConnects=[],this.pendingSubmits=[],this.isProcessingPendingSubmits=!1}get isConnected(){return!!this.eventSource&&!!this.clientId&&!this.pendingConnects.length}async subscribe(e,n,r){if(!e)throw Error(`topic must be set.`);let i=e;if(r){O(r=Object.assign({},r));let e=`options=`+encodeURIComponent(JSON.stringify({query:r.query,headers:r.headers}));i+=(i.includes(`?`)?`&`:`?`)+e}let a=function(e){let r=e,i;try{i=JSON.parse(r?.data)}catch{}n(i||{})};return this.subscriptions[i]||(this.subscriptions[i]=[]),this.subscriptions[i].push(a),this.isConnected?this.subscriptions[i].length===1?await this.submitSubscriptions():this.eventSource?.addEventListener(i,a):await this.connect(),async()=>this.unsubscribeByTopicAndListener(e,a)}async unsubscribe(e){if(e){let n=this.getSubscriptionsByTopic(e);for(let e in n)if(this.hasSubscriptionListeners(e)){for(let n of this.subscriptions[e])this.eventSource?.removeEventListener(e,n);delete this.subscriptions[e]}}else this.subscriptions={};await this.submitSubscriptions()}async unsubscribeByPrefix(e){let n=!1;for(let r in this.subscriptions)if((r+`?`).startsWith(e)){n=!0;for(let e of this.subscriptions[r])this.eventSource?.removeEventListener(r,e);delete this.subscriptions[r]}n&&await this.submitSubscriptions()}async unsubscribeByTopicAndListener(e,n){let r=this.getSubscriptionsByTopic(e);for(let e in r){if(!Array.isArray(this.subscriptions[e])||!this.subscriptions[e].length)continue;let r=!1;for(let i=this.subscriptions[e].length-1;i>=0;i--)this.subscriptions[e][i]===n&&(r=!0,delete this.subscriptions[e][i],this.subscriptions[e].splice(i,1),this.eventSource?.removeEventListener(e,n));r&&(this.subscriptions[e].length||delete this.subscriptions[e])}await this.submitSubscriptions()}hasSubscriptionListeners(e){if(this.subscriptions=this.subscriptions||{},e)return!!this.subscriptions[e]?.length;for(let e in this.subscriptions)if(this.subscriptions[e]?.length)return!0;return!1}async submitSubscriptions(){return new Promise(((e,n)=>{this.pendingSubmits.push({resolve:e,reject:n}),this.pendingSubmits.length==1&&queueMicrotask((()=>this.finalizePendingSubscriptions()))}))}async finalizePendingSubscriptions(){if(this.isProcessingPendingSubmits||!this.pendingSubmits.length)return;let e=this.pendingSubmits.slice();this.pendingSubmits=[],this.isProcessingPendingSubmits=!0;try{await this.sendSubscriptions();for(let n of e)n.resolve()}catch(n){for(let r of e)n?r.reject(n):r.resolve()}finally{this.isProcessingPendingSubmits=!1,this.pendingSubmits.length>0&&await this.finalizePendingSubscriptions()}}getSubscriptionsCancelKey(){return`realtime_`+this.clientId}getSubscriptionsByTopic(e){let n={};e=e.includes(`?`)?e:e+`?`;for(let r in this.subscriptions)(r+`?`).startsWith(e)&&(n[r]=this.subscriptions[r]);return n}getNonEmptySubscriptionKeys(){let e=[];for(let n in this.subscriptions)this.subscriptions[n].length&&e.push(n);return e}hasUnsentSubscriptions(){let e=this.getNonEmptySubscriptionKeys();if(e.length!=this.lastSentSubscriptions.length)return!0;for(let n of e)if(!this.lastSentSubscriptions.includes(n))return!0;return!1}async sendSubscriptions(){if(this.clientId){if(!this.hasSubscriptionListeners())return this.disconnect();if(this.hasUnsentSubscriptions())return this.addAllSubscriptionListeners(),this.lastSentSubscriptions=this.getNonEmptySubscriptionKeys(),this.client.send(`/api/realtime`,{method:`POST`,body:{clientId:this.clientId,subscriptions:this.lastSentSubscriptions},requestKey:this.getSubscriptionsCancelKey()}).catch((e=>{if(!e?.isAbort)throw e}))}}addAllSubscriptionListeners(){if(this.eventSource){this.removeAllSubscriptionListeners();for(let e in this.subscriptions)for(let n of this.subscriptions[e])this.eventSource.addEventListener(e,n)}}removeAllSubscriptionListeners(){if(this.eventSource)for(let e in this.subscriptions)for(let n of this.subscriptions[e])this.eventSource.removeEventListener(e,n)}async connect(){if(!(this.reconnectAttempts>0))return new Promise(((e,n)=>{this.pendingConnects.push({resolve:e,reject:n}),this.pendingConnects.length==1&&queueMicrotask((()=>this.initConnect()))}))}initConnect(){this.disconnect(!0),clearTimeout(this.connectTimeoutId),this.connectTimeoutId=setTimeout((()=>{this.connectErrorHandler(Error(`EventSource connect took too long.`))}),this.maxConnectTimeout),this.eventSource=new EventSource(this.client.buildURL(`/api/realtime`)),this.eventSource.onerror=e=>{this.connectErrorHandler(Error(`Failed to establish realtime connection.`))},this.eventSource.addEventListener(`PB_CONNECT`,(e=>{let n=e;this.clientId=n?.lastEventId,this.lastSentSubscriptions=[],this.submitSubscriptions().then((async()=>{let n=3;for(;this.hasUnsentSubscriptions()&&n>0;)n--,await this.submitSubscriptions();for(let e of this.pendingConnects)e.resolve();this.pendingConnects=[],this.reconnectAttempts=0,clearTimeout(this.reconnectTimeoutId),clearTimeout(this.connectTimeoutId);let r=this.getSubscriptionsByTopic(`PB_CONNECT`);for(let n in r)for(let i of r[n])i(e);this.hasUnsentSubscriptions()&&await this.submitSubscriptions()})).catch((e=>{this.clientId=``,this.lastSentSubscriptions=[],this.connectErrorHandler(e)}))}))}connectErrorHandler(e){if(clearTimeout(this.connectTimeoutId),clearTimeout(this.reconnectTimeoutId),!this.clientId&&!this.reconnectAttempts||this.reconnectAttempts>this.maxReconnectAttempts){for(let n of this.pendingConnects)n.reject(new y(e));this.pendingConnects=[],this.disconnect();return}this.disconnect(!0);let n=this.predefinedReconnectIntervals[this.reconnectAttempts]||this.predefinedReconnectIntervals[this.predefinedReconnectIntervals.length-1];this.reconnectAttempts++,this.reconnectTimeoutId=setTimeout((()=>{this.initConnect()}),n)}disconnect(e=!1){if(this.clientId&&this.onDisconnect&&this.onDisconnect(Object.keys(this.subscriptions)),clearTimeout(this.connectTimeoutId),clearTimeout(this.reconnectTimeoutId),this.removeAllSubscriptionListeners(),this.client.cancelRequest(this.getSubscriptionsCancelKey()),this.eventSource?.close(),this.eventSource=null,this.clientId=``,this.lastSentSubscriptions=[],!e){this.reconnectAttempts=0;for(let e of this.pendingConnects)e.resolve();this.pendingConnects=[]}}},M=class extends T{decode(e){return e}async getFullList(e,n){if(typeof e==`number`)return this._getFullList(e,n);let r=1e3;return(n=Object.assign({},e,n)).batch&&(r=n.batch,delete n.batch),this._getFullList(r,n)}async getList(e=1,n=30,r){return(r=Object.assign({method:`GET`},r)).query=Object.assign({page:e,perPage:n},r.query),this.client.send(this.baseCrudPath,r).then((e=>(e.items=e.items?.map((e=>this.decode(e)))||[],e)))}async getFirstListItem(e,n){return(n=Object.assign({requestKey:`one_by_filter_`+this.baseCrudPath+`_`+e},n)).query=Object.assign({filter:e,skipTotal:1},n.query),this.getList(1,1,n).then((e=>{if(!e?.items?.length)throw new y({status:404,response:{code:404,message:`The requested resource wasn't found.`,data:{}}});return e.items[0]}))}async getOne(e,n){if(!e)throw new y({url:this.client.buildURL(this.baseCrudPath+`/`),status:404,response:{code:404,message:`Missing required record id.`,data:{}}});return n=Object.assign({method:`GET`},n),this.client.send(this.baseCrudPath+`/`+encodeURIComponent(e),n).then((e=>this.decode(e)))}async create(e,n){return n=Object.assign({method:`POST`,body:e},n),this.client.send(this.baseCrudPath,n).then((e=>this.decode(e)))}async update(e,n,r){return r=Object.assign({method:`PATCH`,body:n},r),this.client.send(this.baseCrudPath+`/`+encodeURIComponent(e),r).then((e=>this.decode(e)))}async delete(e,n){return n=Object.assign({method:`DELETE`},n),this.client.send(this.baseCrudPath+`/`+encodeURIComponent(e),n).then((()=>!0))}_getFullList(e=1e3,n){(n||={}).query=Object.assign({skipTotal:1},n.query);let r=[],i=async a=>this.getList(a,e||1e3,n).then((e=>{let n=e.items;return r=r.concat(n),n.length==e.perPage?i(a+1):r}));return i(1)}};function N(e,n,r,i){let a=i!==void 0;return a||r!==void 0?a?(console.warn(e),n.body=Object.assign({},n.body,r),n.query=Object.assign({},n.query,i),n):Object.assign(n,r):n}function le(e){e._resetAutoRefresh?.()}var ue=class extends M{constructor(e,n){super(e),this.collectionIdOrName=n}get baseCrudPath(){return this.baseCollectionPath+`/records`}get baseCollectionPath(){return`/api/collections/`+encodeURIComponent(this.collectionIdOrName)}get isSuperusers(){return this.collectionIdOrName==`_superusers`||this.collectionIdOrName==`_pbc_2773867675`}async subscribe(e,n,r){if(!e)throw Error(`Missing topic.`);if(!n)throw Error(`Missing subscription callback.`);return this.client.realtime.subscribe(this.collectionIdOrName+`/`+e,n,r)}async unsubscribe(e){return e?this.client.realtime.unsubscribe(this.collectionIdOrName+`/`+e):this.client.realtime.unsubscribeByPrefix(this.collectionIdOrName)}async getFullList(e,n){if(typeof e==`number`)return super.getFullList(e,n);let r=Object.assign({},e,n);return super.getFullList(r)}async getList(e=1,n=30,r){return super.getList(e,n,r)}async getFirstListItem(e,n){return super.getFirstListItem(e,n)}async getOne(e,n){return super.getOne(e,n)}async create(e,n){return super.create(e,n)}async update(e,n,r){return super.update(e,n,r).then((e=>{if(this.client.authStore.record?.id===e?.id&&(this.client.authStore.record?.collectionId===this.collectionIdOrName||this.client.authStore.record?.collectionName===this.collectionIdOrName)){let n=Object.assign({},this.client.authStore.record.expand),r=Object.assign({},this.client.authStore.record,e);n&&(r.expand=Object.assign(n,e.expand)),this.client.authStore.save(this.client.authStore.token,r)}return e}))}async delete(e,n){return super.delete(e,n).then((n=>(!n||this.client.authStore.record?.id!==e||this.client.authStore.record?.collectionId!==this.collectionIdOrName&&this.client.authStore.record?.collectionName!==this.collectionIdOrName||this.client.authStore.clear(),n)))}authResponse(e){let n=this.decode(e?.record||{});return this.client.authStore.save(e?.token,n),Object.assign({},e,{token:e?.token||``,record:n})}async listAuthMethods(e){return e=Object.assign({method:`GET`,fields:`mfa,otp,password,oauth2`},e),this.client.send(this.baseCollectionPath+`/auth-methods`,e)}async authWithPassword(e,n,r){let i;r=Object.assign({method:`POST`,body:{identity:e,password:n}},r),this.isSuperusers&&(i=r.autoRefreshThreshold,delete r.autoRefreshThreshold,r.autoRefresh||le(this.client));let a=await this.client.send(this.baseCollectionPath+`/auth-with-password`,r);return a=this.authResponse(a),i&&this.isSuperusers&&function(e,n,r,i){le(e);let a=e.beforeSend,o=e.authStore.record,s=e.authStore.onChange(((n,r)=>{(!n||r?.id!=o?.id||(r?.collectionId||o?.collectionId)&&r?.collectionId!=o?.collectionId)&&le(e)}));e._resetAutoRefresh=function(){s(),e.beforeSend=a,delete e._resetAutoRefresh},e.beforeSend=async(o,s)=>{let c=e.authStore.token;if(s.query?.autoRefresh)return a?a(o,s):{url:o,sendOptions:s};let l=e.authStore.isValid;if(l&&se(e.authStore.token,n))try{await r()}catch{l=!1}l||await i();let u=s.headers||{};for(let n in u)if(n.toLowerCase()==`authorization`&&c==u[n]&&e.authStore.token){u[n]=e.authStore.token;break}return s.headers=u,a?a(o,s):{url:o,sendOptions:s}}}(this.client,i,(()=>this.authRefresh({autoRefresh:!0})),(()=>this.authWithPassword(e,n,Object.assign({autoRefresh:!0},r)))),a}async authWithOAuth2Code(e,n,r,i,a,o,s){let c={method:`POST`,body:{provider:e,code:n,codeVerifier:r,redirectURL:i,createData:a}};return c=N(`This form of authWithOAuth2Code(provider, code, codeVerifier, redirectURL, createData?, body?, query?) is deprecated. Consider replacing it with authWithOAuth2Code(provider, code, codeVerifier, redirectURL, createData?, options?).`,c,o,s),this.client.send(this.baseCollectionPath+`/auth-with-oauth2`,c).then((e=>this.authResponse(e)))}authWithOAuth2(...e){if(e.length>1||typeof e?.[0]==`string`)return console.warn(`PocketBase: This form of authWithOAuth2() is deprecated and may get removed in the future. Please replace with authWithOAuth2Code() OR use the authWithOAuth2() realtime form as shown in https://pocketbase.io/docs/authentication/#oauth2-integration.`),this.authWithOAuth2Code(e?.[0]||``,e?.[1]||``,e?.[2]||``,e?.[3]||``,e?.[4]||{},e?.[5]||{},e?.[6]||{});let n=e?.[0]||{},r=null;n.urlCallback||(r=de(void 0));let i=new j(this.client);function a(){r?.close(),i.unsubscribe()}let o={},s=n.requestKey;return s&&(o.requestKey=s),this.listAuthMethods(o).then((e=>{let o=e.oauth2.providers.find((e=>e.name===n.provider));if(!o)throw new y(Error(`Missing or invalid provider "${n.provider}".`));let c=this.client.buildURL(`/api/oauth2-redirect`);return new Promise((async(e,l)=>{let u=s?this.client.cancelControllers?.[s]:void 0;u&&(u.signal.onabort=()=>{a(),l(new y({isAbort:!0,message:`manually cancelled`}))}),i.onDisconnect=e=>{e.length&&l&&(a(),l(new y(Error(`realtime connection interrupted`))))};try{await i.subscribe(`@oauth2`,(async r=>{let s=i.clientId;try{if(!r.state||s!==r.state)throw Error(`State parameters don't match.`);if(r.error||!r.code)throw Error(`OAuth2 redirect error or missing code: `+r.error);let i=Object.assign({},n);delete i.provider,delete i.scopes,delete i.createData,delete i.urlCallback,u?.signal?.onabort&&(u.signal.onabort=null),e(await this.authWithOAuth2Code(o.name,r.code,o.codeVerifier,c,n.createData,i))}catch(e){l(new y(e))}a()}));let s={state:i.clientId};n.scopes?.length&&(s.scope=n.scopes.join(` `));let d=this._replaceQueryParams(o.authURL+c,s);await(n.urlCallback||function(e){r?r.location.href=e:r=de(e)})(d)}catch(e){u?.signal?.onabort&&(u.signal.onabort=null),a(),l(new y(e))}}))})).catch((e=>{throw a(),e}))}async authRefresh(e,n){let r={method:`POST`};return r=N(`This form of authRefresh(body?, query?) is deprecated. Consider replacing it with authRefresh(options?).`,r,e,n),this.client.send(this.baseCollectionPath+`/auth-refresh`,r).then((e=>this.authResponse(e)))}async requestPasswordReset(e,n,r){let i={method:`POST`,body:{email:e}};return i=N(`This form of requestPasswordReset(email, body?, query?) is deprecated. Consider replacing it with requestPasswordReset(email, options?).`,i,n,r),this.client.send(this.baseCollectionPath+`/request-password-reset`,i).then((()=>!0))}async confirmPasswordReset(e,n,r,i,a){let o={method:`POST`,body:{token:e,password:n,passwordConfirm:r}};return o=N(`This form of confirmPasswordReset(token, password, passwordConfirm, body?, query?) is deprecated. Consider replacing it with confirmPasswordReset(token, password, passwordConfirm, options?).`,o,i,a),this.client.send(this.baseCollectionPath+`/confirm-password-reset`,o).then((()=>!0))}async requestVerification(e,n,r){let i={method:`POST`,body:{email:e}};return i=N(`This form of requestVerification(email, body?, query?) is deprecated. Consider replacing it with requestVerification(email, options?).`,i,n,r),this.client.send(this.baseCollectionPath+`/request-verification`,i).then((()=>!0))}async confirmVerification(e,n,r){let i={method:`POST`,body:{token:e}};return i=N(`This form of confirmVerification(token, body?, query?) is deprecated. Consider replacing it with confirmVerification(token, options?).`,i,n,r),this.client.send(this.baseCollectionPath+`/confirm-verification`,i).then((()=>{let n=S(e),r=this.client.authStore.record;return r&&!r.verified&&r.id===n.id&&r.collectionId===n.collectionId&&(r.verified=!0,this.client.authStore.save(this.client.authStore.token,r)),!0}))}async requestEmailChange(e,n,r){let i={method:`POST`,body:{newEmail:e}};return i=N(`This form of requestEmailChange(newEmail, body?, query?) is deprecated. Consider replacing it with requestEmailChange(newEmail, options?).`,i,n,r),this.client.send(this.baseCollectionPath+`/request-email-change`,i).then((()=>!0))}async confirmEmailChange(e,n,r,i){let a={method:`POST`,body:{token:e,password:n}};return a=N(`This form of confirmEmailChange(token, password, body?, query?) is deprecated. Consider replacing it with confirmEmailChange(token, password, options?).`,a,r,i),this.client.send(this.baseCollectionPath+`/confirm-email-change`,a).then((()=>{let n=S(e),r=this.client.authStore.record;return r&&r.id===n.id&&r.collectionId===n.collectionId&&this.client.authStore.clear(),!0}))}async listExternalAuths(e,n){return this.client.collection(`_externalAuths`).getFullList(Object.assign({},n,{filter:this.client.filter(`recordRef = {:id}`,{id:e})}))}async unlinkExternalAuth(e,n,r){let i=await this.client.collection(`_externalAuths`).getFirstListItem(this.client.filter(`recordRef = {:recordId} && provider = {:provider}`,{recordId:e,provider:n}));return this.client.collection(`_externalAuths`).delete(i.id,r).then((()=>!0))}async requestOTP(e,n){return n=Object.assign({method:`POST`,body:{email:e}},n),this.client.send(this.baseCollectionPath+`/request-otp`,n)}async authWithOTP(e,n,r){return r=Object.assign({method:`POST`,body:{otpId:e,password:n}},r),this.client.send(this.baseCollectionPath+`/auth-with-otp`,r).then((e=>this.authResponse(e)))}async impersonate(e,n,r){(r=Object.assign({method:`POST`,body:{duration:n}},r)).headers=r.headers||{},r.headers.Authorization||(r.headers.Authorization=this.client.authStore.token);let i=new Ee(this.client.baseURL,new ce,this.client.lang),a=await i.send(this.baseCollectionPath+`/impersonate/`+encodeURIComponent(e),r);return i.authStore.save(a?.token,this.decode(a?.record||{})),i}_replaceQueryParams(e,n={}){let r=e,i=``;e.indexOf(`?`)>=0&&(r=e.substring(0,e.indexOf(`?`)),i=e.substring(e.indexOf(`?`)+1));let a={},o=i.split(`&`);for(let e of o){if(e==``)continue;let n=e.split(`=`);a[decodeURIComponent(n[0].replace(/\+/g,` `))]=decodeURIComponent((n[1]||``).replace(/\+/g,` `))}for(let e in n)n.hasOwnProperty(e)&&(n[e]==null?delete a[e]:a[e]=n[e]);i=``;for(let e in a)a.hasOwnProperty(e)&&(i!=``&&(i+=`&`),i+=encodeURIComponent(e.replace(/%20/g,`+`))+`=`+encodeURIComponent(a[e].replace(/%20/g,`+`)));return i==``?r:r+`?`+i}};function de(e){if(typeof window>`u`||!window?.open)throw new y(Error(`Not in a browser context - please pass a custom urlCallback function.`));let n=1024,r=768,i=window.innerWidth,a=window.innerHeight;n=n>i?i:n,r=r>a?a:r;let o=i/2-n/2,s=a/2-r/2;return window.open(e,`popup_window`,`width=`+n+`,height=`+r+`,top=`+s+`,left=`+o+`,resizable,menubar=no`)}var fe=class extends M{get baseCrudPath(){return`/api/collections`}async import(e,n=!1,r){return r=Object.assign({method:`PUT`,body:{collections:e,deleteMissing:n}},r),this.client.send(this.baseCrudPath+`/import`,r).then((()=>!0))}async truncate(e,n){return n=Object.assign({method:`DELETE`},n),this.client.send(this.baseCrudPath+`/`+encodeURIComponent(e)+`/truncate`,n).then((()=>!0))}async getScaffolds(e){return e=Object.assign({method:`GET`},e),this.client.send(this.baseCrudPath+`/meta/scaffolds`,e)}async getAllOAuth2Providers(e){return e=Object.assign({method:`GET`},e),this.client.send(this.baseCrudPath+`/meta/oauth2-providers`,e)}async dryRunViewQuery(e,n){return n=Object.assign({method:`POST`,body:{query:e}},n),this.client.send(this.baseCrudPath+`/meta/dry-run-view`,n)}},pe=class extends T{async getList(e=1,n=30,r){return(r=Object.assign({method:`GET`},r)).query=Object.assign({page:e,perPage:n},r.query),this.client.send(`/api/logs`,r)}async getOne(e,n){if(!e)throw new y({url:this.client.buildURL(`/api/logs/`),status:404,response:{code:404,message:`Missing required log id.`,data:{}}});return n=Object.assign({method:`GET`},n),this.client.send(`/api/logs/`+encodeURIComponent(e),n)}async getStats(e){return e=Object.assign({method:`GET`},e),this.client.send(`/api/logs/stats`,e)}async truncate(e){return e=Object.assign({method:`DELETE`},e),this.client.send(`/api/logs`,e).then((()=>!0))}},me=class extends T{async check(e){return e=Object.assign({method:`GET`},e),this.client.send(`/api/health`,e)}},he=class extends T{getUrl(e,n,r={}){return console.warn(`Please replace pb.files.getUrl() with pb.files.getURL()`),this.getURL(e,n,r)}getURL(e,n,r={}){if(!n||!e?.id||!e?.collectionId&&!e?.collectionName)return``;let i=[];i.push(`api`),i.push(`files`),i.push(encodeURIComponent(e.collectionId||e.collectionName)),i.push(encodeURIComponent(e.id)),i.push(encodeURIComponent(n));let a=this.client.buildURL(i.join(`/`));!1===r.download&&delete r.download;let o=k(r);return o&&(a+=(a.includes(`?`)?`&`:`?`)+o),a}async getToken(e){return e=Object.assign({method:`POST`},e),this.client.send(`/api/files/token`,e).then((e=>e?.token||``))}},ge=class extends T{async getFullList(e){return e=Object.assign({method:`GET`},e),this.client.send(`/api/backups`,e)}async create(e,n){return n=Object.assign({method:`POST`,body:{name:e}},n),this.client.send(`/api/backups`,n).then((()=>!0))}async upload(e,n){return n=Object.assign({method:`POST`,body:e},n),this.client.send(`/api/backups/upload`,n).then((()=>!0))}async delete(e,n){return n=Object.assign({method:`DELETE`},n),this.client.send(`/api/backups/${encodeURIComponent(e)}`,n).then((()=>!0))}async restore(e,n){return n=Object.assign({method:`POST`},n),this.client.send(`/api/backups/${encodeURIComponent(e)}/restore`,n).then((()=>!0))}getDownloadUrl(e,n){return console.warn(`Please replace pb.backups.getDownloadUrl() with pb.backups.getDownloadURL()`),this.getDownloadURL(e,n)}getDownloadURL(e,n){return this.client.buildURL(`/api/backups/${encodeURIComponent(n)}?token=${encodeURIComponent(e)}`)}},_e=class extends T{async getFullList(e){return e=Object.assign({method:`GET`},e),this.client.send(`/api/crons`,e)}async run(e,n){return n=Object.assign({method:`POST`},n),this.client.send(`/api/crons/${encodeURIComponent(e)}`,n).then((()=>!0))}},ve=class extends T{async run(e,n){return n=Object.assign({method:`POST`,body:{query:e}},n),this.client.send(`/api/sql`,n)}};function ye(e){return typeof Blob<`u`&&e instanceof Blob||typeof File<`u`&&e instanceof File||typeof e==`object`&&!!e&&e.uri&&(typeof navigator<`u`&&navigator.product===`ReactNative`||typeof global<`u`&&global.HermesInternal)}function be(e){return e&&(e.constructor?.name===`FormData`||typeof FormData<`u`&&e instanceof FormData)}function xe(e){for(let n in e){let r=Array.isArray(e[n])?e[n]:[e[n]];for(let e of r)if(ye(e))return!0}return!1}var Se=/^[\-\.\d]+$/;function Ce(e){if(typeof e!=`string`)return e;if(e==`true`)return!0;if(e==`false`)return!1;if((e[0]===`-`||e[0]>=`0`&&e[0]<=`9`)&&Se.test(e)){let n=+e;if(``+n===e)return n}return e}var we=class extends T{constructor(){super(...arguments),this.requests=[],this.subs={}}collection(e){return this.subs[e]||(this.subs[e]=new Te(this.requests,e)),this.subs[e]}async send(e){let n=new FormData,r=[];for(let e=0;e{if(r===`@jsonPayload`&&typeof e==`string`)try{let r=JSON.parse(e);Object.assign(n,r)}catch(e){console.warn(`@jsonPayload error:`,e)}else n[r]===void 0?n[r]=Ce(e):(Array.isArray(n[r])||(n[r]=[n[r]]),n[r].push(Ce(e)))})),n}(r));for(let n in r){let i=r[n];if(ye(i))e.files[n]=e.files[n]||[],e.files[n].push(i);else if(Array.isArray(i)){let r=[],a=[];for(let e of i)ye(e)?r.push(e):a.push(e);if(r.length>0&&r.length==i.length){e.files[n]=e.files[n]||[];for(let i of r)e.files[n].push(i)}else if(e.json[n]=a,r.length>0){let i=n;n.startsWith(`+`)||n.endsWith(`+`)||(i+=`+`),e.files[i]=e.files[i]||[];for(let n of r)e.files[i].push(n)}}else e.json[n]=i}}};RegExp.escape||(RegExp.escape=function(e){return e.replace(/[/\-\\^$*+?.()|[\]{}]/g,`\\$&`)});var Ee=class{get baseUrl(){return this.baseURL}set baseUrl(e){this.baseURL=e}constructor(e=`/`,n,r=`en-US`){this.cancelControllers={},this.recordServices={},this.enableAutoCancellation=!0,this.baseURL=e,this.lang=r,this.authStore=n||(typeof window<`u`&&window.Deno?new ce:new w),this.collections=new fe(this),this.files=new he(this),this.logs=new pe(this),this.settings=new E(this),this.realtime=new j(this),this.health=new me(this),this.backups=new ge(this),this.crons=new _e(this),this.sql=new ve(this)}get admins(){return this.collection(`_superusers`)}createBatch(){return new we(this)}collection(e){return this.recordServices[e]||(this.recordServices[e]=new ue(this,e)),this.recordServices[e]}autoCancellation(e){return this.enableAutoCancellation=!!e,this}cancelRequest(e){return this.cancelControllers[e]&&(this.cancelControllers[e].abort(),delete this.cancelControllers[e]),this}cancelAllRequests(){for(let e in this.cancelControllers)this.cancelControllers[e].abort();return this.cancelControllers={},this}filter(e,n){if(!n)return e;let r={};for(let e in n)r[`{:`+e+`}`]=n[e];let i=Object.keys(r).map((e=>RegExp.escape(e))).join(`|`);return i?e.replace(new RegExp(i,`g`),(e=>function(e){switch(typeof e){case`boolean`:case`number`:return``+e;case`string`:return JSON.stringify(e);default:if(e==null)return`null`;if(e instanceof Date)return JSON.stringify(e.toISOString().replace(`T`,` `));let n=JSON.stringify(e);return n.startsWith(`[`)||n.startsWith(`{`)?JSON.stringify(n):n}}(r[e]))):e}getFileUrl(e,n,r={}){return console.warn(`Please replace pb.getFileUrl() with pb.files.getURL()`),this.files.getURL(e,n,r)}buildUrl(e){return console.warn(`Please replace pb.buildUrl() with pb.buildURL()`),this.buildURL(e)}buildURL(e){let n=this.baseURL;return typeof window>`u`||!window.location||n.startsWith(`https://`)||n.startsWith(`http://`)||(n=window.location.origin?.endsWith(`/`)?window.location.origin.substring(0,window.location.origin.length-1):window.location.origin||``,this.baseURL.startsWith(`/`)||(n+=window.location.pathname||`/`,n+=n.endsWith(`/`)?``:`/`),n+=this.baseURL),e&&(n+=n.endsWith(`/`)?``:`/`,n+=e.startsWith(`/`)?e.substring(1):e),n}async send(e,n){n=this.initSendOptions(e,n);let r=this.buildURL(e);if(this.beforeSend){let e=Object.assign({},await this.beforeSend(r,n));e.url!==void 0||e.options!==void 0?(r=e.url||r,n=e.options||n):Object.keys(e).length&&(n=e,console?.warn&&console.warn("Deprecated format of beforeSend return: please use `return { url, options }`, instead of `return options`."))}if(n.query!==void 0){let e=k(n.query);e&&(r+=(r.includes(`?`)?`&`:`?`)+e),delete n.query}return this.getHeader(n.headers,`Content-Type`)==`application/json`&&n.body&&typeof n.body!=`string`&&(n.body=JSON.stringify(n.body)),(n.fetch||fetch)(r,n).then((async e=>{let r={};try{r=await e.json()}catch(e){if(n.signal?.aborted||e?.name==`AbortError`||e?.message==`Aborted`)throw e}if(this.afterSend&&(r=await this.afterSend(e,r,n)),e.status>=400)throw new y({url:e.url,status:e.status,data:r});return r})).catch((e=>{throw new y(e)}))}initSendOptions(e,n){if((n=Object.assign({method:`GET`},n)).body=function(e){if(typeof FormData>`u`||e===void 0||typeof e!=`object`||!e||be(e)||!xe(e))return e;let n=new FormData;for(let r in e){let i=e[r];if(i!==void 0){if(typeof i!=`object`||xe({data:i})){let e=Array.isArray(i)?i:[i];for(let i of e)n.append(r,i)}else{let e={};e[r]=i,n.append(`@jsonPayload`,JSON.stringify(e))}}}return n}(n.body),O(n),n.query=Object.assign({},n.params,n.query),n.requestKey===void 0&&(!1===n.$autoCancel||!1===n.query.$autoCancel?n.requestKey=null:(n.$cancelKey||n.query.$cancelKey)&&(n.requestKey=n.$cancelKey||n.query.$cancelKey)),delete n.$autoCancel,delete n.query.$autoCancel,delete n.$cancelKey,delete n.query.$cancelKey,this.getHeader(n.headers,`Content-Type`)!==null||be(n.body)||(n.headers=Object.assign({},n.headers,{"Content-Type":`application/json`})),this.getHeader(n.headers,`Accept-Language`)===null&&(n.headers=Object.assign({},n.headers,{"Accept-Language":this.lang})),this.authStore.token&&this.getHeader(n.headers,`Authorization`)===null&&(n.headers=Object.assign({},n.headers,{Authorization:this.authStore.token})),this.enableAutoCancellation&&n.requestKey!==null){let r=n.requestKey||(n.method||`GET`)+e;delete n.requestKey,this.cancelRequest(r);let i=new AbortController;this.cancelControllers[r]=i,n.signal=i.signal}return n}getHeader(e,n){e||={},n=n.toLowerCase();for(let r in e)if(r.toLowerCase()==n)return e[r];return null}},De=`#/login`,Oe=window.location.pathname.endsWith(`/`)?window.location.pathname.substring(0,window.location.pathname.length-1):window.location.pathname;window.app=window.app||{},window.app.pb=new Ee(`../`,new w(`__pb_superusers__`+Oe)),app.pb.beforeSend=function(e,n){return n.headers[`x-request-source`]=`pbui`,{url:e,options:n}},app.store.superuser=app.pb.authStore.record,app.pb.authStore.onChange((e,n)=>{!n&&window.location.hash!=De&&(app.modals.close(),window.location.hash=De),app.store.superuser=n}),app.pb.authStore.isValid&&app.pb.collection(app.pb.authStore.record?.collectionName||`_superusers`).authRefresh().catch(e=>{console.warn(`Failed to refresh the existing auth token:`,e);let n=e?.status<<0;(n==401||n==403)&&(app.pb.cancelAllRequests(),app.pb.authStore.clear())}),app.pb.authStore.onChange((e,n)=>{n?.id&&(app.store.loadCollections(),app.store.loadSettings(),app.store.loadOAuth2Providers())});var ke=app.pb.collection;app.pb.collection=function(e){let n=ke.call(this,e);return Ae(n),n};function Ae(e){if(e.__customUIEvents)return;e.__customUIEvents=!0;let n=e.create;e.create=function(){return n.apply(e,arguments).then(e=>(setTimeout(()=>{document.dispatchEvent(new CustomEvent(`record:create`,{detail:e})),document.dispatchEvent(new CustomEvent(`record:save`,{detail:e}))},0),e))};let r=e.update;e.update=function(){return r.apply(e,arguments).then(e=>(setTimeout(()=>{document.dispatchEvent(new CustomEvent(`record:update`,{detail:e})),document.dispatchEvent(new CustomEvent(`record:save`,{detail:e}))},0),e))};let i=e.delete;e.delete=function(){return i.apply(e,arguments).then(n=>{let r={id:arguments[0],collectionId:e.collectionIdOrName,collectionName:e.collectionIdOrName};return setTimeout(()=>{document.dispatchEvent(new CustomEvent(`record:delete`,{detail:r}))},0),n})}}var je=`pbLastFileToken`,Me=!1,Ne=[];app.pb.authStore.onChange((e,n)=>{n?.id||window.localStorage.removeItem(je)}),window.app.getFileToken=async function(e=``){let n=e&&app.store.collections?.find(n=>n.id==e||n.name==e);if(n&&!n.fields?.find(e=>e.type==`file`&&e.protected))return;let r=window.localStorage.getItem(je);return(!r||se(r,60))&&(r=await Pe()),r};async function Pe(){return new Promise(async(e,n)=>{if(Ne.push({resolve:e,reject:n}),!Me){Me=!0;try{let e=await app.pb.files.getToken();window.localStorage.setItem(je,e),Ne.forEach(n=>n.resolve(e))}catch(e){Ne.forEach(n=>n.reject(e))}Me=!1,Ne=[]}})}window.app.checkApiError=function(e,n=!0){if(!e||!(e instanceof Error)||e.isAbort){console.warn(`checkApiError - unexpected error type:`,e);return}let r=e?.status<<0,i=e?.response||{},a=n&&(i.message||e.message||`Something went wrong!`);if(a&&app.toasts.error(a),r==0&&console.log(e),app.utils.isEmpty(i.data)||(app.store.errors=i.data),r===401&&window.location.hash!=De)return app.pb.cancelAllRequests(),app.pb.authStore.clear()};function Fe(){return()=>{if(!(!app.store._ready||!app.store.showHeader||!app.store.superuser?.id))return t.header({pbEvent:`appHeader`,rid:`appHeader`,className:`app-header accent-surface`,onmount:async e=>{await new Promise(e=>setTimeout(e,0)),e._scrollToActiveMenuItem=function(){e?.querySelector(`.app-main-nav .header-link.active`)?.scrollIntoView()},e._scrollToActiveMenuItem(),window.addEventListener(`hashchange`,e._scrollToActiveMenuItem)},onunmount:e=>{window.removeEventListener(`hashchange`,e?._scrollToActiveMenuItem)}},t.a({href:`#/`,className:`logo`},t.img({src:()=>app.store.headerLogo,alt:`App logo`})),t.nav({pbEvent:`mainNav`,className:`app-main-nav`},()=>app.store.headerLinks.map(e=>{let n=e.href.startsWith(`#/`);return t.a({href:()=>e.href,target:()=>n?void 0:`_blank`,rel:()=>n?void 0:`noopener noreferrer`,className:n=>`header-link ${e.isActive?.(n)||app.utils.isActivePath(e.href)?`active`:``}`},()=>{if(e.icon)return t.i({className:e.icon,ariaHidden:!0})},t.span({className:`txt`},()=>e.label))})),t.div({className:`flex-fill app-header-separator`}),Ie(),t.button({type:`button`,className:`header-link logged-user txt-normal`,"html-popovertarget":`logged-user-dropdown`},t.span({className:`superuser-name txt-ellipsis`},()=>app.store.superuser?.email),t.i({className:`ri-arrow-drop-down-line`,ariaHidden:!0})),t.div({pbEvent:`loggedUserDropdown`,id:`logged-user-dropdown`,className:`dropdown sm nowrap logged-user-dropdown`,popover:`auto`},t.a({className:`dropdown-item dropdown-item-manage`,href:`#/collections?collection=_superusers`,onclick:e=>{e.target.closest(`.dropdown`).hidePopover()}},t.i({className:`ri-group-line`,ariaHidden:!0}),t.span({className:`txt`},`Manage superusers`)),t.hr(),t.button({type:`button`,className:`dropdown-item txt-danger dropdown-item-logout`,onclick:()=>app.pb.authStore.clear()},t.i({className:`ri-logout-circle-line`,ariaHidden:!0}),t.span({className:`txt`},`Logout`))))}}function Ie(){let e=[{value:`light`,icon:`ri-sun-line`,label:`Light`},{value:`dark`,icon:`ri-moon-line`,label:`Dark`},{value:``,icon:`ri-subtract-line`,label:`Auto`}];return[t.button({type:`button`,className:`header-link color-scheme-picker`,"html-popovertarget":`color-scheme-dropdown`,title:`Color scheme`},t.i({className:()=>app.store.activeColorScheme==`dark`?`ri-moon-line`:`ri-sun-line`,ariaHidden:!0})),t.div({pbEvent:`colorSchemeDropdown`,id:`color-scheme-dropdown`,className:`dropdown sm nowrap color-scheme-dropdown`,popover:`auto`},()=>e.map(e=>t.button({type:`button`,className:()=>`dropdown-item dropdown-item-light ${app.store.userColorScheme==e.value?`active`:``}`,onclick:n=>{n.target.closest(`.dropdown`).hidePopover(),app.store.userColorScheme=e.value}},t.i({className:e.icon,ariaHidden:!0}),t.span({className:`txt`},e.label))))]}document.addEventListener(`invalid`,e=>{let n=e.target.closest(`details`);n&&!n.open&&!e.target.closest(`summary`)&&(n.open=!0,e.target.reportValidity&&e.target.reportValidity())},!0);var Le=`dropdown-item`;document.addEventListener(`toggle`,e=>{if(e.newState!=`open`||!e.target||e.target.__keyboardNavRegistered||!e.target.matches(`.dropdown`))return;e.target.__keyboardNavRegistered=!0;let n=e.target;function r(e){if(!n.isConnected){document.removeEventListener(`keydown`,r);return}let i;if(i=document.activeElement&&document.activeElement.classList.contains(Le)?document.activeElement:n.querySelector(`.dropdown-item:not([hidden]):not(.disabled)`),i){if(e.key==`ArrowUp`){e.preventDefault();let n=ze(i,-1);i==document.activeElement&&n?.classList?.contains(Le)?n?.focus():i.focus()}else if(e.key==`ArrowDown`){e.preventDefault();let n=ze(i,1);i==document.activeElement&&n?.classList?.contains(Le)?n?.focus():i.focus()}else if(e.keyCode>=65&&e.keyCode<=90||e.keyCode>=48&&e.keyCode<=57){let e=n.querySelectorAll(`input,textare,select`);e.length==1&&e[0].focus()}}}n.addEventListener(`toggle`,e=>{e.newState==`open`?(n.__dropdownSource=e.source,n.__dropdownSource?.setAttribute(`data-popover-state`,!0),Re(e.target.id,!0),document.addEventListener(`keydown`,r)):(n.__dropdownSource?.setAttribute(`data-popover-state`,!1),Re(e.target.id,!1),document.removeEventListener(`keydown`,r))})},!0);function Re(e,n=!1){e&&document.querySelectorAll(`[popovertarget='`+e+`']`)?.forEach(e=>e.setAttribute(`data-popover-state`,n))}function ze(e,n=-1){let r=n<0?e.previousElementSibling:e.nextElementSibling;return r&&(r.hidden||r.classList.contains(`disabled`)||!r.classList.contains(Le))?ze(r,n):r}var Be=5,P=t.div({ariaHidden:!0,popover:`manual`,className:`pb-tooltip`});document.body.appendChild(P);function Ve(e,n,r){P._relatedNode=e;let i=e.getBoundingClientRect();P.setAttribute(`data-style`,r||``),P.setAttribute(`data-position`,n),P.style.top=`0px`,P.style.left=`0px`;let a=P.offsetHeight,o=P.offsetWidth,s=0,c=0;n==`left`?(s=i.top+i.height/2-a/2,c=i.left-o-Be):n==`right`?(s=i.top+i.height/2-a/2,c=i.right+Be):n==`top`?(s=i.top-a-Be,c=i.left+i.width/2-o/2):n==`top-left`?(s=i.top-a-Be,c=i.left):n==`top-right`?(s=i.top-a-Be,c=i.right-o):n==`bottom-left`?(s=i.top+i.height+Be,c=i.left):n==`bottom-right`?(s=i.top+i.height+Be,c=i.right-o):(s=i.top+i.height+Be,c=i.left+i.width/2-o/2),c+o>document.documentElement.clientWidth&&(c=document.documentElement.clientWidth-o),c=c>=0?c:0,s+a>document.documentElement.clientHeight&&(s=document.documentElement.clientHeight-a),s=s>=0?s:0,P.style.top=s+`px`,P.style.left=c+`px`}function He(){P._relatedNode=null,P.hidePopover()}function Ue(e,n,r,i){if(!e||!n){He();return}P.showPopover(),P.textContent=n,Ve(e,r,i)}document.body.addEventListener(`mouseleave`,()=>{He()});function We(e,n=`top`,r=``){return i=>{if(!i._tooltipText){i._tooltipText=store({value:``});let e;function a(){e?.unwatch(),e=watch(()=>i._tooltipText.value,async e=>{Ue(i,e,n,r)})}async function o(){e?.unwatch(),e=null,He()}i.addEventListener(`mouseenter`,a),i.addEventListener(`focusin`,a),i.addEventListener(`mouseleave`,o),i.addEventListener(`focusout`,o),i.addEventListener(`blur`,o);let s=i.onunmount;i.onunmount=n=>{P._relatedNode==n&&He(),e?.unwatch(),n._tooltipText=null,n?.removeEventListener(`mouseenter`,a),n?.removeEventListener(`focusin`,a),n?.removeEventListener(`mouseleave`,o),n?.removeEventListener(`focusout`,o),n?.removeEventListener(`blur`,o),s(n)}}return typeof e==`function`?i._tooltipText.value=e():i._tooltipText.value=e,i._tooltipText.value}}window.app=window.app||{},window.app.attrs=window.app.attrs||{},window.app.attrs.tooltip=We,window.app=window.app||{},window.app.modals=window.app.modals||{},window.app.modals.confirm=function(e,n,r,i={className:void 0,yesButton:``,noButton:``}){F.textOrElem=e,F.yesCallback=n,F.yesCallbackWaiting=!1,F.noCallback=r,F.noCallbackWaiting=!1,F.className=typeof i.className==`string`?i.className:`sm`,F.yesButton=i.yesButton||`Yes`,F.noButton=i.noButton||`No`,Ge.isConnected||document.body.appendChild(Ge),window.app.modals.open(Ge)};var F=store({className:``,textOrElem:null,yesButton:``,yesCallback:null,yesCallbackWaiting:!1,noButton:``,noCallback:null,noCallbackWaiting:!1,get isBusy(){return F.yesCallbackWaiting||F.noCallbackWaiting}}),Ge=t.div({className:()=>`modal popup manual ${F.className||``}`},t.div({className:`modal-content`},e=>typeof F.textOrElem==`string`?t.h6({className:`block txt-center`},F.textOrElem):typeof F.textOrElem==`function`?F.textOrElem(e):F.textOrElem),t.footer({className:`modal-footer p-sm`},t.div({className:`grid sm`},t.div({className:`col-sm-6`},t.button({type:`button`,className:()=>`btn lg block secondary ${F.noCallbackWaiting?`loading`:``}`,disabled:()=>F.isBusy,onclick:async()=>{if(F.noCallback){F.noCallbackWaiting=!0;try{if(await F.noCallback()===!1)return}catch(e){console.log(`confirm noCallback error:`,e)}finally{F.noCallbackWaiting=!1}}window.app.modals.close(Ge)}},()=>typeof F.noButton==`string`?t.span({className:`txt`},F.noButton):typeof F.noButton==`function`?F.noButton(el):F.noButton)),t.div({className:`col-sm-6`},t.button({type:`button`,className:()=>`btn lg block warning ${F.yesCallbackWaiting?`loading`:``}`,disabled:()=>F.isBusy,onclick:async()=>{if(F.yesCallback){F.yesCallbackWaiting=!0;try{if(await F.yesCallback()===!1)return}catch(e){console.log(`confirm yesCallback error:`,e)}finally{F.yesCallbackWaiting=!1}}window.app.modals.close(Ge)}},()=>typeof F.yesButton==`string`?t.span({className:`txt`},F.yesButton):typeof F.yesButton==`function`?F.yesButton(el):F.yesButton)))));window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.dragline=function(e={}){let n,r=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,className:``,tolerance:0,ondragstart:function(e){},ondragstop:function(e){},ondragging:function(e,n,r){}}),i=app.utils.extendStore(r,e),a=store({dragStarted:!1}),o,s,c,l;function u(){document.addEventListener(`touchmove`,m),document.addEventListener(`mousemove`,m),document.addEventListener(`touchend`,p),document.addEventListener(`mouseup`,p)}function d(){document.removeEventListener(`touchmove`,m),document.removeEventListener(`mousemove`,m),document.removeEventListener(`touchend`,p),document.removeEventListener(`mouseup`,p)}function f(e){e.stopPropagation(),o=e.clientX,s=e.clientY,c=e.clientX-n.offsetLeft,l=e.clientY-n.offsetTop,u()}function p(e){a.dragStarted&&(e.preventDefault(),a.dragStarted=!1,r.ondragstop?.(e)),d()}function m(e){let i=e.clientX-o,u=e.clientY-s,d=e.clientX-c,f=e.clientY-l;!a.dragStarted&&Math.abs(d-n.offsetLeft)r.id,hidden:()=>r.hidden,inert:()=>r.inert,className:()=>`dragline ${a.dragStarted?`dragging`:``} ${r.className}`,onmousedown:e=>{e.button==0&&f(e)},ontouchstart:f,onunmount:()=>{d(),i.forEach(e=>e?.unwatch())}}),n},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.slide=function(e,...n){let r;return t.div({className:n=>`block slide-block ${e?.(n)?``:`hidden`}`,onmount:e=>{r=setTimeout(()=>{e?.setAttribute(`data-slide`,`1`)},200)},onunmount:()=>{clearTimeout(r)}},...n)},window.app=window.app||{},window.app.modals=window.app.modals||{};var Ke=`data-modal-state`,qe=`manual`,Je;window.app.modals.open=async function(e){if(!e?.isConnected){console.error(`modals.open requies an active DOM element`,e);return}let n;if(e.onbeforeopen&&(n=await e.onbeforeopen(e)),n===!1)return;if(e.onafteropen){let n=new ResizeObserver(r=>{for(let i of r)i.contentRect.height>0&&e?.onafteropen&&(e.onafteropen(e),n.disconnect(),n=null)});n.observe(e)}Je=document.activeElement,Ye(e),e._forceClose=()=>app.modals.close(e,!0),window.addEventListener(`popstate`,e._forceClose);let r=Math.max(Xe(e)?.style.zIndex<<0,1e3);e.style.zIndex=r+1,e.setAttribute(Ke,`open`)},window.app.modals.close=async function(e=null,n=!1){if(e||=Xe(),e){if(window.removeEventListener(`popstate`,e._forceClose),n)e.onbeforeclose?.(e,!0),e.setAttribute(Ke,`close`),e.onafterclose?.(e,!0);else{if(e.onbeforeclose&&await e.onbeforeclose(e,!1)===!1)return;if(e.onafterclose){let n=new ResizeObserver(r=>{for(let i of r)i.contentRect.height<=0&&e?.onafterclose&&(e.onafterclose(e,!1),n.disconnect(),n=null)});n.observe(e)}e.setAttribute(Ke,`close`)}Je&&(Je.focus?.(),setTimeout(()=>{Je?.blur?.(),Je=null},0))}};function Ye(e){if(e.getAttribute(`tabindex`)||e.setAttribute(`tabindex`,`-1`),setTimeout(()=>{let n=e?.querySelector(`[autofocus]`);n?n.focus():e?.focus()},0),e.getAttribute(Ke))return;e.setAttribute(Ke,``),e.addEventListener(`keydown`,n=>{n.key!=`Escape`||e.classList.contains(qe)||n.target!==e&&e.contains(n.target)||window.app.modals.close(e)});let n=!1,r=r=>{n=r.target!==e&&e.contains(r.target)};e.addEventListener(`mousedown`,r),e.addEventListener(`touchstart`,r);let i=!1,a=n=>{i=n.target!==e&&e.contains(n.target)};e.addEventListener(`mouseup`,a),e.addEventListener(`touchend`,a),e.addEventListener(`click`,r=>{n||i||e.classList.contains(qe)||r.target!==e&&e.contains(r.target)||window.app.modals.close(e)})}function Xe(e){let n=document.querySelectorAll(`[${Ke}="open"]`),r=0,i=0,a;for(let o of n)e&&o==e||(r=o.style.zIndex<<0,r>i&&(i=r,a=o));return a}window.app.modals.getTop=function(){return Xe()};var Ze=new Map,Qe=t.div({className:`toasts-container`});function I(e,n=!0){let r=Ze.get(e);!r||!r.isConnected||(Ze.delete(e),clearTimeout(r._removeTimeout),n?(r.classList.add(`removing`),setTimeout(()=>{r.remove()},300)):r.remove())}function R(e=!0){Ze.forEach((n,r)=>{window.app.toasts.remove(r,e)})}function $e(e,n={}){n.type=`info`,n.duration=n.duration||3e3,nt(e,n)}function et(e,n={}){n.type=`success`,n.duration=n.duration||3e3,nt(e,n)}function tt(e,n={}){n.type=`error`,n.duration=n.duration||3500,nt(e,n)}function nt(e,n={}){n=Object.assign({duration:3e3,key:void 0,type:`info`},n),Qe.isConnected||document.body.appendChild(Qe);let r=n.key||e;Ze.has(r)&&I(r,!1);function i(e){e?._removeTimeout&&clearTimeout(e?._removeTimeout),e._removeTimeout=setTimeout(()=>{I(r)},n.duration)}let a=t.div({className:`toast ${n.type||``}`,onmount:e=>{i(e)},onunmount:e=>{e?._removeTimeout&&(clearTimeout(e?._removeTimeout),a=null)},onmouseover:()=>{clearTimeout(a?._removeTimeout)},onmouseout:()=>{i(a)}},t.div({className:`toast-container`},t.div({className:`toast-icon`}),t.div({className:`toast-content`},e,t.button({type:`button`,className:`m-l-auto btn circle sm transparent secondary toast-remove`,title:`Clear`,onclick:()=>I(r)},t.i({className:`ri-close-line`,ariaHidden:!0})))));Ze.set(r,a),Qe.prepend(a)}window.app=window.app||{},window.app.toasts=window.app.toasts||{},window.app.toasts.info=$e,window.app.toasts.error=tt,window.app.toasts.success=et,window.app.toasts.remove=I,window.app.toasts.removeAll=R,window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.sortable=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,className:``,data:[],dataItem:function(e,n,r){return t.span(null,`Item `+n)},onchange:function(e,n,r){},handle:``,before:void 0,after:void 0}),r=app.utils.extendStore(n,e);function i(e){function r(){e.querySelectorAll(`:scope > [data-dragstart="true"]`)?.forEach(e=>{e.dataset.dragstart=!1}),e.querySelectorAll(`:scope > [data-dragover="true"]`)?.forEach(e=>{e.dataset.dragover=!1})}e.addEventListener(`dragstart`,r=>{if(n.handle&&!r.target.closest(n.handle)){r.preventDefault();return}let i=o(e,r.target);i&&(i.dataset.dragstart=!0)}),e.addEventListener(`dragenter`,n=>{for(let n of e.children)n.dataset.dragover&&(n.dataset.dragover=!1);let r=o(e,n.target);r&&(r.dataset.dragover=!0)}),e.addEventListener(`dragover`,e=>{e.preventDefault()}),e.addEventListener(`drop`,i=>{if(!n.onchange){r();return}let s=e.querySelector(`:scope > [data-dragstart="true"]`),c=o(e,i.target);if(r(),!s||!c||c==s)return;let l=e.querySelectorAll(`:scope > [data-sortable-child]`),u=a(l,s),d=a(l,c);if(u==-1||d==-1)return;let f=n.data.slice(),p=f.splice(u,1);f.splice(d,0,p[0]),n.onchange(f,u,d)}),e._documentDragend=function(){r()},document.addEventListener(`dragend`,e._documentDragend)}function a(e,n){for(let r=0;rn.id,hidden:()=>n.hidden,inert:()=>n.inert,className:()=>n.className,onmount:e=>{i(e)},onunmount:e=>{r.forEach(e=>e?.unwatch()),e._documentDragend&&document.removeEventListener(`dragend`,e._documentDragend)}},e=>typeof n.before==`function`?n.before(e):n.before,e=>{let r=[];for(let i=0;itypeof n.after==`function`?n.after(e):n.after)},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.copyButton=function(e,...n){let r=store({active:!1}),i;function a(){let n=e;typeof n==`function`&&(n=e()),app.utils.copyToClipboard(n),r.active=!0,clearTimeout(i),i=setTimeout(()=>{r.active=!1},500)}return t.button({tabIndex:-1,type:`button`,className:()=>`copy-to-clipboard ${r.active?`active`:``}`,title:`Copy`,ariaDescription:app.attrs.tooltip(()=>r.active?`Copied`:null),onclick:e=>{e.preventDefault(),e.stopPropagation(),a()}},t.i({hidden:n?.length,ariaHidden:!0,className:()=>`copy-icon ${r.active?`ri-check-double-line`:`ri-file-copy-line`}`}),...n)},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.codeBlock=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,className:``,language:`js`,value:void 0,footnote:void 0}),r=app.utils.extendStore(n,e);return t.div({rid:n.rid,id:()=>n.id,hidden:()=>n.hidden,inert:()=>n.inert,className:()=>`code-wrapper ${n.className}`,tabIndex:-1,onmount:e=>{e.addEventListener(`keydown`,n=>{(n.ctrlKey||n.metaKey)&&(n.key==`a`||n.key==`A`)&&(n.preventDefault(),window.getSelection().selectAllChildren(e))})},onunmount:()=>{r.forEach(e=>e?.unwatch())}},t.code({className:`block`,innerHTML:()=>rt(n.value,n.language)}),t.div({className:`footnote`},e=>typeof n.footnote==`function`?n.footnote(e):n.footnote))};function rt(e,n){return e=typeof e==`string`?e:``,e=Prism.plugins.NormalizeWhitespace.normalize(e,{"remove-trailing":!0,"remove-indent":!0,"left-trim":!0,"right-trim":!0}),Prism.highlight(e,Prism.languages[n]||Prism.languages.js,n)}window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.codeEditor=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,name:void 0,className:``,value:``,language:`js`,placeholder:``,disabled:!1,required:!1,singleLine:!1,autocomplete:void 0,oninput:function(e){},onfocus:function(e){},onblur:function(e){}}),r=app.utils.extendStore(n,e,`autocomplete`),i,a,o=!0;function s(e){c(),i=t.div({className:`dropdown autocomplete code-editor-dropdown`,onmount:e=>{e._updatePosition=()=>{o?dt(i):c()},e._closeOnEsc=e=>{e.key==`Escape`&&(e.preventDefault(),c())},window.addEventListener(`scroll`,e._updatePosition,!0),window.addEventListener(`resize`,e._updatePosition),window.addEventListener(`keydown`,e._closeOnEsc),e._updatePosition()},onunmount:e=>{e&&(window.removeEventListener(`scroll`,e._updatePosition,!0),window.removeEventListener(`resize`,e._updatePosition),window.removeEventListener(`keydown`,e._closeOnEsc))}},e),document.body.appendChild(i),p&&(a?.disconnect(),a=new IntersectionObserver(([e])=>{o=e.isIntersecting},{root:null,threshold:.1}),a.observe(p))}function c(){clearTimeout(f),i&&=(i.remove(),null),a&&=(a.disconnect(),null),o=!0}function l(e){n.value=e,n.oninput?.(e),p.dispatchEvent(new CustomEvent(`change`,{detail:e}))}let u=!1,d,f,p=t.div({contentEditable:()=>!n.disabled&&`plaintext-only`,tabIndex:0,spellcheck:!1,autocorrect:!1,autocomplete:`off`,autocapitalize:`off`,role:`textbox`,className:`code-editor-content`,"html-data-placeholder":()=>n.placeholder,onmount:e=>{d?.unwatch(),d=watch(()=>n.value,e=>{e!=p.textContent&&(p.textContent=e,c())})},onunmount:e=>{d?.unwatch(),c()},onfocus:()=>{n.onfocus?.(n.value)},onblur:e=>{i&&!i.contains(e.relatedTarget)&&c(),n.onblur?.(n.value)},oninput:e=>{if(clearTimeout(f),l(p.textContent),!n.value?.length){p.textContent=``,c();return}f=setTimeout(async()=>{if(c(),!p?.isConnected)return;let e=ut(p),r=st(n.value,e);if(!r.word.length||e==r.start)return;let i=[];if(typeof n.autocomplete==`function`)i=await n.autocomplete(r.prefix||r.word)||[];else if(!app.utils.isEmpty(n.autocomplete)){let e=(r.prefix||r.word).toLowerCase();i=n.autocomplete.filter(n=>(typeof n==`object`&&(n=n?.value),n=n?.toLowerCase(),n&&n!=e&&n.includes(e)))}!i?.length||i.length==1&&(i[0].value||i[0])==r.word||s(()=>i.map((e,n)=>t.button({type:`button`,className:`dropdown-item ${n==0?`active`:``}`,onclick:n=>{n.preventDefault(),p.focus();let i=e.value||e;p.textContent=p.textContent.substring(0,r.start)+i+p.textContent.substring(r.end+1),l(p.textContent);try{window.getSelection().setPosition(p.childNodes[0],r.start+i.length)}catch(e){console.warn(`failed to set caret position`,e)}c()}},n=>typeof e?.label==`function`?e.label(n):e?.label||e?.value||e)))},50)},onkeydown:e=>{if(u=e.ctrlKey||e.metaKey,(e.key==`Enter`||e.key==`Tab`)&&i?.isConnected){e.preventDefault(),i.querySelector(`.dropdown-item.active`)?.click();return}if(e.key==`ArrowUp`&&i?.isConnected){e.preventDefault();let n=i.querySelector(`.dropdown-item.active`);n?.previousElementSibling&&(n.classList.remove(`active`),n.previousElementSibling.classList.add(`active`),n.previousElementSibling.scrollIntoView(!1));return}if(e.key==`ArrowDown`&&i?.isConnected){e.preventDefault();let n=i.querySelector(`.dropdown-item.active`);n?.nextElementSibling&&(n.classList.remove(`active`),n.nextElementSibling.classList.add(`active`),n.nextElementSibling.scrollIntoView(!1));return}if(u&&e.key.toLowerCase()==`l`){e.preventDefault(),ct(p);return}if(u&&e.key.toLowerCase()==`d`){e.preventDefault(),lt(p);return}if(!n.singleLine&&e.key==`Escape`&&!i?.isConnected){p?.blur();return}if(!n.singleLine&&e.key==`Tab`){e.preventDefault();let n=window.getSelection();if(!n)return;if(e.shiftKey){n.modify(`extend`,`backward`,`character`),n.toString()[0]==` `?(n.deleteFromDocument(),l(p.textContent)):(n.modify(`extend`,`forward`,`character`),n.toString()[0]==` `&&(n.deleteFromDocument(),l(p.textContent)));return}let r=n.getRangeAt(0);r&&(r.deleteContents(),r.insertNode(document.createTextNode(` `)),r.collapse(),l(p.textContent));return}if(n.singleLine&&e.key==`Enter`){e.preventDefault(),h.click();return}},onscroll:()=>{c(),m&&(m.scrollLeft=p.scrollLeft,m.scrollTop=p.scrollTop)}}),m=t.div({className:`highlight-overlay`,innerHTML:()=>at(n.value,n.language),onscroll:()=>{p&&(p.scrollLeft=m.scrollLeft,p.scrollTop=m.scrollTop)}}),h=t.button({type:`submit`,className:`hidden`});return t.div({rid:n.rid,id:()=>n.id,inert:()=>n.inert,hidden:()=>n.hidden,"html-name":()=>n.name,"html-required":()=>n.required||void 0,className:()=>`input code-editor ${n.className} ${n.disabled?`disabled`:``} ${n.singleLine?`single-line`:``}`,onclick:()=>{p?.focus()},onunmount:()=>{r?.forEach(e=>e?.unwatch())}},t.div({className:`code-editor-container`},p,m,h))};var it=850;function at(e,n){return e=typeof e==`string`?e:``,e?((!Prism.languages[n]||e.length>it)&&(n=`plain`),Prism.highlight(e,Prism.languages[n],n)):``}var ot=new RegExp(/[\p{Alphabetic}\p{Number}_@:\."'{}]/,`u`);function st(e,n){let r=n;for(let i=n-1;i>=0&&ot.test(e[i]);i--)r=i;let i=r;for(let r=n-1;rdocument.documentElement.clientHeight&&(o=Math.max(n.top-r,0)),a+i>document.documentElement.clientWidth&&(a=Math.max(document.documentElement.clientWidth-i,0)),e.style.left=a+`px`,e.style.top=o+`px`}window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.codeBlockTabs=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,className:``,activeTabIndex:0,historyKey:``,tabs:[],get activeTab(){return n.tabs[n.activeTabIndex]||n.tabs[0]}}),r=app.utils.extendStore(n,e);return r.push(watch(()=>n.activeTabIndex,(e,r)=>{r!=null&&n.historyKey&&localStorage.setItem(n.historyKey,e)})),t.div({rid:n.rid,id:()=>n.id,hidden:()=>n.hidden||!n.tabs.length,inert:()=>n.inert,className:()=>`code-block-tabs ${n.className}`,onmount:()=>{n.historyKey&&(n.activeTabIndex=localStorage.getItem(n.historyKey)<<0)},onunmount:()=>{r.forEach(e=>e?.unwatch())}},t.header({className:`tabs-header`},()=>n.tabs.map((e,r)=>t.button({type:`button`,className:()=>`tab-item ${n.activeTabIndex==r?`active`:``}`,onclick:()=>n.activeTabIndex=r},n=>typeof e.title==`function`?e.title(n):e.title))),t.div({className:`code-block-tabs-content`},()=>{if(n.activeTab)return app.components.codeBlock(n.activeTab)}))},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.select=function(e={}){let n=store({rid:void 0,id:void 0,name:void 0,hidden:void 0,inert:void 0,className:``,value:void 0,options:[],before:null,after:null,max:1,searchThreshold:6,required:!1,disabled:!1,placeholder:`- Select -`,noItemsFoundText:`No items found`,onchange:function(e){},ondropdowntoggle:function(e){}}),r=app.utils.extendStore(n,e);n.max<=0&&(n.max=1);let i=store({selected:[],search:``,get hasSearch(){return i.search?.length>0},get allowRemove(){return!n.disabled&&(!n.required||n.max>1)}});function a(){if(n.value===void 0)return;let e=app.utils.toArray(n.value,!0),r=e.slice(0,n.max||1);e.length!=r.length&&(console.warn(`[select] the provided select values (${e.length}) are more than the allowed max selected options (${r.length}):`,e),n.value=n.max>1?r:r[0]),i.selected=e.map(e=>n.options.find(n=>n.value===e)).filter(Boolean)}r.push(watch(()=>n.value,()=>a()));async function o(e){let r=i.selected.findIndex(n=>n.value===e.value);if(r>=0){if(!i.allowRemove){f?.hidePopover();return}i.selected.splice(r,1)}else{let r=i.selected.length-n.max;for(;r>=0;)i.selected.pop(),r--;i.selected.push(e)}n.max<=1&&f?.hidePopover(),n.onchange&&(await n.onchange(i.selected),a()),p?.isConnected&&p.dispatchEvent(new CustomEvent(`change`,{detail:i.selected,bubbles:!0}))}function s(e){return i.selected.findIndex(n=>n.value===e.value)>=0}let c=t.input({type:`text`,placeholder:`Search...`,value:()=>i.search,oninput:e=>i.search=e.target.value});function l(e=!1){i.search=``,e&&c?.focus()}let u=t.div({className:`txt-hint txt-center m-0 p-5`,hidden:!0},e=>typeof n.noItemsFoundText==`function`?n.noItemsFoundText(e):n.noItemsFoundText);async function d(){f&&(await new Promise(e=>setTimeout(e,0)),f.querySelector(`.select-option:not([hidden])`)?u.hidden=!0:u.hidden=!1)}let f=t.div({tabIndex:-1,popover:`auto`,className:`dropdown`,onbeforetoggle:e=>(e.newState==`closed`&&l(),n.ondropdowntoggle?.(e))},t.div({className:`fields dropdown-search`,hidden:()=>n.options.length!i.hasSearch},t.button({type:`button`,title:`Clear`,className:`btn sm secondary transparent circle`,onclick:()=>l(!0)},t.i({className:`ri-close-line`,ariaHidden:!0})))),()=>n.before?.__raw||n.before,()=>n.options.map(e=>t.button({type:`button`,className:()=>`dropdown-item select-option ${s(e)?`active`:``}`,onclick:()=>(o(e),!1)},e.label||e.value)),u,()=>n.after?.__raw||n.after),p=t.button({type:`button`,id:()=>n.id,disabled:()=>n.disabled,className:`selected-container`,popoverTargetElement:f,onclick:e=>{e.stopPropagation()}},()=>i.selected.length?i.selected.map(e=>t.div({className:`selected-item`},e.selected||e.label||e.value,()=>{if(i.allowRemove)return t.i({tabIndex:-1,role:`button`,className:`ri-close-line link-hint btn-option-unset`,ariaLabel:app.attrs.tooltip(`Unset`),onclick:()=>(o(e),!1)})})):t.span({rid:`selected-placeholder`,className:`placeholder`},e=>typeof n.placeholder==`function`?n.placeholder(e):n.placeholder));r.push(watch(()=>n.options,()=>{d()}));let m;return r.push(watch(()=>i.search,()=>{let e=i.search.toLowerCase().replaceAll(` `,``);clearTimeout(m),m=setTimeout(()=>{let n=f.querySelectorAll(`.select-option`);e.length?n.forEach(n=>{n.hidden=!n.textContent.toLowerCase().replaceAll(` `,``).includes(e)}):n.forEach(e=>e.hidden=!1),d()},100)})),t.output({rid:n.rid,name:()=>n.name,hidden:()=>n.hidden,inert:()=>n.inert,onmount:e=>{e.addEventListener(`focusout`,function(n){(!n.relatedTarget||!e.contains(n.relatedTarget))&&f?.hidePopover()})},onunmount:()=>{clearTimeout(m),r.forEach(e=>e.unwatch())},className:()=>[`input`,`select`,n.max>1?`multiple`:`single`,n.disabled?`disabled`:null,n.required?`required`:null,n.className||null].filter(Boolean).join(` `)},p,f)},window.app=window.app||{},window.app.components=window.app.components||{};var ft=Intl.DateTimeFormat().resolvedOptions().timeZone;window.app.components.formattedDate=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,value:``,short:!1}),r=app.utils.extendStore(n,e);return t.div({rid:n.rid,id:()=>n.id,hidden:()=>n.hidden,ariaDescription:app.attrs.tooltip(()=>n.short&&n.value?app.utils.toLocalDatetime(n.value)+` +`+ft:null),"html-class":`formatted-date`,className:()=>`formatted-date ${n.short?`short`:`full`}`,onunmount:()=>{r.forEach(e=>e?.unwatch())}},()=>{if(!n.value)return t.span({className:`missing-value`});if(n.short){let e=n.value.split(` `);return[t.span({className:`primary-date`},e[0]),t.span({className:`secondary-date`},e[1])]}return[t.span({className:`primary-date`},app.utils.toLocalDatetime(n.value)),t.span({className:`secondary-date`},n.value)]})},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.refreshButton=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,tooltip:`Refresh`,className:`btn transparent secondary circle rotate-btn`,disabled:!1,onclick:function(e){}}),r=app.utils.extendStore(n,e),i,a=t.button({rid:n.rid,id:()=>n.id,hidden:()=>n.hidden,inert:()=>n.inert,type:`button`,ariaLabel:app.attrs.tooltip(()=>n.tooltip),disabled:()=>n.disabled,className:()=>n.className,onunmount:()=>{clearTimeout(i),r.forEach(e=>e?.unwatch())},onclick:e=>{e.preventDefault(),n.onclick&&n.onclick(e),a.dataset.rotate=!0,a.addEventListener(`animationend`,()=>{a.dataset.rotate=!1}),clearTimeout(i),i=setTimeout(()=>{clearTimeout(i),a.dataset.rotate=!1},500)}},t.i({className:`ri-refresh-line`,ariaHidden:!0}));return a},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.searchHistoryButton=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,value:void 0,historyKey:`default`,max:15,openInNewTabParam:`filter`,btnClassName:`btn sm pill secondary transparent p-r-5`,onselect:function(e){}}),r=app.utils.extendStore(n,e),i=store({items:app.utils.getLocalHistory(n.historyKey,[])});function a(e){o(e),i.items.unshift(e)}function o(e){app.utils.removeByValue(i.items,e)}let s=`history_dropdown_`+app.utils.randomString();r.push(watch(()=>n.value,e=>{e&&a(e)})),r.push(watch(()=>{i.items.length>n.max&&(i.items=i.items.slice(0,n.max)),app.utils.saveLocalHistory(n.historyKey,i.items)}));let c=t.div({id:s,className:`dropdown sm left nowrap history-searchbar-dropdown`,popover:`hint`,onclick:e=>(e.stopPropagation(),!1)},t.div({className:`block p-5`},t.small({className:`txt-hint`},`Search history`)),()=>i.items?.length?i.items.slice(0,n.max).map(e=>t.button({type:`button`,className:`dropdown-item txt-code`,onclick:()=>{c.hidePopover(),n.onselect?.(e),a(e)},onauxclick:()=>{if(n.openInNewTabParam){a(e),c.hidePopover();let r=app.utils.replaceHashQueryParams({[n.openInNewTabParam]:e},!1);window.open(r,`_blank`)}}},t.span({className:`txt-ellipsis`,title:e,textContent:e}),t.small({role:`button`,className:`remove-btn link-hint m-l-auto p-l-5 p-r-5`,title:`Clear`,onauxclick:e=>(e.stopPropagation(),!1),onclick:n=>(n.stopPropagation(),o(e),!1)},t.i({className:`ri-close-line`,ariaHidden:!0})))):t.div({rid:`no-history`,className:`block p-5`},t.span(null,`Your recent searches will show up here.`)));return t.button({rid:n.rid,id:()=>n.id,hidden:()=>n.hidden,inert:()=>n.inert,type:`button`,title:`Search history`,className:()=>n.btnClassName,"html-popovertarget":s,onunmount:()=>{r?.forEach(e=>e?.unwatch())}},t.i({className:`ri-search-line`,ariaHidden:!0}),t.i({className:`ri-arrow-drop-down-line`,ariaHidden:!0}),c)},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.s3Test=function(e={}){let n=`s3_test_request`,r=store({rid:void 0,config:null,label:`Use S3 storage`,testFilesystem:`storage`}),i=app.utils.extendStore(r,e),a=store({isTesting:!1,testError:null,get hasError(){return!app.utils.isEmpty(a.testError)}}),o,s;function c(e=150){if(!r.config.enabled){clearTimeout(o);return}a.isTesting=!0,clearTimeout(o),o=setTimeout(()=>{l()},e)}async function l(){if(a.isTesting=!0,!r.config.enabled||!r.testFilesystem){a.testError=null,a.isTesting=!1;return}app.pb.cancelRequest(n),clearTimeout(s),s=setTimeout(()=>{app.pb.cancelRequest(n),a.testError=Error(`S3 test connection timeout.`),a.isTesting=!1},3e4);try{await app.pb.props.testS3(r.testFilesystem,{requestKey:n}),a.testError=null,a.isTesting=!1}catch(e){e?.isAbort||(a.testError=e,a.isTesting=!1,clearTimeout(s))}}return i.push(watch(()=>r.testFilesystem&&r.config,()=>c())),t.div({pbEvent:`s3Test`,rid:r.rid,hidden:()=>!r.testFilesystem,className:()=>`label s3-test-label txt-nowrap ${a.hasError?`warning`:`success`}`,ariaDescription:app.attrs.tooltip(()=>a.testError?.data?.message),onunmount:()=>{clearTimeout(s),clearTimeout(o),i.forEach(e=>e?.unwatch())}},()=>a.isTesting?t.span({className:`loader sm`}):a.hasError?[t.i({className:`ri-error-warning-line txt-warning`,ariaHidden:!0}),t.span({className:`txt`},`Failed to establish S3 connection`)]:[t.i({className:`ri-checkbox-circle-line txt-success`,ariaHidden:!0}),t.span({className:`txt`},`S3 connected successfully`)])},window.app=window.app||{},window.app.components=window.app.components||{},window.app.components.s3ConfigFields=function(e={}){let n=store({rid:void 0,id:void 0,hidden:void 0,inert:void 0,className:``,config:{},configKey:`s3`,toggleLabel:`Use S3 storage`,testFilesystem:`storage`,before:null,after:null}),r=app.utils.extendStore(n,e);n.configKey.endsWith(`.`)&&(n.configKey=n.configKey.substring(0,n.configKey.length-1));let i=store({originalHash:``,originalConfig:null});return r.push(watch(()=>n.config,e=>{i.originalHash=JSON.stringify(e),i.originalConfig=JSON.parse(i.originalHash)})),t.div({pbEvent:`s3ConfigFields`,rid:n.rid,id:()=>n.id,hidden:()=>n.hidden,inert:()=>n.inert,className:()=>`block s3-fields s3-config-${n.configKey} ${n.className}`,onunmount:()=>{r.forEach(e=>e?.unwatch())}},t.div({className:`field`},t.input({id:()=>`${n.configKey}.enabled`,name:()=>`${n.configKey}.enabled`,type:`checkbox`,className:`switch`,checked:()=>n.config.enabled,onchange:e=>n.config.enabled=e.target.checked}),t.label({htmlFor:()=>`${n.configKey}.enabled`},()=>n.toggleLabel)),e=>typeof n.before==`function`?n.before(e):n.before,app.components.slide(()=>n.config.enabled,t.div({className:`grid m-t-base`},t.div({className:`col-lg-6`},t.div({className:`field`},t.label({htmlFor:()=>`${n.configKey}.endpoint`},`Endpoint`),t.input({id:()=>`${n.configKey}.endpoint`,name:()=>`${n.configKey}.endpoint`,type:`text`,required:()=>n.config.enabled,value:()=>n.config.endpoint||``,oninput:e=>n.config.endpoint=e.target.value}))),t.div({className:`col-lg-3`},t.div({className:`field`},t.label({htmlFor:()=>`${n.configKey}.bucket`},`Bucket`),t.input({id:()=>`${n.configKey}.bucket`,name:()=>`${n.configKey}.bucket`,type:`text`,required:()=>n.config.enabled,value:()=>n.config.bucket||``,oninput:e=>n.config.bucket=e.target.value}))),t.div({className:`col-lg-3`},t.div({className:`field`},t.label({htmlFor:()=>`${n.configKey}.region`},`Region`),t.input({id:()=>`${n.configKey}.region`,name:()=>`${n.configKey}.region`,type:`text`,required:()=>n.config.enabled,value:()=>n.config.region||``,oninput:e=>n.config.region=e.target.value}))),t.div({className:`col-lg-6`},t.div({className:`field`},t.label({htmlFor:()=>`${n.configKey}.accessKey`},`Access key`),t.input({id:()=>`${n.configKey}.accessKey`,name:()=>`${n.configKey}.accessKey`,type:`text`,autocomplete:`off`,required:()=>n.config.enabled,value:()=>n.config.accessKey||``,oninput:e=>n.config.accessKey=e.target.value}))),t.div({className:`col-lg-6`},t.div({className:()=>`field ${n.config.enabled?``:`required`}`},t.label({htmlFor:()=>`${n.configKey}.secret`},`Secret`),t.input({id:()=>`${n.configKey}.secret`,name:()=>`${n.configKey}.secret`,type:`password`,autocomplete:`new-password`,value:()=>n.config.secret||``,oninput:e=>n.config.secret=e.target.value,onkeyup:e=>{e.key==`Backspace`&&n.config.secret===void 0&&(n.config.secret=``)},placeholder:()=>n.config.secret===void 0?`* * * * * *`:``}))),t.div({className:`col-lg-6`,style:`min-height: 25px`},t.div({className:`field`},t.input({id:()=>`${n.configKey}.forcePathStyle`,name:()=>`${n.configKey}.forcePathStyle`,type:`checkbox`,checked:()=>n.config.forcePathStyle||!1,onchange:e=>n.config.forcePathStyle=e.target.checked}),t.label({htmlFor:()=>`${n.configKey}.forcePathStyle`},t.span({className:`txt`},`Force path-style addressing`),t.i({className:`ri-information-line link-hint`,ariaDescription:app.attrs.tooltip(`Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".`)})))),t.div({className:`col-lg-6 txt-right`},()=>{if(!(!n.config?.enabled||i.originalHash!=JSON.stringify(n.config)))return app.components.s3Test({config:()=>n.config,testFilesystem:()=>n.testFilesystem})}))),e=>typeof n.after==`function`?n.after(e):n.after)};var pt=l(s(((e,n)=>{(function(r,i){typeof e==`object`&&n!==void 0?i(e):typeof define==`function`&&define.amd?define([`exports`],i):(r=typeof globalThis<`u`?globalThis:r||self,i(r.leaflet={}))})(e,(function(e){var n=`1.9.4`;function r(e){var n,r,i,a;for(r=1,i=arguments.length;r`u`||!L||!L.Mixin)){e=v(e)?e:[e];for(var n=0;n0?Math.floor(e):Math.ceil(e)};w.prototype={clone:function(){return new w(this.x,this.y)},add:function(e){return this.clone()._add(E(e))},_add:function(e){return this.x+=e.x,this.y+=e.y,this},subtract:function(e){return this.clone()._subtract(E(e))},_subtract:function(e){return this.x-=e.x,this.y-=e.y,this},divideBy:function(e){return this.clone()._divideBy(e)},_divideBy:function(e){return this.x/=e,this.y/=e,this},multiplyBy:function(e){return this.clone()._multiplyBy(e)},_multiplyBy:function(e){return this.x*=e,this.y*=e,this},scaleBy:function(e){return new w(this.x*e.x,this.y*e.y)},unscaleBy:function(e){return new w(this.x/e.x,this.y/e.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=T(this.x),this.y=T(this.y),this},distanceTo:function(e){e=E(e);var n=e.x-this.x,r=e.y-this.y;return Math.sqrt(n*n+r*r)},equals:function(e){return e=E(e),e.x===this.x&&e.y===this.y},contains:function(e){return e=E(e),Math.abs(e.x)<=Math.abs(this.x)&&Math.abs(e.y)<=Math.abs(this.y)},toString:function(){return`Point(`+d(this.x)+`, `+d(this.y)+`)`}};function E(e,n,r){return e instanceof w?e:v(e)?new w(e[0],e[1]):e==null?e:typeof e==`object`&&`x`in e&&`y`in e?new w(e.x,e.y):new w(e,n,r)}function D(e,n){if(e)for(var r=n?[e,n]:e,i=0,a=r.length;i=this.min.x&&r.x<=this.max.x&&n.y>=this.min.y&&r.y<=this.max.y},intersects:function(e){e=O(e);var n=this.min,r=this.max,i=e.min,a=e.max,o=a.x>=n.x&&i.x<=r.x,s=a.y>=n.y&&i.y<=r.y;return o&&s},overlaps:function(e){e=O(e);var n=this.min,r=this.max,i=e.min,a=e.max,o=a.x>n.x&&i.xn.y&&i.y=n.lat&&a.lat<=r.lat&&i.lng>=n.lng&&a.lng<=r.lng},intersects:function(e){e=A(e);var n=this._southWest,r=this._northEast,i=e.getSouthWest(),a=e.getNorthEast(),o=a.lat>=n.lat&&i.lat<=r.lat,s=a.lng>=n.lng&&i.lng<=r.lng;return o&&s},overlaps:function(e){e=A(e);var n=this._southWest,r=this._northEast,i=e.getSouthWest(),a=e.getNorthEast(),o=a.lat>n.lat&&i.latn.lng&&i.lng1,Ke=function(){var e=!1;try{var n=Object.defineProperty({},"passive",{get:function(){e=!0}});window.addEventListener(`testPassiveEventSupport`,u,n),window.removeEventListener(`testPassiveEventSupport`,u,n)}catch{}return e}(),qe=function(){return!!document.createElement(`canvas`).getContext}(),Je=!!(document.createElementNS&&ge(`svg`).createSVGRect),Ye=!!Je&&(function(){var e=document.createElement(`div`);return e.innerHTML=``,(e.firstChild&&e.firstChild.namespaceURI)===`http://www.w3.org/2000/svg`})(),Xe=!Je&&function(){try{var e=document.createElement(`div`);e.innerHTML=``;var n=e.firstChild;return n.style.behavior=`url(#default#VML)`,n&&typeof n.adj==`object`}catch{return!1}}(),Ze=navigator.platform.indexOf(`Mac`)===0,Qe=navigator.platform.indexOf(`Linux`)===0;function I(e){return navigator.userAgent.toLowerCase().indexOf(e)>=0}var R={ie:ye,ielt9:be,edge:xe,webkit:Se,android:Ce,android23:we,androidStock:Ee,opera:De,chrome:Oe,gecko:ke,safari:Ae,phantom:je,opera12:Me,win:Ne,ie3d:Pe,webkit3d:Fe,gecko3d:Ie,any3d:Le,mobile:Re,mobileWebkit:ze,mobileWebkit3d:Be,msPointer:P,pointer:Ve,touch:Ue,touchNative:He,mobileOpera:We,mobileGecko:F,retina:Ge,passiveEvents:Ke,canvas:qe,svg:Je,vml:Xe,inlineSvg:Ye,mac:Ze,linux:Qe},$e=R.msPointer?`MSPointerDown`:`pointerdown`,et=R.msPointer?`MSPointerMove`:`pointermove`,tt=R.msPointer?`MSPointerUp`:`pointerup`,nt=R.msPointer?`MSPointerCancel`:`pointercancel`,rt={touchstart:$e,touchmove:et,touchend:tt,touchcancel:nt},it={touchstart:mt,touchmove:pt,touchend:pt,touchcancel:pt},at={},ot=!1;function st(e,n,r){return n===`touchstart`&&ft(),it[n]?(r=it[n].bind(this,r),e.addEventListener(rt[n],r,!1),r):(console.warn(`wrong event specified:`,n),u)}function ct(e,n,r){if(!rt[n]){console.warn(`wrong event specified:`,n);return}e.removeEventListener(rt[n],r,!1)}function lt(e){at[e.pointerId]=e}function ut(e){at[e.pointerId]&&(at[e.pointerId]=e)}function dt(e){delete at[e.pointerId]}function ft(){ot||=(document.addEventListener($e,lt,!0),document.addEventListener(et,ut,!0),document.addEventListener(tt,dt,!0),document.addEventListener(nt,dt,!0),!0)}function pt(e,n){if(n.pointerType!==(n.MSPOINTER_TYPE_MOUSE||`mouse`)){for(var r in n.touches=[],at)n.touches.push(at[r]);n.changedTouches=[n],e(n)}}function mt(e,n){n.MSPOINTER_TYPE_TOUCH&&n.pointerType===n.MSPOINTER_TYPE_TOUCH&&X(n),pt(e,n)}function ht(e){var n={},r,i;for(i in e)r=e[i],n[i]=r&&r.bind?r.bind(e):r;return e=n,n.type=`dblclick`,n.detail=2,n.isTrusted=!1,n._simulated=!0,n}var gt=200;function _t(e,n){e.addEventListener(`dblclick`,n);var r=0,i;function a(e){if(e.detail!==1){i=e.detail;return}if(!(e.pointerType===`mouse`||e.sourceCapabilities&&!e.sourceCapabilities.firesTouchEvents)){var a=$t(e);if(!(a.some(function(e){return e instanceof HTMLLabelElement&&e.attributes.for})&&!a.some(function(e){return e instanceof HTMLInputElement||e instanceof HTMLSelectElement}))){var o=Date.now();o-r<=gt?(i++,i===2&&n(ht(e))):i=1,r=o}}}return e.addEventListener(`click`,a),{dblclick:n,simDblclick:a}}function vt(e,n){e.removeEventListener(`dblclick`,n.dblclick),e.removeEventListener(`click`,n.simDblclick)}var yt=jt([`transform`,`webkitTransform`,`OTransform`,`MozTransform`,`msTransform`]),bt=jt([`webkitTransition`,`transition`,`OTransition`,`MozTransition`,`msTransition`]),xt=bt===`webkitTransition`||bt===`OTransition`?bt+`End`:`transitionend`;function St(e){return typeof e==`string`?document.getElementById(e):e}function Ct(e,n){var r=e.style[n]||e.currentStyle&&e.currentStyle[n];if((!r||r===`auto`)&&document.defaultView){var i=document.defaultView.getComputedStyle(e,null);r=i?i[n]:null}return r===`auto`?null:r}function z(e,n,r){var i=document.createElement(e);return i.className=n||``,r&&r.appendChild(i),i}function B(e){var n=e.parentNode;n&&n.removeChild(e)}function wt(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Tt(e){var n=e.parentNode;n&&n.lastChild!==e&&n.appendChild(e)}function Et(e){var n=e.parentNode;n&&n.firstChild!==e&&n.insertBefore(e,n.firstChild)}function Dt(e,n){if(e.classList!==void 0)return e.classList.contains(n);var r=kt(e);return r.length>0&&RegExp(`(^|\\s)`+n+`(\\s|$)`).test(r)}function V(e,n){if(e.classList!==void 0)for(var r=p(n),i=0,a=r.length;i0?2*window.devicePixelRatio:1;function nn(e){return R.edge?e.wheelDeltaY/2:e.deltaY&&e.deltaMode===0?-e.deltaY/tn:e.deltaY&&e.deltaMode===1?-e.deltaY*20:e.deltaY&&e.deltaMode===2?-e.deltaY*60:e.deltaX||e.deltaZ?0:e.wheelDelta?(e.wheelDeltaY||e.wheelDelta)/2:e.detail&&Math.abs(e.detail)<32765?-e.detail*20:e.detail?e.detail/-32765*60:0}function rn(e,n){var r=n.relatedTarget;if(!r)return!0;try{for(;r&&r!==e;)r=r.parentNode}catch{return!1}return r!==e}var an={__proto__:null,on:q,off:Y,stopPropagation:Yt,disableScrollPropagation:Xt,disableClickPropagation:Zt,preventDefault:X,stop:Qt,getPropagationPath:$t,getMousePosition:en,getWheelDelta:nn,isExternalTarget:rn,addListener:q,removeListener:Y},on=ce.extend({run:function(e,n,r,i){this.stop(),this._el=e,this._inProgress=!0,this._duration=r||.25,this._easeOutPower=1/Math.max(i||.5,.2),this._startPos=Nt(e),this._offset=n.subtract(this._startPos),this._startTime=+new Date,this.fire(`start`),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=b(this._animate,this),this._step()},_step:function(e){var n=+new Date-this._startTime,r=this._duration*1e3;nthis.options.maxZoom)?this.setZoom(e):this},panInsideBounds:function(e,n){this._enforcingBounds=!0;var r=this.getCenter(),i=this._limitCenter(r,this._zoom,A(e));return r.equals(i)||this.panTo(i,n),this._enforcingBounds=!1,this},panInside:function(e,n){n||={};var r=E(n.paddingTopLeft||n.padding||[0,0]),i=E(n.paddingBottomRight||n.padding||[0,0]),a=this.project(this.getCenter()),o=this.project(e),s=this.getPixelBounds(),c=O([s.min.add(r),s.max.subtract(i)]),l=c.getSize();if(!c.contains(o)){this._enforcingBounds=!0;var u=o.subtract(c.getCenter()),d=c.extend(o).getSize().subtract(l);a.x+=u.x<0?-d.x:d.x,a.y+=u.y<0?-d.y:d.y,this.panTo(this.unproject(a),n),this._enforcingBounds=!1}return this},invalidateSize:function(e){if(!this._loaded)return this;e=r({animate:!1,pan:!0},e===!0?{animate:!0}:e);var n=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var i=this.getSize(),o=n.divideBy(2).round(),s=i.divideBy(2).round(),c=o.subtract(s);return!c.x&&!c.y?this:(e.animate&&e.pan?this.panBy(c):(e.pan&&this._rawPanBy(c),this.fire(`move`),e.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,`moveend`),200)):this.fire(`moveend`)),this.fire(`resize`,{oldSize:n,newSize:i}))},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire(`viewreset`),this._stop()},locate:function(e){if(e=this._locateOptions=r({timeout:1e4,watch:!1},e),!(`geolocation`in navigator))return this._handleGeolocationError({code:0,message:`Geolocation not supported.`}),this;var n=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this);return e.watch?this._locationWatchId=navigator.geolocation.watchPosition(n,i,e):navigator.geolocation.getCurrentPosition(n,i,e),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(e){if(this._container._leaflet_id){var n=e.code,r=e.message||(n===1?`permission denied`:n===2?`position unavailable`:`timeout`);this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire(`locationerror`,{code:n,message:`Geolocation error: `+r+`.`})}},_handleGeolocationResponse:function(e){if(this._container._leaflet_id){var n=e.coords.latitude,r=e.coords.longitude,i=new j(n,r),a=i.toBounds(e.coords.accuracy*2),o=this._locateOptions;if(o.setView){var s=this.getBoundsZoom(a);this.setView(i,o.maxZoom?Math.min(s,o.maxZoom):s)}var c={latlng:i,bounds:a,timestamp:e.timestamp};for(var l in e.coords)typeof e.coords[l]==`number`&&(c[l]=e.coords[l]);this.fire(`locationfound`,c)}},addHandler:function(e,n){if(!n)return this;var r=this[e]=new n(this);return this._handlers.push(r),this.options[e]&&r.enable(),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off(`moveend`,this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw Error(`Map container is being reused by another instance`);try{delete this._container._leaflet_id,delete this._containerId}catch{this._container._leaflet_id=void 0,this._containerId=void 0}for(var e in this._locationWatchId!==void 0&&this.stopLocate(),this._stop(),B(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&=(x(this._resizeRequest),null),this._clearHandlers(),this._loaded&&this.fire(`unload`),this._layers)this._layers[e].remove();for(e in this._panes)B(this._panes[e]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(e,n){var r=z(`div`,`leaflet-pane`+(e?` leaflet-`+e.replace(`Pane`,``)+`-pane`:``),n||this._mapPane);return e&&(this._panes[e]=r),r},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var e=this.getPixelBounds();return new k(this.unproject(e.getBottomLeft()),this.unproject(e.getTopRight()))},getMinZoom:function(){return this.options.minZoom===void 0?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return this.options.maxZoom===void 0?this._layersMaxZoom===void 0?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(e,n,r){e=A(e),r=E(r||[0,0]);var i=this.getZoom()||0,a=this.getMinZoom(),o=this.getMaxZoom(),s=e.getNorthWest(),c=e.getSouthEast(),l=this.getSize().subtract(r),u=O(this.project(c,i),this.project(s,i)).getSize(),d=R.any3d?this.options.zoomSnap:1,f=l.x/u.x,p=l.y/u.y,m=n?Math.max(f,p):Math.min(f,p);return i=this.getScaleZoom(m,i),d&&(i=Math.round(i/(d/100))*(d/100),i=n?Math.ceil(i/d)*d:Math.floor(i/d)*d),Math.max(a,Math.min(o,i))},getSize:function(){return(!this._size||this._sizeChanged)&&(this._size=new w(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(e,n){var r=this._getTopLeftPoint(e,n);return new D(r,r.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(e){return this.options.crs.getProjectedBounds(e===void 0?this.getZoom():e)},getPane:function(e){return typeof e==`string`?this._panes[e]:e},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(e,n){var r=this.options.crs;return n=n===void 0?this._zoom:n,r.scale(e)/r.scale(n)},getScaleZoom:function(e,n){var r=this.options.crs;n=n===void 0?this._zoom:n;var i=r.zoom(e*r.scale(n));return isNaN(i)?1/0:i},project:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.latLngToPoint(M(e),n)},unproject:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.pointToLatLng(E(e),n)},layerPointToLatLng:function(e){var n=E(e).add(this.getPixelOrigin());return this.unproject(n)},latLngToLayerPoint:function(e){return this.project(M(e))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(e){return this.options.crs.wrapLatLng(M(e))},wrapLatLngBounds:function(e){return this.options.crs.wrapLatLngBounds(A(e))},distance:function(e,n){return this.options.crs.distance(M(e),M(n))},containerPointToLayerPoint:function(e){return E(e).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(e){return E(e).add(this._getMapPanePos())},containerPointToLatLng:function(e){var n=this.containerPointToLayerPoint(E(e));return this.layerPointToLatLng(n)},latLngToContainerPoint:function(e){return this.layerPointToContainerPoint(this.latLngToLayerPoint(M(e)))},mouseEventToContainerPoint:function(e){return en(e,this._container)},mouseEventToLayerPoint:function(e){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e))},mouseEventToLatLng:function(e){return this.layerPointToLatLng(this.mouseEventToLayerPoint(e))},_initContainer:function(e){var n=this._container=St(e);if(!n)throw Error(`Map container not found.`);if(n._leaflet_id)throw Error(`Map container is already initialized.`);q(n,`scroll`,this._onScroll,this),this._containerId=s(n)},_initLayout:function(){var e=this._container;this._fadeAnimated=this.options.fadeAnimation&&R.any3d,V(e,`leaflet-container`+(R.touch?` leaflet-touch`:``)+(R.retina?` leaflet-retina`:``)+(R.ielt9?` leaflet-oldie`:``)+(R.safari?` leaflet-safari`:``)+(this._fadeAnimated?` leaflet-fade-anim`:``));var n=Ct(e,`position`);n!==`absolute`&&n!==`relative`&&n!==`fixed`&&n!==`sticky`&&(e.style.position=`relative`),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var e=this._panes={};this._paneRenderers={},this._mapPane=this.createPane(`mapPane`,this._container),W(this._mapPane,new w(0,0)),this.createPane(`tilePane`),this.createPane(`overlayPane`),this.createPane(`shadowPane`),this.createPane(`markerPane`),this.createPane(`tooltipPane`),this.createPane(`popupPane`),this.options.markerZoomAnimation||(V(e.markerPane,`leaflet-zoom-hide`),V(e.shadowPane,`leaflet-zoom-hide`))},_resetView:function(e,n,r){W(this._mapPane,new w(0,0));var i=!this._loaded;this._loaded=!0,n=this._limitZoom(n),this.fire(`viewprereset`);var a=this._zoom!==n;this._moveStart(a,r)._move(e,n)._moveEnd(a),this.fire(`viewreset`),i&&this.fire(`load`)},_moveStart:function(e,n){return e&&this.fire(`zoomstart`),n||this.fire(`movestart`),this},_move:function(e,n,r,i){n===void 0&&(n=this._zoom);var a=this._zoom!==n;return this._zoom=n,this._lastCenter=e,this._pixelOrigin=this._getNewPixelOrigin(e),i?r&&r.pinch&&this.fire(`zoom`,r):((a||r&&r.pinch)&&this.fire(`zoom`,r),this.fire(`move`,r)),this},_moveEnd:function(e){return e&&this.fire(`zoomend`),this.fire(`moveend`)},_stop:function(){return x(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(e){W(this._mapPane,this._getMapPanePos().subtract(e))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw Error(`Set map center and zoom first.`)},_initEvents:function(e){this._targets={},this._targets[s(this._container)]=this;var n=e?Y:q;n(this._container,`click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup`,this._handleDOMEvent,this),this.options.trackResize&&n(window,`resize`,this._onResize,this),R.any3d&&this.options.transform3DLimit&&(e?this.off:this.on).call(this,`moveend`,this._onMoveEnd)},_onResize:function(){x(this._resizeRequest),this._resizeRequest=b(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var e=this._getMapPanePos();Math.max(Math.abs(e.x),Math.abs(e.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(e,n){for(var r=[],i,a=n===`mouseout`||n===`mouseover`,o=e.target||e.srcElement,c=!1;o;){if(i=this._targets[s(o)],i&&(n===`click`||n===`preclick`)&&this._draggableMoved(i)){c=!0;break}if(i&&i.listens(n,!0)&&(a&&!rn(o,e)||(r.push(i),a))||o===this._container)break;o=o.parentNode}return!r.length&&!c&&!a&&this.listens(n,!0)&&(r=[this]),r},_isClickDisabled:function(e){for(;e&&e!==this._container;){if(e._leaflet_disable_click)return!0;e=e.parentNode}},_handleDOMEvent:function(e){var n=e.target||e.srcElement;if(!(!this._loaded||n._leaflet_disable_events||e.type===`click`&&this._isClickDisabled(n))){var r=e.type;r===`mousedown`&&K(n),this._fireDOMEvent(e,r)}},_mouseEvents:[`click`,`dblclick`,`mouseover`,`mouseout`,`contextmenu`],_fireDOMEvent:function(e,n,i){if(e.type===`click`){var a=r({},e);a.type=`preclick`,this._fireDOMEvent(a,a.type,i)}var o=this._findEventTargets(e,n);if(i){for(var s=[],c=0;c0?Math.round(e-n)/2:Math.max(0,Math.ceil(e))-Math.max(0,Math.floor(n))},_limitZoom:function(e){var n=this.getMinZoom(),r=this.getMaxZoom(),i=R.any3d?this.options.zoomSnap:1;return i&&(e=Math.round(e/i)*i),Math.max(n,Math.min(r,e))},_onPanTransitionStep:function(){this.fire(`move`)},_onPanTransitionEnd:function(){H(this._mapPane,`leaflet-pan-anim`),this.fire(`moveend`)},_tryAnimatedPan:function(e,n){var r=this._getCenterOffset(e)._trunc();return(n&&n.animate)!==!0&&!this.getSize().contains(r)?!1:(this.panBy(r,n),!0)},_createAnimProxy:function(){var e=this._proxy=z(`div`,`leaflet-proxy leaflet-zoom-animated`);this._panes.mapPane.appendChild(e),this.on(`zoomanim`,function(e){var n=yt,r=this._proxy.style[n];Mt(this._proxy,this.project(e.center,e.zoom),this.getZoomScale(e.zoom,1)),r===this._proxy.style[n]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on(`load moveend`,this._animMoveEnd,this),this._on(`unload`,this._destroyAnimProxy,this)},_destroyAnimProxy:function(){B(this._proxy),this.off(`load moveend`,this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var e=this.getCenter(),n=this.getZoom();Mt(this._proxy,this.project(e,n),this.getZoomScale(n,1))},_catchTransitionEnd:function(e){this._animatingZoom&&e.propertyName.indexOf(`transform`)>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName(`leaflet-zoom-animated`).length},_tryAnimatedZoom:function(e,n,r){if(this._animatingZoom)return!0;if(r||={},!this._zoomAnimated||r.animate===!1||this._nothingToAnimate()||Math.abs(n-this._zoom)>this.options.zoomAnimationThreshold)return!1;var i=this.getZoomScale(n),a=this._getCenterOffset(e)._divideBy(1-1/i);return r.animate!==!0&&!this.getSize().contains(a)?!1:(b(function(){this._moveStart(!0,r.noMoveStart||!1)._animateZoom(e,n,!0)},this),!0)},_animateZoom:function(e,n,r,i){this._mapPane&&(r&&(this._animatingZoom=!0,this._animateToCenter=e,this._animateToZoom=n,V(this._mapPane,`leaflet-zoom-anim`)),this.fire(`zoomanim`,{center:e,zoom:n,noUpdate:i}),this._tempFireZoomEvent||=this._zoom!==this._animateToZoom,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&H(this._mapPane,`leaflet-zoom-anim`),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire(`zoom`),delete this._tempFireZoomEvent,this.fire(`move`),this._moveEnd(!0))}});function sn(e,n){return new Z(e,n)}var Q=S.extend({options:{position:`topright`},initialize:function(e){m(this,e)},getPosition:function(){return this.options.position},setPosition:function(e){var n=this._map;return n&&n.removeControl(this),this.options.position=e,n&&n.addControl(this),this},getContainer:function(){return this._container},addTo:function(e){this.remove(),this._map=e;var n=this._container=this.onAdd(e),r=this.getPosition(),i=e._controlCorners[r];return V(n,`leaflet-control`),r.indexOf(`bottom`)===-1?i.appendChild(n):i.insertBefore(n,i.firstChild),this._map.on(`unload`,this.remove,this),this},remove:function(){return this._map?(B(this._container),this.onRemove&&this.onRemove(this._map),this._map.off(`unload`,this.remove,this),this._map=null,this):this},_refocusOnMap:function(e){this._map&&e&&e.screenX>0&&e.screenY>0&&this._map.getContainer().focus()}}),cn=function(e){return new Q(e)};Z.include({addControl:function(e){return e.addTo(this),this},removeControl:function(e){return e.remove(),this},_initControlPos:function(){var e=this._controlCorners={},n=`leaflet-`,r=this._controlContainer=z(`div`,n+`control-container`,this._container);function i(i,a){var o=n+i+` `+n+a;e[i+a]=z(`div`,o,r)}i(`top`,`left`),i(`top`,`right`),i(`bottom`,`left`),i(`bottom`,`right`)},_clearControlPos:function(){for(var e in this._controlCorners)B(this._controlCorners[e]);B(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var ln=Q.extend({options:{collapsed:!0,position:`topright`,autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(e,n,r,i){return r1,this._baseLayersList.style.display=e?``:`none`),this._separator.style.display=n&&e?``:`none`,this},_onLayerChange:function(e){this._handlingClick||this._update();var n=this._getLayer(s(e.target)),r=n.overlay?e.type===`add`?`overlayadd`:`overlayremove`:e.type===`add`?`baselayerchange`:null;r&&this._map.fire(r,n)},_createRadioElement:function(e,n){var r=``,i=document.createElement(`div`);return i.innerHTML=r,i.firstChild},_addItem:function(e){var n=document.createElement(`label`),r=this._map.hasLayer(e.layer),i;e.overlay?(i=document.createElement(`input`),i.type=`checkbox`,i.className=`leaflet-control-layers-selector`,i.defaultChecked=r):i=this._createRadioElement(`leaflet-base-layers_`+s(this),r),this._layerControlInputs.push(i),i.layerId=s(e.layer),q(i,`click`,this._onInputClick,this);var a=document.createElement(`span`);a.innerHTML=` `+e.name;var o=document.createElement(`span`);return n.appendChild(o),o.appendChild(i),o.appendChild(a),(e.overlay?this._overlaysList:this._baseLayersList).appendChild(n),this._checkDisabledLayers(),n},_onInputClick:function(){if(!this._preventClick){var e=this._layerControlInputs,n,r,i=[],a=[];this._handlingClick=!0;for(var o=e.length-1;o>=0;o--)n=e[o],r=this._getLayer(n.layerId).layer,n.checked?i.push(r):n.checked||a.push(r);for(o=0;o=0;a--)n=e[a],r=this._getLayer(n.layerId).layer,n.disabled=r.options.minZoom!==void 0&&ir.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var e=this._section;this._preventClick=!0,q(e,`click`,X),this.expand();var n=this;setTimeout(function(){Y(e,`click`,X),n._preventClick=!1})}}),un=function(e,n,r){return new ln(e,n,r)},dn=Q.extend({options:{position:`topleft`,zoomInText:``,zoomInTitle:`Zoom in`,zoomOutText:``,zoomOutTitle:`Zoom out`},onAdd:function(e){var n=`leaflet-control-zoom`,r=z(`div`,n+` leaflet-bar`),i=this.options;return this._zoomInButton=this._createButton(i.zoomInText,i.zoomInTitle,n+`-in`,r,this._zoomIn),this._zoomOutButton=this._createButton(i.zoomOutText,i.zoomOutTitle,n+`-out`,r,this._zoomOut),this._updateDisabled(),e.on(`zoomend zoomlevelschange`,this._updateDisabled,this),r},onRemove:function(e){e.off(`zoomend zoomlevelschange`,this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(e){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(e.shiftKey?3:1))},_createButton:function(e,n,r,i,a){var o=z(`a`,r,i);return o.innerHTML=e,o.href=`#`,o.title=n,o.setAttribute(`role`,`button`),o.setAttribute(`aria-label`,n),Zt(o),q(o,`click`,Qt),q(o,`click`,a,this),q(o,`click`,this._refocusOnMap,this),o},_updateDisabled:function(){var e=this._map,n=`leaflet-disabled`;H(this._zoomInButton,n),H(this._zoomOutButton,n),this._zoomInButton.setAttribute(`aria-disabled`,`false`),this._zoomOutButton.setAttribute(`aria-disabled`,`false`),(this._disabled||e._zoom===e.getMinZoom())&&(V(this._zoomOutButton,n),this._zoomOutButton.setAttribute(`aria-disabled`,`true`)),(this._disabled||e._zoom===e.getMaxZoom())&&(V(this._zoomInButton,n),this._zoomInButton.setAttribute(`aria-disabled`,`true`))}});Z.mergeOptions({zoomControl:!0}),Z.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new dn,this.addControl(this.zoomControl))});var fn=function(e){return new dn(e)},pn=Q.extend({options:{position:`bottomleft`,maxWidth:100,metric:!0,imperial:!0},onAdd:function(e){var n=`leaflet-control-scale`,r=z(`div`,n),i=this.options;return this._addScales(i,n+`-line`,r),e.on(i.updateWhenIdle?`moveend`:`move`,this._update,this),e.whenReady(this._update,this),r},onRemove:function(e){e.off(this.options.updateWhenIdle?`moveend`:`move`,this._update,this)},_addScales:function(e,n,r){e.metric&&(this._mScale=z(`div`,n,r)),e.imperial&&(this._iScale=z(`div`,n,r))},_update:function(){var e=this._map,n=e.getSize().y/2,r=e.distance(e.containerPointToLatLng([0,n]),e.containerPointToLatLng([this.options.maxWidth,n]));this._updateScales(r)},_updateScales:function(e){this.options.metric&&e&&this._updateMetric(e),this.options.imperial&&e&&this._updateImperial(e)},_updateMetric:function(e){var n=this._getRoundNum(e),r=n<1e3?n+` m`:n/1e3+` km`;this._updateScale(this._mScale,r,n/e)},_updateImperial:function(e){var n=e*3.2808399,r,i,a;n>5280?(r=n/5280,i=this._getRoundNum(r),this._updateScale(this._iScale,i+` mi`,i/r)):(a=this._getRoundNum(n),this._updateScale(this._iScale,a+` ft`,a/n))},_updateScale:function(e,n,r){e.style.width=Math.round(this.options.maxWidth*r)+`px`,e.innerHTML=n},_getRoundNum:function(e){var n=10**((Math.floor(e)+``).length-1),r=e/n;return r=r>=10?10:r>=5?5:r>=3?3:r>=2?2:1,n*r}}),mn=function(e){return new pn(e)},hn=Q.extend({options:{position:`bottomright`,prefix:``+(R.inlineSvg?` `:``)+`Leaflet`},initialize:function(e){m(this,e),this._attributions={}},onAdd:function(e){for(var n in e.attributionControl=this,this._container=z(`div`,`leaflet-control-attribution`),Zt(this._container),e._layers)e._layers[n].getAttribution&&this.addAttribution(e._layers[n].getAttribution());return this._update(),e.on(`layeradd`,this._addAttribution,this),this._container},onRemove:function(e){e.off(`layeradd`,this._addAttribution,this)},_addAttribution:function(e){e.layer.getAttribution&&(this.addAttribution(e.layer.getAttribution()),e.layer.once(`remove`,function(){this.removeAttribution(e.layer.getAttribution())},this))},setPrefix:function(e){return this.options.prefix=e,this._update(),this},addAttribution:function(e){return e?(this._attributions[e]||(this._attributions[e]=0),this._attributions[e]++,this._update(),this):this},removeAttribution:function(e){return e&&this._attributions[e]&&(this._attributions[e]--,this._update()),this},_update:function(){if(this._map){var e=[];for(var n in this._attributions)this._attributions[n]&&e.push(n);var r=[];this.options.prefix&&r.push(this.options.prefix),e.length&&r.push(e.join(`, `)),this._container.innerHTML=r.join(` `)}}});Z.mergeOptions({attributionControl:!0}),Z.addInitHook(function(){this.options.attributionControl&&new hn().addTo(this)}),Q.Layers=ln,Q.Zoom=dn,Q.Scale=pn,Q.Attribution=hn,cn.layers=un,cn.zoom=fn,cn.scale=mn,cn.attribution=function(e){return new hn(e)};var gn=S.extend({initialize:function(e){this._map=e},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}});gn.addTo=function(e,n){return e.addHandler(n,this),this};var _n={Events:C},vn=R.touch?`touchstart mousedown`:`mousedown`,yn=ce.extend({options:{clickTolerance:3},initialize:function(e,n,r,i){m(this,i),this._element=e,this._dragStartTarget=n||e,this._preventOutline=r},enable:function(){this._enabled||=(q(this._dragStartTarget,vn,this._onDown,this),!0)},disable:function(){this._enabled&&(yn._dragging===this&&this.finishDrag(!0),Y(this._dragStartTarget,vn,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(e){if(this._enabled&&(this._moved=!1,!Dt(this._element,`leaflet-zoom-anim`))){if(e.touches&&e.touches.length!==1){yn._dragging===this&&this.finishDrag();return}if(!(yn._dragging||e.shiftKey||e.which!==1&&e.button!==1&&!e.touches)&&(yn._dragging=this,this._preventOutline&&K(this._element),Lt(),G(),!this._moving)){this.fire(`down`);var n=e.touches?e.touches[0]:e,r=Ht(this._element);this._startPoint=new w(n.clientX,n.clientY),this._startPos=Nt(this._element),this._parentScale=Ut(r);var i=e.type===`mousedown`;q(document,i?`mousemove`:`touchmove`,this._onMove,this),q(document,i?`mouseup`:`touchend touchcancel`,this._onUp,this)}}},_onMove:function(e){if(this._enabled){if(e.touches&&e.touches.length>1){this._moved=!0;return}var n=e.touches&&e.touches.length===1?e.touches[0]:e,r=new w(n.clientX,n.clientY)._subtract(this._startPoint);!r.x&&!r.y||Math.abs(r.x)+Math.abs(r.y)o&&(s=c,o=l);o>r&&(n[s]=1,On(e,n,r,i,s),On(e,n,r,s,a))}function kn(e,n){for(var r=[e[0]],i=1,a=0,o=e.length;in&&(r.push(e[i]),a=i);return an.max.x&&(r|=2),e.yn.max.y&&(r|=8),r}function Pn(e,n){var r=n.x-e.x,i=n.y-e.y;return r*r+i*i}function Fn(e,n,r,i){var a=n.x,o=n.y,s=r.x-a,c=r.y-o,l=s*s+c*c,u;return l>0&&(u=((e.x-a)*s+(e.y-o)*c)/l,u>1?(a=r.x,o=r.y):u>0&&(a+=s*u,o+=c*u)),s=e.x-a,c=e.y-o,i?s*s+c*c:new w(a,o)}function $(e){return!v(e[0])||typeof e[0][0]!=`object`&&e[0][0]!==void 0}function In(e){return console.warn(`Deprecated use of _flat, please use L.LineUtil.isFlat instead.`),$(e)}function Ln(e,n){var r,i,a,o,s,c,l,u;if(!e||e.length===0)throw Error(`latlngs not passed`);$(e)||(console.warn(`latlngs are not flat! Only the first ring will be used`),e=e[0]);var d=M([0,0]),f=A(e);f.getNorthWest().distanceTo(f.getSouthWest())*f.getNorthEast().distanceTo(f.getNorthWest())<1700&&(d=Sn(e));var p=e.length,m=[];for(r=0;ri){l=(o-i)/a,u=[c.x-l*(c.x-s.x),c.y-l*(c.y-s.y)];break}var g=n.unproject(E(u));return M([g.lat+d.lat,g.lng+d.lng])}var Rn={__proto__:null,simplify:wn,pointToSegmentDistance:Tn,closestPointOnSegment:En,clipSegment:jn,_getEdgeIntersection:Mn,_getBitCode:Nn,_sqClosestPointOnSegment:Fn,isFlat:$,_flat:In,polylineCenter:Ln},zn={project:function(e){return new w(e.lng,e.lat)},unproject:function(e){return new j(e.y,e.x)},bounds:new D([-180,-90],[180,90])},Bn={R:6378137,R_MINOR:6356752.314245179,bounds:new D([-20037508.34279,-15496570.73972],[20037508.34279,18764656.23138]),project:function(e){var n=Math.PI/180,r=this.R,i=e.lat*n,a=this.R_MINOR/r,o=Math.sqrt(1-a*a),s=o*Math.sin(i),c=Math.tan(Math.PI/4-i/2)/((1-s)/(1+s))**(o/2);return i=-r*Math.log(Math.max(c,1e-10)),new w(e.lng*n*r,i)},unproject:function(e){for(var n=180/Math.PI,r=this.R,i=this.R_MINOR/r,a=Math.sqrt(1-i*i),o=Math.exp(-e.y/r),s=Math.PI/2-2*Math.atan(o),c=0,l=.1,u;c<15&&Math.abs(l)>1e-7;c++)u=a*Math.sin(s),u=((1-u)/(1+u))**(a/2),l=Math.PI/2-2*Math.atan(o*u)-s,s+=l;return new j(s*n,e.x*n/r)}},Vn={__proto__:null,LonLat:zn,Mercator:Bn,SphericalMercator:de},Hn=r({},le,{code:`EPSG:3395`,projection:Bn,transformation:function(){var e=.5/(Math.PI*Bn.R);return pe(e,.5,-e,.5)}()}),Un=r({},le,{code:`EPSG:4326`,projection:zn,transformation:pe(1/180,1,-1/180,.5)}),Wn=r({},N,{projection:zn,transformation:pe(1,0,-1,0),scale:function(e){return 2**e},zoom:function(e){return Math.log(e)/Math.LN2},distance:function(e,n){var r=n.lng-e.lng,i=n.lat-e.lat;return Math.sqrt(r*r+i*i)},infinite:!0});N.Earth=le,N.EPSG3395=Hn,N.EPSG3857=me,N.EPSG900913=he,N.EPSG4326=Un,N.Simple=Wn;var Gn=ce.extend({options:{pane:`overlayPane`,attribution:null,bubblingMouseEvents:!0},addTo:function(e){return e.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(e){return e&&e.removeLayer(this),this},getPane:function(e){return this._map.getPane(e?this.options[e]||e:this.options.pane)},addInteractiveTarget:function(e){return this._map._targets[s(e)]=this,this},removeInteractiveTarget:function(e){return delete this._map._targets[s(e)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(e){var n=e.target;if(n.hasLayer(this)){if(this._map=n,this._zoomAnimated=n._zoomAnimated,this.getEvents){var r=this.getEvents();n.on(r,this),this.once(`remove`,function(){n.off(r,this)},this)}this.onAdd(n),this.fire(`add`),n.fire(`layeradd`,{layer:this})}}});Z.include({addLayer:function(e){if(!e._layerAdd)throw Error(`The provided object is not a Layer.`);var n=s(e);return this._layers[n]?this:(this._layers[n]=e,e._mapToAdd=this,e.beforeAdd&&e.beforeAdd(this),this.whenReady(e._layerAdd,e),this)},removeLayer:function(e){var n=s(e);return this._layers[n]?(this._loaded&&e.onRemove(this),delete this._layers[n],this._loaded&&(this.fire(`layerremove`,{layer:e}),e.fire(`remove`)),e._map=e._mapToAdd=null,this):this},hasLayer:function(e){return s(e)in this._layers},eachLayer:function(e,n){for(var r in this._layers)e.call(n,this._layers[r]);return this},_addLayers:function(e){e=e?v(e)?e:[e]:[];for(var n=0,r=e.length;nthis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===void 0&&this._layersMinZoom&&this.getZoom()=2&&n[0]instanceof j&&n[0].equals(n[r-1])&&n.pop(),n},_setLatLngs:function(e){sr.prototype._setLatLngs.call(this,e),$(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return $(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var e=this._renderer._bounds,n=this.options.weight,r=new w(n,n);if(e=new D(e.min.subtract(r),e.max.add(r)),this._parts=[],!(!this._pxBounds||!this._pxBounds.intersects(e))){if(this.options.noClip){this._parts=this._rings;return}for(var i=0,a=this._rings.length,o;ie.y!=a.y>e.y&&e.x<(a.x-i.x)*(e.y-i.y)/(a.y-i.y)+i.x&&(n=!n);return n||sr.prototype._containsPoint.call(this,e,!0)}});function ur(e,n){return new lr(e,n)}var dr=Jn.extend({initialize:function(e,n){m(this,n),this._layers={},e&&this.addData(e)},addData:function(e){var n=v(e)?e:e.features,r,i,a;if(n){for(r=0,i=n.length;r0&&a.push(a[0].slice()),a}function vr(e,n){return e.feature?r({},e.feature,{geometry:n}):yr(n)}function yr(e){return e.type===`Feature`||e.type===`FeatureCollection`?e:{type:`Feature`,properties:{},geometry:e}}var br={toGeoJSON:function(e){return vr(this,{type:`Point`,coordinates:gr(this.getLatLng(),e)})}};er.include(br),ar.include(br),rr.include(br),sr.include({toGeoJSON:function(e){var n=!$(this._latlngs),r=_r(this._latlngs,+!!n,!1,e);return vr(this,{type:(n?`Multi`:``)+`LineString`,coordinates:r})}}),lr.include({toGeoJSON:function(e){var n=!$(this._latlngs),r=n&&!$(this._latlngs[0]),i=_r(this._latlngs,r?2:+!!n,!0,e);return n||(i=[i]),vr(this,{type:(r?`Multi`:``)+`Polygon`,coordinates:i})}}),Kn.include({toMultiPoint:function(e){var n=[];return this.eachLayer(function(r){n.push(r.toGeoJSON(e).geometry.coordinates)}),vr(this,{type:`MultiPoint`,coordinates:n})},toGeoJSON:function(e){var n=this.feature&&this.feature.geometry&&this.feature.geometry.type;if(n===`MultiPoint`)return this.toMultiPoint(e);var r=n===`GeometryCollection`,i=[];return this.eachLayer(function(n){if(n.toGeoJSON){var a=n.toGeoJSON(e);if(r)i.push(a.geometry);else{var o=yr(a);o.type===`FeatureCollection`?i.push.apply(i,o.features):i.push(o)}}}),r?vr(this,{geometries:i,type:`GeometryCollection`}):{type:`FeatureCollection`,features:i}}});function xr(e,n){return new dr(e,n)}var Sr=xr,Cr=Gn.extend({options:{opacity:1,alt:``,interactive:!1,crossOrigin:!1,errorOverlayUrl:``,zIndex:1,className:``},initialize:function(e,n,r){this._url=e,this._bounds=A(n),m(this,r)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(V(this._image,`leaflet-interactive`),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){B(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(e){return this.options.opacity=e,this._image&&this._updateOpacity(),this},setStyle:function(e){return e.opacity&&this.setOpacity(e.opacity),this},bringToFront:function(){return this._map&&Tt(this._image),this},bringToBack:function(){return this._map&&Et(this._image),this},setUrl:function(e){return this._url=e,this._image&&(this._image.src=e),this},setBounds:function(e){return this._bounds=A(e),this._map&&this._reset(),this},getEvents:function(){var e={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(e.zoomanim=this._animateZoom),e},setZIndex:function(e){return this.options.zIndex=e,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var e=this._url.tagName===`IMG`,n=this._image=e?this._url:z(`img`);if(V(n,`leaflet-image-layer`),this._zoomAnimated&&V(n,`leaflet-zoom-animated`),this.options.className&&V(n,this.options.className),n.onselectstart=u,n.onmousemove=u,n.onload=a(this.fire,this,`load`),n.onerror=a(this._overlayOnError,this,`error`),(this.options.crossOrigin||this.options.crossOrigin===``)&&(n.crossOrigin=this.options.crossOrigin===!0?``:this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),e){this._url=n.src;return}n.src=this._url,n.alt=this.options.alt},_animateZoom:function(e){var n=this._map.getZoomScale(e.zoom),r=this._map._latLngBoundsToNewLayerBounds(this._bounds,e.zoom,e.center).min;Mt(this._image,r,n)},_reset:function(){var e=this._image,n=new D(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),r=n.getSize();W(e,n.min),e.style.width=r.x+`px`,e.style.height=r.y+`px`},_updateOpacity:function(){U(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&this.options.zIndex!==void 0&&this.options.zIndex!==null&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire(`error`);var e=this.options.errorOverlayUrl;e&&this._url!==e&&(this._url=e,this._image.src=e)},getCenter:function(){return this._bounds.getCenter()}}),wr=function(e,n,r){return new Cr(e,n,r)},Tr=Cr.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var e=this._url.tagName===`VIDEO`,n=this._image=e?this._url:z(`video`);if(V(n,`leaflet-image-layer`),this._zoomAnimated&&V(n,`leaflet-zoom-animated`),this.options.className&&V(n,this.options.className),n.onselectstart=u,n.onmousemove=u,n.onloadeddata=a(this.fire,this,`load`),e){for(var r=n.getElementsByTagName(`source`),i=[],o=0;o0?i:[n.src];return}v(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(n.style,`objectFit`)&&(n.style.objectFit=`fill`),n.autoplay=!!this.options.autoplay,n.loop=!!this.options.loop,n.muted=!!this.options.muted,n.playsInline=!!this.options.playsInline;for(var s=0;s