mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf1745fa13 | ||
|
|
efc095f7d0 | ||
|
|
14e7286840 | ||
|
|
4ace75b3d5 | ||
|
|
d23963aaca | ||
|
|
d35a0d841c | ||
|
|
1b18ab9bec | ||
|
|
93e6ebfe49 | ||
|
|
c3a53cb183 | ||
|
|
ba554b8470 | ||
|
|
61ce760e0f | ||
|
|
7b92b7c857 | ||
|
|
8c127b2849 | ||
|
|
e6b8841421 | ||
|
|
862064e061 | ||
|
|
90594cc331 | ||
|
|
6012ba701d | ||
|
|
5cc95a2e63 | ||
|
|
e41f43241b | ||
|
|
3ad737e606 | ||
|
|
3b49e8489e | ||
|
|
07679dd5ba | ||
|
|
3b8bb4cba9 | ||
|
|
e63fdf4dd0 | ||
|
|
c96415caae | ||
|
|
075e20efae | ||
|
|
624c3357be | ||
|
|
7673798fa3 | ||
|
|
d4987a153e | ||
|
|
b02d9b3662 | ||
|
|
4c44044c0c | ||
|
|
58f605e90c | ||
|
|
6ae3d47eeb | ||
|
|
cb185ad6bf | ||
|
|
f89858f1ec | ||
|
|
0695ca254d | ||
|
|
e91694154f | ||
|
|
b251a4cf65 | ||
|
|
01949b059b | ||
|
|
89f3668da2 | ||
|
|
7865ca7b95 | ||
|
|
d92a98b100 | ||
|
|
e9118fa6b6 | ||
|
|
e49b64b114 | ||
|
|
1204362e9c | ||
|
|
cc535cde3b | ||
|
|
cb44d9e716 | ||
|
|
5cb66bd52f | ||
|
|
3a893d15ad | ||
|
|
64854ef08d | ||
|
|
9f3cdf4ad5 | ||
|
|
2dbc70d60d | ||
|
|
a2b14bcb93 | ||
|
|
864bac6dc4 | ||
|
|
78dc12dc29 | ||
|
|
4b4c2ec7c3 | ||
|
|
d87fa3bd80 | ||
|
|
45d353ffdb | ||
|
|
e5390c3d86 | ||
|
|
e3d2608d03 | ||
|
|
650a4255cc | ||
|
|
de70af2584 | ||
|
|
ba7ed78b73 | ||
|
|
cea149cb6e | ||
|
|
70d8d1ee9d | ||
|
|
29c2e209f4 | ||
|
|
ef465957ff | ||
|
|
ba8b51af58 | ||
|
|
93e3eb3a35 | ||
|
|
cf77c0e7eb | ||
|
|
d4578c3b8c | ||
|
|
c2f3fe6a52 | ||
|
|
bb18799a0b | ||
|
|
4a40c1b897 | ||
|
|
e9d4b1fe77 | ||
|
|
9cefd0128c | ||
|
|
d695e9d180 | ||
|
|
faf96896e7 | ||
|
|
72364bf4e0 | ||
|
|
969d4e2bfb | ||
|
|
430d892e64 | ||
|
|
fb8af7a8cf | ||
|
|
6ecb412849 | ||
|
|
bc7080337e | ||
|
|
c157331721 | ||
|
|
27ab2d45e8 | ||
|
|
d5d8decf6e | ||
|
|
eb28f898d0 | ||
|
|
ab0edb80df | ||
|
|
233dd2ac67 | ||
|
|
21d58d389f | ||
|
|
7f21e31145 | ||
|
|
19ad2f3b04 | ||
|
|
f6675702ea | ||
|
|
3b9f2141fe | ||
|
|
2b39757fbc | ||
|
|
15091999e3 | ||
|
|
fcc680794c | ||
|
|
bc72525013 | ||
|
|
23ca5a77e1 | ||
|
|
266b56ecbb | ||
|
|
ac8a4583cb | ||
|
|
1d72c8487e | ||
|
|
97eb9b300b | ||
|
|
5b2cae8509 | ||
|
|
5715e11e52 |
+85
-3
@@ -1,7 +1,89 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
|
||||
|
||||
**This is non-commercial personal open source project, so there are no bounties!**
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io** or submit a private [GitHub Security advisory](https://github.com/pocketbase/pocketbase/security/advisories).
|
||||
|
||||
All reports will be promptly addressed and you'll be credited in the fix release notes.
|
||||
I try to be as responsive as possible and usually address security reports within a day or two, but if you didn't receive a reply from me for more than 5 days it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
|
||||
In case the vulnerability is confirmed, within another couple days I'll try to submit a fix, GitHub security advisory and CVE with remediation steps and **minimal details** regarding the found exploit to minimize giving too much hints to malicious actors (you'll be credited both in the fix release notes and in the public report).
|
||||
|
||||
### Please:
|
||||
|
||||
- DO NOT use LLM as part of your report or email communication - it is extremely frustrating to spend an hour or more reading a wall of generated text, writing an elaborate reply and then to receive another generic LLM prompt response in return.
|
||||
|
||||
- DO NOT reserve and publish MITRE CVE number on your own _(I prefer to do it through the GitHub Security advisory)_ and try to communicate first privately the details to better understand how the code is being used and whether the supposed vulnerability can be actually exploited in any real practical scenarios. Otherwise you are risking needlessly causing scaremongering and annoyance for users that rely on security scanners as part of their CI/CD pipeline.
|
||||
|
||||
- Wait before publicly disclosing and sharing details about the found vulnerability, **ideally at least 5 days after the fix**, to make it harder to exploit and give enough time for users to patch their instances _(you are free to provide a PoC and as much details as you want in your own blog/gist/etc.)_.
|
||||
|
||||
### Below is a short list of previous reports that are NOT considered security issues:
|
||||
|
||||
<details>
|
||||
<summary><strong>Stored XSS</strong></summary>
|
||||
|
||||
This was discussed several times, both privately and [publicly](https://github.com/pocketbase/pocketbase/discussions/6694), but I remain on the opinion that it should be handled primarily on the client-side.
|
||||
|
||||
Modern browsers recently introduced a basic [`Sanitizer` interface](https://developer.mozilla.org/en-US/docs/Web/API/Sanitizer) that could help filtering HTML strings without external libraries.
|
||||
|
||||
Having also a default [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as meta tag or response header is always a good idea to minimize the risk of XSS.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>SQL injection in low level DB methods like <code>app.DeleteTable(dangerousName)</code></strong></summary>
|
||||
|
||||
This is working correctly and it is not an issue but it is a common report most likely found by LLM or some other automated tools that may have stumbled on the [NB! code comments](https://pkg.go.dev/github.com/pocketbase/pocketbase@master/core#BaseApp.DeleteTable).
|
||||
|
||||
Raw SQL statements, table and column names are not parameterized and they are vulnerable to SQL injection if used with untrusted input. The documentation as seen above already warns against it. In recent PocketBase releases, many of the arguments of these methods were also prefixed with `dangerous*` to make it even more clear that they should be used with caution.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Race conditions</strong></summary>
|
||||
|
||||
To avoid DB locks PocketBase deliberately tries to minimize the use of DB transactions.
|
||||
This means that operations like record update don't wrap out of the box for example the `SELECT` and `UPDATE` SQL statements in a single transaction, and this can technically lead to a race condition if multiple users edit the same record.
|
||||
|
||||
This is an accepted tradeoff and for the majority of cases it has no security implications.
|
||||
|
||||
This also apply for the read and delete of MFA and OTP records but for those cases, since they operate in a security sensitive context, they have an extra short-lived duration that is configurable from the collection settings _(there are also system cron jobs that takes care for deleting forgotten/expired entries to prevent accumulation of invalid records)_.
|
||||
|
||||
For the cases where transactions are really needed, users can utilize the [Batch Web API](https://pocketbase.io/docs/api-records/#batch-createupdateupsertdelete-records) or [create a transaction programmatically](https://pocketbase.io/docs/go-records/#transaction) _(with PocketBase v0.23+ it is also possible to wrap an entire hook chain in a single transaction)_.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>List/Search side-channel attacks</strong></summary>
|
||||
|
||||
Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
|
||||
|
||||
This is by design and it is accepted tradeoff between performance, security and usability.
|
||||
|
||||
If you are concerned about timing attacks and have security sensitive collection data such as `secret`, `code`, `token`, etc. then the general recommendation is to mark their related fields as "Hidden" in order to disallow use in client-side filters.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Connecting to a vulnerable OAuth2 provider</strong></summary>
|
||||
|
||||
Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on user create _(need to be specified from the auth collection OAuth2 fields mapping)_ some security researchers raised a concern regarding a Blind SSRF but this implies that an attacker controls the OAuth2 vendor and this is a very serious assumption in the first place.
|
||||
|
||||
The entire OAuth2 flow relies that the application server (PocketBase) trusts the configured OAuth2 vendor.
|
||||
If you suspect that an OAuth2 vendor is malicious and cannot be trusted then you MUST NOT use that OAuth2 vendor at all and you should report it.
|
||||
|
||||
If someone is able to tamper with the OAuth2 responses then the entire OAuth2 flow can be thrown out of the window because they will be practically able to authenticate as any of your existing users and the eventual avatar URL probing request is the least of your problem.
|
||||
|
||||
~Nonetheless, in future PocketBase releases there will be [extra `localhost` domain like checks](https://github.com/orgs/pocketbase/projects/2/views/1?pane=issue&itemId=159545722) when assigning the OAuth2 avatar URL to a `file` field that will further minimize the risk of internal network probing requests in case of a vulnerable OAuth2 provider.~ _Done._
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
|
||||
|
||||
Just for the past month, due to some corporate security scanners 5 different people raised concerns over [CVE-2023-36308](https://nvd.nist.gov/vuln/detail/CVE-2023-36308) but this is not really a vulnerability, especially not in PocketBase.
|
||||
|
||||
[`disintegration/imaging`](https://github.com/disintegration/imaging) is a direct PocketBase dependency responsible for the thumbs generation.
|
||||
|
||||
First, a panic (similar to exception in other languages) is NOT a security issue and Go programs usually have to be written defensively with that in mind. In PocketBase specifically all routes have auto panic-recover handling, no matter what the source of the panic is, so the worst case scenario would be an HTTP error response when attempting to access the thumb.
|
||||
|
||||
Second, the related issue that the CVE describes is probably caused by a bug in an outdated `golang.org/x/image` dependency listed in the `go.mod` of that package but PocketBase uses a newer patched version of it that is expected to take precedence.
|
||||
|
||||
Third, even if that issue is still available, with PocketBase it would have been triggerable ONLY if we supported TIFF thumbs generation but we don't. The supported thumbs formats at the moment are JPG, PNG, GIF (its first frame) and partially WebP (stored as PNG). All other images are served as it is, without any transformation.
|
||||
|
||||
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
|
||||
</details>
|
||||
|
||||
@@ -16,19 +16,19 @@ jobs:
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.17.0
|
||||
node-version: '>=25.2.1'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.25.6'
|
||||
go-version: '>=1.26.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.
|
||||
|
||||
+144
-3
@@ -1,3 +1,144 @@
|
||||
## 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.
|
||||
@@ -608,7 +749,7 @@ and the minor performance boost that you may get when used on large records is n
|
||||
|
||||
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||
|
||||
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||
- Normalized the hidden fields filter checks and allow targeting hidden fields in the List API rule.
|
||||
|
||||
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||
|
||||
@@ -700,7 +841,7 @@ and the minor performance boost that you may get when used on large records is n
|
||||
- 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 anoying during development, the rules confirmation currently is enabled only when using https_).
|
||||
(_to avoid getting annoying during development, the rules confirmation currently is enabled only when using https_).
|
||||
|
||||
|
||||
## v0.23.1
|
||||
@@ -743,7 +884,7 @@ There are a lot of changes but to highlight some of the most notable ones:
|
||||
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
|
||||
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
|
||||
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
|
||||
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||
- Various `Record` model improvements (_support for get/set modifiers, simplified file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
|
||||
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
||||
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
||||
|
||||
+26
-3
@@ -2,6 +2,29 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.41
|
||||
|
||||
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
|
||||
|
||||
- (_Backported from v0.36.9_) Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
|
||||
|
||||
- (_Backported from v0.36.9_) Bumped min Go GitHub action version to 1.26.2 because it comes with several [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
|
||||
|
||||
|
||||
## v0.22.40
|
||||
|
||||
- (_Backported from v0.36.7_) Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||
|
||||
- (_Backported from v0.36.7_) Updated other minor Go and npm deps.
|
||||
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||
|
||||
|
||||
## v0.22.39
|
||||
|
||||
- (_Backported from v0.36.6_) Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||
|
||||
|
||||
## v0.22.38
|
||||
|
||||
- (_Backported from v0.36.0_) Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||
@@ -80,7 +103,7 @@
|
||||
|
||||
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
|
||||
|
||||
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks.
|
||||
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibility of eventual side-channel attacks.
|
||||
|
||||
|
||||
## v0.22.24
|
||||
@@ -482,7 +505,7 @@
|
||||
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 everytime with the app startup.
|
||||
- Added timestamp at the beginning of the generated JSVM types file to avoid creating it every time with the app startup.
|
||||
|
||||
|
||||
## v0.20.0
|
||||
@@ -862,7 +885,7 @@
|
||||
|
||||
- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
|
||||
|
||||
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
|
||||
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successful email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
|
||||
|
||||
- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
|
||||
|
||||
|
||||
+2
-2
@@ -326,7 +326,7 @@
|
||||
|
||||
- Added "tags" support for all Record and Model related event hooks.
|
||||
|
||||
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
|
||||
The "tags" allow registering event handlers that will be called only on matching table name(s) or collection id(s)/name(s).
|
||||
For example:
|
||||
```go
|
||||
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
|
||||
@@ -840,7 +840,7 @@ Please check the individual SDK package changelog and apply the necessary change
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
|
||||
- All datetime strings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
|
||||
<table class="d-table" width="100%">
|
||||
<tr>
|
||||
<th>Old</th>
|
||||
|
||||
+16
-13
@@ -1,17 +1,20 @@
|
||||
# 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-admin-ui)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+ (for making changes in the Go code)
|
||||
- Node 18+ (for making changes in the Admin UI)
|
||||
- Go 1.25+ (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:
|
||||
|
||||
@@ -34,7 +37,7 @@ So, let's assume that you already done some changes in the PocketBase Go code an
|
||||
1. Navigate to `examples/base`
|
||||
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:**
|
||||
|
||||
@@ -57,11 +60,11 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
|
||||
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
|
||||
@@ -70,13 +73,13 @@ To start the Admin UI:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
You could open the browser and access the running Superuser UI at `http://localhost:5173`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
Since the Superuser UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
> By default, the Superuser UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
Every change you make in the Superuser UI should be automatically reflected in the browser at `http://localhost:5173` without reloading the page.
|
||||
|
||||
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
|
||||
Once you are done with your changes, you have to build the Superuser UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://pocketbase.io" target="_blank" rel="noopener">
|
||||
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
|
||||
<img src="https://i.imgur.com/aCBbjKx.png" alt="PocketBase - open source backend in 1 file" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
|
||||
|
||||
Here is a minimal example:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
|
||||
1. Create a new project directory with the following `main.go` file inside it:
|
||||
```go
|
||||
@@ -92,7 +92,7 @@ _For more details please refer to [Extend with Go](https://pocketbase.io/docs/go
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
@@ -146,10 +146,13 @@ You could help continuing its development by:
|
||||
- [Contribute to the source code](CONTRIBUTING.md)
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
|
||||
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
|
||||
|
||||
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).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
|
||||
> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
|
||||
> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
|
||||
|
||||
+7
-3
@@ -15,7 +15,7 @@ import (
|
||||
// StaticWildcardParam is the name of Static handler wildcard parameter.
|
||||
const StaticWildcardParam = "path"
|
||||
|
||||
// NewRouter returns a new router instance loaded with the default app middlewares and api routes.
|
||||
// NewRouter returns a new router instance loaded with the default app middlewares and routes.
|
||||
func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
|
||||
event := new(core.RequestEvent)
|
||||
@@ -34,6 +34,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
pbRouter.Bind(securityHeaders())
|
||||
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
||||
|
||||
// API routes
|
||||
apiGroup := pbRouter.Group("/api")
|
||||
bindSettingsApi(app, apiGroup)
|
||||
bindCollectionApi(app, apiGroup)
|
||||
@@ -47,6 +48,9 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
bindHealthApi(app, apiGroup)
|
||||
|
||||
// UI routes
|
||||
bindUIExtensions(app)
|
||||
|
||||
return pbRouter, nil
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
|
||||
|
||||
// Static is a handler function to serve static directory content from fsys.
|
||||
//
|
||||
// If a file resource is missing and indexFallback is set, the request
|
||||
// If a file resource is missing and indexFallback is true, the request
|
||||
// will be forwarded to the base index.html (useful for SPA with pretty urls).
|
||||
//
|
||||
// NB! Expects the route to have a "{path...}" wildcard parameter.
|
||||
@@ -94,7 +98,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
|
||||
// Special redirects:
|
||||
// - 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 set
|
||||
// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
|
||||
+2
-1
@@ -88,7 +88,7 @@ func (brs batchRequestsForm) validate() error {
|
||||
}
|
||||
|
||||
// NB! When the request is submitted as multipart/form-data,
|
||||
// the regular fields data is expected to be submitted as serailized
|
||||
// the regular fields data is expected to be submitted as serialized
|
||||
// json under the @jsonPayload field and file keys need to follow the
|
||||
// pattern "requests.N.fileField" or requests[N].fileField.
|
||||
func batchTransaction(e *core.RequestEvent) error {
|
||||
@@ -364,6 +364,7 @@ func processInternalRequest(
|
||||
// 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()
|
||||
|
||||
@@ -3,10 +3,12 @@ package apis
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -23,6 +25,10 @@ func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
|
||||
subGroup.DELETE("/{collection}/truncate", collectionTruncate)
|
||||
subGroup.PUT("/import", collectionsImport)
|
||||
subGroup.GET("/meta/scaffolds", collectionScaffolds)
|
||||
|
||||
// @todo experimental
|
||||
subGroup.GET("/meta/oauth2-providers", collectionListOAuth2Providers)
|
||||
subGroup.POST("/meta/dry-run-view", collectionDryRunView)
|
||||
}
|
||||
|
||||
func collectionsList(e *core.RequestEvent) error {
|
||||
@@ -207,3 +213,84 @@ func collectionScaffolds(e *core.RequestEvent) error {
|
||||
|
||||
return e.JSON(http.StatusOK, collections)
|
||||
}
|
||||
|
||||
type providerListItem struct {
|
||||
order int
|
||||
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Logo string `json:"logo"`
|
||||
}
|
||||
|
||||
func collectionListOAuth2Providers(e *core.RequestEvent) error {
|
||||
providers := make([]*providerListItem, 0, len(auth.Providers))
|
||||
|
||||
for name, factory := range auth.Providers {
|
||||
p := factory()
|
||||
|
||||
providers = append(providers, &providerListItem{
|
||||
order: p.Order(),
|
||||
Name: name,
|
||||
DisplayName: p.DisplayName(),
|
||||
Logo: p.Logo(),
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortStableFunc(providers, func(a, b *providerListItem) int {
|
||||
// sort by order
|
||||
if a.order < b.order {
|
||||
return -1
|
||||
}
|
||||
if a.order > b.order {
|
||||
return 1
|
||||
}
|
||||
|
||||
// fallback sort by name
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
}
|
||||
if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return e.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
func collectionDryRunView(e *core.RequestEvent) error {
|
||||
// extra precaution in case reused in custom route group
|
||||
if !e.HasSuperuserAuth() {
|
||||
return e.ForbiddenError("", nil)
|
||||
}
|
||||
|
||||
form := dryRunViewForm{}
|
||||
|
||||
err := e.BindBody(&form)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
err = form.validate()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
result, err := e.App.DryRunView(form.Query, 10)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Invalid view query. Raw error: \n"+err.Error(), nil))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
type dryRunViewForm struct {
|
||||
Query string `form:"query" json:"query"`
|
||||
}
|
||||
|
||||
func (form *dryRunViewForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
|
||||
)
|
||||
}
|
||||
|
||||
+190
-6
@@ -536,7 +536,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
// ensures that id field was prepended
|
||||
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
|
||||
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -585,7 +585,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"verified"`,
|
||||
`"duration":123`,
|
||||
// should overwrite the user required option but keep the min value
|
||||
`{"autogeneratePattern":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
|
||||
`{"autogeneratePattern":"","help":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"secret":"`,
|
||||
@@ -751,7 +751,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery":"invalid"
|
||||
"viewQuery":"select '123' as abc"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -780,7 +780,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + `"
|
||||
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + ` limit 1"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -789,7 +789,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"view"`,
|
||||
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
`"fields":[{"autogeneratePattern":"","help":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -1262,7 +1262,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
Body: strings.NewReader(`{
|
||||
"name":"view2_update",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + `"
|
||||
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + ` limit 1"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -1584,3 +1584,187 @@ func TestCollectionTruncate(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionOAuth2Providers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"order":`,
|
||||
`"pkce":`,
|
||||
`"scopes":`,
|
||||
`"authURL":`,
|
||||
`"tokenURL":`,
|
||||
`"userInfoURL":`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionTestView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"fields":[{`,
|
||||
`"name":"id"`,
|
||||
`"type":"text"`,
|
||||
`"sample":[{`,
|
||||
`"id":"1"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "empty query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":""}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{"query":`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query length beyond validator limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{"query":`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query with length equal to the validator limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id` + strings.Repeat(" ", 4986) + `"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"fields":[{`,
|
||||
`"name":"id"`,
|
||||
`"type":"text"`,
|
||||
`"sample":[`,
|
||||
`"id":"1"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "missing ids sample",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"(select 1 as id union select '' as id)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicated ids sample",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"(select 1 as id union all select 1 as id)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"CREATE TABLE t1(x INT)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
// bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router.
|
||||
//
|
||||
// This method does nothing if the superuser UI is not bundled (aka. build with "no_ui" tag),
|
||||
func bindUIExtensions(app core.App) {
|
||||
if ui.DistDirFS == nil {
|
||||
return
|
||||
}
|
||||
|
||||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Priority: 9999, // execute as latest as possible
|
||||
Func: func(se *core.ServeEvent) error {
|
||||
uiGroup := se.Router.Group("/_").
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
if !e.App.IsDev() && e.Response.Header().Get("Cache-Control") == "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
|
||||
// register static extension routes
|
||||
for _, ext := range se.UIExtensions {
|
||||
if ext.Name == "" || ext.FS == nil {
|
||||
se.App.Logger().Debug("Invalid UI extension configuration", slog.Any("extension", ext))
|
||||
continue
|
||||
}
|
||||
|
||||
uiGroup.GET("/extensions/"+ext.Name+"/{path...}", Static(ext.FS, false))
|
||||
}
|
||||
|
||||
// combine all extensions main.js in one file
|
||||
//
|
||||
// note: don't cache in memory to allow previewing changes without restart
|
||||
uiGroup.GET("/extensions.js", func(re *core.RequestEvent) error {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
for _, ext := range se.UIExtensions {
|
||||
err := copyExtensionMainjs(buf, ext)
|
||||
if err != nil {
|
||||
return re.InternalServerError("An error occurred while generating the main.js extension file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return re.Stream(200, "text/javascript", buf)
|
||||
}).Bind(SkipSuccessActivityLog())
|
||||
|
||||
return se.Next()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
|
||||
f, err := ext.FS.Open("main.js")
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil // nothing to copy
|
||||
}
|
||||
|
||||
return fmt.Errorf("[UI extension %q] main.js open error: %w", ext.Name, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// wrap in a self-executing function to avoid scope and concatenation issues
|
||||
_, _ = buf.WriteString("(function(){")
|
||||
|
||||
_, err = io.Copy(buf, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[UI extension %q] main.js copy error: %w", ext.Name, err)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString("})();")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
|
||||
func TestUIExtensions_Mainjs(t *testing.T) {
|
||||
successAfterTestFunc := func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
expected := "text/javascript"
|
||||
if ct := res.Header.Get("content-type"); ct != expected {
|
||||
t.Fatalf("Expected response Content-Type %q, got %q", expected, ct)
|
||||
}
|
||||
}
|
||||
|
||||
oldDistDirFS := ui.DistDirFS
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "disabled UI",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// simulate no_ui tag (needs to be cleared before the router is initialized)
|
||||
ui.DistDirFS = nil
|
||||
|
||||
return app
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
ui.DistDirFS = oldDistDirFS
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "no extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
AfterTestFunc: successAfterTestFunc,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
e.UIExtensions = createTestExtensions()
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
return app
|
||||
},
|
||||
AfterTestFunc: successAfterTestFunc,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
|
||||
func TestUIExtensions_Files(t *testing.T) {
|
||||
testAppFactory := func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
e.UIExtensions = createTestExtensions()
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "no extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/test.txt",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with missing extension file",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/missing",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with existing extension file (ext1)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/test.txt",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"ext1_txt"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with existing extension file (extension name escape)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext3%20with%20spaces/test.txt",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"ext3_txt"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestExtensions() []core.UIExtension {
|
||||
return []core.UIExtension{
|
||||
{
|
||||
Name: "ext1",
|
||||
FS: fstest.MapFS{
|
||||
"main.js": &fstest.MapFile{
|
||||
Data: []byte("ext1_main"),
|
||||
},
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext1_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ext2",
|
||||
FS: fstest.MapFS{
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext2_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ext3 with spaces",
|
||||
FS: fstest.MapFS{
|
||||
"main.js": &fstest.MapFile{
|
||||
Data: []byte("ext3_main"),
|
||||
},
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext3_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func healthCheck(e *core.RequestEvent) error {
|
||||
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)
|
||||
|
||||
+7
-1
@@ -12,6 +12,7 @@ import (
|
||||
"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.
|
||||
@@ -22,13 +23,18 @@ import (
|
||||
//
|
||||
// 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/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
|
||||
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)
|
||||
|
||||
+6
-4
@@ -207,11 +207,13 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
||||
|
||||
func getAuthTokenFromRequest(e *core.RequestEvent) string {
|
||||
token := e.Request.Header.Get("Authorization")
|
||||
if token != "" {
|
||||
// the schema prefix is not required and it is only for
|
||||
// compatibility with the defaults of some HTTP clients
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
// 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:]
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil)
|
||||
|
||||
const DefaultMaxBodySize int64 = 32 << 20
|
||||
const DefaultMaxBodySize int64 = 32 << 20 // @todo consider replacing with router.DefaultMaxMemory
|
||||
|
||||
const (
|
||||
DefaultBodyLimitMiddlewareId = "pbBodyLimit"
|
||||
@@ -112,9 +112,21 @@ func (r *limitedReader) Read(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// explicit casts to ensure that the main struct methods will be invoked
|
||||
// (extra precautions in case of nested interface wrapping erasure)
|
||||
// ---
|
||||
|
||||
func (r *limitedReader) Reread() {
|
||||
rr, ok := r.ReadCloser.(router.Rereader)
|
||||
rereader, ok := r.ReadCloser.(router.Rereader)
|
||||
if ok {
|
||||
rr.Reread()
|
||||
rereader.Reread()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Close() error {
|
||||
closer, ok := r.ReadCloser.(io.Closer)
|
||||
if ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,28 +108,6 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
//
|
||||
//nolint:unused
|
||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok || rateLimiters == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rt, ok := rateLimiters.GetOk(rtId)
|
||||
if !ok || rt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client, ok := rt.getClient(e.RealIP())
|
||||
if !ok || client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
|
||||
}
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
|
||||
switch rule.Audience {
|
||||
@@ -154,7 +132,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
}
|
||||
|
||||
rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter {
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, rule.Duration+1800)
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, 1800)
|
||||
})
|
||||
if rt == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
|
||||
@@ -311,30 +289,27 @@ func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
|
||||
}
|
||||
}
|
||||
|
||||
// @todo evaluate swiching to a more traditional fixed window or sliding window counter
|
||||
// implementations since some users complained that it is not intuitive (see #7329).
|
||||
// @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
|
||||
//
|
||||
// rateClient is a mixture of token bucket and fixed window rate limit strategies
|
||||
// that refills the allowance only after at least "interval" seconds
|
||||
// has elapsed since the last request.
|
||||
// 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
|
||||
interval int64 // in seconds
|
||||
lastConsume int64 // the time of the last consume
|
||||
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 since the lastConsume time.
|
||||
// hasExpired checks whether it has been at least minElapsed seconds after the last active window.
|
||||
// (usually used to perform periodic cleanup of staled instances).
|
||||
func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
return relativeNow-l.lastConsume > minElapsed
|
||||
return relativeNow-(l.start+l.interval) > minElapsed
|
||||
}
|
||||
|
||||
// consume decreases the current allowance with 1 (if not exhausted already).
|
||||
@@ -347,15 +322,14 @@ func (l *rateClient) consume() bool {
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
|
||||
// reset consumed counter
|
||||
if nowUnix-l.lastConsume >= l.interval {
|
||||
// reset
|
||||
if nowUnix-l.start >= l.interval {
|
||||
l.available = l.maxAllowed
|
||||
l.start = nowUnix
|
||||
}
|
||||
|
||||
if l.available > 0 {
|
||||
l.available--
|
||||
l.lastConsume = nowUnix
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
wait float64
|
||||
wait float64 // ms
|
||||
authenticated bool
|
||||
expectedStatus int
|
||||
}{
|
||||
@@ -85,10 +85,13 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/norate", 0, false, 200},
|
||||
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 700, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 1.1, false, 200},
|
||||
{"/rate/a", 1000, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
{"/rate/b", 1.1, false, 200},
|
||||
{"/rate/b", 1000, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
@@ -118,7 +121,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/guest", 0, false, 429},
|
||||
|
||||
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||
{"/rate/guest", 1.1, true, 200},
|
||||
{"/rate/guest", 1000, true, 200},
|
||||
{"/rate/guest", 0, true, 200},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
@@ -126,10 +129,6 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.url, func(t *testing.T) {
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Second)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", s.url, nil)
|
||||
|
||||
@@ -147,6 +146,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
@@ -224,6 +224,22 @@ func TestRequireAuth(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record auth token with Bearer case-insensitive prefix",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
// regular user
|
||||
"Authorization": "BeArEr eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireAuth())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -34,7 +34,7 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
|
||||
|
||||
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
|
||||
if err != nil {
|
||||
e.InternalServerError("Failed to generate static auth token", err)
|
||||
return e.InternalServerError("Failed to generate static auth token", err)
|
||||
}
|
||||
|
||||
return recordAuthResponse(e, record, token, "", nil)
|
||||
|
||||
@@ -34,6 +34,7 @@ type oauth2Response struct {
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Logo string `json:"logo"`
|
||||
State string `json:"state"`
|
||||
AuthURL string `json:"authURL"`
|
||||
|
||||
@@ -68,7 +69,14 @@ func (amr *authMethodsResponse) fillLegacyFields() {
|
||||
amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
|
||||
|
||||
if amr.OAuth2.Enabled {
|
||||
amr.AuthProviders = amr.OAuth2.Providers
|
||||
// clone without the logo
|
||||
legacyProviders := make([]providerInfo, len(amr.OAuth2.Providers))
|
||||
for i, p := range amr.OAuth2.Providers {
|
||||
legacyProviders[i] = p
|
||||
legacyProviders[i].Logo = ""
|
||||
}
|
||||
|
||||
amr.AuthProviders = legacyProviders
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +136,7 @@ func recordAuthMethods(e *core.RequestEvent) error {
|
||||
info := providerInfo{
|
||||
Name: config.Name,
|
||||
DisplayName: provider.DisplayName(),
|
||||
Logo: provider.Logo(),
|
||||
State: security.RandomString(30),
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
`"providers":[{`,
|
||||
`"name":"google"`,
|
||||
`"name":"gitlab"`,
|
||||
`"logo":"\u003csvg`,
|
||||
`"logo":""`, // for the legacy fields
|
||||
`"state":`,
|
||||
`"displayName":`,
|
||||
`"codeVerifier":`,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -18,6 +23,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -213,16 +219,31 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
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, collection.OAuth2.MappedFields.Username)
|
||||
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("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
expr = dbx.NewExp("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{"username": username}
|
||||
expr = dbx.HashExp{colName: username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
@@ -232,10 +253,7 @@ func oldCanAssignUsername(txApp core.App, collection *core.Collection, username
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that the value matches the pattern of the username field (if text)
|
||||
txtField, _ := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username).(*core.TextField)
|
||||
|
||||
return txtField != nil && txtField.ValidatePlainValue(username) == nil
|
||||
return true
|
||||
}
|
||||
|
||||
func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
|
||||
@@ -281,9 +299,12 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
|
||||
// download the avatar if the mapped field is a file
|
||||
avatarFile, err := func() (*filesystem.File, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return filesystem.NewFileFromURL(ctx, e.OAuth2User.AvatarURL)
|
||||
|
||||
// the extra checks are not required because the OAuth2 APIs are trusted vendor
|
||||
// but are here to minimize the impact in case the provider is vulnerable
|
||||
return safeFileFromURL(ctx, e.OAuth2User.AvatarURL)
|
||||
}()
|
||||
if err != nil {
|
||||
txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
|
||||
@@ -386,3 +407,88 @@ func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2R
|
||||
|
||||
return createdRecord, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,17 +29,12 @@ type oauth2RedirectData struct {
|
||||
}
|
||||
|
||||
func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
redirectStatusCode := http.StatusTemporaryRedirect
|
||||
if e.Request.Method != http.MethodGet {
|
||||
redirectStatusCode = http.StatusSeeOther
|
||||
}
|
||||
|
||||
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 e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
} else {
|
||||
query := e.Request.URL.Query()
|
||||
@@ -49,13 +45,13 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
|
||||
if data.State == "" {
|
||||
e.App.Logger().Debug("Missing OAuth2 state parameter")
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
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 e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
defer client.Unsubscribe(oauth2SubscriptionTopic)
|
||||
|
||||
@@ -76,7 +72,7 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
@@ -88,10 +84,36 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
|
||||
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 e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
|
||||
return successRedirect(e)
|
||||
}
|
||||
|
||||
func redirectStatusCode(e *core.RequestEvent) int {
|
||||
if e.Request.Method != http.MethodGet {
|
||||
return http.StatusSeeOther
|
||||
}
|
||||
|
||||
return http.StatusTemporaryRedirect
|
||||
}
|
||||
|
||||
func failureRedirect(e *core.RequestEvent) error {
|
||||
// fallback if UI is not bundled
|
||||
if ui.DistDirFS == nil {
|
||||
return e.String(http.StatusOK, "Failed to authenticate. You can close this window and go back to the app to try again.")
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode(e), oauth2RedirectFailurePath)
|
||||
}
|
||||
|
||||
func successRedirect(e *core.RequestEvent) error {
|
||||
// fallback if UI is not bundled
|
||||
if ui.DistDirFS == nil {
|
||||
return e.HTML(http.StatusOK, "Auth completed. You can close this window and go back to the app.")
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode(e), oauth2RedirectSuccessPath)
|
||||
}
|
||||
|
||||
// parseAndStoreAppleRedirectName extracts the first and last name
|
||||
|
||||
@@ -45,12 +45,14 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// start a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
localServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
buf := new(bytes.Buffer)
|
||||
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 server.Close()
|
||||
defer localServer.Close()
|
||||
|
||||
externalImageURL := "https://pocketbase.io/images/logo.svg"
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -1176,7 +1178,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
AvatarURL: externalImageURL,
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
@@ -1208,7 +1210,98 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"username":"oauth2_username"`,
|
||||
`"verified":true`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
`"avatar":"oauth2_avatar_`,
|
||||
`"avatar":"logo_`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 2, // the auth response and from the create request
|
||||
// ---
|
||||
"OnModelCreate": 3, // record + authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 3,
|
||||
"OnModelAfterCreateSuccess": 3,
|
||||
"OnRecordCreate": 3,
|
||||
"OnRecordCreateExecute": 3,
|
||||
"OnRecordAfterCreateSuccess": 3,
|
||||
// ---
|
||||
"OnModelUpdate": 1, // created record verified state change
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelValidate": 4,
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and local avatarURL->file field; ensures that safeHTTPClient is being used)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"name": "test_name",
|
||||
"emailVisibility": true,
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
}
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
usersCol, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
AvatarURL: localServer.URL + "/oauth2_avatar.png", // local/private file download is not allowed
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
|
||||
Username: "name", // should be ignored because of the explicit submitted value
|
||||
Id: "username",
|
||||
AvatarURL: "avatar",
|
||||
}
|
||||
if err := app.Save(usersCol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"verified":true`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
`"avatar":"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
@@ -1343,7 +1436,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
Email: "oauth2@example.com",
|
||||
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
|
||||
Name: "oauth2_name",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
AvatarURL: localServer.URL + "/oauth2_avatar.png", // allowed because it is not being downloaded
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
|
||||
}
|
||||
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumeration protection
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
|
||||
if err != nil {
|
||||
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
||||
|
||||
@@ -554,7 +554,7 @@ func firstApiError(errs ...error) *router.ApiError {
|
||||
return router.NewInternalServerError("", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction.
|
||||
// execAfterSuccessTx ensures that fn is executed only after a successful transaction.
|
||||
//
|
||||
// If the current app instance is not a transactional or checkTx is false,
|
||||
// then fn is directly executed.
|
||||
|
||||
+25
-15
@@ -22,6 +22,8 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
const defaultCSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' http://127.0.0.1:*; frame-src 'none'"
|
||||
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
@@ -77,21 +79,25 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
// ignore root path
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
// @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")
|
||||
}
|
||||
|
||||
// add a default CSP
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
|
||||
}
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
}
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
@@ -279,8 +285,12 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
|
||||
+7
-5
@@ -16,6 +16,8 @@ func bindSettingsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup.PATCH("", settingsSet)
|
||||
subGroup.POST("/test/s3", settingsTestS3)
|
||||
subGroup.POST("/test/email", settingsTestEmail)
|
||||
|
||||
// @todo move to collections
|
||||
subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret)
|
||||
}
|
||||
|
||||
@@ -62,12 +64,12 @@ func settingsSet(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("An error occurred while saving the new settings.", err)
|
||||
}
|
||||
|
||||
appSettings, err := e.App.Settings().Clone()
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to clone app settings.", err)
|
||||
}
|
||||
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
appSettings, err := e.App.Settings().Clone()
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to clone app settings.", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, appSettings)
|
||||
})
|
||||
})
|
||||
|
||||
+22
-2
@@ -97,8 +97,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
|
||||
validData := `{
|
||||
"meta":{"appName":"update_test"},
|
||||
"s3":{"secret": "s3_secret"},
|
||||
"backups":{"s3":{"secret":"backups_s3_secret"}}
|
||||
"smtp":{"password": "new_smtp_password"},
|
||||
"s3":{"secret": "new_s3_secret"},
|
||||
"backups":{"s3":{"secret":"new_backups_s3_secret"}}
|
||||
}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -179,6 +180,25 @@ func TestSettingsSet(t *testing.T) {
|
||||
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{
|
||||
`"meta":{`,
|
||||
|
||||
+20
-8
@@ -240,29 +240,41 @@ type App interface {
|
||||
// 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
|
||||
// "tableName" argument must come only from trusted input!
|
||||
DeleteTable(tableName string) error
|
||||
// "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
|
||||
// "name" argument must come only from trusted input!
|
||||
DeleteView(name string) error
|
||||
// "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 the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
SaveView(name string, selectQuery string) error
|
||||
// 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.
|
||||
CreateViewFields(selectQuery string) (FieldsList, error)
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
|
||||
|
||||
// DryRunView executes the provided query by creating a temporary view
|
||||
// collection and returning a sample of the resulting query records (if valid).
|
||||
//
|
||||
// The same caveats from CreateViewFields apply here too.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
|
||||
|
||||
+1
-1
@@ -1220,7 +1220,7 @@ var sqlLogReplacements = []struct {
|
||||
{regexp.MustCompile(`<nil>`), "NULL"},
|
||||
}
|
||||
|
||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||
// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
|
||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||
// (the normalization is done here to avoid breaking changes in dbx).
|
||||
func normalizeSQLLog(sql string) string {
|
||||
|
||||
+3
-3
@@ -69,7 +69,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// archive pb_data in a temp directory, exluding the "backups" and the temp dirs
|
||||
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
|
||||
//
|
||||
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
|
||||
// ---
|
||||
@@ -138,9 +138,9 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// 4. Move the extracted dir content to the app "pb_data".
|
||||
//
|
||||
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
|
||||
// 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If a failure occur during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
//
|
||||
// Note that if your pb_data has custom network mounts as subdirectories, then
|
||||
|
||||
@@ -545,7 +545,7 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
//
|
||||
// Note that non-type related fields are ignored from the serialization
|
||||
// (ex. for "view" colections the "auth" fields are skipped).
|
||||
// (ex. for "view" collections the "auth" fields are skipped).
|
||||
func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
switch m.Type {
|
||||
case CollectionTypeView:
|
||||
@@ -559,19 +559,26 @@ func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
collectionAuthOptions
|
||||
}{m.baseCollection, m.collectionAuthOptions}
|
||||
|
||||
// ensure that it is always returned as array
|
||||
if alias.OAuth2.Providers == nil {
|
||||
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
|
||||
}
|
||||
|
||||
// @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 = ""
|
||||
for i := range alias.OAuth2.Providers {
|
||||
alias.OAuth2.Providers[i].ClientSecret = ""
|
||||
|
||||
if alias.OAuth2.Providers == nil {
|
||||
// ensure that it is always returned as array
|
||||
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
|
||||
} else {
|
||||
// create a deep copy of the slice to avoid modifying the cached model state
|
||||
redactedProviders := make([]OAuth2ProviderConfig, len(alias.OAuth2.Providers))
|
||||
copy(redactedProviders, alias.OAuth2.Providers)
|
||||
for i := range redactedProviders {
|
||||
redactedProviders[i].ClientSecret = ""
|
||||
}
|
||||
alias.OAuth2.Providers = redactedProviders
|
||||
}
|
||||
|
||||
return json.Marshal(alias)
|
||||
|
||||
@@ -760,6 +760,46 @@ func TestCollectionSerialize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionSerializeNotModifyingCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
c, err := app.FindCachedCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
redactedFields := map[string]string{
|
||||
"AuthToken.Secret": c.AuthToken.Secret,
|
||||
"FileToken.Secret": c.FileToken.Secret,
|
||||
"PasswordResetToken.Secret": c.PasswordResetToken.Secret,
|
||||
"EmailChangeToken.Secret": c.EmailChangeToken.Secret,
|
||||
"VerificationToken.Secret": c.VerificationToken.Secret,
|
||||
}
|
||||
|
||||
if len(c.OAuth2.Providers) == 0 {
|
||||
t.Fatal("Expected at least one users OAuth2 provider, got 0")
|
||||
}
|
||||
for _, p := range c.OAuth2.Providers {
|
||||
redactedFields[p.Name+".ClientSecret"] = p.ClientSecret
|
||||
}
|
||||
|
||||
for k, v := range redactedFields {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
if v == "" {
|
||||
t.Fatalf("Expected the redacted field %q to remain unmodified after serialization, got empty value", k)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionDBExport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -777,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
"unknown",
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeBase,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeView,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeAuth,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1536,60 +1576,62 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
|
||||
viewName := "test_wrapping"
|
||||
|
||||
// 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 - 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 id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select 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 - 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",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
|
||||
"select rel_one as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"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",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
|
||||
"select email as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"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",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
|
||||
"select url as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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))",
|
||||
"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",
|
||||
"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))",
|
||||
"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",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
|
||||
"select json as id, text, url from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
|
||||
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -314,11 +314,15 @@ func (cv *collectionValidator) checkViewQuery(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if _, err := cv.app.CreateViewFields(v); err != nil {
|
||||
return validation.NewError(
|
||||
"validation_invalid_view_query",
|
||||
fmt.Sprintf("Invalid query - %s", err.Error()),
|
||||
)
|
||||
_, 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
|
||||
|
||||
+3
-3
@@ -85,11 +85,11 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(tableName string) error {
|
||||
// "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}}",
|
||||
tableName,
|
||||
dangerousTableName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -57,6 +58,9 @@ type baseCollectionEventData struct {
|
||||
Collection *Collection
|
||||
}
|
||||
|
||||
// @todo consider storing the original collection name and use that as a tag
|
||||
// to avoid the ambiguity when the collection is being modified (#7613);
|
||||
// for new collection also maybe return empty tags?
|
||||
func (e *baseCollectionEventData) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
@@ -125,6 +129,21 @@ type ServeEvent struct {
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
|
||||
) 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)
|
||||
|
||||
@@ -46,12 +46,12 @@ type AutodateField struct {
|
||||
// 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"`
|
||||
|
||||
|
||||
+6
-1
@@ -36,11 +36,15 @@ type BoolField struct {
|
||||
// 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"`
|
||||
@@ -120,5 +124,6 @@ func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,4 +147,5 @@ func TestBoolFieldValidateValue(t *testing.T) {
|
||||
func TestBoolFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldHelpValidation[core.BoolField](t)
|
||||
}
|
||||
|
||||
+6
-1
@@ -36,11 +36,15 @@ type DateField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
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))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestDateFieldValidateValue(t *testing.T) {
|
||||
func TestDateFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldHelpValidation[core.DateField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -41,11 +41,15 @@ type EditorField struct {
|
||||
// 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).
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ func TestEditorFieldValidateValue(t *testing.T) {
|
||||
func TestEditorFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldHelpValidation[core.EditorField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+6
-1
@@ -39,11 +39,15 @@ type EmailField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -155,6 +159,7 @@ func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *
|
||||
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)),
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestEmailFieldValidateValue(t *testing.T) {
|
||||
func TestEmailFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldHelpValidation[core.EmailField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+7
-2
@@ -47,7 +47,7 @@ var (
|
||||
// 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 *filesytem.File.
|
||||
// New files (aka. files to upload) are expected to be of *filesystem.File.
|
||||
//
|
||||
// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
|
||||
//
|
||||
@@ -88,11 +88,15 @@ type FileField struct {
|
||||
// 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).
|
||||
//
|
||||
@@ -223,6 +227,7 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
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(
|
||||
|
||||
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
|
||||
func TestFileFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldHelpValidation[core.FileField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -46,11 +46,15 @@ type GeoPointField struct {
|
||||
// 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"`
|
||||
@@ -144,5 +148,6 @@ func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,4 +199,5 @@ func TestGeoPointFieldValidateValue(t *testing.T) {
|
||||
func TestGeoPointFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldHelpValidation[core.GeoPointField](t)
|
||||
}
|
||||
|
||||
+6
-1
@@ -45,11 +45,15 @@ type JSONField struct {
|
||||
// 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).
|
||||
//
|
||||
@@ -181,6 +185,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldHelpValidation[core.JSONField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -48,11 +48,15 @@ type NumberField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -173,6 +177,7 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
|
||||
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...),
|
||||
)
|
||||
|
||||
@@ -214,6 +214,7 @@ func TestNumberFieldValidateValue(t *testing.T) {
|
||||
func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeNumber)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeNumber)
|
||||
testDefaultFieldHelpValidation[core.NumberField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -61,11 +61,17 @@ type PasswordField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -209,6 +215,7 @@ func (f *PasswordField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
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)),
|
||||
|
||||
@@ -287,6 +287,7 @@ func TestPasswordFieldValidateValue(t *testing.T) {
|
||||
func TestPasswordFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypePassword)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypePassword)
|
||||
testDefaultFieldHelpValidation[core.PasswordField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -66,11 +66,15 @@ type RelationField struct {
|
||||
// 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"`
|
||||
@@ -237,6 +241,7 @@ func (f *RelationField) ValidateSettings(ctx context.Context, app App, collectio
|
||||
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)),
|
||||
|
||||
@@ -348,6 +348,7 @@ func TestRelationFieldValidateValue(t *testing.T) {
|
||||
func TestRelationFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeRelation)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeRelation)
|
||||
testDefaultFieldHelpValidation[core.RelationField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -66,11 +66,15 @@ type SelectField struct {
|
||||
// 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"`
|
||||
@@ -216,6 +220,7 @@ func (f *SelectField) ValidateSettings(ctx context.Context, app App, collection
|
||||
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)),
|
||||
)
|
||||
|
||||
@@ -337,6 +337,7 @@ func TestSelectFieldValidateValue(t *testing.T) {
|
||||
func TestSelectFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeSelect)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeSelect)
|
||||
testDefaultFieldHelpValidation[core.SelectField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+61
-2
@@ -2,6 +2,8 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -113,7 +115,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
||||
|
||||
hasErr := errs["id"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -254,7 +256,64 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
||||
|
||||
hasErr := errs["name"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testDefaultFieldHelpValidation[T any](t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test_collection")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
json string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty value",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"< max limit",
|
||||
`{"help":"abc"}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"= max limit",
|
||||
`{"help":"` + strings.Repeat("a", 300) + `"}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"> max limit",
|
||||
`{"help":"` + strings.Repeat("a", 301) + `"}`,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run("[help] "+s.name, func(t *testing.T) {
|
||||
var zeroField T
|
||||
|
||||
field, ok := reflect.New(reflect.TypeOf(zeroField)).Interface().(core.Field)
|
||||
if !ok {
|
||||
t.Fatalf("Expected core.Field instance, got %T", zeroField)
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(s.json), &field)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
errs, _ := field.ValidateSettings(context.Background(), app, collection).(validation.Errors)
|
||||
|
||||
hasErr := errs["help"] != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+6
-1
@@ -72,11 +72,15 @@ type TextField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -283,6 +287,7 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
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)),
|
||||
|
||||
@@ -381,6 +381,7 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
func TestTextFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeText)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeText)
|
||||
testDefaultFieldHelpValidation[core.TextField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+6
-1
@@ -39,11 +39,15 @@ type URLField struct {
|
||||
// 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.
|
||||
//
|
||||
@@ -156,6 +160,7 @@ func (f *URLField) ValidateSettings(ctx context.Context, app App, collection *Co
|
||||
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)),
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestURLFieldValidateValue(t *testing.T) {
|
||||
func TestURLFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeURL)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeURL)
|
||||
testDefaultFieldHelpValidation[core.URLField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
|
||||
return extractedFields, nil
|
||||
}
|
||||
|
||||
// try to unmarshal first into a new fieds list
|
||||
// try to unmarshal first into a new fields list
|
||||
// (assuming that rawJSON is array of objects)
|
||||
err := json.Unmarshal(rawJSON, &extractedFields)
|
||||
if err != nil {
|
||||
|
||||
@@ -473,13 +473,13 @@ func TestFieldsListScan(t *testing.T) {
|
||||
"only the minimum field options",
|
||||
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
},
|
||||
{
|
||||
"all field options",
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -523,13 +523,13 @@ func TestFieldsListJSON(t *testing.T) {
|
||||
"only the minimum field options",
|
||||
`[{"id":"123","name":"test1","type":"text","required":true},{"id":"456","name":"test2","type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"","hidden":false,"id":"123","max":0,"min":0,"name":"test1","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":false,"type":"bool"}]`,
|
||||
},
|
||||
{
|
||||
"all field options",
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
false,
|
||||
`[{"autogeneratePattern":"","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
`[{"autogeneratePattern":"","help":"abc","hidden":true,"id":"123","max":12,"min":0,"name":"test1","pattern":"","presentable":true,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"def","hidden":false,"id":"456","name":"test2","presentable":false,"required":false,"system":true,"type":"bool"}]`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ const (
|
||||
changedModifier string = "changed"
|
||||
)
|
||||
|
||||
type ruleJoin struct {
|
||||
collection *Collection
|
||||
tableAlias string
|
||||
}
|
||||
|
||||
// ensure that `search.FieldResolver` interface is implemented
|
||||
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
|
||||
@@ -51,8 +56,8 @@ type RecordFieldResolver struct {
|
||||
joins []*search.Join
|
||||
allowHiddenFields bool
|
||||
// ---
|
||||
listRuleJoins map[string]*Collection // tableAlias->collection
|
||||
joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
|
||||
listRuleJoins []ruleJoin
|
||||
joinAliasSuffix string // used for uniqueness in the flatten collection list rule join
|
||||
baseCollectionAlias string
|
||||
}
|
||||
|
||||
@@ -141,8 +146,8 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
|
||||
// note: for now the joins are not applied for multi-match conditions to avoid excessive checks
|
||||
if len(r.listRuleJoins) > 0 {
|
||||
for alias, c := range r.listRuleJoins {
|
||||
err := r.updateQueryWithCollectionListRule(c, alias, query)
|
||||
for _, join := range r.listRuleJoins {
|
||||
err := r.updateQueryWithCollectionListRule(join.collection, join.tableAlias, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,11 +169,28 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
|
||||
cloneR.allowHiddenFields = true
|
||||
cloneR.joinAliasSuffix = security.PseudorandomString(8)
|
||||
|
||||
expr, err := search.FilterData(*c.ListRule).BuildExpr(&cloneR)
|
||||
// The extra "id='' || (\nRULE\n)" concatenated part on its own
|
||||
// doesn't make much sense because all records are required to have an id,
|
||||
// but it is necessary to properly resolve client-side filters when
|
||||
// referencing missing relations (the "\n" is for leading and trailing comments).
|
||||
//
|
||||
// Consider the client-side filter: "a.name != '' || b.name != ''",
|
||||
// where both "a" and "b" ref collections have non-empty ListRule.
|
||||
// Without the empty check the query will always evaluate to FALSE
|
||||
// when one of the "a" or "b" relation fields are empty,
|
||||
// even if for example "a.name != ''" is true.
|
||||
expr, err := search.FilterData("id='' || (\n" + *c.ListRule + "\n)").BuildExpr(&cloneR)
|
||||
if err != nil {
|
||||
return fmt.Errorf("to buld %q list rule join subquery filter expression: %w", c.Name, err)
|
||||
return fmt.Errorf("failed to build %q ListRule join subquery filter expression: %w", c.Name, err)
|
||||
}
|
||||
|
||||
// Bind the extra rule expression at the top query level for performance and security reasons
|
||||
// (it is more strict and minimizes the risk of data disclosure from a side-channel attack).
|
||||
//
|
||||
// @todo Investigate with the refactoring if there is a way to group it
|
||||
// together with the client-side constraint that invoked it and benchmark
|
||||
// it with many (tag.name="1"||...) like statements to evaluate the impact of applying the check many times.
|
||||
// If not feasible - document it as caveat and maybe add --dev log.
|
||||
query.AndWhere(expr)
|
||||
|
||||
if len(cloneR.joins) > 0 {
|
||||
@@ -188,7 +210,7 @@ func (r *RecordFieldResolver) updateQueryWithCollectionListRule(c *Collection, t
|
||||
func (r *RecordFieldResolver) updateQueryWithDeduplicateConstraint(query *dbx.SelectQuery) {
|
||||
query.Distinct(true)
|
||||
|
||||
// @todo Reasearch better options for generic rows deduplication.
|
||||
// @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)
|
||||
@@ -326,7 +348,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
// no further processing is needed...
|
||||
default:
|
||||
// non-plain value
|
||||
// try casting to string (in case for exampe fmt.Stringer is implemented)
|
||||
// try casting to string (in case for example fmt.Stringer is implemented)
|
||||
val, castErr := cast.ToStringE(v)
|
||||
|
||||
// if that doesn't work, try encoding it
|
||||
@@ -393,10 +415,7 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
|
||||
return fmt.Errorf("%q fields can be accessed only when allowHiddenFields is enabled or by superusers", c.Name)
|
||||
}
|
||||
|
||||
if r.listRuleJoins == nil {
|
||||
r.listRuleJoins = map[string]*Collection{}
|
||||
}
|
||||
r.listRuleJoins[newJoin.TableAlias] = c
|
||||
r.registerRuleJoin(c, newJoin.TableAlias)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +432,19 @@ func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
"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 (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo4_rel_one_cascade.created]] > 1)",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `demo4_rel_one_cascade` ON [[demo4_rel_one_cascade.id]] = [[demo4.rel_one_cascade]] WHERE ((([[demo4_rel_one_cascade.id]] = '' OR [[demo4_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_rel_one_cascade.created]] > 1)",
|
||||
},
|
||||
{
|
||||
"rel to collection with non-empty list rule (with allowHiddenFields)",
|
||||
@@ -208,14 +208,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
"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 (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo4_self_rel_one_rel_one_cascade.created]] > 1)",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo4_self_rel_one_rel_one_cascade.created]] > 1)",
|
||||
},
|
||||
{
|
||||
"nested rels with non-empty list rule (joins reuse test)",
|
||||
"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 (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (([[demo4_self_rel_one_rel_one_cascade.created]] > 1 AND [[demo4_self_rel_one_rel_one_cascade.updated]] > 1))",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN `demo3` `demo4_self_rel_one_rel_one_cascade` ON [[demo4_self_rel_one_rel_one_cascade.id]] = [[demo4_self_rel_one.rel_one_cascade]] WHERE ((([[demo4_self_rel_one_rel_one_cascade.id]] = '' OR [[demo4_self_rel_one_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (([[demo4_self_rel_one_rel_one_cascade.created]] > 1 AND [[demo4_self_rel_one_rel_one_cascade.updated]] > 1))",
|
||||
},
|
||||
{
|
||||
"nested rels with non-empty list rule (with allowHiddenFields)",
|
||||
@@ -341,7 +341,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
"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 (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND ([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1)",
|
||||
"SELECT DISTINCT `demo3`.* FROM `demo3` LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade` ON [[demo3.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade}}) LEFT JOIN `demo3` `demo3_demo4_via_rel_many_cascade_rel_one_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = [[demo3_demo4_via_rel_many_cascade.rel_one_cascade]] LEFT JOIN `demo4` `demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade` ON [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IN (SELECT [[__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.value]] FROM json_each(CASE WHEN iif(json_valid([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]), json_type([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]])='array', FALSE) THEN [[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]] ELSE json_array([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.rel_many_cascade]]) END) {{__je_demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade}}) WHERE ((([[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] = '' OR [[demo3_demo4_via_rel_many_cascade_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ([[demo3_demo4_via_rel_many_cascade_rel_one_cascade_demo4_via_rel_many_cascade.id]] = 1)",
|
||||
},
|
||||
{
|
||||
"recursive back relations with non-empty list rule (with allowHiddenFields)",
|
||||
@@ -425,7 +425,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
"demo4",
|
||||
"@collection.demo3.title > true",
|
||||
false,
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))))",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__collection_demo3` WHERE ((([[__collection_demo3.id]] = '' OR [[__collection_demo3.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND (((([[__collection_demo3.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___collection_demo3.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__mm___collection_demo3` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1))))))",
|
||||
},
|
||||
{
|
||||
"collection filter in a non-empty list rule collection (with allowHiddenFields)",
|
||||
@@ -495,7 +495,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
// 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 ((({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST})) AND (({:TEST} IS NOT '' AND {:TEST} IS NOT {:TEST}))) AND (([[__data_demo3_rel_one_cascade.title]] > 1 AND [[__data_demo3_rel_one_no_cascade.title]] < 1 AND (([[__data_demo4_self_rel_many.title]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___data_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm___data_demo4_self_rel_many` ON [[__mm___data_demo4_self_rel_many.id]]={:p11} WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))))",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_one_no_cascade` ON [[__data_demo3_rel_one_no_cascade.id]]={:p1} LEFT JOIN `demo4` `__data_demo4_self_rel_many` ON [[__data_demo4_self_rel_many.id]]={:p2} WHERE (((([[__data_demo3_rel_one_cascade.id]] = '' OR [[__data_demo3_rel_one_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST}))) AND ((([[__data_demo3_rel_one_no_cascade.id]] = '' OR [[__data_demo3_rel_one_no_cascade.id]] IS NULL) OR ({:fTEST} IS NOT '' AND {:fTEST} IS NOT {:tTEST})))) AND (([[__data_demo3_rel_one_cascade.title]] > 1 AND [[__data_demo3_rel_one_no_cascade.title]] < 1 AND (([[__data_demo4_self_rel_many.title]] = 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm___data_demo4_self_rel_many.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm___data_demo4_self_rel_many` ON [[__mm___data_demo4_self_rel_many.id]]={:p13} WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] = 1))))))",
|
||||
},
|
||||
{
|
||||
"@request.body.arrayble:each fields",
|
||||
@@ -778,7 +778,7 @@ func TestRecordFieldResolverResolveCollectionFields(t *testing.T) {
|
||||
{"@request.auth.demo1_via_file_one.id", false, "NULL"}, // not a relation field
|
||||
{"@request.auth.demo1_via_rel_one.id", false, "NULL"}, // relation field but to a different collection
|
||||
|
||||
// @collection fieds
|
||||
// @collection fields
|
||||
{"@collect", true, ""},
|
||||
{"collection.demo4.title", true, ""},
|
||||
{"@collection", true, ""},
|
||||
|
||||
@@ -983,7 +983,7 @@ func (m *Record) GetStringSlice(key string) []string {
|
||||
}
|
||||
|
||||
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
|
||||
// (aka. the current [*filesytem.File] values) so that you can apply further
|
||||
// (aka. the current [*filesystem.File] values) so that you can apply further
|
||||
// validations or modifications (including changing the file name or content before persisting).
|
||||
//
|
||||
// Example:
|
||||
|
||||
+32
-1
@@ -143,6 +143,7 @@ func newDefaultSettings() *Settings {
|
||||
isNew: true,
|
||||
settings: settings{
|
||||
Meta: MetaConfig{
|
||||
AccentColor: "#1055c9",
|
||||
AppName: "Acme",
|
||||
AppURL: "http://localhost:8090",
|
||||
HideControls: false,
|
||||
@@ -327,6 +328,8 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
|
||||
copy := s.settings
|
||||
s.mu.RUnlock()
|
||||
|
||||
copy.SMTP.hidePassword = true
|
||||
|
||||
sensitiveFields := []*string{
|
||||
©.SMTP.Password,
|
||||
©.S3.Secret,
|
||||
@@ -346,11 +349,17 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
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,omitempty"`
|
||||
Password string `form:"password" json:"password"`
|
||||
|
||||
// SMTP AUTH - PLAIN (default) or LOGIN
|
||||
AuthMethod string `form:"authMethod" json:"authMethod"`
|
||||
@@ -392,6 +401,22 @@ func (c SMTPConfig) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
func (c SMTPConfig) MarshalJSON() ([]byte, error) {
|
||||
type alias SMTPConfig
|
||||
|
||||
if c.hidePassword {
|
||||
v := struct {
|
||||
alias
|
||||
Password string `json:"password,omitempty"`
|
||||
}{alias(c), ""}
|
||||
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
return json.Marshal(alias(c))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type S3Config struct {
|
||||
@@ -489,6 +514,11 @@ func checkCronExpression(value any) error {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
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"`
|
||||
@@ -499,6 +529,7 @@ type MetaConfig struct {
|
||||
// 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)),
|
||||
validation.Field(&c.AppURL, validation.Required, is.URL),
|
||||
validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)),
|
||||
|
||||
+100
-1
@@ -3,6 +3,7 @@ package core_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"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) {
|
||||
@@ -24,7 +26,75 @@ func TestSettingsDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_DBExport(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
encryption bool
|
||||
}{
|
||||
{"no encryption", false},
|
||||
{"with encryption", true},
|
||||
}
|
||||
|
||||
encryptionKey := strings.Repeat("a", 32)
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
originalEnv := os.Getenv(app.EncryptionEnv())
|
||||
defer func() {
|
||||
os.Setenv(app.EncryptionEnv(), originalEnv)
|
||||
}()
|
||||
|
||||
settings := &core.Settings{}
|
||||
settings.Meta.AppName = "test_app_name"
|
||||
settings.Logs.MaxDays = 123
|
||||
settings.SMTP.Host = "smtp_host"
|
||||
settings.SMTP.Username = "smtp_username"
|
||||
settings.SMTP.Password = "" // ensures that empty password is exported
|
||||
settings.S3.Endpoint = "s3_endpoint"
|
||||
settings.S3.Secret = "s3_secret"
|
||||
settings.Backups.Cron = "* * * * *"
|
||||
settings.Backups.S3.Enabled = true
|
||||
settings.Backups.S3.Secret = ""
|
||||
settings.Batch.Timeout = 15
|
||||
settings.RateLimits.Enabled = true
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
|
||||
if s.encryption {
|
||||
os.Setenv(app.EncryptionEnv(), encryptionKey)
|
||||
}
|
||||
|
||||
export, err := settings.DBExport(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var valueStr string
|
||||
|
||||
if s.encryption {
|
||||
decrypted, err := security.Decrypt(export["value"].(string), encryptionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt test value: %v", err)
|
||||
}
|
||||
|
||||
valueStr = string(decrypted)
|
||||
} else {
|
||||
valueStr = string(export["value"].([]byte))
|
||||
}
|
||||
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
if valueStr != expected {
|
||||
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMerge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s1 := &core.Settings{}
|
||||
s1.Meta.AppURL = "app_url" // should be unset
|
||||
|
||||
@@ -58,6 +128,8 @@ func TestSettingsMerge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s1 := &core.Settings{}
|
||||
s1.Meta.AppName = "test_name"
|
||||
|
||||
@@ -88,6 +160,8 @@ func TestSettingsClone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsMarshalJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
settings := &core.Settings{}
|
||||
|
||||
// control fields
|
||||
@@ -106,7 +180,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||
|
||||
if rawStr != expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
||||
@@ -162,6 +236,8 @@ func TestSettingsValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMetaConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.MetaConfig
|
||||
@@ -180,12 +256,14 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
{
|
||||
"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",
|
||||
@@ -195,6 +273,7 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
{
|
||||
"valid data",
|
||||
core.MetaConfig{
|
||||
AccentColor: "#ffffff",
|
||||
AppName: "test",
|
||||
AppURL: "https://example.com",
|
||||
SenderName: "test",
|
||||
@@ -214,6 +293,8 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.LogsConfig
|
||||
@@ -246,6 +327,8 @@ func TestLogsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSMTPConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.SMTPConfig
|
||||
@@ -305,6 +388,8 @@ func TestSMTPConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3ConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.S3Config
|
||||
@@ -376,6 +461,8 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.BackupsConfig
|
||||
@@ -431,6 +518,8 @@ func TestBackupsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBatchConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.BatchConfig
|
||||
@@ -486,6 +575,8 @@ func TestBatchConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitsConfigValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config core.RateLimitsConfig
|
||||
@@ -631,6 +722,8 @@ func TestRateLimitsConfigValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := core.RateLimitsConfig{
|
||||
Rules: []core.RateLimitRule{
|
||||
{Label: "abc"},
|
||||
@@ -685,6 +778,8 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
rule core.RateLimitRule
|
||||
@@ -792,6 +887,8 @@ func TestRateLimitRuleValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleDurationTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
rule core.RateLimitRule
|
||||
expected time.Duration
|
||||
@@ -812,6 +909,8 @@ func TestRateLimitRuleDurationTime(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimitRuleString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
rule core.RateLimitRule
|
||||
|
||||
+143
-32
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -19,11 +20,11 @@ import (
|
||||
// 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
|
||||
// "name" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteView(name string) error {
|
||||
// "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}}",
|
||||
name,
|
||||
dangerousViewName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
@@ -31,39 +32,37 @@ func (app *BaseApp) DeleteView(name string) error {
|
||||
|
||||
// SaveView creates (or updates already existing) persistent SQL view.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) SaveView(name string, selectQuery string) error {
|
||||
// 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)
|
||||
if err := txApp.DeleteView(name); err != nil {
|
||||
err := txApp.DeleteView(dangerousViewName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
|
||||
// try to loosely detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(selectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
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)", name, selectQuery)
|
||||
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
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(name); err != nil {
|
||||
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(name)
|
||||
txApp.DeleteView(dangerousViewName)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -77,18 +76,21 @@ func (app *BaseApp) SaveView(name string, selectQuery string) error {
|
||||
// There are some caveats:
|
||||
// - The select query must have an "id" column.
|
||||
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
|
||||
func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, error) {
|
||||
result := NewFieldsList()
|
||||
|
||||
suggestedFields, err := parseQueryToFields(app, selectQuery)
|
||||
suggestedFields, err := parseQueryToFields(app, dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// note wrap in a transaction in case the selectQuery contains
|
||||
// note wrap in a transaction in case the dangerousSelectQuery contains
|
||||
// multiple statements allowing us to rollback on any error
|
||||
txErr := app.RunInTransaction(func(txApp App) error {
|
||||
info, err := getQueryTableInfo(txApp, selectQuery)
|
||||
info, err := getQueryTableInfo(txApp, dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,6 +123,76 @@ func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
|
||||
return result, txErr
|
||||
}
|
||||
|
||||
type DryRunViewResult struct {
|
||||
Fields FieldsList `json:"fields"`
|
||||
Sample []*Record `json:"sample"`
|
||||
}
|
||||
|
||||
// DryRunView executes the provided query by creating a temporary view
|
||||
// collection and returning a sample of the resulting query records (if valid).
|
||||
//
|
||||
// The same caveats from CreateViewFields apply here too.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
func (app *BaseApp) DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error) {
|
||||
dangerousSelectQuery, err := normalizeViewSelectQuery(dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields, err := app.CreateViewFields(dangerousSelectQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tempName := "temp_view_" + security.RandomString(5)
|
||||
tempCollection := NewViewCollection(tempName)
|
||||
tempCollection.Fields = fields
|
||||
|
||||
// validate generated view fields
|
||||
ctx := context.Background()
|
||||
for i, f := range fields {
|
||||
err = f.ValidateSettings(ctx, app, tempCollection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid field %q (%d): %w", f.GetName(), i, err)
|
||||
}
|
||||
}
|
||||
|
||||
records := []*Record{}
|
||||
|
||||
err = app.RecordQuery(tempCollection).
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution
|
||||
From("(SELECT * FROM (" + dangerousSelectQuery + ")) as " + tempName).
|
||||
Limit(int64(sampleSize)).
|
||||
All(&records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve query records: %w", err)
|
||||
}
|
||||
|
||||
// warn for possible empty or duplicated record ids found in the sample
|
||||
// (it is not intended for security and it is here to quickly provide a
|
||||
// helpful error message without doing multiple query executions)
|
||||
ids := make(map[string]struct{}, len(records))
|
||||
for _, r := range records {
|
||||
if r.Id == "" {
|
||||
return nil, errors.New("the query could return records with empty or invalid ids")
|
||||
}
|
||||
|
||||
if _, ok := ids[r.Id]; ok {
|
||||
return nil, errors.New("the query could return records with non-unique ids")
|
||||
}
|
||||
|
||||
ids[r.Id] = struct{}{}
|
||||
}
|
||||
|
||||
return &DryRunViewResult{
|
||||
Fields: fields,
|
||||
Sample: records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
|
||||
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
|
||||
@@ -195,6 +267,20 @@ func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fi
|
||||
// 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
|
||||
@@ -209,12 +295,26 @@ type queryField struct {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -242,19 +342,22 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
||||
// pk (always assume text field for now)
|
||||
if col.alias == FieldNameId {
|
||||
result[col.alias] = &queryField{
|
||||
field: &TextField{
|
||||
Name: col.alias,
|
||||
System: true,
|
||||
Required: true,
|
||||
PrimaryKey: true,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
},
|
||||
field: defaultViewIdField(),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// numeric aggregations
|
||||
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
|
||||
if strings.HasPrefix(colLower, "count(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
OnlyInt: true,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(colLower, "total(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &NumberField{
|
||||
Name: col.alias,
|
||||
@@ -265,16 +368,24 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
||||
|
||||
castMatch := castRegex.FindStringSubmatch(colLower)
|
||||
|
||||
// numeric casts
|
||||
// casts
|
||||
if len(castMatch) == 2 {
|
||||
switch castMatch[1] {
|
||||
case "real", "integer", "int", "decimal", "numeric":
|
||||
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{
|
||||
|
||||
+182
-1
@@ -326,7 +326,7 @@ func TestCreateViewFields(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with multiple froms, joins and style of aliasses",
|
||||
"query with multiple froms, joins and style of aliases",
|
||||
`
|
||||
select
|
||||
a.id as id,
|
||||
@@ -545,6 +545,61 @@ func TestCreateViewFields(t *testing.T) {
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewFieldsWithNumberOnlyInt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
sql := `select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
total(a.id) total,
|
||||
cast(a.id as int) cast_int,
|
||||
cast(a.id as integer) cast_integer,
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric
|
||||
from demo1 a`
|
||||
|
||||
result, err := app.CreateViewFields(sql)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
onlyInts := map[string]bool{
|
||||
"count": true,
|
||||
"total": false,
|
||||
"cast_int": true,
|
||||
"cast_integer": true,
|
||||
"cast_real": false,
|
||||
"cast_decimal": false,
|
||||
"cast_numeric": false,
|
||||
}
|
||||
|
||||
totalExpected := len(onlyInts) + 1
|
||||
if total := len(result); total != totalExpected {
|
||||
t.Fatalf("Expected %d, got %d", totalExpected, total)
|
||||
}
|
||||
|
||||
for _, f := range result {
|
||||
if f.GetName() == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(f.GetName(), func(t *testing.T) {
|
||||
nf, ok := f.(*core.NumberField)
|
||||
if !ok {
|
||||
t.Fatalf("Expected *core.NumberField, got %v", f)
|
||||
}
|
||||
|
||||
if nf.OnlyInt != onlyInts[nf.Name] {
|
||||
t.Fatalf("Expected OnlyInt %v, got %v", onlyInts[nf.Name], nf.OnlyInt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -677,3 +732,129 @@ func TestFindRecordByViewFile(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
sampleSize int
|
||||
expectError bool
|
||||
expectFields map[string]string // name-type pairs
|
||||
expectSampleIds []string // record ids of the resulting sample
|
||||
}{
|
||||
{
|
||||
"empty query",
|
||||
"",
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"non-select query",
|
||||
"CREATE TABLE t1(x INT)",
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"multiple inline select statements",
|
||||
"select 'a' as id; select 'b' as id",
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"select with invalid formatted field name",
|
||||
"select 'a' as id, count(*)", // missing field alias
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"select resolving to records with missing id",
|
||||
"(select 'a' as id UNION ALL select null as id UNION ALL select 'c' as id)",
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"select resolving to records with duplicated ids",
|
||||
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
|
||||
10,
|
||||
true,
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"no sample size and valid select query but with invalid records result",
|
||||
"(select 'a' as id UNION ALL select 'a' as id UNION ALL select 'c' as id)",
|
||||
0,
|
||||
false, // still "valid" because there is no sample to check
|
||||
map[string]string{"id": "text"},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"sample size < total select records",
|
||||
"(select 'a' as id UNION ALL select 'b' as id UNION ALL select 'c' as id UNION ALL select 'd' as id)",
|
||||
3,
|
||||
false,
|
||||
map[string]string{"id": "text"},
|
||||
[]string{"a", "b", "c"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, err := app.DryRunView(s.query, s.sampleSize)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
// check fields
|
||||
// ---
|
||||
if len(s.expectFields) != len(result.Fields) {
|
||||
serialized, _ := json.Marshal(result.Fields)
|
||||
t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized)
|
||||
}
|
||||
for name, typ := range s.expectFields {
|
||||
field := result.Fields.GetByName(name)
|
||||
if field == nil {
|
||||
t.Fatalf("Expected to find field %s, got nil", name)
|
||||
}
|
||||
|
||||
if field.Type() != typ {
|
||||
t.Fatalf("Expected field %s to be %q, got %q", name, typ, field.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// check sample ids
|
||||
// ---
|
||||
if len(s.expectSampleIds) != len(result.Sample) {
|
||||
t.Fatalf("Expected %d sample records, got %d", len(s.expectSampleIds), len(result.Sample))
|
||||
}
|
||||
for i, r := range result.Sample {
|
||||
if s.expectSampleIds[i] != r.Id {
|
||||
t.Fatalf("Expected sample record id %q, got %q at %d", s.expectSampleIds[i], r.Id, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"password", "passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and invalid form data (without manager acess)",
|
||||
name: "new auth collection record with invalid record and invalid form data (without manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
data: map[string]any{
|
||||
"verified": true,
|
||||
@@ -552,7 +552,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"verified", "passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and valid form data (without manager acess)",
|
||||
name: "new auth collection record with invalid record and valid form data (without manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
data: map[string]any{
|
||||
"verified": false,
|
||||
@@ -570,7 +570,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"password", "username"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and invalid form data (with manager acess)",
|
||||
name: "new auth collection record with invalid record and invalid form data (with manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
managerAccess: true,
|
||||
data: map[string]any{
|
||||
@@ -589,7 +589,7 @@ func TestRecordUpsertSubmitValidations(t *testing.T) {
|
||||
expectedErrors: []string{"passwordConfirm"},
|
||||
},
|
||||
{
|
||||
name: "new auth collection record with invalid record and valid form data (with manager acess)",
|
||||
name: "new auth collection record with invalid record and valid form data (with manager access)",
|
||||
record: core.NewRecord(usersCol),
|
||||
managerAccess: true,
|
||||
data: map[string]any{
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
module github.com/pocketbase/pocketbase
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/ganigeorgiev/fexpr v0.5.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
modernc.org/sqlite v1.44.3
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
modernc.org/sqlite v1.49.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,20 +31,19 @@ require (
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -14,14 +14,14 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/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-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
@@ -41,8 +41,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -55,14 +55,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
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/tygoja v0.0.0-20250812183945-97ffe055281f h1:ahrn66FNJYsFkO0EOTStYs+jdBKBop/anp9hoQSzZjI=
|
||||
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -82,53 +82,50 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -137,8 +134,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
expectedDriverVersion = "v1.44.3"
|
||||
expectedLibcVersion = "v1.67.6"
|
||||
expectedDriverVersion = "v1.49.1"
|
||||
expectedLibcVersion = "v1.72.0"
|
||||
|
||||
// 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 buit with the
|
||||
// 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
|
||||
|
||||
+57
-12
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -288,9 +289,13 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
||||
return wrappedMiddlewares, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
|
||||
|
||||
func baseBinds(vm *goja.Runtime) {
|
||||
// BindCore registers common core objects and functions such as sleep,
|
||||
// toString, DynamicModel, etc. into the provided runtime.
|
||||
func BindCore(vm *goja.Runtime) {
|
||||
vm.SetFieldNameMapper(FieldMapper{})
|
||||
|
||||
// deprecated: use toString
|
||||
@@ -658,7 +663,10 @@ func baseBinds(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func dbxBinds(vm *goja.Runtime) {
|
||||
// BindDbx registers $dbx.* namespaced object with dbx database builder related methods.
|
||||
//
|
||||
// See https://pocketbase.io/jsvm/modules/_dbx.html.
|
||||
func BindDbx(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$dbx", obj)
|
||||
|
||||
@@ -681,7 +689,10 @@ 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)
|
||||
|
||||
@@ -692,7 +703,10 @@ func mailsBinds(vm *goja.Runtime) {
|
||||
obj.Set("sendRecordAuthAlert", mails.SendRecordAuthAlert)
|
||||
}
|
||||
|
||||
func securityBinds(vm *goja.Runtime) {
|
||||
// BindSecurity registers $security.* namespaced object with common security related helpers.
|
||||
//
|
||||
// See https://pocketbase.io/jsvm/modules/_security.html.
|
||||
func BindSecurity(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$security", obj)
|
||||
|
||||
@@ -735,10 +749,16 @@ func securityBinds(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func filesystemBinds(vm *goja.Runtime) {
|
||||
// BindFilesystem registers $filesystem.* namespaced object with
|
||||
// common filesystem package related helpers.
|
||||
//
|
||||
// See https://pocketbase.io/jsvm/modules/_filesystem.html.
|
||||
func BindFilesystem(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
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)
|
||||
@@ -754,7 +774,11 @@ func filesystemBinds(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func filepathBinds(vm *goja.Runtime) {
|
||||
// BindFilepath registers $filepath.* namespaced object with
|
||||
// common std Go filepath package related exports.
|
||||
//
|
||||
// See https://pocketbase.io/jsvm/modules/_filepath.html.
|
||||
func BindFilepath(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$filepath", obj)
|
||||
|
||||
@@ -775,7 +799,11 @@ 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)
|
||||
|
||||
@@ -801,19 +829,32 @@ func osBinds(vm *goja.Runtime) {
|
||||
obj.Set("openInRoot", os.OpenInRoot)
|
||||
}
|
||||
|
||||
func formsBinds(vm *goja.Runtime) {
|
||||
// BindForms registers various application form constructors.
|
||||
// These bindings are mostly used internally and/or preserved for backward compatibility with earlier versions.
|
||||
func BindForms(vm *goja.Runtime) {
|
||||
registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
|
||||
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
|
||||
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("static", func(dir string, indexFallback bool) func(*core.RequestEvent) error {
|
||||
return apis.Static(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
|
||||
@@ -840,7 +881,11 @@ func apisBinds(vm *goja.Runtime) {
|
||||
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)
|
||||
|
||||
|
||||
+124
-90
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -42,16 +43,16 @@ func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T)
|
||||
|
||||
// note: this test is useful as a reminder to update the tests in case
|
||||
// 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", 41, t)
|
||||
}
|
||||
|
||||
func TestBaseBindsSleep(t *testing.T) {
|
||||
func TestBindCoreSleep(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("reader", strings.NewReader("test"))
|
||||
|
||||
start := time.Now()
|
||||
@@ -68,9 +69,9 @@ func TestBaseBindsSleep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsReaderToString(t *testing.T) {
|
||||
func TestBindCoreReaderToString(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("reader", strings.NewReader("test"))
|
||||
|
||||
_, err := vm.RunString(`
|
||||
@@ -85,9 +86,9 @@ func TestBaseBindsReaderToString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsToString(t *testing.T) {
|
||||
func TestBindCoreToString(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("scenarios", []struct {
|
||||
Name string
|
||||
Value any
|
||||
@@ -119,9 +120,9 @@ func TestBaseBindsToString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsToBytes(t *testing.T) {
|
||||
func TestBindCoreToBytes(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("bytesEqual", bytes.Equal)
|
||||
vm.Set("scenarios", []struct {
|
||||
Name string
|
||||
@@ -159,9 +160,9 @@ func TestBaseBindsToBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsUnmarshal(t *testing.T) {
|
||||
func TestBindCoreUnmarshal(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("data", &map[string]any{"a": 123})
|
||||
|
||||
_, err := vm.RunString(`
|
||||
@@ -180,9 +181,9 @@ func TestBaseBindsUnmarshal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsContext(t *testing.T) {
|
||||
func TestBindCoreContext(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const base = new Context(null, "a", 123);
|
||||
@@ -204,9 +205,9 @@ func TestBaseBindsContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsCookie(t *testing.T) {
|
||||
func TestBindCoreCookie(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const cookie = new Cookie({
|
||||
@@ -233,9 +234,9 @@ func TestBaseBindsCookie(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsSubscriptionMessage(t *testing.T) {
|
||||
func TestBindCoreSubscriptionMessage(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("bytesToString", func(b []byte) string {
|
||||
return string(b)
|
||||
})
|
||||
@@ -261,7 +262,7 @@ func TestBaseBindsSubscriptionMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsRecord(t *testing.T) {
|
||||
func TestBindCoreRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -271,7 +272,7 @@ func TestBaseBindsRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("collection", collection)
|
||||
|
||||
// without record data
|
||||
@@ -307,9 +308,9 @@ func TestBaseBindsRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsCollection(t *testing.T) {
|
||||
func TestBindCoreCollection(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
v, err := vm.RunString(`new Collection({ name: "test", createRule: "@request.auth.id != ''", fields: [{name: "title", "type": "text"}] })`)
|
||||
if err != nil {
|
||||
@@ -335,9 +336,9 @@ func TestBaseBindsCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsFieldsList(t *testing.T) {
|
||||
func TestBindCoreFieldsList(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
v, err := vm.RunString(`new FieldsList([{name: "title", "type": "text"}])`)
|
||||
if err != nil {
|
||||
@@ -354,9 +355,9 @@ func TestBaseBindsFieldsList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsField(t *testing.T) {
|
||||
func TestBindCoreField(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
v, err := vm.RunString(`new Field({name: "test", "type": "bool"})`)
|
||||
if err != nil {
|
||||
@@ -378,11 +379,11 @@ func isType[T any](v any) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func TestBaseBindsNamedFields(t *testing.T) {
|
||||
func TestBindCoreNamedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
scenarios := []struct {
|
||||
js string
|
||||
@@ -469,9 +470,9 @@ func TestBaseBindsNamedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
@@ -516,9 +517,9 @@ func TestBaseBindsMailerMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsCommand(t *testing.T) {
|
||||
func TestBindCoreCommand(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
let runCalls = 0;
|
||||
@@ -545,9 +546,9 @@ func TestBaseBindsCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsRequestInfo(t *testing.T) {
|
||||
func TestBindCoreRequestInfo(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const info = new RequestInfo({
|
||||
@@ -563,9 +564,9 @@ func TestBaseBindsRequestInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsMiddleware(t *testing.T) {
|
||||
func TestBindCoreMiddleware(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const m = new Middleware(
|
||||
@@ -583,9 +584,9 @@ func TestBaseBindsMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsTimezone(t *testing.T) {
|
||||
func TestBindCoreTimezone(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const v0 = (new Timezone()).string();
|
||||
@@ -608,9 +609,9 @@ func TestBaseBindsTimezone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsDateTime(t *testing.T) {
|
||||
func TestBindCoreDateTime(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const now = new DateTime();
|
||||
@@ -649,9 +650,9 @@ func TestBaseBindsDateTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBindsValidationError(t *testing.T) {
|
||||
func TestBindCoreValidationError(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
|
||||
scenarios := []struct {
|
||||
js string
|
||||
@@ -696,14 +697,14 @@ func TestBaseBindsValidationError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDbxBinds(t *testing.T) {
|
||||
func TestBindDbx(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
vm := goja.New()
|
||||
vm.Set("db", app.DB())
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
BindCore(vm)
|
||||
BindDbx(vm)
|
||||
|
||||
testBindsCount(vm, "$dbx", 15, t)
|
||||
|
||||
@@ -791,14 +792,14 @@ func TestDbxBinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailsBindsCount(t *testing.T) {
|
||||
func TestBindMailsCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
mailsBinds(vm)
|
||||
BindMails(vm)
|
||||
|
||||
testBindsCount(vm, "$mails", 5, t)
|
||||
}
|
||||
|
||||
func TestMailsBinds(t *testing.T) {
|
||||
func TestBindMails(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -808,8 +809,8 @@ func TestMailsBinds(t *testing.T) {
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
mailsBinds(vm)
|
||||
BindCore(vm)
|
||||
BindMails(vm)
|
||||
vm.Set("$app", app)
|
||||
vm.Set("record", record)
|
||||
|
||||
@@ -844,17 +845,17 @@ func TestMailsBinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityBindsCount(t *testing.T) {
|
||||
func TestBindSecurityCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
securityBinds(vm)
|
||||
BindSecurity(vm)
|
||||
|
||||
testBindsCount(vm, "$security", 16, t)
|
||||
}
|
||||
|
||||
func TestSecurityCryptoBinds(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
securityBinds(vm)
|
||||
BindCore(vm)
|
||||
BindSecurity(vm)
|
||||
|
||||
sceneraios := []struct {
|
||||
js string
|
||||
@@ -887,8 +888,8 @@ func TestSecurityCryptoBinds(t *testing.T) {
|
||||
|
||||
func TestSecurityRandomStringBinds(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
securityBinds(vm)
|
||||
BindCore(vm)
|
||||
BindSecurity(vm)
|
||||
|
||||
sceneraios := []struct {
|
||||
js string
|
||||
@@ -963,8 +964,8 @@ func TestSecurityJWTBinds(t *testing.T) {
|
||||
for _, s := range sceneraios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
securityBinds(vm)
|
||||
BindCore(vm)
|
||||
BindSecurity(vm)
|
||||
|
||||
_, err := vm.RunString(s.js)
|
||||
if err != nil {
|
||||
@@ -976,8 +977,8 @@ func TestSecurityJWTBinds(t *testing.T) {
|
||||
|
||||
func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
securityBinds(vm)
|
||||
BindCore(vm)
|
||||
BindSecurity(vm)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
const key = "abcdabcdabcdabcdabcdabcdabcdabcd"
|
||||
@@ -995,7 +996,7 @@ func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemBinds(t *testing.T) {
|
||||
func TestBindFilesystem(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1008,14 +1009,47 @@ func TestFilesystemBinds(t *testing.T) {
|
||||
}))
|
||||
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"))
|
||||
vm.Set("baseURL", srv.URL)
|
||||
baseBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
BindCore(vm)
|
||||
BindFilesystem(vm)
|
||||
|
||||
testBindsCount(vm, "$filesystem", 4, t)
|
||||
testBindsCount(vm, "$filesystem", 6, t)
|
||||
|
||||
// s3
|
||||
{
|
||||
v, err := vm.RunString(`$filesystem.s3("bucketName", "region", "endpoint", "accessKey", "secretKey", true)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fsys, ok := v.Export().(*filesystem.System)
|
||||
if !ok {
|
||||
t.Fatalf("[s3] Expected System instance got %v", fsys)
|
||||
}
|
||||
}
|
||||
|
||||
// local
|
||||
{
|
||||
v, err := vm.RunString(`$filesystem.local(tmpDir)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fsys, ok := v.Export().(*filesystem.System)
|
||||
if !ok {
|
||||
t.Fatalf("[s3] Expected System instance got %v", fsys)
|
||||
}
|
||||
}
|
||||
|
||||
// fileFromPath
|
||||
{
|
||||
@@ -1082,24 +1116,24 @@ func TestFilesystemBinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormsBinds(t *testing.T) {
|
||||
func TestBindForms(t *testing.T) {
|
||||
vm := goja.New()
|
||||
formsBinds(vm)
|
||||
BindForms(vm)
|
||||
|
||||
testBindsCount(vm, "this", 4, t)
|
||||
}
|
||||
|
||||
func TestApisBindsCount(t *testing.T) {
|
||||
func TestBindApisCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
apisBinds(vm)
|
||||
BindApis(vm)
|
||||
|
||||
testBindsCount(vm, "this", 8, t)
|
||||
testBindsCount(vm, "$apis", 11, t)
|
||||
}
|
||||
|
||||
func TestApisBindsApiError(t *testing.T) {
|
||||
func TestBindApisErrors(t *testing.T) {
|
||||
vm := goja.New()
|
||||
apisBinds(vm)
|
||||
BindApis(vm)
|
||||
|
||||
scenarios := []struct {
|
||||
js string
|
||||
@@ -1156,8 +1190,8 @@ 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(`
|
||||
@@ -1213,7 +1247,7 @@ func TestLoadingDynamicModel(t *testing.T) {
|
||||
"nullObjectEmpty": null,
|
||||
};
|
||||
|
||||
// constuct dummy SELECT column value literals based on the expectations
|
||||
// construct dummy SELECT column value literals based on the expectations
|
||||
const selectColumns = [];
|
||||
for (const col in expectations) {
|
||||
const val = expectations[col]
|
||||
@@ -1257,8 +1291,8 @@ func TestDynamicModelMapFieldCaching(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
BindCore(vm)
|
||||
BindDbx(vm)
|
||||
vm.Set("$app", app)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
@@ -1316,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(`
|
||||
@@ -1357,18 +1391,18 @@ 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
|
||||
@@ -1413,8 +1447,8 @@ func TestHttpClientBindsSend(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
BindCore(vm)
|
||||
BindHTTP(vm)
|
||||
vm.Set("testURL", server.URL)
|
||||
|
||||
_, err := vm.RunString(`
|
||||
@@ -1592,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
|
||||
@@ -1678,7 +1712,7 @@ func TestHooksExceptionUnwrapping(t *testing.T) {
|
||||
|
||||
vmFactory := func() *goja.Runtime {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
BindCore(vm)
|
||||
vm.Set("$app", app)
|
||||
vm.Set("goErr", goErr)
|
||||
return vm
|
||||
@@ -1732,8 +1766,8 @@ func TestRouterBinds(t *testing.T) {
|
||||
|
||||
vmFactory := func() *goja.Runtime {
|
||||
vm := goja.New()
|
||||
baseBinds(vm)
|
||||
apisBinds(vm)
|
||||
BindCore(vm)
|
||||
BindApis(vm)
|
||||
vm.Set("$app", app)
|
||||
vm.Set("result", result)
|
||||
return vm
|
||||
@@ -1821,16 +1855,16 @@ func TestRouterBinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilepathBindsCount(t *testing.T) {
|
||||
func TestBindFilepathCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
filepathBinds(vm)
|
||||
BindFilepath(vm)
|
||||
|
||||
testBindsCount(vm, "$filepath", 15, t)
|
||||
}
|
||||
|
||||
func TestOsBindsCount(t *testing.T) {
|
||||
func TestBindOSCount(t *testing.T) {
|
||||
vm := goja.New()
|
||||
osBinds(vm)
|
||||
BindOS(vm)
|
||||
|
||||
testBindsCount(vm, "$os", 20, t)
|
||||
}
|
||||
|
||||
+4485
-4019
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,29 @@ declare function sleep(milliseconds: number): void;
|
||||
*/
|
||||
declare function arrayOf<T>(model: T): Array<T>;
|
||||
|
||||
/**
|
||||
* unmarshal clones and merges the data argument on top of dst.
|
||||
*
|
||||
* This method is rarely used directly by the users and it is most
|
||||
* commonly used in the autogenerated migrations.
|
||||
*
|
||||
* To an extent it is similar to the JS native ` + "`" + `Object.assign` + "`" + `
|
||||
* but the arguments are reversed and it invokes the Go standard
|
||||
* ` + "`" + `json.Marshal/Unmarshal` + "`" + ` methods under the hood.
|
||||
*
|
||||
* The data argument could be anything serializable, usually a plain object (map).
|
||||
* The dst argument could be any pointer value, usually a model instance.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ` + "```" + `js
|
||||
* unmarshal({ authAlert: { enabled: true } }, collection)
|
||||
* ` + "```" + `
|
||||
*
|
||||
* @group PocketBase
|
||||
*/
|
||||
declare function unmarshal(data: any, dst: any): void;
|
||||
|
||||
/**
|
||||
* DynamicModel creates a new dynamic model with fields from the provided data shape.
|
||||
*
|
||||
@@ -870,17 +893,17 @@ declare namespace $security {
|
||||
/**
|
||||
* {@inheritDoc security.newJWT}
|
||||
*/
|
||||
export function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
|
||||
function createJWT(payload: { [key:string]: any }, signingKey: string, secDuration: number): string
|
||||
|
||||
/**
|
||||
* {@inheritDoc security.parseUnverifiedJWT}
|
||||
*/
|
||||
export function parseUnverifiedJWT(token: string): _TygojaDict
|
||||
function parseUnverifiedJWT(token: string): _TygojaDict
|
||||
|
||||
/**
|
||||
* {@inheritDoc security.parseJWT}
|
||||
*/
|
||||
export function parseJWT(token: string, verificationKey: string): _TygojaDict
|
||||
function parseJWT(token: string, verificationKey: string): _TygojaDict
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -898,6 +921,24 @@ declare namespace $filesystem {
|
||||
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.
|
||||
@@ -912,7 +953,7 @@ declare namespace $filesystem {
|
||||
* const file2 = $filesystem.fileFromURL("https://...", 15)
|
||||
* ` + "```" + `
|
||||
*/
|
||||
export function fileFromURL(url: string, secTimeout?: number): filesystem.File
|
||||
function fileFromURL(url: string, secTimeout?: number): filesystem.File
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -926,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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -957,7 +998,7 @@ declare namespace $os {
|
||||
/**
|
||||
* Legacy alias for $os.cmd().
|
||||
*/
|
||||
export let exec: exec.command
|
||||
let exec: exec.command
|
||||
|
||||
/**
|
||||
* Prepares an external OS command.
|
||||
@@ -972,30 +1013,30 @@ declare namespace $os {
|
||||
* const output = toString(cmd.output());
|
||||
* ` + "```" + `
|
||||
*/
|
||||
export let cmd: exec.command
|
||||
let cmd: exec.command
|
||||
|
||||
/**
|
||||
* Args hold the command-line arguments, starting with the program name.
|
||||
*/
|
||||
export let args: Array<string>
|
||||
let args: Array<string>
|
||||
|
||||
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 stat: os.stat
|
||||
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
|
||||
export let openRoot: os.openRoot
|
||||
export let openInRoot: os.openInRoot
|
||||
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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -1121,10 +1162,28 @@ 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 static(dir: string, indexFallback: boolean): (e: core.RequestEvent) => void
|
||||
function static(dirOrFS: string|fs.FS, indexFallback: boolean): (e: core.RequestEvent) => void
|
||||
|
||||
let requireGuestOnly: apis.requireGuestOnly
|
||||
let requireAuth: apis.requireAuth
|
||||
@@ -1146,7 +1205,7 @@ declare namespace $apis {
|
||||
* 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 recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
|
||||
function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+24
-21
@@ -1,6 +1,9 @@
|
||||
// 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{
|
||||
@@ -200,15 +203,15 @@ func (p *plugin) registerMigrations() error {
|
||||
process.Enable(vm)
|
||||
buffer.Enable(vm)
|
||||
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
securityBinds(vm)
|
||||
osBinds(vm)
|
||||
filepathBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
formsBinds(vm)
|
||||
mailsBinds(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)
|
||||
@@ -288,16 +291,16 @@ func (p *plugin) registerHooks() error {
|
||||
process.Enable(vm)
|
||||
buffer.Enable(vm)
|
||||
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
securityBinds(vm)
|
||||
osBinds(vm)
|
||||
filepathBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
formsBinds(vm)
|
||||
apisBinds(vm)
|
||||
mailsBinds(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)
|
||||
@@ -440,8 +443,8 @@ func (p *plugin) watchHooks() error {
|
||||
//
|
||||
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
|
||||
dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
// ignore hidden directories, node_modules, symlinks, sockets, etc.
|
||||
if !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
|
||||
// skip access failures, hidden directories, node_modules, etc.
|
||||
if err != nil || !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ migrate((app) => {
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 15,
|
||||
@@ -64,6 +65,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "password@TEST_RANDOM",
|
||||
"max": 0,
|
||||
@@ -77,6 +79,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 60,
|
||||
@@ -91,6 +94,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "email@TEST_RANDOM",
|
||||
"name": "email",
|
||||
@@ -101,6 +105,7 @@ migrate((app) => {
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "emailVisibility",
|
||||
@@ -110,6 +115,7 @@ migrate((app) => {
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "verified",
|
||||
@@ -226,6 +232,7 @@ func init() {
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 15,
|
||||
@@ -240,6 +247,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "password@TEST_RANDOM",
|
||||
"max": 0,
|
||||
@@ -253,6 +261,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 60,
|
||||
@@ -267,6 +276,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "email@TEST_RANDOM",
|
||||
"name": "email",
|
||||
@@ -277,6 +287,7 @@ func init() {
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "emailVisibility",
|
||||
@@ -286,6 +297,7 @@ func init() {
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "verified",
|
||||
@@ -491,6 +503,7 @@ migrate((app) => {
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 15,
|
||||
@@ -505,6 +518,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "password@TEST_RANDOM",
|
||||
"max": 0,
|
||||
@@ -518,6 +532,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 60,
|
||||
@@ -532,6 +547,7 @@ migrate((app) => {
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "email3885137012",
|
||||
"name": "email",
|
||||
@@ -542,6 +558,7 @@ migrate((app) => {
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "emailVisibility",
|
||||
@@ -551,6 +568,7 @@ migrate((app) => {
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool256245529",
|
||||
"name": "verified",
|
||||
@@ -670,6 +688,7 @@ func init() {
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 15,
|
||||
@@ -684,6 +703,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"cost": 0,
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "password@TEST_RANDOM",
|
||||
"max": 0,
|
||||
@@ -697,6 +717,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "[a-zA-Z0-9]{50}",
|
||||
"help": "",
|
||||
"hidden": true,
|
||||
"id": "text@TEST_RANDOM",
|
||||
"max": 60,
|
||||
@@ -711,6 +732,7 @@ func init() {
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "email3885137012",
|
||||
"name": "email",
|
||||
@@ -721,6 +743,7 @@ func init() {
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool@TEST_RANDOM",
|
||||
"name": "emailVisibility",
|
||||
@@ -730,6 +753,7 @@ func init() {
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool256245529",
|
||||
"name": "verified",
|
||||
@@ -923,6 +947,7 @@ migrate((app) => {
|
||||
// add field
|
||||
collection.fields.addAt(8, new Field({
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f4_id",
|
||||
"max": 0,
|
||||
@@ -938,6 +963,7 @@ migrate((app) => {
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(7, new Field({
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f2_id",
|
||||
"max": null,
|
||||
@@ -976,6 +1002,7 @@ migrate((app) => {
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(8, new Field({
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f3_id",
|
||||
"name": "f3_name",
|
||||
@@ -990,6 +1017,7 @@ migrate((app) => {
|
||||
|
||||
// update field
|
||||
collection.fields.addAt(7, new Field({
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f2_id",
|
||||
"max": null,
|
||||
@@ -1054,6 +1082,7 @@ func init() {
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f4_id",
|
||||
"max": 0,
|
||||
@@ -1071,6 +1100,7 @@ func init() {
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f2_id",
|
||||
"max": null,
|
||||
@@ -1116,6 +1146,7 @@ func init() {
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f3_id",
|
||||
"name": "f3_name",
|
||||
@@ -1132,6 +1163,7 @@ func init() {
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "f2_id",
|
||||
"max": null,
|
||||
|
||||
+2
-1
@@ -232,7 +232,8 @@ func (scenario *ApiScenario) test(t testing.TB) {
|
||||
|
||||
// set scenario headers
|
||||
for k, v := range scenario.Headers {
|
||||
req.Header.Set(k, v)
|
||||
// trim whitespaces for consistency with the net/http request parsing
|
||||
req.Header.Set(k, strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// execute request
|
||||
|
||||
Binary file not shown.
@@ -36,6 +36,8 @@ func NewAppleProvider() *Apple {
|
||||
return &Apple{
|
||||
BaseProvider: BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 1,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="315" preserveAspectRatio="xMidYMid"><path d="M213.8 167c.4 47.6 41.7 63.4 42.2 63.6-.3 1.2-6.6 22.6-21.8 44.8-13 19.1-26.7 38.2-48 38.6-21.1.4-28-12.5-52-12.5s-31.6 12.1-51.5 12.9c-20.7.8-36.4-20.7-49.6-39.8-27-39-47.7-110.3-20-158.4a77 77 0 0 1 65.1-39.4c20.3-.4 39.5 13.6 51.9 13.6s35.7-16.9 60.2-14.4c10.2.4 39 4.2 57.5 31.2-1.5 1-34.4 20-34 59.8M174.2 50.2A69 69 0 0 0 190.6 0c-15.8.6-35 10.5-46.3 23.8-10.2 11.8-19.1 30.6-16.7 48.7 17.6 1.3 35.7-9 46.6-22.3"/></svg>`,
|
||||
displayName: "Apple",
|
||||
pkce: true,
|
||||
scopes: []string{"name", "email"},
|
||||
|
||||
+21
-2
@@ -28,8 +28,20 @@ func NewProviderByName(name string) (Provider, error) {
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
// @todo refactor and consider replace with a plain struct
|
||||
//
|
||||
// Provider defines a common interface for an OAuth2 client.
|
||||
type Provider interface {
|
||||
// @todo temp backport
|
||||
//
|
||||
// Order returns the sorting order of the provider usually used in the auth methods list response.
|
||||
Logo() string
|
||||
|
||||
// @todo temp backport
|
||||
//
|
||||
// Order returns the sorting order of the provider usually used in the auth methods list response.
|
||||
Order() int
|
||||
|
||||
// Context returns the context associated with the provider (if any).
|
||||
Context() context.Context
|
||||
|
||||
@@ -93,7 +105,7 @@ type Provider interface {
|
||||
SetUserInfoURL(url string)
|
||||
|
||||
// Extra returns a shallow copy of any custom config data
|
||||
// that the provider may be need.
|
||||
// that the provider may need.
|
||||
Extra() map[string]any
|
||||
|
||||
// SetExtra updates the provider's custom config data.
|
||||
@@ -133,11 +145,18 @@ type AuthUser struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
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
|
||||
|
||||
@@ -13,16 +13,28 @@ import (
|
||||
// BaseProvider defines common fields and methods used by OAuth2 client providers.
|
||||
type BaseProvider struct {
|
||||
ctx context.Context
|
||||
extra map[string]any
|
||||
redirectURL string
|
||||
clientId string
|
||||
clientSecret string
|
||||
displayName string
|
||||
redirectURL string
|
||||
logo string
|
||||
authURL string
|
||||
tokenURL string
|
||||
userInfoURL string
|
||||
scopes []string
|
||||
order int
|
||||
pkce bool
|
||||
extra map[string]any
|
||||
}
|
||||
|
||||
// Order implements Provider.Order() interface method.
|
||||
func (p *BaseProvider) Order() int {
|
||||
return p.order
|
||||
}
|
||||
|
||||
// Logo implements Provider.Logo() interface method.
|
||||
func (p *BaseProvider) Logo() string {
|
||||
return p.logo
|
||||
}
|
||||
|
||||
// Context implements Provider.Context() interface method.
|
||||
|
||||
@@ -30,14 +30,46 @@ func TestDisplayName(t *testing.T) {
|
||||
|
||||
before := b.DisplayName()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected displayName to be empty, got %v", before)
|
||||
t.Fatalf("Expected displayName to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetDisplayName("test")
|
||||
|
||||
after := b.DisplayName()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected displayName to be 'test', got %v", after)
|
||||
t.Fatalf("Expected displayName to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrder(t *testing.T) {
|
||||
b := BaseProvider{}
|
||||
|
||||
before := b.Order()
|
||||
if before != 0 {
|
||||
t.Fatalf("Expected order to be empty, got %d", before)
|
||||
}
|
||||
|
||||
b.order = 123
|
||||
|
||||
after := b.Order()
|
||||
if after != 123 {
|
||||
t.Fatalf("Expected order to be %d, got %d", 123, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogo(t *testing.T) {
|
||||
b := BaseProvider{}
|
||||
|
||||
before := b.Logo()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected logo to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.logo = "test"
|
||||
|
||||
after := b.Logo()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected logo to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,14 +110,14 @@ func TestClientId(t *testing.T) {
|
||||
|
||||
before := b.ClientId()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected clientId to be empty, got %v", before)
|
||||
t.Fatalf("Expected clientId to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetClientId("test")
|
||||
|
||||
after := b.ClientId()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected clientId to be 'test', got %v", after)
|
||||
t.Fatalf("Expected clientId to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +126,14 @@ func TestClientSecret(t *testing.T) {
|
||||
|
||||
before := b.ClientSecret()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected clientSecret to be empty, got %v", before)
|
||||
t.Fatalf("Expected clientSecret to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetClientSecret("test")
|
||||
|
||||
after := b.ClientSecret()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected clientSecret to be 'test', got %v", after)
|
||||
t.Fatalf("Expected clientSecret to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,14 +142,14 @@ func TestRedirectURL(t *testing.T) {
|
||||
|
||||
before := b.RedirectURL()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected RedirectURL to be empty, got %v", before)
|
||||
t.Fatalf("Expected RedirectURL to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetRedirectURL("test")
|
||||
|
||||
after := b.RedirectURL()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected RedirectURL to be 'test', got %v", after)
|
||||
t.Fatalf("Expected RedirectURL to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +158,14 @@ func TestAuthURL(t *testing.T) {
|
||||
|
||||
before := b.AuthURL()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected authURL to be empty, got %v", before)
|
||||
t.Fatalf("Expected authURL to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetAuthURL("test")
|
||||
|
||||
after := b.AuthURL()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected authURL to be 'test', got %v", after)
|
||||
t.Fatalf("Expected authURL to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,14 +174,14 @@ func TestTokenURL(t *testing.T) {
|
||||
|
||||
before := b.TokenURL()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected tokenURL to be empty, got %v", before)
|
||||
t.Fatalf("Expected tokenURL to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetTokenURL("test")
|
||||
|
||||
after := b.TokenURL()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected tokenURL to be 'test', got %v", after)
|
||||
t.Fatalf("Expected tokenURL to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,14 +190,14 @@ func TestUserInfoURL(t *testing.T) {
|
||||
|
||||
before := b.UserInfoURL()
|
||||
if before != "" {
|
||||
t.Fatalf("Expected userInfoURL to be empty, got %v", before)
|
||||
t.Fatalf("Expected userInfoURL to be empty, got %q", before)
|
||||
}
|
||||
|
||||
b.SetUserInfoURL("test")
|
||||
|
||||
after := b.UserInfoURL()
|
||||
if after != "test" {
|
||||
t.Fatalf("Expected userInfoURL to be 'test', got %v", after)
|
||||
t.Fatalf("Expected userInfoURL to be %q, got %q", "test", after)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ type Bitbucket struct {
|
||||
func NewBitbucketProvider() *Bitbucket {
|
||||
return &Bitbucket{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 9,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2256" preserveAspectRatio="xMidYMid" viewBox="-1 -0.6 257.9 230.8"><linearGradient id="a" x1="108.6%" x2="46.9%" y1="13.8%" y2="78.8%"><stop offset=".2" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><g fill="none"><path d="M101 153h54l13-76H87z"/><path fill="#2684ff" d="M8 0a8 8 0 0 0-8 10l35 211a11 11 0 0 0 11 9h167a8 8 0 0 0 8-7l35-213a8 8 0 0 0-8-10zm147 153h-53L87 77h81z"/><path fill="url(#a)" d="M245 77h-77l-13 76h-53l-63 74 7 3h167a8 8 0 0 0 8-7z"/></g></svg>`,
|
||||
displayName: "Bitbucket",
|
||||
pkce: false,
|
||||
scopes: []string{"account"},
|
||||
|
||||
@@ -27,6 +27,8 @@ type Box struct {
|
||||
func NewBoxProvider() *Box {
|
||||
return &Box{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 20,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 21.6"><path fill="#0061d5" d="M39.7 19.2q.7 1.2-.2 2.1-1.2.7-2.2-.2l-3.5-4.5-3.4 4.4c-.5.7-1.5.7-2.2.2q-1-.9-.3-2.1l4-5.2-4-5.2c-.5-.7-.3-1.7.3-2.2s1.7-.3 2.2.3l3.4 4.5L37.3 7q.9-1 2.2-.3 1 1 .2 2.2L35.8 14zm-18.2-.6c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6a4.7 4.7 0 0 1-4.7 4.6m-13.8 0c-2.6 0-4.7-2-4.7-4.6s2.1-4.6 4.7-4.6 4.7 2.1 4.7 4.6c0 2.6-2.1 4.6-4.7 4.6M21.5 6.4a8 8 0 0 0-6.8 4 8 8 0 0 0-6.9-4q-2.7 0-4.7 1.5V1.5Q3 .2 1.6 0 .1.1 0 1.5v12.6a7.7 7.7 0 0 0 7.7 7.5c3 0 5.6-1.7 6.9-4.1a8 8 0 0 0 6.8 4.1c4.3 0 7.8-3.4 7.8-7.7a7.5 7.5 0 0 0-7.7-7.5"/></svg>`,
|
||||
displayName: "Box",
|
||||
pkce: true,
|
||||
scopes: []string{"root_readonly"},
|
||||
|
||||
+21
-5
@@ -29,6 +29,8 @@ func NewDiscordProvider() *Discord {
|
||||
// https://discord.com/developers/docs/resources/user#get-current-user
|
||||
return &Discord{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 12,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199" preserveAspectRatio="xMidYMid"><path fill="#5865f2" d="M216.9 16.6A209 209 0 0 0 164 0c-2.2 4.1-4.9 9.6-6.7 14a194 194 0 0 0-58.6 0C97 9.6 94.2 4.1 92 0a208 208 0 0 0-53 16.6A222 222 0 0 0 1 165a211 211 0 0 0 65 33 161 161 0 0 0 13.8-22.8q-11.5-4.4-21.8-10.6l5.3-4.3a149 149 0 0 0 129.6 0q2.6 2.3 5.3 4.3a136 136 0 0 1-21.9 10.6q6 12 13.9 22.9a211 211 0 0 0 64.8-33.2c5.3-56.3-9-105.1-38-148.4M85.5 135.1c-12.7 0-23-11.8-23-26.2s10.1-26.2 23-26.2 23.2 11.8 23 26.2c0 14.4-10.2 26.2-23 26.2m85 0c-12.6 0-23-11.8-23-26.2s10.2-26.2 23-26.2 23.3 11.8 23 26.2c0 14.4-10.1 26.2-23 26.2"/></svg>`,
|
||||
displayName: "Discord",
|
||||
pkce: true,
|
||||
scopes: []string{"identify", "email"},
|
||||
@@ -54,6 +56,7 @@ 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"`
|
||||
@@ -64,16 +67,29 @@ func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||
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,
|
||||
RawUser: rawUser,
|
||||
|
||||
@@ -27,6 +27,8 @@ type Facebook struct {
|
||||
func NewFacebookProvider() *Facebook {
|
||||
return &Facebook{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 5,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#1877f2" d="M256 128a128 128 0 1 0-148 126.4V165H75.5v-37H108V99.8c0-32 19.1-49.8 48.3-49.8 14 0 28.7 2.5 28.7 2.5V84h-16.1c-16 0-20.9 9.9-20.9 20v24h35.5l-5.7 37H148v89.4A128 128 0 0 0 256 128"/><path fill="#fff" d="m177.8 165 5.7-37H148v-24c0-10.1 5-20 20.9-20H185V52.5S170.4 50 156.3 50C127.1 50 108 67.7 108 99.8V128H75.5v37H108v89.4a129 129 0 0 0 40 0V165z"/></svg>`,
|
||||
displayName: "Facebook",
|
||||
pkce: true,
|
||||
scopes: []string{"email"},
|
||||
|
||||
@@ -27,6 +27,8 @@ type Gitea struct {
|
||||
func NewGiteaProvider() *Gitea {
|
||||
return &Gitea{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 11,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640"><path d="m396 484-127-61c-12-6-18-21-12-34l61-127c6-12 21-17 34-11l27 13V154h17v118s57 24 83 40q7 3 13 14 3 10-1 19l-61 127c-6 13-22 18-34 12" style="fill:#fff"/><path d="M623 150c-4-4-10-4-10-4l-178 8-39 1v117l-17-8V155l-89-3-157-8q-15-2-39 1c-9 2-34 8-54 27C-5 212 7 276 8 286c2 12 7 44 32 72 46 56 144 55 144 55s12 29 31 56c25 33 50 59 75 62h189s12 0 29-11c14-8 26-23 26-23s13-14 31-45l14-28s55-118 55-232c-1-34-9-40-11-42M126 354c-26-9-37-19-37-19s-19-13-29-40c-16-44-1-71-1-71s8-22 38-30c14-4 31-3 31-3s7 59 16 94c7 30 25 78 25 78s-26-3-43-9m300 108s-6 14-20 15l-10-1-5-2-113-55s-11-6-13-16c-2-8 3-18 3-18l54-112s5-10 12-13l5-1c8-3 18 2 18 2l110 54s13 6 16 16q1 12-2 17c-6 16-55 114-55 114" style="fill:#609926"/><path d="M327 380q-14 1-17 14-2 13 9 20c7 4 17 2 22-5q8-13-1-24l24-49h6l7-4 29 16 5 5c2 6-2 15-2 15-2 7-18 40-18 40q-13 1-18 13-4 14 9 21a18 18 0 0 0 21-28l6-11 13-30c1-2 6-11 3-22-2-11-13-16-13-16-12-8-29-16-29-16l-1-7-4-6 14-29-12-6-14 29q-11 0-16 10t1 20z" style="fill:#609926"/></svg>`,
|
||||
displayName: "Gitea",
|
||||
pkce: true,
|
||||
scopes: []string{"read:user", "user:email"},
|
||||
|
||||
@@ -29,6 +29,8 @@ type Gitee struct {
|
||||
func NewGiteeProvider() *Gitee {
|
||||
return &Gitee{BaseProvider{
|
||||
ctx: context.Background(),
|
||||
order: 10,
|
||||
logo: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="120 13 72 72"><g fill="none" fill-rule="evenodd"><path d="M0 0h312v100H0z"/><path fill="#c71d23" d="M156 85a36 36 0 1 1 0-72 36 36 0 0 1 0 72m18.2-40h-20.4q-1.7.1-1.8 1.8v4.4q.2 1.6 1.8 1.8h12.4q1.7.1 1.8 1.8v.9c0 3-2.4 5.3-5.3 5.3h-17q-1.6-.1-1.7-1.8V42.3c0-3 2.4-5.3 5.3-5.3h25q1.5-.1 1.7-1.8v-4.4a2 2 0 0 0-1.8-1.8h-24.9C142 29 136 35 136 42.3v25q.2 1.5 1.8 1.7H164a12 12 0 0 0 12-12V46.8q-.2-1.6-1.8-1.8"/></g></svg>`,
|
||||
displayName: "Gitee",
|
||||
pkce: true,
|
||||
scopes: []string{"user_info", "emails"},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user