Compare commits

...
76 Commits
Author SHA1 Message Date
Gani Georgiev 83e44a7cfb added view query sample loading indicator 2026-04-22 16:04:57 +03:00
Gani Georgiev 866b8b8029 added missing name attribute, fixed initial collections page load routing params persistence and removed unnecessery sub label required mark 2026-04-22 15:42:17 +03:00
Gani Georgiev 857214e10d reordered number settings for consistency with the other fields 2026-04-22 14:46:19 +03:00
Gani Georgiev 592b13913f added min-height to the page tables and adjusted light surface colors 2026-04-22 07:00:36 +03:00
Gani Georgiev 84b50c4869 minor color and styles improvements 2026-04-21 17:40:11 +03:00
Gani Georgiev 3c33868ea8 [#7653] updated tinymce options to fix its dialogs position 2026-04-21 14:16:23 +03:00
Gani Georgiev 223ac7a64a enabled text wrapping for the API rule fileds 2026-04-21 11:52:45 +03:00
Gani Georgiev 0cee0662f6 fixed 0 total count on page back/forward navigation 2026-04-21 10:26:37 +03:00
Gani Georgiev bf1745fa13 fixed changelog typo 2026-04-20 17:40:11 +03:00
Gani Georgiev efc095f7d0 updated changelog and ui/dist 2026-04-20 17:26:00 +03:00
Gani Georgiev 14e7286840 updated color vars and hide Safari negative margin horizontal scroll 2026-04-20 16:51:20 +03:00
Gani Georgiev 4ace75b3d5 [#7650] workarounded Safari position-try-fallbacks freeze 2026-04-20 16:49:55 +03:00
Gani Georgiev d23963aaca updated dark complementary colors 2026-04-20 12:25:04 +03:00
Gani Georgiev d35a0d841c [#7649] renamed the stringify util and removed its unnecessery tags stripping 2026-04-20 11:48:44 +03:00
Gani Georgiev 1b18ab9bec [#7648] darken the surface colors a little bit more 2026-04-20 10:32:23 +03:00
Gani Georgiev 93e6ebfe49 [#7648] fixed firefox autoexpandable input and updated dark theme colors 2026-04-19 23:44:04 +03:00
Gani Georgiev c3a53cb183 fallback to 0 2026-04-19 15:36:38 +03:00
Gani Georgiev ba554b8470 [#7646] fixed number field min/max input value normalization 2026-04-19 15:31:33 +03:00
Gani Georgiev 61ce760e0f show collection name in the page title on initial load 2026-04-19 12:29:45 +03:00
Gani Georgiev 7b92b7c857 updated ui/dist 2026-04-19 12:20:37 +03:00
Gani Georgiev 8c127b2849 updated changelog 2026-04-19 11:54:57 +03:00
Gani Georgiev e6b8841421 allow to open collections page in new tab on middle click and minor flickering improvements 2026-04-19 11:53:24 +03:00
Gani Georgiev 862064e061 enable back npm build check 2026-04-19 09:45:41 +03:00
Gani Georgiev 90594cc331 temp disable npm build check 2026-04-19 09:19:52 +03:00
Gani Georgiev 6012ba701d fixed relation and file custom change event trigger 2026-04-19 08:52:40 +03:00
Gani Georgiev 5cc95a2e63 reset responsive table padding and min-height 2026-04-19 08:33:32 +03:00
Gani Georgiev e41f43241b reorder records list watchers and cancel prev count requests 2026-04-19 08:28:03 +03:00
Gani Georgiev 3ad737e606 sync superusers mfa and otp toggles 2026-04-19 01:22:42 +03:00
Gani Georgiev 3b49e8489e added otp superusers help tooltip 2026-04-19 01:19:38 +03:00
Gani Georgiev 07679dd5ba fixed collection getter 2026-04-19 00:57:24 +03:00
Gani Georgiev 3b8bb4cba9 updated changelog 2026-04-19 00:02:25 +03:00
Gani Georgiev e63fdf4dd0 updated changelog and /ui/dist 2026-04-18 23:55:24 +03:00
Gani Georgiev c96415caae responsive adjustments 2026-04-18 22:47:10 +03:00
Gani Georgiev 075e20efae minor screen reader improvements 2026-04-18 22:11:58 +03:00
Gani Georgiev 624c3357be sync forgotten field tooltip 2026-04-18 18:34:18 +03:00
Gani Georgiev 7673798fa3 normalized the names of the exposed jsvm bind funcs 2026-04-18 17:48:41 +03:00
Gani Georgiev d4987a153e updated modernc.org/sqlite to v1.49.1 2026-04-18 17:39:10 +03:00
Gani Georgiev b02d9b3662 updated node action 2026-04-18 17:33:21 +03:00
Gani Georgiev 4c44044c0c merge newui branch 2026-04-18 16:50:39 +03:00
Gani Georgiev 58f605e90c bumped modernc.org/sqlite to 1.48.2 2026-04-09 17:34:00 +03:00
Gani Georgiev 6ae3d47eeb updated changelog 2026-04-09 13:06:34 +03:00
Gani Georgiev cb185ad6bf ratelimit test flakiness adjustments 2026-04-09 10:31:03 +03:00
Gani Georgiev f89858f1ec [#7632] added missing error check in the jsvm watcher 2026-04-09 10:12:58 +03:00
Gani Georgiev 0695ca254d [#7630] added missing file close after seek error 2026-04-09 09:50:36 +03:00
Gani Georgiev e91694154f bumped ui/dist and go action version 2026-04-08 14:27:53 +03:00
Gani Georgiev b251a4cf65 updated backport changelog 2026-04-07 08:35:24 +03:00
Gani Georgiev 01949b059b removed unnecessery struct wrapping 2026-04-05 16:28:11 +03:00
Gani Georgiev 89f3668da2 updated settings update test 2026-04-05 14:15:12 +03:00
Gani Georgiev 7865ca7b95 updated jsvm types 2026-04-05 13:51:57 +03:00
Gani Georgiev d92a98b100 fixed settings smtp password clear persistence 2026-04-05 13:47:06 +03:00
Gani Georgiev e9118fa6b6 removed unnecessery error return 2026-04-02 21:00:16 +03:00
Gani Georgiev e49b64b114 attempt to reduce ratelimit test flakiness 2026-04-02 20:22:16 +03:00
Gani Georgiev 1204362e9c use the raw address in the error message 2026-04-02 20:12:01 +03:00
Gani Georgiev cc535cde3b updated ui/dist 2026-04-02 20:03:56 +03:00
Gani Georgiev cb44d9e716 added extra OAuth2 avatar url download checks 2026-04-02 19:55:05 +03:00
Gani Georgiev 5cb66bd52f updated .static jsvm docs 2026-04-02 08:06:51 +03:00
Gani Georgiev 3a893d15ad avoid explicit fs.FS wrapping 2026-04-02 08:06:11 +03:00
Gani Georgiev 64854ef08d fixed changelog typos 2026-03-30 21:38:11 +03:00
Gani Georgiev 9f3cdf4ad5 use the explicitly mapped username column name for the unique legacy checks 2026-03-30 08:51:11 +03:00
Gani Georgiev 2dbc70d60d assign discord avatar only if exist 2026-03-30 08:15:26 +03:00
Gani GeorgievandHans a2b14bcb93 [#7603] updated the Discord AuthUser.Name field to use global_name
Co-authored-by: Hans <hi@hans0805.me>
2026-03-30 07:42:22 +03:00
Gani Georgiev 864bac6dc4 fixed grammar 2026-03-28 10:22:37 +02:00
Gani Georgiev 78dc12dc29 regenerated jsvm types 2026-03-28 01:14:43 +02:00
Gani Georgiev 4b4c2ec7c3 updated ui/dist 2026-03-28 00:22:41 +02:00
Gani Georgiev d87fa3bd80 bumped go deps 2026-03-28 00:16:34 +02:00
Gani Georgiev 45d353ffdb fixed OAuth2 client secret reset when marshalizing a cached collection model 2026-03-27 23:56:17 +02:00
Gani Georgiev e5390c3d86 added missing error return and fixed comment typo 2026-03-19 08:48:30 +02:00
Gani Georgiev e3d2608d03 updated backport changelog 2026-03-16 19:01:40 +02:00
Gani Georgiev 650a4255cc updated changelog 2026-03-16 18:58:08 +02:00
Gani Georgiev de70af2584 updated npm deps and ui/dist 2026-03-16 18:52:51 +02:00
Gani Georgiev ba7ed78b73 updated modernc.org/sqlite to 1.46.2 (SQLite 3.51.3) 2026-03-16 18:45:54 +02:00
Gani Georgiev cea149cb6e updated jsvm types 2026-03-11 15:02:59 +02:00
Gani Georgiev 70d8d1ee9d replace the custom ratelimiter strategy with a fixed window 2026-03-11 11:25:15 +02:00
Gani Georgiev 29c2e209f4 updated ui/dist 2026-03-09 17:33:04 +02:00
Gani Georgiev ef465957ff fixed comment typo 2026-03-09 17:30:35 +02:00
Gani Georgiev ba8b51af58 [#7575] use memory+file buffer when rereading the request body (fix #7572) 2026-03-09 17:19:09 +02:00
828 changed files with 59209 additions and 55869 deletions
+4 -4
View File
@@ -10,13 +10,13 @@ In case the vulnerability is confirmed, within another couple days I'll try to s
### 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 in the end to just receive another generic LLM prompt response in return.
- 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 list of common vulnerabilities that were previously reported but are NOT considered a security issue:
### Below is a short list of previous reports that are NOT considered security issues:
<details>
<summary><strong>Stored XSS</strong></summary>
@@ -67,9 +67,9 @@ Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on
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.
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._
~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>
+4 -4
View File
@@ -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.26.1'
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.
+116 -3
View File
@@ -1,3 +1,116 @@
## v0.37.3
- Fixed total count load on page back/forward navigation.
- Fixed `editor` floating dialogs position when scrolling ([#7653](https://github.com/pocketbase/pocketbase/issues/7653)).
- Enabled text wrapping for the API rule fields.
- Added view query sample loading indicator.
- Other minor light UI contrast and styles improvements.
## v0.37.2
- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Slightly adjusted the dark theme colors for better readability ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
- Removed unnecessary tags stripping from the displayed log attributes ([#7649](https://github.com/pocketbase/pocketbase/issues/7649)).
- Workarounded Safari freeze caused by a buggy CSS popover property ([#7650](https://github.com/pocketbase/pocketbase/issues/7650)).
## v0.37.1
- Minor UI bugfixes:
- Fixed `number` field input values normalization ([#7646](https://github.com/pocketbase/pocketbase/issues/7646)).
- Allow opening collections in new tab with middle click.
- Show collection name in the page title on initial load.
## v0.37.0
- New UI rewritten from scratch and with support for external customization in mind.
> Note that as explained in [#7612](https://github.com/pocketbase/pocketbase/discussions/7612) the new UI kit and extensions APIs will intentionally remain undocumented until "Stage 2 completion" _(there no ETAs)_.
The new UI also introduced several other small improvements:
- ~2MB smaller bundle size.
- Dark mode and theming support.
- Basic responsive/mobile support _(it is far from perfect but certainly more usable than before)_.
- Help text option for the collection fields.
- Lifted the max nested level restriction of presentable relations _(children are lazy loaded)_.
- Lighter rules autocomplete.
- Live view query preview.
- Insert of an audio/video embed tag in the richtext editor from a collection file.
- Option to bulk export records as JSON.
- Local search history for all searchbars.
- API rules overview across all collections.
- Very basic ERD-like visualization for the collections structure and relations.
- New stepped logs chart visualization with panning support.
- `listAuthMethods()` (aka. `/api/collection/{col}/auth-methods`) now returns the OAuth2 provider logo for each provider as inlined SVG string in its response data.
_⚠️ Note that if your app for whatever reason rely on the dashboard OAuth2 logos available under `/_/images/oauth2/*` they are still available for now but will be removed in future versions and it is recommended to use the new inline SVGs!_
- Added optional `no_ui` build tag to exclude the UI from bundling with the executable ([#7548](https://github.com/pocketbase/pocketbase/issues/7548)).
```sh
go build -tags no_ui
```
- Exported the internal JSVM bind functions ([#7600](https://github.com/pocketbase/pocketbase/discussions/7600)).
```go
jsvm.BindCore(vm)
jsvm.BindDbx(vm)
jsvm.BindSecurity(vm)
jsvm.BindOS(vm)
jsvm.BindFilepath(vm)
jsvm.BindHTTP(vm)
jsvm.BindFilesystem(vm)
jsvm.BindForms(vm)
jsvm.BindMails(vm)
jsvm.BindApis(vm)
```
- Updated `modernc.org/sqlite` to v1.49.1 (SQLite 3.53.0).
## v0.36.9
- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
- Fixed settings SMTP password clear persistence.
- Added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor.
- Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
- Updated min Go GitHub action version to 1.26.2 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
- Other small improvements _(updated `$apis.static` JSVM documentation, fixed comment typos, added missing file close on seek error, etc.)_.
## v0.36.8
- Fixed OAuth2 client secret reset when serializing a cached collection model.
- Bumped all Go and npm deps.
_This should also silence recent spam reports and security scanners regarding `golang.org/x/image` [CVE-2026-33809](https://www.cve.org/CVERecord?id=CVE-2026-33809) (it is not an issue in PocketBase because we don't support TIFF thumbs)._
## v0.36.7
- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
_Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
- Updated other minor Go and npm deps.
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
## v0.36.6
- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
@@ -649,7 +762,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)).
@@ -741,7 +854,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
@@ -784,7 +897,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.).
+21 -3
View File
@@ -2,6 +2,24 @@
> 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).
@@ -85,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
@@ -487,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
@@ -867,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
View File
@@ -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
View File
@@ -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.24+ (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.
+9 -6
View File
@@ -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.24+](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
View File
@@ -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:
//
+1
View File
@@ -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()
+87
View File
@@ -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
View File
@@ -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)
}
}
+94
View File
@@ -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
}
+177
View File
@@ -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"),
},
},
},
}
}
+1
View File
@@ -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
View File
@@ -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)
+15 -3
View File
@@ -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
}
+12 -38
View File
@@ -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
}
+11 -8
View File
@@ -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()
+1 -1
View File
@@ -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)
+10 -1
View File
@@ -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),
}
+2
View File
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
`"providers":[{`,
`"name":"google"`,
`"name":"gitlab"`,
`"logo":"\u003csvg`,
`"logo":""`, // for the legacy fields
`"state":`,
`"displayName":`,
`"codeVerifier":`,
+115 -9
View File
@@ -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))
}
+33 -11
View File
@@ -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
+98 -5
View File
@@ -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"},
}
+25 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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":{`,
+9
View File
@@ -267,6 +267,15 @@ type App interface {
// "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)
+14 -7
View File
@@ -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)
+68 -26
View File
@@ -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) {
+9 -5
View File
@@ -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
+19
View File
@@ -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
}
// -------------------------------------------------------------------
+20
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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)),
)
}
+1
View File
@@ -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
View File
@@ -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))),
)
}
+1
View File
@@ -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()
+6 -1
View File
@@ -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)),
)
}
+1
View File
@@ -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
View File
@@ -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)),
+1
View File
@@ -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()
+6 -1
View File
@@ -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(
+1
View File
@@ -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()
+6 -1
View File
@@ -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)),
)
}
+1
View File
@@ -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
View File
@@ -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)),
)
}
+1
View File
@@ -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()
+6 -1
View File
@@ -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...),
)
+1
View File
@@ -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()
+8 -1
View File
@@ -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)),
+1
View File
@@ -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()
+6 -1
View File
@@ -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)),
+1
View File
@@ -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()
+6 -1
View File
@@ -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)),
)
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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)),
+1
View File
@@ -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
View File
@@ -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)),
+1
View File
@@ -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()
+6 -6
View File
@@ -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"}]`,
},
}
+32 -1
View File
@@ -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{
&copy.SMTP.Password,
&copy.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
View File
@@ -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
+107 -16
View File
@@ -1,6 +1,7 @@
package core
import (
"context"
"errors"
"fmt"
"io"
@@ -36,17 +37,14 @@ func (app *BaseApp) DeleteView(dangerousViewName string) error {
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(dangerousViewName); err != nil {
err := txApp.DeleteView(dangerousViewName)
if err != nil {
return err
}
dangerousSelectQuery = strings.Trim(strings.TrimSpace(dangerousSelectQuery), ";")
// try to loosely detect multiple inline statements
tk := tokenizer.NewFromString(dangerousSelectQuery)
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
@@ -54,7 +52,8 @@ func (app *BaseApp) SaveView(dangerousViewName string, dangerousSelectQuery stri
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", dangerousViewName, dangerousSelectQuery)
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
_, err = txApp.DB().NewQuery(viewQuery).Execute()
if err != nil {
return err
}
@@ -124,6 +123,76 @@ func (app *BaseApp) CreateViewFields(dangerousSelectQuery string) (FieldsList, e
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)
@@ -198,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
@@ -212,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) {
@@ -245,13 +342,7 @@ 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
}
+126
View File
@@ -732,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)
}
+16 -17
View File
@@ -1,13 +1,13 @@
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 v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
github.com/fatih/color v1.18.0
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
@@ -17,12 +17,12 @@ require (
github.com/pocketbase/tygoja v0.0.0-20250812183945-97ffe055281f
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.19.0
modernc.org/sqlite v1.46.1
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.41.0 // indirect
golang.org/x/text v0.34.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
)
+38 -41
View File
@@ -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 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,8 +55,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.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=
@@ -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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/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=
+2 -2
View File
@@ -10,8 +10,8 @@ import (
)
const (
expectedDriverVersion = "v1.46.1"
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.
+55 -12
View File
@@ -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,7 +749,11 @@ 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)
@@ -756,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)
@@ -777,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)
@@ -803,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
@@ -842,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)
+88 -88
View File
@@ -43,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()
@@ -69,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(`
@@ -86,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
@@ -120,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
@@ -160,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(`
@@ -181,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);
@@ -205,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({
@@ -234,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)
})
@@ -262,7 +262,7 @@ func TestBaseBindsSubscriptionMessage(t *testing.T) {
}
}
func TestBaseBindsRecord(t *testing.T) {
func TestBindCoreRecord(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -272,7 +272,7 @@ func TestBaseBindsRecord(t *testing.T) {
}
vm := goja.New()
baseBinds(vm)
BindCore(vm)
vm.Set("collection", collection)
// without record data
@@ -308,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 {
@@ -336,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 {
@@ -355,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 {
@@ -379,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
@@ -470,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"},
@@ -517,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;
@@ -546,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({
@@ -564,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(
@@ -584,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();
@@ -609,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();
@@ -650,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
@@ -697,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)
@@ -792,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()
@@ -809,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)
@@ -845,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
@@ -888,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
@@ -964,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 {
@@ -977,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"
@@ -996,7 +996,7 @@ func TestSecurityEncryptAndDecryptBinds(t *testing.T) {
}
}
func TestFilesystemBinds(t *testing.T) {
func TestBindFilesystem(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -1020,8 +1020,8 @@ func TestFilesystemBinds(t *testing.T) {
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", 6, t)
@@ -1116,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
@@ -1190,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(`
@@ -1291,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(`
@@ -1350,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(`
@@ -1391,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
@@ -1447,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(`
@@ -1626,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
@@ -1712,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
@@ -1766,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
@@ -1855,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)
}
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -1162,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))
* ` + "```" + `
*/
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
+24 -21
View File
@@ -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
}
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14 -2
View File
@@ -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.
+46 -14
View File
@@ -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)
}
}
+2
View File
@@ -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"},
+2
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -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"},
+2
View File
@@ -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"},
+2
View File
@@ -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"},
+2
View File
@@ -29,6 +29,8 @@ type Github struct {
func NewGithubProvider() *Github {
return &Github{BaseProvider{
ctx: context.Background(),
order: 7,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="250" preserveAspectRatio="xMidYMid"><path fill="#161614" d="M128 0a128 128 0 0 0-40.5 249.5c6.4 1.1 8.8-2.8 8.8-6.2l-.2-23.8C60.5 227.2 53 204.4 53 204.4c-5.8-14.8-14.2-18.8-14.2-18.8-11.6-7.9.8-7.7.8-7.7 12.9.9 19.7 13.1 19.7 13.1 11.4 19.6 30 14 37.2 10.7 1.2-8.3 4.5-14 8.1-17.1-28.4-3.3-58.3-14.2-58.3-63.3 0-14 5-25.4 13.2-34.3a46 46 0 0 1 1.3-34S71.5 49.7 96 66.3a123 123 0 0 1 64 0c24.5-16.6 35.2-13.1 35.2-13.1a46 46 0 0 1 1.3 33.9c8.2 9 13.2 20.3 13.2 34.3 0 49.2-30 60-58.5 63.2 4.6 4 8.7 11.7 8.7 23.7l-.2 35.1c0 3.4 2.4 7.4 8.8 6.1A128 128 0 0 0 128 0M48 182.3q-.6 1.1-2.3.4c-.9-.4-1.4-1.3-1.1-1.9q.6-1 2.2-.4 1.6.8 1.1 2m6.2 5.7c-.6.5-1.8.3-2.6-.6q-1.2-1.7-.4-2.7 1.2-.8 2.7.6 1.3 1.5.3 2.7m4.4 7.1c-.8.6-2.1 0-2.9-1-.8-1.2-.8-2.6 0-3.1q1.4-.7 2.9 1c.8 1.2.8 2.6 0 3.1m7.3 8.4c-.7.7-2.2.5-3.3-.5s-1.5-2.5-.8-3.3c.8-.8 2.3-.5 3.4.5 1 1 1.4 2.5.7 3.3m9.4 2.8c-.3 1-1.7 1.4-3.2 1-1.4-.4-2.4-1.6-2.1-2.6s1.7-1.5 3.2-1 2.4 1.6 2.1 2.6m10.7 1.2q-.1 1.7-2.7 2-2.5-.2-2.8-2c0-1 1.2-1.9 2.8-1.9s2.7.8 2.7 1.9m10.6-.4c.2 1-.9 2-2.4 2.3s-2.8-.3-3-1.3c-.2-1.1.9-2.2 2.3-2.4 1.6-.3 3 .3 3.1 1.4"/></svg>`,
displayName: "GitHub",
pkce: true, // technically is not supported yet but it is safe as the PKCE params are just ignored
scopes: []string{"read:user", "user:email"},
+2
View File
@@ -27,6 +27,8 @@ type Gitlab struct {
func NewGitlabProvider() *Gitlab {
return &Gitlab{BaseProvider{
ctx: context.Background(),
order: 8,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="247" fill="none"><path fill="#e24329" d="m251.7 97.7-.3-.9-34.7-90.6a9 9 0 0 0-9-5.7q-3 .2-5.2 2a9 9 0 0 0-3.1 4.7L176 78.9H81L57.7 7.2a9.1 9.1 0 0 0-17.2-1L5.6 96.8l-.4.9a64.4 64.4 0 0 0 21.4 74.5h.1l.3.3L80 212l26 19.8 16 12a11 11 0 0 0 13 0l15.9-12 26.2-19.8 53.1-39.9h.2a64.5 64.5 0 0 0 21.3-74.5"/><path fill="#fc6d26" d="m251.7 97.7-.3-.9c-17 3.5-32.9 10.6-46.7 21l-76.2 57.6 48.5 36.7 53.2-39.8.2-.1a64.5 64.5 0 0 0 21.3-74.5"/><path fill="#fca326" d="m80 212.1 26 19.8 16 12a11 11 0 0 0 13 0l15.9-12 26.2-19.8s-22.7-17-48.6-36.7z"/><path fill="#fc6d26" d="M52.2 117.8a117 117 0 0 0-46.6-21l-.4.9a64.4 64.4 0 0 0 21.4 74.5h.1l.3.3L80 212l48.5-36.7z"/></svg>`,
displayName: "GitLab",
pkce: true,
scopes: []string{"read_user"},
+2
View File
@@ -26,6 +26,8 @@ type Google struct {
func NewGoogleProvider() *Google {
return &Google{BaseProvider{
ctx: context.Background(),
order: 2,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="262" preserveAspectRatio="xMidYMid"><path fill="#4285f4" d="M255.9 133.5c0-10.8-.9-18.6-2.8-26.7H130.6v48.4h71.9a64 64 0 0 1-26.7 42.4l-.2 1.6 38.7 30 2.7.3c24.7-22.8 38.9-56.3 38.9-96"/><path fill="#34a853" d="M130.6 261.1c35.2 0 64.8-11.6 86.4-31.6l-41.2-32a76 76 0 0 1-45.2 13.1 79 79 0 0 1-74.3-54.2l-1.5.1-40.3 31.2-.6 1.5A131 131 0 0 0 130.6 261"/><path fill="#fbbc05" d="M56.3 156.4a80 80 0 0 1-.2-51.7V103L15.3 71.3l-1.4.6a131 131 0 0 0 0 117.3z"/><path fill="#eb4335" d="M130.6 50.5c24.5 0 41 10.6 50.4 19.4L218 34c-22.8-21-52.2-34-87.4-34C79.5 0 35.4 29.3 13.9 72l42.2 32.7a79 79 0 0 1 74.5-54.2"/></svg>`,
displayName: "Google",
pkce: true,
scopes: []string{
+2
View File
@@ -26,6 +26,8 @@ type Instagram struct {
func NewInstagramProvider() *Instagram {
return &Instagram{BaseProvider{
ctx: context.Background(),
order: 6,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 132 132"><defs><radialGradient xlink:href="#a" id="c" cx="158.4" cy="578.1" r="65" fx="158.4" fy="578.1" gradientTransform="matrix(0 -1.98198 1.8439 0 -1031.4 454)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#b" id="d" cx="147.7" cy="473.5" r="65" fx="147.7" fy="473.5" gradientTransform="rotate(78.7 1103.9 776.3)scale(.88596 3.6529)" gradientUnits="userSpaceOnUse"/><linearGradient id="b"><stop offset="0" stop-color="#3771c8"/><stop offset=".1" stop-color="#3771c8"/><stop offset="1" stop-color="#60f" stop-opacity="0"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#fd5"/><stop offset=".1" stop-color="#fd5"/><stop offset=".5" stop-color="#ff543e"/><stop offset="1" stop-color="#c837ab"/></linearGradient></defs><path fill="url(#c)" d="M65 0C38 0 30 0 28.4.2c-5.6.4-9 1.3-12.8 3.2a28 28 0 0 0-15 21.3c-.4 3-.6 3.6-.6 19.1V65c0 27 0 35 .2 36.6.4 5.4 1.3 8.8 3 12.5 3.5 7.2 10 12.5 17.8 14.5q4 1 9.5 1.3a2913 2913 0 0 0 68.8 0c4.4-.2 7-.6 9.8-1.3 7.8-2 14.2-7.3 17.7-14.5 1.8-3.7 2.7-7.2 3.1-12.3a2759 2759 0 0 0 0-73.6c-.4-5.2-1.3-8.8-3.1-12.5q-2.1-4.3-5.6-7.6A28 28 0 0 0 105.4.6c-3-.4-3.7-.6-19.2-.6z" transform="translate(1 1)"/><path fill="url(#d)" d="M65 0C38 0 30 0 28.4.2c-5.6.4-9 1.3-12.8 3.2a28 28 0 0 0-15 21.3c-.4 3-.6 3.6-.6 19.1V65c0 27 0 35 .2 36.6.4 5.4 1.3 8.8 3 12.5 3.5 7.2 10 12.5 17.8 14.5q4 1 9.5 1.3a2913 2913 0 0 0 68.8 0c4.4-.2 7-.6 9.8-1.3 7.8-2 14.2-7.3 17.7-14.5 1.8-3.7 2.7-7.2 3.1-12.3a2759 2759 0 0 0 0-73.6c-.4-5.2-1.3-8.8-3.1-12.5q-2.1-4.3-5.6-7.6A28 28 0 0 0 105.4.6c-3-.4-3.7-.6-19.2-.6z" transform="translate(1 1)"/><path fill="#fff" d="M66 18c-13 0-14.7 0-19.8.3s-8.6 1-11.6 2.2a24 24 0 0 0-8.5 5.6 24 24 0 0 0-5.6 8.5 35 35 0 0 0-2.2 11.6C18 51.3 18 53 18 66s0 14.7.3 19.8 1 8.6 2.2 11.6q1.7 4.7 5.6 8.5 3.8 4 8.5 5.6c3 1.2 6.5 2 11.6 2.2s6.8.3 19.8.3 14.7 0 19.8-.3 8.6-1 11.6-2.2q4.7-1.7 8.5-5.6t5.6-8.5c1.2-3 2-6.5 2.2-11.6s.3-6.8.3-19.8 0-14.7-.3-19.8-1-8.6-2.2-11.6a24 24 0 0 0-5.6-8.5 24 24 0 0 0-8.5-5.6c-3-1.2-6.5-2-11.6-2.2S79 18 66 18m-4.3 8.7H66c12.8 0 14.3 0 19.4.2 4.7.2 7.2 1 9 1.7 2.2.8 3.7 1.9 5.4 3.6q2.4 2.2 3.6 5.5c.7 1.7 1.5 4.2 1.7 8.9.2 5 .3 6.6.3 19.4s-.1 14.3-.3 19.4c-.2 4.7-1 7.2-1.7 8.9q-1.1 3.1-3.6 5.5a15 15 0 0 1-5.5 3.6c-1.7.7-4.2 1.4-8.9 1.6-5 .3-6.6.3-19.4.3a334 334 0 0 1-19.4-.3 28 28 0 0 1-8.9-1.6q-3.1-1.3-5.5-3.6a15 15 0 0 1-3.6-5.5 25 25 0 0 1-1.7-9c-.2-5-.2-6.5-.2-19.3s0-14.4.2-19.4a26 26 0 0 1 1.7-9q1.2-3.1 3.6-5.4 2.4-2.5 5.5-3.6a25 25 0 0 1 9-1.7c4.3-.2 6-.3 15-.3zm30 8a5.8 5.8 0 1 0 0 11.4 5.8 5.8 0 0 0 0-11.5M66 41.2a24.7 24.7 0 1 0 0 49.4 24.7 24.7 0 0 0 0-49.3m0 8.7a16 16 0 1 1 0 32 16 16 0 0 1 0-32"/></svg>`,
displayName: "Instagram",
pkce: true,
scopes: []string{"instagram_business_basic"},
+2
View File
@@ -28,6 +28,8 @@ type Kakao struct {
func NewKakaoProvider() *Kakao {
return &Kakao{BaseProvider{
ctx: context.Background(),
order: 14,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 256 256"><path fill="#ffe812" d="M256 236q-2 18-20 20H20q-18-2-20-20V20Q2 2 20 0h216q18 2 20 20z"/><path d="M128 36C71 36 24 73 24 118c0 29 19 55 49 69l-11 38 1 3h3l44-29 18 1c57 0 104-37 104-82s-47-82-104-82"/><path fill="#ffe812" d="M71 147q-6-1-6-6v-36H55a6 6 0 0 1 0-11h31a6 6 0 0 1 0 11h-9v36q-1 5-6 6m52 0q-3 0-5-3l-3-8H97l-3 8q-2 3-5 3l-4-1q-3-1-1-9l14-38q2-4 8-5 6 1 8 5l14 38q2 8-1 9zm-11-22-6-17-6 17zm26 21q-5-1-6-6v-40q1-6 6-6 7 0 7 6v35h12q5 0 6 5 0 7-6 6zm33 1q-5-1-6-6v-41a6 6 0 0 1 12 0v12l17-16 3-2 5 2 1 4-1 4-14 13 15 20 1 4-2 4-4 1-5-2-14-19-2 2v14a6 6 0 0 1-6 6"/></svg>`,
displayName: "Kakao",
pkce: true,
scopes: []string{"account_email", "profile_nickname", "profile_image"},
+2
View File
@@ -26,6 +26,8 @@ type Lark struct {
func NewLarkProvider() *Lark {
return &Lark{BaseProvider{
ctx: context.Background(),
order: 19,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" fill="none"><path fill="#00d6b9" d="M137 126v-1l2-1v-1l3-2 3-3 3-3 2-3 3-2 2-3 4-3 2-2 4-3 9-7 5-3 9-3 2-1a135 135 0 0 0-26-51 12 12 0 0 0-9-4H56l-2 1 1 2a288 288 0 0 1 82 93"/><path fill="#3370ff" d="M98 212c51 0 95-28 118-69l3-5-6 9-4 4-6 5-2 2-4 3-11 4-6 2h-15l-4-1h-3l-2-1-5-1-3-1-4-1-3-1-3-1-1-1-3-1h-2l-3-2h-2l-2-1-3-1-2-1-2-1-2-1h-2l-1-1-2-1h-1l-1-1-2-1-2-1-2-1-2-1a285 285 0 0 1-81-60l-3 1v94a12 12 0 0 0 5 11 135 135 0 0 0 76 22"/><path fill="#133c9a" d="M248 90a78 78 0 0 0-58-5l-2 1-14 6-6 4-7 6-2 2-4 3-2 3-3 2-2 3-3 3-3 3-3 2v1l-2 1-1 1-1 1a137 137 0 0 1-28 20l2 1 1 1h2l1 1 1 1h2l2 1 2 1 2 1 3 1 2 1h2l3 2h2l3 1 2 1 2 1 3 1 4 1 8 2 2 1 7 1h15l6-2 7-2 6-4 2-1 2-2 3-1 7-8 6-9 1-2 12-24a77 77 0 0 1 16-22"/></svg>`,
displayName: "Lark",
pkce: true,
// Lark has two domains with the same API: feishu.cn and larksuite.com.
+2
View File
@@ -31,6 +31,8 @@ type Linear struct {
func NewLinearProvider() *Linear {
return &Linear{BaseProvider{
ctx: context.Background(),
order: 16,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#222326" viewBox="0 0 100 100"><path d="M1.2 61.5c-.2-1 1-1.5 1.6-.8l36.5 36.5c.7.7.1 1.8-.8 1.6A50 50 0 0 1 1.2 61.5M0 47q0 .4.3.7l52 52.1q.4.3.8.3a50 50 0 0 0 7-1 1 1 0 0 0 .5-1.6l-58-58a1 1 0 0 0-1.7.5 50 50 0 0 0-.9 7m4.2-17.2q-.2.6.2 1.1l64.8 64.8q.5.5 1 .2l5.3-2.7q.8-.6.2-1.5L8.4 24.3a1 1 0 0 0-1.5.2Q5.4 27 4.2 29.7m8.5-11.6a1 1 0 0 1 0-1.4A50 50 0 0 1 100 50a50 50 0 0 1-16.7 37.4 1 1 0 0 1-1.4 0z"/></svg>`,
displayName: "Linear",
pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled
scopes: []string{"read"},
+2
View File
@@ -26,6 +26,8 @@ type Livechat struct {
func NewLivechatProvider() *Livechat {
return &Livechat{BaseProvider{
ctx: context.Background(),
order: 27,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 80 80"><path d="M79.5 49.7a19 19 0 0 1-19 17.3H50L30 80V67l20-13h10.5a6 6 0 0 0 6.1-5.3q1-15-.2-30a5.6 5.6 0 0 0-5.2-5.1Q50.9 13.1 40 13c-10.9-.1-14.4.2-21.2.7-2.8.2-5 2.3-5.2 5.1q-1 15-.2 30c.3 3.1 3 5.3 6.1 5.2H30v13H19.5c-9.9.1-18.2-7.4-19-17.3q-1-16 .2-32C1.5 8.5 8.8 1.3 18 .7a313 313 0 0 1 44.1.1c9.2.6 16.5 7.8 17.3 17q1.1 16 .1 31.9" style="fill:#ff5100"/></svg>`,
displayName: "LiveChat",
pkce: true,
scopes: []string{}, // default scopes are specified from the provider dashboard
+2
View File
@@ -28,6 +28,8 @@ type Mailcow struct {
func NewMailcowProvider() *Mailcow {
return &Mailcow{BaseProvider{
ctx: context.Background(),
order: 28,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="394.8" height="376.9"><path d="M56 213.3c0-20.3-.8-17.3-3.7-39.3 2.2-22.5-7.6-38.9-7.7-58.4 0-4.7-5.8-6.7-4.7-11A99 99 0 0 0 56 214.7z" style="fill:#3d5263" transform="translate(10 10)"/><path d="m254.8 180.4-.6.5a121 121 0 0 1-80 84l11 9.2 53.2 44.8 31.6 26.5q1.3-3 1.3-6.4V167.1zM23 185.5l-6.5-5.2-16.5-13v171.6q0 3.5 1.2 6.5l58.4-48.7 27-22.4L99 263.9a121 121 0 0 1-76-78.4" style="fill:#f9e82d" transform="translate(10 10)"/><path d="M238.4 318.9 185.1 274l-11-9.2a120 120 0 0 1-75.1-1l-12.4 10.4-27 22.5-58.4 48.6A18 18 0 0 0 18 357h235.4a18 18 0 0 0 16.7-11.5z" style="fill:#edd514;fill-opacity:.89499996" transform="translate(10 10)"/><path d="M238.4 318.9c-41.4 4-115 5.5-178.8-22.1-21.5-9.4-42-22.1-59.6-39.2V339q0 3.5 1.2 6.5A18 18 0 0 0 18 357h235.4a18 18 0 0 0 18-18v-24.5s-12.6 2.5-32.9 4.5" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M86.6 274.3a120 120 0 0 0 98.5-.2 120 120 0 0 0 69.7-93.7l-.6.5a120.5 120.5 0 0 1-155.2 83 121 121 0 0 1-76-78.4l-6.5-5.2c5.5 42 32.7 77.3 70 94" style="opacity:.1;fill:#3d5263" transform="translate(10 10)"/><path d="M54.3 63.9q-2.7 2.6-5.2 5.4l-.2.1-.7.8.2-.2a120.2 120.2 0 0 1 174.8 165 120 120 0 0 0 35-84.8A120 120 0 0 0 187 40.4a119 119 0 0 0-100.7 1.2 120 120 0 0 0-32 22.3" style="fill:#fff" transform="translate(10 10)"/><path d="M95.8 118.5a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2m91.1 0a4.6 4.6 0 1 0 0-9.2 4.6 4.6 0 0 0 0 9.2" style="fill:#fff" transform="translate(10 10)"/><path d="M223.7 234.4A120.2 120.2 0 0 0 48.4 70 121 121 0 0 0 23 185.5 120.5 120.5 0 0 0 174.2 265a120 120 0 0 0 47.7-28.6zm-5.8-58.4q-3.2 11-8.7 20.5A94.8 94.8 0 0 1 56 214.8a99 99 0 0 1-16-110.3 96 96 0 0 1 97.8-54 97.3 97.3 0 0 1 84.3 97.2q0 14.9-4 28.3M49 69.3" style="fill:#f1f2f2" transform="translate(10 10)"/><path d="M257.6 161.9a120 120 0 0 1-3.4 19l.6-.5 16.5-13.2zM0 167.2l16.5 13.1 6.5 5.2q-3.5-11.6-4.7-23.9l-2.9.9zm87.5 25.1a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m93.8 0a11.3 11.3 0 1 0 0 22.5 11.3 11.3 0 0 0 0-22.5m1.7-90c-7 0-15.4 7.7-15.4 14.7s8 17.2 15 17.2 15.8-9.5 15.8-16.5-8.4-15.4-15.4-15.4m3.9 16.2a4.6 4.6 0 1 1 0-9.2 4.6 4.6 0 0 1 0 9.2m-97.2-15.9c-7 0-14.4 8.1-14.4 15s8.9 16.2 15.8 16.2 15.8-9.1 15.8-16.1-10.2-15.1-17.2-15.1m6.1 16a4.6 4.6 0 1 1 0-9.3 4.6 4.6 0 0 1 0 9.2" style="fill:#5a3620" transform="translate(10 10)"/><path d="M336.3 256.4c3.6-9.1 7.7-11 9.3-11.3-40.7 3.7-36.6 27.7-34 36l1 2.7s2 4.8 7.6 8.7c4.1 3 10.3 5.5 19 5.2 27 .4 35.6-50.2 35.6-50.2-6.6 11.7-26.4 9.9-38.5 9M49 69.4l5.3-5.4c-9-7-24.4-15.9-41.8-11.7A54 54 0 0 0 36 86.5q5.5-8.8 12.4-16.5l-.2.2zm209.8-17.2a49 49 0 0 0-39.2 9.8q10.8 9.9 19 22.3a54 54 0 0 0 20.2-32M134.3 160.2c-43.3 0-78.4 23-78.4 51.3v1.7l.2 1.6a94.8 94.8 0 0 0 153.1-18.3c-9.8-21-39.6-36.3-75-36.3M87.5 215a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5m93.8 0a11.3 11.3 0 1 1 0-22.6 11.3 11.3 0 0 1 0 22.5M86.3 0c-18.2 16.4-.2 41.4 0 41.6Q102.6 34 121 31.1C97.6 27.7 86.3 0 86.3 0m99.9 0S175.3 26.5 153 30.9q18 2.3 34 9.5c3.4-5.3 15-26.1-.8-40.4" style="fill:#fef3df" transform="translate(10 10)"/><path d="M218 176a163 163 0 0 0 6.5-35.7c0-50-39.3-83.9-86.8-89.8-2.1 28 3.7 87 80.2 125.5m-47.6-58.3a12.6 12.6 0 1 1 25.2 0 12.6 12.6 0 0 1-25.2 0" style="fill:#87654a" transform="translate(10 10)"/><path d="m312.7 283.8-1-2.8a54 54 0 0 1-27.1 12.5q-6 1-13.3.5v28a206 206 0 0 0 26.2-12.5h.1c8.2-4.7 16.4-10.5 22.6-17-5.5-4-7.5-8.8-7.5-8.8M12.5 52.2C30 48 45.4 57 54.3 64q8.4-8.2 18.3-14.6C48.3 18.5 2.2 37.2 2.2 37.2A55 55 0 0 0 31.7 94q2-3.7 4.3-7.5C15.4 72.7 12.5 52.2 12.5 52.2m187.9-4.8q10.3 6.3 19.2 14.6a49 49 0 0 1 39.2-9.8s-2.5 18.4-20.3 32q2.5 3.8 4.6 7.7a54 54 0 0 0 26-54.7s-44-18-68.7 10.2m-61.5 3.1a96 96 0 0 0-99 54q-1.5 6.6-1.6 13.6v4.2q.3 6 1 11.1c4.2 25 7.9 42.5 13.4 66.8l.2 1.2q1 6 3 11.8v-1.7c0-28.3 35-51.3 78.4-51.3 35.3 0 65.1 15.3 75 36.3a99 99 0 0 0 8.6-20.5c-38-23.5-53.9-41.1-64.6-64.2-10.8-23-15.5-47.3-14.4-61.3M91 130.3a12.6 12.6 0 1 1 0-25.2 12.6 12.6 0 0 1 0 25.2" style="fill:#b58765" transform="translate(10 10)"/></svg>`,
displayName: "mailcow",
pkce: true,
scopes: []string{"profile"},
+2
View File
@@ -28,6 +28,8 @@ func NewMicrosoftProvider() *Microsoft {
endpoints := microsoft.AzureADEndpoint("")
return &Microsoft{BaseProvider{
ctx: context.Background(),
order: 3,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#f1511b" d="M122 122H0V0h122z"/><path fill="#80cc28" d="M256 122H134V0h122z"/><path fill="#00adef" d="M122 256H0V134h122z"/><path fill="#fbbc09" d="M256 256H134V134h122z"/></svg>`,
displayName: "Microsoft",
pkce: true,
scopes: []string{"User.Read"},
+2
View File
@@ -29,6 +29,8 @@ type Monday struct {
func NewMondayProvider() *Monday {
return &Monday{BaseProvider{
ctx: context.Background(),
order: 18,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 127 127"><path fill="#fb275d" d="M24.8 87.2c-3.7 0-7.1-1.9-8.9-5.1a9 9 0 0 1 .3-9.9L34.5 44c1.9-3.1 5.4-4.9 9.1-4.8s7.1 2.1 8.8 5.3 1.5 7-.5 9.9L33.4 82.6a10 10 0 0 1-8.6 4.6"/><path fill="#fc0" d="M56.1 87.2c-3.7 0-7.1-1.9-8.9-5a9 9 0 0 1 .3-9.9l18.4-28.1c1.9-3.1 5.3-5 9.1-4.9s7.1 2.1 8.8 5.3 1.5 7-.7 10l-18.4 28a11 11 0 0 1-8.6 4.6"/><path fill="#00ca72" d="M86.7 87.2c5.6 0 10.2-4.6 10.2-10.2s-4.6-10.2-10.2-10.2S76.5 71.4 76.5 77c0 5.7 4.5 10.2 10.2 10.2"/></svg>`,
displayName: "monday.com",
pkce: true,
scopes: []string{"me:read"},
+2
View File
@@ -27,6 +27,8 @@ type Notion struct {
func NewNotionProvider() *Notion {
return &Notion{BaseProvider{
ctx: context.Background(),
order: 17,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="none"><path fill="#000" d="M61 0 6 4q-6 2-6 7v61l3 8 13 17q2 4 8 3l65-4q7-1 7-7V21q0-3-4-5L74 3q-4-4-13-3M26 20q-7 1-9-2l-8-6q-1-2 1-2l54-4q5 0 8 2l9 7 1 1zm-6 68V30q0-3 3-4l63-3q3 0 3 3v58q1 5-4 5l-60 3q-5 1-5-4m59-55q1 4-1 4l-3 1v43l-7 2q-4 0-6-4L43 49v29l6 1s0 4-5 4H30l2-3 3-1V41h-5q0-4 4-5l14-1 20 31V39l-5-1q0-3 3-4z"/></svg>`,
displayName: "Notion",
pkce: true,
authURL: "https://api.notion.com/v1/oauth/authorize",
+2
View File
@@ -57,6 +57,8 @@ type OIDC struct {
func NewOIDCProvider() *OIDC {
return &OIDC{BaseProvider{
ctx: context.Background(),
order: 99,
logo: `<svg xmlns="http://www.w3.org/2000/svg" width="93" height="84" fill="none"><path fill="#ccc" d="M83.4 32.9c-8.7-5.5-21-8.8-34.3-8.8C22 24 .4 37.5.4 54 .4 69.3 18.5 81.8 42 84v-8.7c-15.9-2-27.8-10.7-27.8-21 0-11.9 15.5-21.6 34.8-21.6 9.5 0 18.2 2.4 24.5 6.3l-9 5.6h27.9V27.3z"/><path fill="#ff6200" d="M42 9.2V84l14-8.7V.2z"/></svg>`,
displayName: "OIDC",
pkce: true,
scopes: []string{
+2
View File
@@ -27,6 +27,8 @@ type Patreon struct {
func NewPatreonProvider() *Patreon {
return &Patreon{BaseProvider{
ctx: context.Background(),
order: 24,
logo: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 1080 1080"><path d="M1033 324c0-137-108-250-234-291a746 746 0 0 0-512 27C106 145 49 333 47 519c-2 154 14 558 242 561 169 2 194-216 273-321 56-75 127-96 216-118a320 320 0 0 0 255-317"/></svg>`,
displayName: "Patreon",
pkce: true,
scopes: []string{"identity", "identity[email]"},

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